""" expectancy/tracker.py — SignalTracker: records signals with full market state and computes forward outcomes. This is the entry point for populating signal_features — THE moat table. """ from datetime import date as Date, timedelta from typing import Optional import sqlite3 import json import logging import pandas as pd import numpy as np from models import ( MarketStateVector, SignalFeatureRecord, MarketRegime, OIState, BreadthBucket, VolRegime, SignalGrade, ) from config import config logger = logging.getLogger(__name__) class SignalTracker: """ Records trading signals with full market state context. Usage: tracker = SignalTracker() tracker.record( date=Date(2026, 6, 24), signal_type="B3", entry_price=96500.0, state=market_state_vector, # from scoring pipeline signal_grade="A", ) """ def __init__(self, db_path: Optional[str] = None): self.db_path = db_path or config.db_path def record(self, date: Date, signal_type: str, entry_price: float, state: MarketStateVector, signal_version: str = "b3_v1", signal_grade: Optional[str] = None, signal_strength: Optional[float] = None) -> int: """ Record a signal with market state snapshot and compute forward outcomes. Returns the record ID in signal_features. """ conn = sqlite3.connect(self.db_path) # Compute forward outcomes outcomes = self._compute_outcomes(conn, date, entry_price) # Build embedding embedding = json.dumps(state.state_embedding()) record_id = conn.execute(""" INSERT INTO signal_features (date, signal_type, signal_version, symbol, regime_version, signal_grade, signal_strength, regime, regime_confidence, regime_maturity_score, market_state_hash, state_embedding, breadth_top20, breadth_top30, breadth_top50, breadth_bucket, breadth_divergence, oi_state, volatility_regime, price_structure_score, entry_price, result_1d, result_3d, result_5d, result_7d, result_14d, max_favorable_excursion, max_adverse_excursion, is_win_7d) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(date), signal_type, signal_version, state.symbol, state.regime_version, signal_grade, signal_strength, state.regime.value, state.regime_confidence, state.regime_maturity_score, state.market_state_hash, embedding, state.breadth_top20, state.breadth_top30, state.breadth_top50, state.breadth_bucket.value, state.breadth_divergence, state.oi_state.value, state.volatility_regime.value, state.price_structure_score.score, entry_price, outcomes.get("result_1d"), outcomes.get("result_3d"), outcomes.get("result_5d"), outcomes.get("result_7d"), outcomes.get("result_14d"), outcomes.get("mfe"), outcomes.get("mae"), outcomes.get("is_win_7d"), )).lastrowid conn.commit() conn.close() is_win = outcomes.get("is_win_7d", 0) ret_7d = outcomes.get("result_7d", 0) or 0 logger.info( f"Recorded {signal_type} on {date} @ {entry_price:.0f} " f"(regime={state.regime.value}, breadth={state.breadth_bucket.value}, " f"oi={state.oi_state.value}) → 7d={ret_7d:+.1f}%" ) return record_id def _compute_outcomes(self, conn: sqlite3.Connection, date: Date, entry_price: float) -> dict: """ Compute forward returns, MFE, MAE from OHLCV data. Queries future daily bars relative to the signal date. """ # Get future OHLCV data df = pd.read_sql_query( "SELECT date, high, low, close FROM ohlcv_daily " "WHERE date > ? AND symbol = 'BTC/USDT:USDT' " "ORDER BY date ASC LIMIT 20", conn, params=(str(date),) ) if df.empty: return {} outcomes = {} entry = entry_price # Forward returns for horizon_days, col in [(1, "result_1d"), (3, "result_3d"), (5, "result_5d"), (7, "result_7d"), (14, "result_14d")]: if len(df) >= horizon_days: exit_price = float(df.iloc[horizon_days - 1]["close"]) outcomes[col] = round((exit_price - entry) / entry * 100, 2) # MFE / MAE if len(df) > 0: highs = df["high"].astype(float).values[:14] lows = df["low"].astype(float).values[:14] outcomes["mfe"] = round((max(highs) - entry) / entry * 100, 2) outcomes["mae"] = round((min(lows) - entry) / entry * 100, 2) # is_win_7d outcomes["is_win_7d"] = 1 if outcomes.get("result_7d", 0) > 0 else 0 return outcomes def backfill_signals(self, signals: list[dict]) -> int: """ Backfill multiple signals from historical data. Each signal dict: {"date": Date, "signal_type": str, "entry_price": float, "signal_grade": str (optional), "signal_strength": float (optional)} This requires the scoring pipeline to have been run for those dates (breadth_daily, ohlcv_daily, derivatives all populated). """ from scoring.price_structure import PriceStructureScorer from scoring.breadth_scorer import BreadthScorer from scoring.oi_matrix import OIMatrixScorer from scoring.volatility_regime import VolatilityRegimeScorer from regime_detector import RegimeDetector detector = RegimeDetector() count = 0 for sig in signals: target = sig["date"] try: # Compute market state for this date ps = PriceStructureScorer(self.db_path).compute(target) br = BreadthScorer(self.db_path).compute(target) oi = OIMatrixScorer(self.db_path).compute(target) vol = VolatilityRegimeScorer(self.db_path).compute(target) regime_result = detector.detect( price_structure_score=ps.score, breadth_score=br.breadth_top50, volatility_regime=vol.vol_regime.value, date=target, ) state = MarketStateVector( date=target, regime=regime_result.regime, regime_confidence=regime_result.confidence, regime_version=regime_result.regime_version, regime_maturity_score=regime_result.maturity_score, breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30, breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket, breadth_divergence=br.breadth_divergence, oi_state=oi.oi_state, volatility_regime=vol.vol_regime, price_structure_score=ps, breadth_score=br, oi_matrix_score=oi, volatility_regime_score=vol, ) state.market_state_hash = state.compute_hash() self.record( date=target, signal_type=sig["signal_type"], entry_price=sig["entry_price"], state=state, signal_grade=sig.get("signal_grade"), signal_strength=sig.get("signal_strength"), ) count += 1 except Exception as e: logger.warning(f"Failed to backfill {sig['signal_type']} on {target}: {e}") return count def get_samples(self, signal_type: Optional[str] = None, regime: Optional[str] = None, breadth_bucket: Optional[str] = None, oi_state: Optional[str] = None, volatility_regime: Optional[str] = None, limit: int = 5000) -> list[dict]: """Query signal_features with optional filters.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row query = "SELECT * FROM signal_features WHERE 1=1" params = [] if signal_type: query += " AND signal_type = ?" params.append(signal_type) if regime: query += " AND regime = ?" params.append(regime) if breadth_bucket: query += " AND breadth_bucket = ?" params.append(breadth_bucket) if oi_state: query += " AND oi_state = ?" params.append(oi_state) if volatility_regime: query += " AND volatility_regime = ?" params.append(volatility_regime) query += " ORDER BY date DESC LIMIT ?" params.append(limit) rows = conn.execute(query, params).fetchall() conn.close() return [dict(r) for r in rows] def count_samples(self) -> dict: """Count signal_features by signal_type and regime.""" conn = sqlite3.connect(self.db_path) rows = conn.execute(""" SELECT signal_type, regime, COUNT(*) as cnt FROM signal_features GROUP BY signal_type, regime ORDER BY signal_type, regime """).fetchall() conn.close() return {f"{r[0]}/{r[1]}": r[2] for r in rows}