Phase A-C complete: 4 core factors, regime detection, signal tracking, Bayesian expectancy. chanmacro/ (32 files, ~4000 lines): - models: 12 enums + 15 Pydantic v2 models (DateAwareModel, MarketStateVector, etc.) - fetchers: OHLCV + Breadth (from data_provider) + Derivatives (new endpoint) - scoring: Price Structure / Breadth (quantile buckets) / OI Matrix (5 discrete states) / Volatility Regime - regime_detector: 3-state (TREND/RANGE/PANIC), factor-locked (Price+Breadth+Vol), versioned, 2-day confirmation - expectancy: SignalTracker (record+outcomes), TimeDecay (half-life=180d), BayesianExpectancyEngine (Empirical Bayes, Leveled, SufficiencyGuard) - validation: FactorValidator (IC/ICIR/Hit Ratio), RegimeValidator (MI/KL/ANOVA), TransitionValidator (stability) - CLI: fetch|score|regime|track|backfill|expectancy|validate|serve - tests: 52 passing (models, scoring, regime, expectancy) data_provider: - /api/derivatives endpoint: funding rate, OI, OI change, basis - _derivatives storage: same persist pattern as K-line (merge→lock→snapshot→atomic write) - background refresh every 60s Co-Authored-By: Claude <noreply@anthropic.com>
122 lines
4.9 KiB
Python
122 lines
4.9 KiB
Python
"""Test all 4 core scorers."""
|
|
import pytest
|
|
from datetime import date
|
|
|
|
|
|
class TestPriceStructureScorer:
|
|
def test_computes_score(self, db_path):
|
|
from scoring.price_structure import PriceStructureScorer
|
|
scorer = PriceStructureScorer()
|
|
result = scorer.compute(date(2026, 3, 15))
|
|
assert result.name == "Price Structure"
|
|
assert 0 <= result.score <= 100
|
|
assert result.trend_strength >= 0
|
|
assert result.volatility_compression >= 0
|
|
assert result.momentum >= 0
|
|
assert result.label
|
|
|
|
def test_bullish_in_trend(self, db_path):
|
|
from scoring.price_structure import PriceStructureScorer
|
|
scorer = PriceStructureScorer()
|
|
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
|
assert result.score > 50 # Should be bullish in uptrend
|
|
|
|
def test_bearish_in_panic(self, db_path):
|
|
from scoring.price_structure import PriceStructureScorer
|
|
scorer = PriceStructureScorer()
|
|
result = scorer.compute(date(2026, 5, 15)) # PANIC period
|
|
# In panic period, EMA alignment should be bearish
|
|
assert result.trend_strength < 60
|
|
|
|
def test_no_data_handling(self, db_path):
|
|
from scoring.price_structure import PriceStructureScorer
|
|
scorer = PriceStructureScorer()
|
|
result = scorer.compute(date(2020, 1, 1))
|
|
assert result.score == 50.0
|
|
assert result.label == "No Data"
|
|
|
|
|
|
class TestBreadthScorer:
|
|
def test_computes_score(self, db_path):
|
|
from scoring.breadth_scorer import BreadthScorer
|
|
scorer = BreadthScorer()
|
|
result = scorer.compute(date(2026, 3, 15))
|
|
assert result.name == "Breadth"
|
|
assert 0 <= result.score <= 100
|
|
assert result.breadth_bucket
|
|
assert result.breadth_top20 >= 0
|
|
assert result.breadth_top50 >= 0
|
|
|
|
def test_tier_values(self, db_path):
|
|
from scoring.breadth_scorer import BreadthScorer
|
|
scorer = BreadthScorer()
|
|
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
|
# Top20 should generally be higher than Top50 (large caps lead)
|
|
assert result.breadth_top20 >= 0
|
|
assert result.breadth_top50 >= 0
|
|
|
|
def test_bucket_assignment(self, db_path):
|
|
from scoring.breadth_scorer import BreadthScorer, BreadthBucket
|
|
scorer = BreadthScorer()
|
|
result = scorer.compute(date(2025, 11, 15)) # TREND: adv=42/50
|
|
assert result.breadth_bucket in (
|
|
BreadthBucket.EXTREME, BreadthBucket.STRONG, BreadthBucket.NORMAL
|
|
)
|
|
|
|
def test_no_data(self, db_path):
|
|
from scoring.breadth_scorer import BreadthScorer
|
|
scorer = BreadthScorer()
|
|
result = scorer.compute(date(2020, 1, 1))
|
|
assert result.score == 50.0
|
|
|
|
|
|
class TestOIMatrixScorer:
|
|
def test_computes_state(self, db_path):
|
|
from scoring.oi_matrix import OIMatrixScorer, OIState
|
|
scorer = OIMatrixScorer()
|
|
result = scorer.compute(date(2025, 11, 15)) # TREND period, oi_chg=+3.5
|
|
assert result.oi_state in OIState
|
|
assert 0 <= result.score <= 100
|
|
|
|
def test_new_longs_in_trend(self, db_path):
|
|
from scoring.oi_matrix import OIMatrixScorer, OIState
|
|
scorer = OIMatrixScorer()
|
|
# Test multiple dates in TREND period — at least one should be NEW_LONGS or NEUTRAL
|
|
found_bullish = False
|
|
for d in ["2025-11-15", "2025-11-20", "2025-12-01", "2025-12-15"]:
|
|
result = scorer.compute(date.fromisoformat(d))
|
|
if result.oi_state in (OIState.NEW_LONGS, OIState.SHORT_COVERING, OIState.NEUTRAL):
|
|
found_bullish = True
|
|
break
|
|
assert found_bullish, "No bullish OI state found in TREND period"
|
|
|
|
def test_no_data(self, db_path):
|
|
from scoring.oi_matrix import OIMatrixScorer
|
|
scorer = OIMatrixScorer()
|
|
result = scorer.compute(date(2020, 1, 1))
|
|
assert result.score == 50.0
|
|
assert result.label == "No Data"
|
|
|
|
|
|
class TestVolatilityRegimeScorer:
|
|
def test_computes_regime(self, db_path):
|
|
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
|
scorer = VolatilityRegimeScorer()
|
|
result = scorer.compute(date(2026, 3, 15))
|
|
assert result.vol_regime in VolRegime
|
|
assert 0 <= result.score <= 100
|
|
|
|
def test_higher_vol_in_panic(self, db_path):
|
|
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
|
scorer = VolatilityRegimeScorer()
|
|
trend_result = scorer.compute(date(2025, 11, 15))
|
|
panic_result = scorer.compute(date(2026, 5, 15))
|
|
# PANIC period has higher ATR → higher vol regime or score
|
|
assert panic_result.atr_pct >= trend_result.atr_pct * 0.5 # at least comparable
|
|
|
|
def test_no_data(self, db_path):
|
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
|
scorer = VolatilityRegimeScorer()
|
|
result = scorer.compute(date(2020, 1, 1))
|
|
assert result.score == 50.0
|