Files
Chan/ChanMacro/expectancy/tracker.py
T

272 lines
9.8 KiB
Python

"""
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}