|
24 | 24 | BucketStats, |
25 | 25 | SignalScore, |
26 | 26 | _deviation_bucket, |
| 27 | + _infer_insider_from_signal, |
| 28 | + _insider_bucket, |
| 29 | + _parse_sentiment, |
27 | 30 | _sentiment_bucket, |
| 31 | + clear_cache, |
| 32 | + compute_bucket_stats, |
28 | 33 | compute_outcomes, |
29 | 34 | get_bucket_name, |
| 35 | + load_signals, |
30 | 36 | score_signal, |
31 | 37 | ) |
32 | 38 |
|
@@ -401,3 +407,273 @@ def test_multiple_buys_same_symbol_all_scored_when_open(self): |
401 | 407 | returns = [o["return_pct"] for o in outcomes] |
402 | 408 | assert pytest.approx(5.0) in returns # (105-100)/100 * 100 |
403 | 409 | assert pytest.approx(10.526, rel=0.01) in returns # (105-95)/95 * 100 |
| 410 | + |
| 411 | + |
| 412 | +# =================================================================== |
| 413 | +# _insider_bucket() |
| 414 | +# =================================================================== |
| 415 | + |
| 416 | +class TestInsiderBucket: |
| 417 | + """Insider presence classification.""" |
| 418 | + |
| 419 | + def test_insider_yes(self): |
| 420 | + assert _insider_bucket(True) == "insider_yes" |
| 421 | + |
| 422 | + def test_insider_no(self): |
| 423 | + assert _insider_bucket(False) == "insider_no" |
| 424 | + |
| 425 | + def test_insider_unknown(self): |
| 426 | + assert _insider_bucket(None) == "insider_unknown" |
| 427 | + |
| 428 | + |
| 429 | +# =================================================================== |
| 430 | +# _parse_sentiment() |
| 431 | +# =================================================================== |
| 432 | + |
| 433 | +class TestParseSentiment: |
| 434 | + """Safe parsing of sentiment scores from CSV strings.""" |
| 435 | + |
| 436 | + def test_valid_float_string(self): |
| 437 | + assert _parse_sentiment("0.75") == pytest.approx(0.75) |
| 438 | + |
| 439 | + def test_negative_float_string(self): |
| 440 | + assert _parse_sentiment("-0.5") == pytest.approx(-0.5) |
| 441 | + |
| 442 | + def test_none_returns_none(self): |
| 443 | + assert _parse_sentiment(None) is None |
| 444 | + |
| 445 | + def test_empty_string_returns_none(self): |
| 446 | + assert _parse_sentiment("") is None |
| 447 | + |
| 448 | + def test_whitespace_only_returns_none(self): |
| 449 | + assert _parse_sentiment(" ") is None |
| 450 | + |
| 451 | + def test_non_numeric_returns_none(self): |
| 452 | + assert _parse_sentiment("positive") is None |
| 453 | + |
| 454 | + def test_integer_string(self): |
| 455 | + assert _parse_sentiment("1") == pytest.approx(1.0) |
| 456 | + |
| 457 | + |
| 458 | +# =================================================================== |
| 459 | +# _infer_insider_from_signal() |
| 460 | +# =================================================================== |
| 461 | + |
| 462 | +class TestInferInsiderFromSignal: |
| 463 | + """Infer insider presence from sentiment_reasoning text.""" |
| 464 | + |
| 465 | + def test_insider_keyword_detected(self): |
| 466 | + signal = {"sentiment_reasoning": "Strong insider buying activity detected"} |
| 467 | + assert _infer_insider_from_signal(signal) is True |
| 468 | + |
| 469 | + def test_executive_buy_detected(self): |
| 470 | + signal = {"sentiment_reasoning": "Executive buy at $150 per share"} |
| 471 | + assert _infer_insider_from_signal(signal) is True |
| 472 | + |
| 473 | + def test_no_insider_keywords(self): |
| 474 | + signal = {"sentiment_reasoning": "Positive earnings report"} |
| 475 | + assert _infer_insider_from_signal(signal) is None |
| 476 | + |
| 477 | + def test_empty_reasoning(self): |
| 478 | + signal = {"sentiment_reasoning": ""} |
| 479 | + assert _infer_insider_from_signal(signal) is None |
| 480 | + |
| 481 | + def test_missing_reasoning_key(self): |
| 482 | + signal = {} |
| 483 | + assert _infer_insider_from_signal(signal) is None |
| 484 | + |
| 485 | + def test_none_reasoning(self): |
| 486 | + signal = {"sentiment_reasoning": None} |
| 487 | + assert _infer_insider_from_signal(signal) is None |
| 488 | + |
| 489 | + def test_case_insensitive(self): |
| 490 | + signal = {"sentiment_reasoning": "INSIDER buying trend"} |
| 491 | + assert _infer_insider_from_signal(signal) is True |
| 492 | + |
| 493 | + |
| 494 | +# =================================================================== |
| 495 | +# compute_bucket_stats() |
| 496 | +# =================================================================== |
| 497 | + |
| 498 | +class TestComputeBucketStats: |
| 499 | + """Bucket statistics computation from outcomes.""" |
| 500 | + |
| 501 | + def test_single_bucket_stats(self): |
| 502 | + """Compute stats for a single bucket of outcomes.""" |
| 503 | + outcomes = [ |
| 504 | + {"bucket": "deep_dip+positive_sent", "symbol": "AAPL", "return_pct": 5.0, "won": True}, |
| 505 | + {"bucket": "deep_dip+positive_sent", "symbol": "NVDA", "return_pct": -2.0, "won": False}, |
| 506 | + {"bucket": "deep_dip+positive_sent", "symbol": "MSFT", "return_pct": 8.0, "won": True}, |
| 507 | + ] |
| 508 | + stats = compute_bucket_stats(outcomes) |
| 509 | + assert len(stats) == 1 |
| 510 | + b = stats["deep_dip+positive_sent"] |
| 511 | + assert b.sample_size == 3 |
| 512 | + assert b.win_count == 2 |
| 513 | + assert b.win_rate == pytest.approx(2 / 3) |
| 514 | + assert b.avg_return == pytest.approx((5.0 - 2.0 + 8.0) / 3) |
| 515 | + assert b.min_return == pytest.approx(-2.0) |
| 516 | + assert b.max_return == pytest.approx(8.0) |
| 517 | + |
| 518 | + def test_multiple_buckets(self): |
| 519 | + """Outcomes from different buckets are grouped separately.""" |
| 520 | + outcomes = [ |
| 521 | + {"bucket": "deep_dip+positive_sent", "symbol": "AAPL", "return_pct": 10.0, "won": True}, |
| 522 | + {"bucket": "shallow_dip+neutral_sent", "symbol": "NVDA", "return_pct": -3.0, "won": False}, |
| 523 | + ] |
| 524 | + stats = compute_bucket_stats(outcomes) |
| 525 | + assert len(stats) == 2 |
| 526 | + assert stats["deep_dip+positive_sent"].sample_size == 1 |
| 527 | + assert stats["shallow_dip+neutral_sent"].sample_size == 1 |
| 528 | + |
| 529 | + def test_empty_outcomes(self): |
| 530 | + """Empty outcomes returns empty stats.""" |
| 531 | + stats = compute_bucket_stats([]) |
| 532 | + assert stats == {} |
| 533 | + |
| 534 | + def test_unique_symbols_tracked(self): |
| 535 | + """Symbols list in BucketStats should contain unique entries.""" |
| 536 | + outcomes = [ |
| 537 | + {"bucket": "b1", "symbol": "AAPL", "return_pct": 5.0, "won": True}, |
| 538 | + {"bucket": "b1", "symbol": "AAPL", "return_pct": 3.0, "won": True}, |
| 539 | + {"bucket": "b1", "symbol": "NVDA", "return_pct": -1.0, "won": False}, |
| 540 | + ] |
| 541 | + stats = compute_bucket_stats(outcomes) |
| 542 | + assert set(stats["b1"].symbols) == {"AAPL", "NVDA"} |
| 543 | + |
| 544 | + |
| 545 | +# =================================================================== |
| 546 | +# load_signals() |
| 547 | +# =================================================================== |
| 548 | + |
| 549 | +class TestLoadSignals: |
| 550 | + """Signal loading from CSV.""" |
| 551 | + |
| 552 | + def test_missing_file_returns_empty(self, tmp_path, monkeypatch): |
| 553 | + """Missing signals.csv should return empty list.""" |
| 554 | + import strategies.signal_scorer as ss |
| 555 | + monkeypatch.setattr(ss, "SIGNAL_LOG", tmp_path / "nonexistent.csv") |
| 556 | + assert load_signals() == [] |
| 557 | + |
| 558 | + def test_valid_csv(self, tmp_path, monkeypatch): |
| 559 | + """A valid CSV should return list of dicts.""" |
| 560 | + import strategies.signal_scorer as ss |
| 561 | + csv_file = tmp_path / "signals.csv" |
| 562 | + csv_file.write_text("symbol,price,final_action\nAAPL,150.0,buy\n") |
| 563 | + monkeypatch.setattr(ss, "SIGNAL_LOG", csv_file) |
| 564 | + result = load_signals() |
| 565 | + assert len(result) == 1 |
| 566 | + assert result[0]["symbol"] == "AAPL" |
| 567 | + assert result[0]["price"] == "150.0" |
| 568 | + |
| 569 | + |
| 570 | +# =================================================================== |
| 571 | +# clear_cache() |
| 572 | +# =================================================================== |
| 573 | + |
| 574 | +class TestClearCache: |
| 575 | + """Cache management.""" |
| 576 | + |
| 577 | + def test_clear_cache_resets_state(self): |
| 578 | + """clear_cache should reset _cached_stats to None.""" |
| 579 | + import strategies.signal_scorer as ss |
| 580 | + old = ss._cached_stats |
| 581 | + ss._cached_stats = {"test": "data"} |
| 582 | + clear_cache() |
| 583 | + assert ss._cached_stats is None |
| 584 | + ss._cached_stats = old |
| 585 | + |
| 586 | + |
| 587 | +# =================================================================== |
| 588 | +# score_signal() — relaxed match |
| 589 | +# =================================================================== |
| 590 | + |
| 591 | +class TestScoreSignalRelaxedMatch: |
| 592 | + """score_signal falls back to a relaxed bucket (dropping insider dimension).""" |
| 593 | + |
| 594 | + def test_relaxed_match_drops_insider(self): |
| 595 | + """If exact bucket not found, falls back to bucket without insider dimension.""" |
| 596 | + # Only the non-insider bucket exists in stats |
| 597 | + relaxed = get_bucket_name(-8.0, 0.5, None) # "moderate_dip+positive_sent" |
| 598 | + stats = { |
| 599 | + relaxed: BucketStats( |
| 600 | + bucket=relaxed, |
| 601 | + sample_size=10, |
| 602 | + win_count=7, |
| 603 | + win_rate=0.7, |
| 604 | + avg_return=4.0, |
| 605 | + min_return=-1.0, |
| 606 | + max_return=9.0, |
| 607 | + symbols=["AAPL"], |
| 608 | + ), |
| 609 | + } |
| 610 | + # Query with has_insider=True -> exact bucket doesn't exist -> falls back |
| 611 | + score = score_signal(-8.0, 0.5, True, stats=stats) |
| 612 | + assert score.conviction == pytest.approx(0.7) |
| 613 | + assert score.sample_size == 10 |
| 614 | + |
| 615 | + def test_no_relaxed_match_returns_default(self): |
| 616 | + """If neither exact nor relaxed bucket exists, return default.""" |
| 617 | + stats = { |
| 618 | + "deep_dip+negative_sent": BucketStats( |
| 619 | + bucket="deep_dip+negative_sent", |
| 620 | + sample_size=10, |
| 621 | + win_count=3, |
| 622 | + win_rate=0.3, |
| 623 | + avg_return=-2.0, |
| 624 | + min_return=-10.0, |
| 625 | + max_return=5.0, |
| 626 | + symbols=["AAPL"], |
| 627 | + ), |
| 628 | + } |
| 629 | + # Query a bucket that doesn't exist in stats |
| 630 | + score = score_signal(-3.0, 0.5, None, stats=stats) |
| 631 | + assert score.conviction == DEFAULT_CONVICTION |
| 632 | + assert score.sample_size == 0 |
| 633 | + |
| 634 | + |
| 635 | +# =================================================================== |
| 636 | +# compute_outcomes() — edge cases |
| 637 | +# =================================================================== |
| 638 | + |
| 639 | +class TestComputeOutcomesEdgeCases: |
| 640 | + """Edge cases for compute_outcomes.""" |
| 641 | + |
| 642 | + def test_invalid_price_skipped(self): |
| 643 | + """Signals with non-numeric prices should be skipped.""" |
| 644 | + signals = [{ |
| 645 | + "symbol": "AAPL", |
| 646 | + "price": "INVALID", |
| 647 | + "final_action": "buy", |
| 648 | + "deviation_pct": "-5.0", |
| 649 | + "sentiment_score": "0.5", |
| 650 | + "sentiment_reasoning": "", |
| 651 | + }] |
| 652 | + outcomes = compute_outcomes(signals, {"AAPL": 100.0}) |
| 653 | + assert len(outcomes) == 0 |
| 654 | + |
| 655 | + def test_zero_entry_price_skipped(self): |
| 656 | + """Entry price of zero should be skipped (division by zero).""" |
| 657 | + signals = [{ |
| 658 | + "symbol": "AAPL", |
| 659 | + "price": "0", |
| 660 | + "final_action": "buy", |
| 661 | + "deviation_pct": "-5.0", |
| 662 | + "sentiment_score": "0.5", |
| 663 | + "sentiment_reasoning": "", |
| 664 | + }] |
| 665 | + outcomes = compute_outcomes(signals, {"AAPL": 100.0}) |
| 666 | + assert len(outcomes) == 0 |
| 667 | + |
| 668 | + def test_missing_current_price_skipped(self): |
| 669 | + """Signals for symbols without current price are skipped.""" |
| 670 | + signals = [_make_buy_signal("AAPL", 100.0)] |
| 671 | + outcomes = compute_outcomes(signals, {}) # no prices |
| 672 | + assert len(outcomes) == 0 |
| 673 | + |
| 674 | + def test_outcome_bucket_assignment(self): |
| 675 | + """Each outcome should have the correct bucket assigned.""" |
| 676 | + signals = [_make_buy_signal("AAPL", 100.0, deviation_pct=-12.0, sentiment_score="0.8")] |
| 677 | + outcomes = compute_outcomes(signals, {"AAPL": 110.0}) |
| 678 | + assert len(outcomes) == 1 |
| 679 | + assert outcomes[0]["bucket"] == "deep_dip+positive_sent" |
0 commit comments