7626328c9c
- Fernet encrypt qd_exchange_credentials via SECRET_KEY (cryptography) - Remove global read-only demo middleware; drop is_demo from auth payloads - Egress whitelist: /api/credentials/egress-ip returns ipv4 + ipv6 (ipify) - Exchange factory: demo/testnet URLs and OKX simulated-trading header - Bitget spot connection test; misc route/service fixes Made-with: Cursor
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""
|
|
AI Calibration Service (offline).
|
|
|
|
Goal:
|
|
- Calibrate the objective-score -> decision thresholds using validated historical analysis outcomes.
|
|
- Make FastAnalysisService "self-tuning" based on performance.
|
|
|
|
Approach (approx rules as requested):
|
|
- Use qd_analysis_memory.actual_return_pct and apply simple correctness rules:
|
|
BUY correct if return_pct > +2
|
|
SELL correct if return_pct < -2
|
|
HOLD correct if abs(return_pct) <= 5
|
|
- Search candidate absolute thresholds for score mapping:
|
|
score >= +thr => BUY
|
|
score <= -thr => SELL
|
|
else => HOLD
|
|
|
|
We calibrate on consensus_score because it's the main "objective" signal used for overriding decisions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
|
|
from app.utils.db import get_db_connection
|
|
from app.utils.logger import get_logger
|
|
from app.services.analysis_memory import get_analysis_memory, AnalysisMemory
|
|
from app.services.market_data_collector import MarketDataCollector
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
DEFAULTS = {
|
|
"buy_threshold": 20.0,
|
|
"sell_threshold": -20.0,
|
|
"min_consensus_abs_override": 15.0,
|
|
"quality_hold_threshold": 0.7,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class CalibrationResult:
|
|
market: str
|
|
buy_threshold: float
|
|
sell_threshold: float
|
|
best_accuracy: float
|
|
coverage: Dict[str, int]
|
|
sample_count: int
|
|
validated_count: int
|
|
updated_at_ts: float
|
|
|
|
|
|
class AICalibrationService:
|
|
def __init__(self):
|
|
self._ensure_table()
|
|
|
|
def _ensure_table(self) -> None:
|
|
try:
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS qd_ai_calibration (
|
|
id SERIAL PRIMARY KEY,
|
|
market VARCHAR(50) NOT NULL,
|
|
buy_threshold DECIMAL(10,4) NOT NULL,
|
|
sell_threshold DECIMAL(10,4) NOT NULL,
|
|
min_consensus_abs_override DECIMAL(10,4) NOT NULL,
|
|
quality_hold_threshold DECIMAL(10,4) NOT NULL,
|
|
validated_at TIMESTAMP DEFAULT NOW(),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
"""
|
|
)
|
|
# Index for latest lookup
|
|
cur.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_ai_calibration_market_validated_at
|
|
ON qd_ai_calibration(market, validated_at DESC);
|
|
"""
|
|
)
|
|
db.commit()
|
|
cur.close()
|
|
except Exception as e:
|
|
logger.error(f"Failed to ensure qd_ai_calibration table: {e}", exc_info=True)
|
|
|
|
def get_latest(self, market: str) -> Dict[str, Any]:
|
|
"""
|
|
Get latest calibration config for market.
|
|
Falls back to DEFAULTS if not found.
|
|
"""
|
|
market = (market or "").strip()
|
|
if not market:
|
|
return dict(DEFAULTS)
|
|
|
|
try:
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT buy_threshold, sell_threshold,
|
|
min_consensus_abs_override, quality_hold_threshold
|
|
FROM qd_ai_calibration
|
|
WHERE market = %s
|
|
ORDER BY validated_at DESC
|
|
LIMIT 1
|
|
""",
|
|
(market,),
|
|
)
|
|
row = cur.fetchone() or {}
|
|
cur.close()
|
|
if not row:
|
|
return dict(DEFAULTS)
|
|
out = dict(DEFAULTS)
|
|
out["buy_threshold"] = float(row.get("buy_threshold") or DEFAULTS["buy_threshold"])
|
|
out["sell_threshold"] = float(row.get("sell_threshold") or DEFAULTS["sell_threshold"])
|
|
out["min_consensus_abs_override"] = float(
|
|
row.get("min_consensus_abs_override") or DEFAULTS["min_consensus_abs_override"]
|
|
)
|
|
out["quality_hold_threshold"] = float(
|
|
row.get("quality_hold_threshold") or DEFAULTS["quality_hold_threshold"]
|
|
)
|
|
return out
|
|
except Exception as e:
|
|
logger.warning(f"get_latest calibration failed: {e}", exc_info=True)
|
|
return dict(DEFAULTS)
|
|
|
|
def _candidate_abs_thresholds(self) -> List[float]:
|
|
env = os.getenv("AI_CALIBRATION_CANDIDATE_ABS_THRESHOLDS", "").strip()
|
|
if env:
|
|
parts = [p.strip() for p in env.split(",") if p.strip()]
|
|
out = []
|
|
for p in parts:
|
|
try:
|
|
out.append(float(p))
|
|
except Exception:
|
|
continue
|
|
if out:
|
|
return sorted(set(out))
|
|
# Default grid
|
|
return [10, 12, 14, 16, 18, 20, 22, 25, 30]
|
|
|
|
def _correctness_for_return(self, decision: str, return_pct: float) -> bool:
|
|
# Approx correctness rules (as requested)
|
|
if decision == "BUY":
|
|
return return_pct > 2.0
|
|
if decision == "SELL":
|
|
return return_pct < -2.0
|
|
# HOLD
|
|
return abs(return_pct) <= 5.0
|
|
|
|
def _predict_decision_from_score(self, score: float, abs_thr: float) -> str:
|
|
if score >= abs_thr:
|
|
return "BUY"
|
|
if score <= -abs_thr:
|
|
return "SELL"
|
|
return "HOLD"
|
|
|
|
def calibrate_market(
|
|
self,
|
|
market: str = "Crypto",
|
|
*,
|
|
lookback_days: int = 30,
|
|
min_samples: int = 80,
|
|
validate_before: bool = True,
|
|
) -> Optional[CalibrationResult]:
|
|
market = (market or "").strip()
|
|
if not market:
|
|
return None
|
|
|
|
abs_thresholds = self._candidate_abs_thresholds()
|
|
|
|
validated_count = 0
|
|
try:
|
|
# Best-effort: validate old unvalidated records first.
|
|
if validate_before:
|
|
memory: AnalysisMemory = get_analysis_memory()
|
|
# Validate anything older than ~7 days (matching your existing approx rules).
|
|
validated_stats = memory.validate_unvalidated_older_than(
|
|
min_age_days=7, limit=300
|
|
)
|
|
validated_count = int(validated_stats.get("validated", 0) or 0)
|
|
except Exception as e:
|
|
logger.warning(f"pre-validation failed (skipped): {e}", exc_info=True)
|
|
|
|
# Fetch validated rows with consensus_score and actual_return_pct
|
|
rows: List[Dict[str, Any]] = []
|
|
try:
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
# Use f-string for interval since Postgres doesn't allow placeholder in INTERVAL literal
|
|
cur.execute(
|
|
f"""
|
|
SELECT
|
|
decision,
|
|
consensus_score,
|
|
consensus_abs,
|
|
quality_multiplier,
|
|
agreement_ratio,
|
|
actual_return_pct
|
|
FROM qd_analysis_memory
|
|
WHERE market = %s
|
|
AND validated_at IS NOT NULL
|
|
AND actual_return_pct IS NOT NULL
|
|
AND consensus_score IS NOT NULL
|
|
AND created_at > NOW() - INTERVAL '{int(lookback_days)} days'
|
|
""",
|
|
(market,),
|
|
)
|
|
rows = cur.fetchall() or []
|
|
cur.close()
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch memory rows for calibration: {e}", exc_info=True)
|
|
return None
|
|
|
|
sample_count = len(rows)
|
|
if sample_count < min_samples:
|
|
logger.warning(
|
|
f"[AI Calibration] Not enough samples for {market}: {sample_count} < min_samples={min_samples}"
|
|
)
|
|
return None
|
|
|
|
best_abs_thr = abs_thresholds[0]
|
|
best_accuracy = -1.0
|
|
best_coverage: Dict[str, int] = {"BUY": 0, "SELL": 0, "HOLD": 0}
|
|
|
|
# Evaluate each threshold
|
|
for thr in abs_thresholds:
|
|
correct = 0
|
|
total = 0
|
|
coverage = {"BUY": 0, "SELL": 0, "HOLD": 0}
|
|
|
|
for r in rows:
|
|
try:
|
|
score = float(r.get("consensus_score") or 0.0)
|
|
return_pct = float(r.get("actual_return_pct") or 0.0)
|
|
except Exception:
|
|
continue
|
|
|
|
pred = self._predict_decision_from_score(score, thr)
|
|
coverage[pred] += 1
|
|
total += 1
|
|
if self._correctness_for_return(pred, return_pct):
|
|
correct += 1
|
|
|
|
if total <= 0:
|
|
continue
|
|
acc = correct / total * 100.0
|
|
|
|
# Tie-break: prefer higher BUY+SELL coverage (avoid always HOLD)
|
|
# Secondary tie-break: higher accuracy
|
|
buy_sell_cov = coverage["BUY"] + coverage["SELL"]
|
|
best_buy_sell_cov = best_coverage["BUY"] + best_coverage["SELL"]
|
|
|
|
if acc > best_accuracy:
|
|
best_accuracy = acc
|
|
best_abs_thr = thr
|
|
best_coverage = coverage
|
|
elif acc == best_accuracy:
|
|
if buy_sell_cov > best_buy_sell_cov:
|
|
best_abs_thr = thr
|
|
best_coverage = coverage
|
|
|
|
# Write new calibration row
|
|
buy_threshold = float(best_abs_thr)
|
|
sell_threshold = float(-best_abs_thr)
|
|
cfg = self.get_latest(market)
|
|
min_consensus_abs_override = float(cfg.get("min_consensus_abs_override") or DEFAULTS["min_consensus_abs_override"])
|
|
quality_hold_threshold = float(cfg.get("quality_hold_threshold") or DEFAULTS["quality_hold_threshold"])
|
|
|
|
try:
|
|
with get_db_connection() as db:
|
|
cur = db.cursor()
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO qd_ai_calibration
|
|
(market, buy_threshold, sell_threshold,
|
|
min_consensus_abs_override, quality_hold_threshold,
|
|
validated_at, created_at)
|
|
VALUES
|
|
(%s, %s, %s, %s, %s, NOW(), NOW())
|
|
""",
|
|
(
|
|
market,
|
|
buy_threshold,
|
|
sell_threshold,
|
|
min_consensus_abs_override,
|
|
quality_hold_threshold,
|
|
),
|
|
)
|
|
db.commit()
|
|
cur.close()
|
|
except Exception as e:
|
|
logger.error(f"[AI Calibration] Failed to persist calibration: {e}", exc_info=True)
|
|
return None
|
|
|
|
return CalibrationResult(
|
|
market=market,
|
|
buy_threshold=buy_threshold,
|
|
sell_threshold=sell_threshold,
|
|
best_accuracy=float(best_accuracy),
|
|
coverage=best_coverage,
|
|
sample_count=sample_count,
|
|
validated_count=int(validated_count),
|
|
updated_at_ts=time.time(),
|
|
)
|
|
|
|
|
|
def start_ai_calibration_worker() -> None:
|
|
"""
|
|
Run offline calibration once on service startup (best-effort).
|
|
"""
|
|
enabled = os.getenv("ENABLE_OFFLINE_AI_CALIBRATION", "true").lower() == "true"
|
|
if not enabled:
|
|
logger.info("AI calibration worker disabled (ENABLE_OFFLINE_AI_CALIBRATION=false).")
|
|
return
|
|
|
|
try:
|
|
svc = AICalibrationService()
|
|
lookback_days = int(os.getenv("AI_CALIBRATION_LOOKBACK_DAYS", "30"))
|
|
min_samples = int(os.getenv("AI_CALIBRATION_MIN_SAMPLES", "80"))
|
|
market = os.getenv("AI_CALIBRATION_MARKET", "Crypto").strip() or "Crypto"
|
|
logger.info(
|
|
f"Starting offline AI calibration: market={market}, lookback_days={lookback_days}, min_samples={min_samples}"
|
|
)
|
|
result = svc.calibrate_market(market=market, lookback_days=lookback_days, min_samples=min_samples)
|
|
if result:
|
|
logger.info(
|
|
f"[AI Calibration] market={result.market} best_thr=+{result.buy_threshold:.1f} "
|
|
f"accuracy={result.best_accuracy:.2f}% sample={result.sample_count} "
|
|
f"coverage={result.coverage} validated_new={result.validated_count}"
|
|
)
|
|
else:
|
|
logger.info("[AI Calibration] No calibration update applied (not enough data).")
|
|
except Exception as e:
|
|
logger.error(f"start_ai_calibration_worker failed: {e}", exc_info=True)
|
|
|