138 lines
5.1 KiB
Python
138 lines
5.1 KiB
Python
"""
|
||
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")
|