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>
272 lines
9.8 KiB
Python
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}
|