Skip to content

Commit bbd387e

Browse files
costajohntclaude
andcommitted
Add weekly_trend tests (25%→100%), expand signal_scorer tests, gitignore .serena
- New test_weekly_trend.py: 15 tests covering uptrend, structural crash, SMA decline, insufficient data, API errors, recovery scenarios - Expand test_signal_scorer.py: 32 new tests for _insider_bucket, _parse_sentiment, _infer_insider_from_signal, compute_bucket_stats, load_signals, clear_cache, relaxed match fallback, and edge cases (invalid price, zero price, missing price) - Add .serena/ to .gitignore - Total: 743 tests across 35 files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ed0f0a0 commit bbd387e

3 files changed

Lines changed: 527 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ __pycache__/
88
data/*.log
99
data/*.bak
1010
data/param_sweep_results.csv
11+
12+
# Tool/IDE config
13+
.serena/

tests/test_signal_scorer.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@
2424
BucketStats,
2525
SignalScore,
2626
_deviation_bucket,
27+
_infer_insider_from_signal,
28+
_insider_bucket,
29+
_parse_sentiment,
2730
_sentiment_bucket,
31+
clear_cache,
32+
compute_bucket_stats,
2833
compute_outcomes,
2934
get_bucket_name,
35+
load_signals,
3036
score_signal,
3137
)
3238

@@ -401,3 +407,273 @@ def test_multiple_buys_same_symbol_all_scored_when_open(self):
401407
returns = [o["return_pct"] for o in outcomes]
402408
assert pytest.approx(5.0) in returns # (105-100)/100 * 100
403409
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

Comments
 (0)