""" scoring/oi_matrix.py — OI × Price 2×2 state machine. Discrete states, NOT a continuous score: NEW_LONGS: Price↑ OI↑ → new money entering, trend continuation SHORT_COVERING: Price↑ OI↓ → shorts covering, rally fragile NEW_SHORTS: Price↓ OI↑ → new shorts entering, trend continuation LONG_EXIT: Price↓ OI↓ → longs stopping out, panic (possible bottom) NEUTRAL: flat → noise, don't force classification """ from datetime import date as Date import sqlite3 from .base import BaseScorer from .constants import OI_PRICE_THRESHOLD, OI_OI_THRESHOLD, OI_STATE_SCORES from models import FactorScore, OIMatrixScore, OIState, MacroDirection from config import config class OIMatrixScorer(BaseScorer): """Classifies OI × Price state and assigns score.""" def compute(self, target_date: Date) -> OIMatrixScore: conn = self.get_connection() try: row = conn.execute( "SELECT * FROM derivatives WHERE date = ? AND symbol = 'BTC/USDT:USDT'", (str(target_date),) ).fetchone() if row is None: return OIMatrixScore( name="OI Matrix", score=50.0, label="No Data", oi_state=OIState.NEUTRAL, ) row = dict(row) oi_change = row.get("oi_24h_change_pct") or 0 # Get price change from OHLCV price_change = self._get_price_change(conn, str(target_date)) # Classify state oi_state = self._classify(price_change, oi_change) # Score from state score = OI_STATE_SCORES.get(oi_state.value, 50) # Direction if oi_state == OIState.NEW_LONGS: direction = MacroDirection.BULLISH elif oi_state == OIState.SHORT_COVERING: direction = MacroDirection.BULLISH # bullish but fragile elif oi_state == OIState.NEW_SHORTS: direction = MacroDirection.BEARISH elif oi_state == OIState.LONG_EXIT: direction = MacroDirection.BEARISH # bearish but possible bottom else: direction = MacroDirection.NEUTRAL # Narrative narrative = self._build_narrative(oi_state, price_change, oi_change) return OIMatrixScore( name="OI Matrix", score=float(score), label=oi_state.value, direction=direction, oi_state=oi_state, price_change_pct=round(price_change, 2), oi_change_pct=round(oi_change, 2), sub_scores={ "price_change_pct": round(price_change, 2), "oi_change_pct": round(oi_change, 2), }, narrative=narrative, ) finally: conn.close() def _get_price_change(self, conn: sqlite3.Connection, date_str: str) -> float: """Get BTC 24h price change % for a given date.""" row = conn.execute( "SELECT close FROM ohlcv_daily WHERE date = ? AND symbol = 'BTC/USDT:USDT'", (date_str,) ).fetchone() if row is None: return 0.0 # Get previous day close prev = conn.execute( "SELECT close FROM ohlcv_daily WHERE date < ? AND symbol = 'BTC/USDT:USDT' ORDER BY date DESC LIMIT 1", (date_str,) ).fetchone() if prev is None: return 0.0 current_close = float(row["close"]) prev_close = float(prev["close"]) if prev_close == 0: return 0.0 return (current_close - prev_close) / prev_close * 100 @staticmethod def _classify(price_change_pct: float, oi_change_pct: float) -> OIState: """Classify OI × Price into discrete state.""" price_up = price_change_pct > OI_PRICE_THRESHOLD price_down = price_change_pct < -OI_PRICE_THRESHOLD oi_up = oi_change_pct > OI_OI_THRESHOLD oi_down = oi_change_pct < -OI_OI_THRESHOLD if price_up and oi_up: return OIState.NEW_LONGS elif price_up and oi_down: return OIState.SHORT_COVERING elif price_down and oi_up: return OIState.NEW_SHORTS elif price_down and oi_down: return OIState.LONG_EXIT else: return OIState.NEUTRAL @staticmethod def _build_narrative(state: OIState, price_chg: float, oi_chg: float) -> str: mapping = { OIState.NEW_LONGS: f"新多进场: 价格+{price_chg:.1f}%, OI+{oi_chg:.1f}%, 真金白银推动", OIState.SHORT_COVERING: f"空头回补: 价格+{price_chg:.1f}%, OI{oi_chg:.1f}%, 上涨脆弱", OIState.NEW_SHORTS: f"新空进场: 价格{price_chg:.1f}%, OI+{oi_chg:.1f}%, 趋势延续", OIState.LONG_EXIT: f"多头止损: 价格{price_chg:.1f}%, OI{oi_chg:.1f}%, 恐慌(可能见底)", OIState.NEUTRAL: "OI/价格变化不显著, 噪音区", } return mapping.get(state, "Unknown")