feat: AI 即时分析计费/共识/校准与 Docker 前端构建
- 即时分析:先扣费、防重入(429)、失败退款;记忆库与离线校准 worker - 多周期共识、客观分与设置项 AI_ANALYSIS_CONSENSUS_TIMEFRAMES - Docker:前端多阶段构建(QuantDinger-Vue-src)、根目录 .dockerignore、compose 调整 - 同步 frontend/dist 静态资源 Made-with: Cursor
This commit is contained in:
@@ -273,6 +273,12 @@ def create_app(config_name='default'):
|
||||
start_portfolio_monitor()
|
||||
start_usdt_order_worker()
|
||||
start_polymarket_worker()
|
||||
# Offline calibration to make AI thresholds self-tuning.
|
||||
try:
|
||||
from app.services.ai_calibration import start_ai_calibration_worker
|
||||
start_ai_calibration_worker()
|
||||
except Exception:
|
||||
pass
|
||||
restore_running_strategies()
|
||||
|
||||
return app
|
||||
|
||||
@@ -4,16 +4,45 @@ Fast Analysis API Routes
|
||||
New high-performance analysis endpoints that replace the slow multi-agent system.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.utils.auth import login_required
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.fast_analysis import get_fast_analysis_service
|
||||
from app.services.analysis_memory import get_analysis_memory
|
||||
from app.services.billing_service import get_billing_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
fast_analysis_bp = Blueprint('fast_analysis', __name__)
|
||||
|
||||
# In-memory in-flight guard to avoid duplicate analysis charges caused by rapid repeated clicks.
|
||||
_analysis_inflight_lock = threading.Lock()
|
||||
_analysis_inflight = {} # key -> expire_ts
|
||||
|
||||
|
||||
def _build_inflight_key(user_id: int, market: str, symbol: str, timeframe: str) -> str:
|
||||
return f"{int(user_id)}|{str(market or '').strip().upper()}|{str(symbol or '').strip().upper()}|{str(timeframe or '').strip().upper()}"
|
||||
|
||||
|
||||
def _acquire_inflight(key: str, ttl_sec: int = 90) -> bool:
|
||||
now = time.time()
|
||||
with _analysis_inflight_lock:
|
||||
# Cleanup stale entries
|
||||
stale = [k for k, exp in _analysis_inflight.items() if float(exp) <= now]
|
||||
for k in stale[:1024]:
|
||||
_analysis_inflight.pop(k, None)
|
||||
if key in _analysis_inflight and float(_analysis_inflight.get(key) or 0) > now:
|
||||
return False
|
||||
_analysis_inflight[key] = now + int(ttl_sec)
|
||||
return True
|
||||
|
||||
|
||||
def _release_inflight(key: str):
|
||||
with _analysis_inflight_lock:
|
||||
_analysis_inflight.pop(key, None)
|
||||
|
||||
|
||||
@fast_analysis_bp.route('/analyze', methods=['POST'])
|
||||
@login_required
|
||||
@@ -51,6 +80,58 @@ def analyze():
|
||||
|
||||
# Get current user's ID to associate analysis with user
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401
|
||||
|
||||
inflight_key = _build_inflight_key(user_id, market, symbol, timeframe)
|
||||
if not _acquire_inflight(inflight_key, ttl_sec=90):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.',
|
||||
'data': {'in_progress': True}
|
||||
}), 429
|
||||
|
||||
# Billing / credits (best-effort, consistent with polymarket deep analysis)
|
||||
credits_charged = 0
|
||||
remaining_credits = None
|
||||
billing_consumed = False
|
||||
billing = None
|
||||
try:
|
||||
billing = get_billing_service()
|
||||
if billing.is_billing_enabled():
|
||||
credits_charged = int(billing.get_feature_cost('ai_analysis') or 0)
|
||||
if credits_charged > 0:
|
||||
ok, msg = billing.check_and_consume(
|
||||
user_id=int(user_id),
|
||||
feature='ai_analysis',
|
||||
reference_id=f"fast_analysis_{market}:{symbol}:{timeframe}"
|
||||
)
|
||||
if not ok:
|
||||
# Standardize insufficient credits message
|
||||
if str(msg or "").startswith('insufficient_credits'):
|
||||
# Format: insufficient_credits:<current>:<cost>
|
||||
parts = str(msg).split(':')
|
||||
cur = float(parts[1]) if len(parts) >= 2 else 0.0
|
||||
req = float(parts[2]) if len(parts) >= 3 else float(credits_charged)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Insufficient credits',
|
||||
'data': {
|
||||
'required': req,
|
||||
'current': cur,
|
||||
'shortage': max(0.0, req - cur),
|
||||
}
|
||||
}), 400
|
||||
return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500
|
||||
billing_consumed = True
|
||||
# Query remaining credits after successful consumption
|
||||
try:
|
||||
remaining_credits = float(billing.get_user_credits(int(user_id)))
|
||||
except Exception:
|
||||
remaining_credits = None
|
||||
except Exception as e:
|
||||
# Billing failure should not crash analysis by default, but should be visible in logs.
|
||||
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
|
||||
|
||||
service = get_fast_analysis_service()
|
||||
result = service.analyze(
|
||||
@@ -63,6 +144,18 @@ def analyze():
|
||||
)
|
||||
|
||||
if result.get('error'):
|
||||
# Best-effort refund if we already charged but analysis failed.
|
||||
if billing_consumed and billing and credits_charged > 0:
|
||||
try:
|
||||
billing.add_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(credits_charged),
|
||||
action='refund',
|
||||
remark=f'Auto refund: fast-analysis failed ({market}:{symbol}:{timeframe})'
|
||||
)
|
||||
remaining_credits = float(billing.get_user_credits(int(user_id)))
|
||||
except Exception as re:
|
||||
logger.error(f"Auto refund failed: {re}", exc_info=True)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': result['error'],
|
||||
@@ -75,16 +168,37 @@ def analyze():
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
'data': {
|
||||
**(result or {}),
|
||||
'credits_charged': credits_charged,
|
||||
'remaining_credits': remaining_credits,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Best-effort refund on unexpected error after charge.
|
||||
try:
|
||||
if 'billing_consumed' in locals() and billing_consumed and 'billing' in locals() and billing and credits_charged > 0 and 'user_id' in locals() and user_id:
|
||||
billing.add_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(credits_charged),
|
||||
action='refund',
|
||||
remark=f'Auto refund: fast-analysis exception ({market}:{symbol}:{timeframe})'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"Fast analysis API failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': str(e),
|
||||
'data': None
|
||||
}), 500
|
||||
finally:
|
||||
try:
|
||||
if 'inflight_key' in locals() and inflight_key:
|
||||
_release_inflight(inflight_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@fast_analysis_bp.route('/analyze-legacy', methods=['POST'])
|
||||
@@ -115,6 +229,56 @@ def analyze_legacy():
|
||||
'msg': 'market and symbol are required',
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# Billing / credits (same behavior as /analyze)
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401
|
||||
|
||||
inflight_key = _build_inflight_key(user_id, market, symbol, timeframe)
|
||||
if not _acquire_inflight(inflight_key, ttl_sec=90):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.',
|
||||
'data': {'in_progress': True}
|
||||
}), 429
|
||||
|
||||
credits_charged = 0
|
||||
remaining_credits = None
|
||||
billing_consumed = False
|
||||
billing = None
|
||||
try:
|
||||
billing = get_billing_service()
|
||||
if billing.is_billing_enabled():
|
||||
credits_charged = int(billing.get_feature_cost('ai_analysis') or 0)
|
||||
if credits_charged > 0:
|
||||
ok, msg = billing.check_and_consume(
|
||||
user_id=int(user_id),
|
||||
feature='ai_analysis',
|
||||
reference_id=f"fast_analysis_legacy_{market}:{symbol}:{timeframe}"
|
||||
)
|
||||
if not ok:
|
||||
if str(msg or "").startswith('insufficient_credits'):
|
||||
parts = str(msg).split(':')
|
||||
cur = float(parts[1]) if len(parts) >= 2 else 0.0
|
||||
req = float(parts[2]) if len(parts) >= 3 else float(credits_charged)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Insufficient credits',
|
||||
'data': {
|
||||
'required': req,
|
||||
'current': cur,
|
||||
'shortage': max(0.0, req - cur),
|
||||
}
|
||||
}), 400
|
||||
return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500
|
||||
billing_consumed = True
|
||||
try:
|
||||
remaining_credits = float(billing.get_user_credits(int(user_id)))
|
||||
except Exception:
|
||||
remaining_credits = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
|
||||
|
||||
service = get_fast_analysis_service()
|
||||
result = service.analyze_legacy_format(
|
||||
@@ -126,6 +290,17 @@ def analyze_legacy():
|
||||
)
|
||||
|
||||
if result.get('error'):
|
||||
if billing_consumed and billing and credits_charged > 0:
|
||||
try:
|
||||
billing.add_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(credits_charged),
|
||||
action='refund',
|
||||
remark=f'Auto refund: fast-analysis-legacy failed ({market}:{symbol}:{timeframe})'
|
||||
)
|
||||
remaining_credits = float(billing.get_user_credits(int(user_id)))
|
||||
except Exception as re:
|
||||
logger.error(f"Auto refund failed (legacy): {re}", exc_info=True)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': result['error'],
|
||||
@@ -135,16 +310,36 @@ def analyze_legacy():
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
'data': {
|
||||
**(result or {}),
|
||||
'credits_charged': credits_charged,
|
||||
'remaining_credits': remaining_credits,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
if 'billing_consumed' in locals() and billing_consumed and 'billing' in locals() and billing and credits_charged > 0 and 'user_id' in locals() and user_id:
|
||||
billing.add_credits(
|
||||
user_id=int(user_id),
|
||||
amount=int(credits_charged),
|
||||
action='refund',
|
||||
remark=f'Auto refund: fast-analysis-legacy exception ({market}:{symbol}:{timeframe})'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"Fast analysis legacy API failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': str(e),
|
||||
'data': None
|
||||
}), 500
|
||||
finally:
|
||||
try:
|
||||
if 'inflight_key' in locals() and inflight_key:
|
||||
_release_inflight(inflight_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@fast_analysis_bp.route('/history', methods=['GET'])
|
||||
|
||||
@@ -220,6 +220,14 @@ CONFIG_SCHEMA = {
|
||||
'required': False,
|
||||
'description': 'Custom model list in JSON format for model selector'
|
||||
},
|
||||
{
|
||||
'key': 'AI_ANALYSIS_CONSENSUS_TIMEFRAMES',
|
||||
'label': 'Consensus Timeframes',
|
||||
'type': 'text',
|
||||
'default': '1D,4H',
|
||||
'required': False,
|
||||
'description': 'Multi-timeframe consensus for fast AI analysis. Comma-separated, e.g. "1D,4H"'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
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,
|
||||
sample_count INT NOT NULL DEFAULT 0,
|
||||
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,
|
||||
sample_count, validated_at, created_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
""",
|
||||
(
|
||||
market,
|
||||
buy_threshold,
|
||||
sell_threshold,
|
||||
min_consensus_abs_override,
|
||||
quality_hold_threshold,
|
||||
sample_count,
|
||||
),
|
||||
)
|
||||
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).
|
||||
"""
|
||||
is_demo_mode = os.getenv("IS_DEMO_MODE", "false").lower() == "true"
|
||||
if is_demo_mode:
|
||||
logger.info("AI calibration worker skipped in demo mode.")
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
@@ -67,6 +67,11 @@ class AnalysisMemory:
|
||||
scores JSONB,
|
||||
indicators_snapshot JSONB,
|
||||
raw_result JSONB,
|
||||
consensus_decision VARCHAR(10),
|
||||
consensus_score DECIMAL(24, 8),
|
||||
consensus_abs DECIMAL(24, 8),
|
||||
agreement_ratio DECIMAL(10, 6),
|
||||
quality_multiplier DECIMAL(10, 6),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
validated_at TIMESTAMP,
|
||||
actual_outcome VARCHAR(20),
|
||||
@@ -96,6 +101,42 @@ class AnalysisMemory:
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB;
|
||||
END IF;
|
||||
|
||||
-- 添加多周期共识列(如果不存在)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_decision'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_decision VARCHAR(10);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_score'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_score DECIMAL(24, 8);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_abs'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_abs DECIMAL(24, 8);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'agreement_ratio'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN agreement_ratio DECIMAL(10, 6);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'quality_multiplier'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6);
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
@@ -150,16 +191,26 @@ class AnalysisMemory:
|
||||
scores = json.dumps(analysis_result.get("scores", {}))
|
||||
indicators = json.dumps(analysis_result.get("indicators", {}))
|
||||
raw = json.dumps(analysis_result)
|
||||
|
||||
consensus = analysis_result.get("consensus") or {}
|
||||
consensus_decision = consensus.get("consensus_decision")
|
||||
consensus_score = consensus.get("consensus_score")
|
||||
consensus_abs = consensus.get("consensus_abs")
|
||||
agreement_ratio = consensus.get("agreement_ratio")
|
||||
quality_multiplier = consensus.get("quality_multiplier")
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO qd_analysis_memory (
|
||||
user_id, market, symbol, decision, confidence,
|
||||
price_at_analysis, entry_price, stop_loss, take_profit,
|
||||
summary, reasons, risks, scores, indicators_snapshot, raw_result
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
summary, reasons, risks, scores, indicators_snapshot, raw_result,
|
||||
consensus_decision, consensus_score, consensus_abs, agreement_ratio, quality_multiplier
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (user_id, market, symbol, decision, confidence, price, entry, stop, take,
|
||||
summary, reasons, risks, scores, indicators, raw))
|
||||
""", (user_id, market, symbol, decision, confidence, price, entry, stop, take,
|
||||
summary, reasons, risks, scores, indicators, raw,
|
||||
consensus_decision, consensus_score, consensus_abs, agreement_ratio, quality_multiplier))
|
||||
|
||||
# 使用 lastrowid 属性获取 ID(execute 内部已经处理了 RETURNING)
|
||||
memory_id = cur.lastrowid
|
||||
@@ -512,6 +563,84 @@ class AnalysisMemory:
|
||||
|
||||
logger.info(f"Validation completed: {stats}")
|
||||
return stats
|
||||
|
||||
def validate_unvalidated_older_than(self, min_age_days: int = 7, limit: int = 200) -> Dict[str, Any]:
|
||||
"""
|
||||
Best-effort backfill:
|
||||
Validate unvalidated decisions older than `min_age_days`.
|
||||
|
||||
This is used by offline AI calibration so the system can tune itself automatically.
|
||||
"""
|
||||
from app.services.market_data_collector import MarketDataCollector
|
||||
collector = MarketDataCollector()
|
||||
|
||||
stats = {
|
||||
"validated": 0,
|
||||
"correct": 0,
|
||||
"incorrect": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, market, symbol, decision, price_at_analysis
|
||||
FROM qd_analysis_memory
|
||||
WHERE validated_at IS NULL
|
||||
AND created_at < NOW() - INTERVAL '{int(min_age_days)} days'
|
||||
LIMIT {int(limit)}
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
current_price = collector._get_price(row["market"], row["symbol"])
|
||||
if not current_price or current_price <= 0:
|
||||
continue
|
||||
analysis_price = float(row.get("price_at_analysis") or 0.0)
|
||||
if analysis_price <= 0:
|
||||
continue
|
||||
|
||||
return_pct = ((float(current_price) - analysis_price) / analysis_price) * 100.0
|
||||
decision = str(row.get("decision") or "HOLD")
|
||||
|
||||
was_correct = False
|
||||
if decision == "BUY" and return_pct > 2:
|
||||
was_correct = True
|
||||
elif decision == "SELL" and return_pct < -2:
|
||||
was_correct = True
|
||||
elif decision == "HOLD" and abs(return_pct) <= 5:
|
||||
was_correct = True
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_analysis_memory
|
||||
SET validated_at = NOW(),
|
||||
actual_return_pct = %s,
|
||||
was_correct = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(return_pct, was_correct, int(row["id"])),
|
||||
)
|
||||
|
||||
stats["validated"] += 1
|
||||
if was_correct:
|
||||
stats["correct"] += 1
|
||||
else:
|
||||
stats["incorrect"] += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to validate memory {row.get('id')}: {e}", exc_info=True)
|
||||
stats["errors"] += 1
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.error(f"validate_unvalidated_older_than failed: {e}", exc_info=True)
|
||||
|
||||
return stats
|
||||
|
||||
def get_performance_stats(self, market: str = None, symbol: str = None,
|
||||
days: int = 30) -> Dict[str, Any]:
|
||||
|
||||
@@ -9,6 +9,7 @@ Fast Analysis Service 3.0
|
||||
4. 单次LLM调用 - 强约束prompt,输出结构化分析
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
@@ -37,7 +38,17 @@ class FastAnalysisService:
|
||||
|
||||
# ==================== Data Collection Layer ====================
|
||||
|
||||
def _collect_market_data(self, market: str, symbol: str, timeframe: str = "1D") -> Dict[str, Any]:
|
||||
def _collect_market_data(
|
||||
self,
|
||||
market: str,
|
||||
symbol: str,
|
||||
timeframe: str = "1D",
|
||||
*,
|
||||
include_macro: bool = True,
|
||||
include_news: bool = True,
|
||||
include_polymarket: bool = True,
|
||||
timeout: int = 45,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
使用统一的数据采集器收集市场数据
|
||||
|
||||
@@ -52,10 +63,10 @@ class FastAnalysisService:
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
include_macro=True,
|
||||
include_news=True,
|
||||
include_polymarket=True, # 包含预测市场数据
|
||||
timeout=45 # 增加超时时间,确保数据收集完成
|
||||
include_macro=include_macro,
|
||||
include_news=include_news,
|
||||
include_polymarket=include_polymarket, # 包含预测市场数据
|
||||
timeout=timeout, # 增加超时时间,确保数据收集完成
|
||||
)
|
||||
|
||||
def _calculate_indicators(self, kline_data: List[Dict]) -> Dict[str, Any]:
|
||||
@@ -694,9 +705,148 @@ IMPORTANT:
|
||||
}
|
||||
|
||||
try:
|
||||
# Phase 1: Data collection (parallel)
|
||||
# Phase 1: Data collection (multi-timeframe for consensus)
|
||||
logger.info(f"Fast analysis starting: {market}:{symbol}")
|
||||
data = self._collect_market_data(market, symbol, timeframe)
|
||||
|
||||
# Consensus timeframes:
|
||||
# - 默认:用用户传入的 timeframe 作为主周期,再加一个上层周期(1D/4H)提升稳定性
|
||||
# - 也允许通过 env 覆盖(逗号分隔),例如 AI_ANALYSIS_CONSENSUS_TIMEFRAMES=1D,4H
|
||||
env_tfs = os.getenv("AI_ANALYSIS_CONSENSUS_TIMEFRAMES", "").strip()
|
||||
if env_tfs:
|
||||
consensus_timeframes = [t.strip() for t in env_tfs.split(",") if t.strip()]
|
||||
else:
|
||||
# Heuristic defaults
|
||||
tf0 = (timeframe or "").strip().upper()
|
||||
# Primary first
|
||||
consensus_timeframes = [tf0] if tf0 else [timeframe]
|
||||
# Add 4H/1D depending on primary
|
||||
if tf0 in ("1H", "1HOUR", "60M"):
|
||||
consensus_timeframes += ["4H", "1D"]
|
||||
elif tf0 in ("4H",):
|
||||
consensus_timeframes += ["1D"]
|
||||
elif tf0 in ("1D", "1DAY", "D"):
|
||||
consensus_timeframes += ["4H"]
|
||||
else:
|
||||
# Generic fallback
|
||||
consensus_timeframes += ["1D", "4H"]
|
||||
# Dedup keep order
|
||||
seen = set()
|
||||
consensus_timeframes = [x for x in consensus_timeframes if not (x in seen or seen.add(x))]
|
||||
|
||||
primary_tf = (timeframe or "").strip().upper() or "1D"
|
||||
# Always include the primary timeframe in consensus,
|
||||
# even when env overrides timeframes.
|
||||
if primary_tf and primary_tf not in consensus_timeframes:
|
||||
consensus_timeframes = [primary_tf] + list(consensus_timeframes)
|
||||
# De-dup keep order
|
||||
seen = set()
|
||||
consensus_timeframes = [x for x in consensus_timeframes if not (x in seen or seen.add(x))]
|
||||
# Collect primary data including macro/news/polymarket for prompt quality
|
||||
primary_data = self._collect_market_data(
|
||||
market,
|
||||
symbol,
|
||||
primary_tf,
|
||||
include_macro=True,
|
||||
include_news=True,
|
||||
include_polymarket=True,
|
||||
)
|
||||
|
||||
# Collect extra timeframes for objective consensus (technical-only for cost)
|
||||
objective_by_tf: Dict[str, Dict[str, Any]] = {}
|
||||
decision_votes: Dict[str, int] = {"BUY": 0, "SELL": 0, "HOLD": 0}
|
||||
weighted_score_sum = 0.0
|
||||
weighted_score_w_sum = 0.0
|
||||
|
||||
def _extract_current_price(d: Dict[str, Any]) -> Optional[float]:
|
||||
if d.get("price") and d["price"].get("price"):
|
||||
try:
|
||||
return float(d["price"]["price"])
|
||||
except Exception:
|
||||
return None
|
||||
ind = d.get("indicators") or {}
|
||||
cp = ind.get("current_price")
|
||||
try:
|
||||
if cp:
|
||||
return float(cp)
|
||||
except Exception:
|
||||
pass
|
||||
# fallback to kline close
|
||||
kl = d.get("kline") or []
|
||||
if kl:
|
||||
try:
|
||||
return float(kl[-1].get("close") or 0)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
logger.info(f"Consensus timeframes: {consensus_timeframes}")
|
||||
for tf in consensus_timeframes:
|
||||
tf_norm = (tf or "").strip().upper()
|
||||
if not tf_norm:
|
||||
continue
|
||||
|
||||
if tf_norm == primary_tf:
|
||||
d_tf = primary_data
|
||||
else:
|
||||
d_tf = self._collect_market_data(
|
||||
market,
|
||||
symbol,
|
||||
tf_norm,
|
||||
include_macro=False,
|
||||
include_news=False,
|
||||
include_polymarket=False,
|
||||
timeout=25,
|
||||
)
|
||||
|
||||
current_price_tf = _extract_current_price(d_tf) or 0.0
|
||||
objective = self._calculate_objective_score(d_tf, current_price_tf)
|
||||
overall_score = float(objective.get("overall_score", 0.0) or 0.0)
|
||||
decision = self._score_to_decision(overall_score, market=market)
|
||||
abs_score = abs(overall_score)
|
||||
|
||||
objective_by_tf[tf_norm] = {
|
||||
"objective_score": objective,
|
||||
"overall_score": overall_score,
|
||||
"decision": decision,
|
||||
"abs_score": abs_score,
|
||||
}
|
||||
decision_votes[decision] = decision_votes.get(decision, 0) + 1
|
||||
|
||||
# Weight by strength so strong cycles dominate
|
||||
w = 1.0 + min(1.5, abs_score / 100.0)
|
||||
weighted_score_sum += overall_score * w
|
||||
weighted_score_w_sum += w
|
||||
|
||||
consensus_score = weighted_score_sum / weighted_score_w_sum if weighted_score_w_sum > 0 else 0.0
|
||||
consensus_decision = self._score_to_decision(consensus_score, market=market)
|
||||
consensus_abs = abs(consensus_score)
|
||||
|
||||
# Agreement factor: how many timeframes support the consensus decision
|
||||
tf_count = max(1, len(objective_by_tf))
|
||||
agreement_cnt = sum(1 for x in objective_by_tf.values() if str(x.get("decision") or "").upper() == consensus_decision)
|
||||
agreement_ratio = agreement_cnt / tf_count
|
||||
|
||||
# Data quality degradation: derive from primary_data meta
|
||||
meta = primary_data.get("_meta") or {}
|
||||
failed_items = set(meta.get("failed_items") or [])
|
||||
quality_multiplier = 1.0
|
||||
if "macro" in failed_items:
|
||||
quality_multiplier *= 0.85
|
||||
if "news" in failed_items:
|
||||
quality_multiplier *= 0.8
|
||||
if "polymarket" in failed_items:
|
||||
quality_multiplier *= 0.9
|
||||
# If indicators missing key sections, reduce confidence more
|
||||
ind = primary_data.get("indicators") or {}
|
||||
if not ind or not ind.get("rsi") or not ind.get("moving_averages"):
|
||||
quality_multiplier *= 0.65
|
||||
|
||||
logger.info(
|
||||
f"Consensus decision={consensus_decision}, score={consensus_score:.2f}, "
|
||||
f"agreement_ratio={agreement_ratio:.2f}, quality_multiplier={quality_multiplier:.2f}"
|
||||
)
|
||||
|
||||
data = primary_data # keep original variable usage for prompt/LLM input
|
||||
|
||||
# Validate we have essential data - with fallback to indicators
|
||||
current_price = None
|
||||
@@ -771,35 +921,69 @@ IMPORTANT:
|
||||
llm_time = int((time.time() - llm_start) * 1000)
|
||||
logger.info(f"LLM call completed in {llm_time}ms")
|
||||
|
||||
# Phase 4: Calculate objective score and determine decision based on score
|
||||
# Phase 4: Objective score (primary tf) + consensus calibration
|
||||
objective_score = self._calculate_objective_score(data, current_price)
|
||||
logger.info(f"Objective score calculated: {objective_score['overall_score']:.1f} (Technical: {objective_score['technical_score']:.1f}, Fundamental: {objective_score['fundamental_score']:.1f}, Sentiment: {objective_score['sentiment_score']:.1f}, Macro: {objective_score['macro_score']:.1f})")
|
||||
|
||||
# Determine decision based on objective score thresholds
|
||||
score_based_decision = self._score_to_decision(objective_score['overall_score'])
|
||||
logger.info(f"Score-based decision: {score_based_decision} (score: {objective_score['overall_score']:.1f})")
|
||||
|
||||
# Override LLM decision with score-based decision if they differ significantly
|
||||
llm_decision = analysis.get("decision", "HOLD")
|
||||
if llm_decision != score_based_decision:
|
||||
score_abs = abs(objective_score['overall_score'])
|
||||
# 降低阈值,因为现在HOLD区间更小了(±20),±15以上的评分就应该覆盖
|
||||
if score_abs >= 15: # 如果评分达到±15以上,就覆盖LLM决策(因为阈值是±20)
|
||||
logger.warning(f"LLM decision '{llm_decision}' conflicts with score-based decision '{score_based_decision}' (score: {objective_score['overall_score']:.1f}). Overriding to score-based decision.")
|
||||
analysis["decision"] = score_based_decision
|
||||
# Adjust confidence based on score strength
|
||||
# 评分越高,置信度越高(最高95,最低60)
|
||||
analysis["confidence"] = min(95, max(60, int(50 + score_abs * 0.45)))
|
||||
# Update summary to mention score-based decision
|
||||
original_summary = analysis.get("summary", "")
|
||||
score_level = "强烈" if score_abs >= 70 else "明显" if score_abs >= 40 else "轻微"
|
||||
analysis["summary"] = f"{original_summary} [基于客观评分系统:综合评分{objective_score['overall_score']:.1f}分({score_level}{'利多' if objective_score['overall_score'] > 0 else '利空'}),建议{score_based_decision}]"
|
||||
else:
|
||||
logger.info(f"LLM decision '{llm_decision}' differs from score-based '{score_based_decision}' but score is close to neutral ({objective_score['overall_score']:.1f}), keeping LLM decision")
|
||||
|
||||
# Add objective scores to analysis
|
||||
logger.info(
|
||||
f"Primary objective score: {objective_score['overall_score']:.1f} "
|
||||
f"(Technical: {objective_score['technical_score']:.1f}, Fundamental: {objective_score['fundamental_score']:.1f}, "
|
||||
f"Sentiment: {objective_score['sentiment_score']:.1f}, Macro: {objective_score['macro_score']:.1f})"
|
||||
)
|
||||
|
||||
score_based_decision = self._score_to_decision(objective_score["overall_score"], market=market)
|
||||
llm_decision = str(analysis.get("decision", "HOLD") or "HOLD").upper()
|
||||
|
||||
# Consensus confidence:
|
||||
consensus_conf = int(max(40, min(98, 50 + consensus_abs * 0.35)))
|
||||
# Agreement boosts, disagreement reduces
|
||||
consensus_conf = int(max(35, min(98, consensus_conf * (0.85 + 0.3 * agreement_ratio))))
|
||||
consensus_conf = int(max(0, min(100, consensus_conf * quality_multiplier)))
|
||||
|
||||
# Decide whether to enforce consensus over LLM / primary-score decision
|
||||
cfg = self._get_ai_calibration(market=market)
|
||||
min_abs_override = float(cfg.get("min_consensus_abs_override") or 15.0)
|
||||
quality_hold_thr = float(cfg.get("quality_hold_threshold") or 0.7)
|
||||
|
||||
if consensus_abs >= min_abs_override:
|
||||
final_decision = consensus_decision
|
||||
if llm_decision != final_decision:
|
||||
logger.warning(
|
||||
f"Override: llm_decision={llm_decision}, consensus_decision={final_decision}, "
|
||||
f"consensus_score={consensus_score:.1f}, consensus_abs={consensus_abs:.1f}"
|
||||
)
|
||||
analysis["decision"] = final_decision
|
||||
analysis["confidence"] = consensus_conf
|
||||
original_summary = analysis.get("summary", "")
|
||||
level = "强烈" if consensus_abs >= 70 else "明显" if consensus_abs >= 40 else "轻微"
|
||||
analysis["summary"] = (
|
||||
f"{original_summary} [多周期客观共识:综合评分{consensus_score:.1f}分("
|
||||
f"{level}{'利多' if consensus_score > 0 else '利空'}),建议{final_decision}]"
|
||||
)
|
||||
else:
|
||||
# Near-neutral: keep LLM but shrink confidence by quality and enforce HOLD if quality is poor
|
||||
analysis["confidence"] = int(max(0, min(100, int(analysis.get("confidence", 50) or 50) * quality_multiplier)))
|
||||
|
||||
if quality_multiplier < quality_hold_thr:
|
||||
analysis["decision"] = "HOLD"
|
||||
analysis["confidence"] = min(int(analysis.get("confidence", 50) or 50), 55)
|
||||
|
||||
# Add objective scores and consensus to analysis
|
||||
analysis["objective_score"] = objective_score
|
||||
analysis["score_based_decision"] = score_based_decision
|
||||
analysis["objective_scores_by_timeframe"] = {
|
||||
k: {
|
||||
"overall_score": v.get("overall_score"),
|
||||
"decision": v.get("decision"),
|
||||
"abs_score": v.get("abs_score"),
|
||||
}
|
||||
for k, v in objective_by_tf.items()
|
||||
}
|
||||
analysis["consensus"] = {
|
||||
"consensus_score": consensus_score,
|
||||
"consensus_decision": consensus_decision,
|
||||
"consensus_abs": consensus_abs,
|
||||
"agreement_ratio": agreement_ratio,
|
||||
"quality_multiplier": quality_multiplier,
|
||||
}
|
||||
|
||||
# Phase 5: Validate and constrain output (pass indicators for decision validation)
|
||||
# Check for major news or macro events that could override technical indicators
|
||||
@@ -815,6 +999,21 @@ IMPORTANT:
|
||||
has_major_news=has_major_news,
|
||||
has_macro_event=has_macro_event
|
||||
)
|
||||
|
||||
# Post-validate: adjust position sizing based on quality + agreement
|
||||
try:
|
||||
ps = analysis.get("position_size_pct", 10)
|
||||
ps = int(float(ps or 10))
|
||||
# Lower position size if data is incomplete or multi-timeframe disagreement exists
|
||||
# agreement_ratio in [0..1]
|
||||
agreement_scale = 0.6 + 0.4 * float(agreement_ratio)
|
||||
ps_scaled = ps * float(quality_multiplier) * agreement_scale
|
||||
if str(analysis.get("decision") or "").upper() == "HOLD":
|
||||
ps_scaled *= 0.25
|
||||
analysis["position_size_pct"] = max(1, min(100, int(round(ps_scaled))))
|
||||
except Exception:
|
||||
# Keep model-provided position_size_pct
|
||||
pass
|
||||
|
||||
# Build final result
|
||||
total_time = int((time.time() - start_time) * 1000)
|
||||
@@ -860,6 +1059,7 @@ IMPORTANT:
|
||||
"resistance": data["indicators"].get("levels", {}).get("resistance"),
|
||||
},
|
||||
"indicators": data.get("indicators", {}),
|
||||
"consensus": analysis.get("consensus", {}),
|
||||
"analysis_time_ms": total_time,
|
||||
"llm_time_ms": llm_time,
|
||||
"data_collection_time_ms": data.get("collection_time_ms", 0),
|
||||
@@ -1207,14 +1407,39 @@ IMPORTANT:
|
||||
macro_score = self._calculate_macro_score(macro, data.get("market", ""))
|
||||
|
||||
# 5. 综合评分(加权平均)
|
||||
# 优化权重:技术35%,基本面20%,情绪25%(包含地缘政治),宏观20%(提高宏观权重)
|
||||
# 提高情绪和宏观权重,因为地缘政治和宏观经济因素对市场影响更大
|
||||
overall_score = (
|
||||
technical_score * 0.35 +
|
||||
fundamental_score * 0.20 +
|
||||
sentiment_score * 0.25 + # 提高情绪权重,包含地缘政治事件
|
||||
macro_score * 0.20 # 提高宏观权重
|
||||
)
|
||||
# 优化权重:默认技术35%,基本面20%,情绪25%(包含地缘政治),宏观20%(提高宏观权重)
|
||||
# 但要做“可用信息重加权”:当某些模块缺失(如新闻/宏观没取到),不要用0分去稀释整体强度,
|
||||
# 而是重新归一化权重,让技术信号在缺失时仍可发挥主导作用。
|
||||
market_type = str(data.get("market") or "")
|
||||
fundamental_present = (market_type == "USStock") and bool(fundamental)
|
||||
sentiment_present = bool(news)
|
||||
macro_present = bool(macro)
|
||||
# indicators 一旦成功计算通常就存在,但这里也做一次保护
|
||||
technical_present = bool(indicators)
|
||||
|
||||
weights = {
|
||||
"technical": 0.35,
|
||||
"fundamental": 0.20,
|
||||
"sentiment": 0.25,
|
||||
"macro": 0.20,
|
||||
}
|
||||
present_flags = {
|
||||
"technical": technical_present,
|
||||
"fundamental": fundamental_present,
|
||||
"sentiment": sentiment_present,
|
||||
"macro": macro_present,
|
||||
}
|
||||
|
||||
total_w = sum(w for k, w in weights.items() if present_flags.get(k))
|
||||
if total_w <= 0:
|
||||
overall_score = technical_score
|
||||
else:
|
||||
overall_score = (
|
||||
(technical_score * weights["technical"] if present_flags.get("technical") else 0.0)
|
||||
+ (fundamental_score * weights["fundamental"] if present_flags.get("fundamental") else 0.0)
|
||||
+ (sentiment_score * weights["sentiment"] if present_flags.get("sentiment") else 0.0)
|
||||
+ (macro_score * weights["macro"] if present_flags.get("macro") else 0.0)
|
||||
) / total_w
|
||||
|
||||
return {
|
||||
"technical_score": technical_score,
|
||||
@@ -1223,6 +1448,34 @@ IMPORTANT:
|
||||
"macro_score": macro_score,
|
||||
"overall_score": overall_score
|
||||
}
|
||||
|
||||
def _get_ai_calibration(self, market: str = "Crypto") -> Dict[str, Any]:
|
||||
"""
|
||||
Load latest offline calibration thresholds for the given market.
|
||||
Cached briefly to avoid DB load on every request.
|
||||
"""
|
||||
# Simple per-process cache
|
||||
now = time.time()
|
||||
if not hasattr(self, "_calibration_cache"):
|
||||
self._calibration_cache = {}
|
||||
self._calibration_cache_ts = {}
|
||||
ttl = int(os.getenv("AI_CALIBRATION_CACHE_TTL_SEC", "300"))
|
||||
key = (market or "").strip() or "Crypto"
|
||||
ts = self._calibration_cache_ts.get(key) or 0.0
|
||||
if ts and (now - float(ts)) < ttl:
|
||||
return self._calibration_cache.get(key) or {}
|
||||
|
||||
try:
|
||||
from app.services.ai_calibration import AICalibrationService
|
||||
svc = AICalibrationService()
|
||||
cfg = svc.get_latest(key)
|
||||
except Exception as e:
|
||||
logger.warning(f"_get_ai_calibration failed (fallback): {e}", exc_info=True)
|
||||
cfg = {}
|
||||
|
||||
self._calibration_cache[key] = cfg
|
||||
self._calibration_cache_ts[key] = now
|
||||
return cfg
|
||||
|
||||
def _calculate_technical_score(self, indicators: Dict, price_data: Dict) -> float:
|
||||
"""计算技术指标评分 (-100 to +100)"""
|
||||
@@ -1288,6 +1541,94 @@ IMPORTANT:
|
||||
change_score = change_24h * 2 # 线性映射
|
||||
score += change_score * 0.20
|
||||
weight_sum += 0.20
|
||||
|
||||
# ========== 额外技术特征(轻量增强,不改变主体结构) ==========
|
||||
# 这些特征来自 MarketDataCollector._calculate_indicators 的输出:
|
||||
# - price_position: 过去20根K线区间位置 0~100
|
||||
# - volume_ratio: 最新成交量 / 20期均量
|
||||
# - bollinger: BB_upper/BB_lower/BB_width
|
||||
# - volatility: atr, pct
|
||||
extra_score = 0.0
|
||||
extra_weight = 0.0
|
||||
|
||||
# 1) 区间位置:接近区间顶部更偏利空,接近区间底部更偏利多
|
||||
try:
|
||||
pp = float(indicators.get("price_position", 50.0))
|
||||
# 0~100 -> -15~+15 (线性映射,中心50为0)
|
||||
pp_score = (50.0 - pp) * 0.3
|
||||
# 在极端区域增强信号
|
||||
if pp >= 85:
|
||||
pp_score -= 5
|
||||
elif pp <= 15:
|
||||
pp_score += 5
|
||||
extra_score += pp_score
|
||||
extra_weight += 0.20
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) 布林带触及:突破上轨偏利空,跌破下轨偏利多
|
||||
try:
|
||||
cur_px = float(indicators.get("current_price") or price_data.get("price") or 0.0)
|
||||
bb = indicators.get("bollinger") or {}
|
||||
bb_u = float(bb.get("BB_upper") or 0.0)
|
||||
bb_l = float(bb.get("BB_lower") or 0.0)
|
||||
if cur_px > 0 and bb_u > 0 and bb_l > 0 and bb_u > bb_l:
|
||||
if cur_px >= bb_u:
|
||||
extra_score += -12
|
||||
extra_weight += 0.20
|
||||
elif cur_px <= bb_l:
|
||||
extra_score += +12
|
||||
extra_weight += 0.20
|
||||
else:
|
||||
# Within bands: small contribution by relative position
|
||||
rel = (cur_px - bb_l) / (bb_u - bb_l) # 0..1
|
||||
extra_score += (0.5 - float(rel)) * 10
|
||||
extra_weight += 0.10
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) 成交量放大:在趋势方向上加分,逆趋势减分(弱信号)
|
||||
try:
|
||||
vr = float(indicators.get("volume_ratio") or 1.0)
|
||||
trend = str(indicators.get("trend") or indicators.get("moving_averages", {}).get("trend") or "").lower()
|
||||
if vr >= 1.8:
|
||||
if "uptrend" in trend:
|
||||
extra_score += +8
|
||||
extra_weight += 0.15
|
||||
elif "downtrend" in trend:
|
||||
extra_score += -8
|
||||
extra_weight += 0.15
|
||||
else:
|
||||
# 放量但无趋势:更偏不确定,略微降低(当作偏利空风险)
|
||||
extra_score += -3
|
||||
extra_weight += 0.10
|
||||
elif vr <= 0.6:
|
||||
# 缩量:趋势信号可信度下降(轻微回归到0)
|
||||
extra_score += 0
|
||||
extra_weight += 0.05
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) 高波动:减少强方向自信(用“缩放”形式实现,避免硬反转)
|
||||
try:
|
||||
vol = indicators.get("volatility") or {}
|
||||
vol_pct = float(vol.get("pct") or 0.0)
|
||||
if vol_pct >= 6.0:
|
||||
# 极高波动:把额外分数打折,并轻微把总体拉回0
|
||||
extra_score *= 0.6
|
||||
score *= 0.92
|
||||
elif vol_pct >= 3.5:
|
||||
extra_score *= 0.8
|
||||
score *= 0.96
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Combine extra into main score (treat as another component)
|
||||
if extra_weight > 0:
|
||||
# Normalize extra to roughly -100..+100 scale
|
||||
extra_norm = max(-100.0, min(100.0, float(extra_score)))
|
||||
score += extra_norm * 0.15
|
||||
weight_sum += 0.15
|
||||
|
||||
# 归一化到-100到+100
|
||||
if weight_sum > 0:
|
||||
@@ -1536,17 +1877,38 @@ IMPORTANT:
|
||||
tnx_score = 0
|
||||
score += tnx_score
|
||||
factors += 1
|
||||
|
||||
# 恐惧贪婪指数(更适合 Crypto):极端贪婪偏利空,极端恐惧偏利多(弱信号)
|
||||
try:
|
||||
fg = macro.get("FEAR_GREED", {}) or {}
|
||||
fg_value = float(fg.get("price") or 0.0)
|
||||
if fg_value > 0 and market in ["Crypto"]:
|
||||
if fg_value >= 80:
|
||||
score += -15
|
||||
factors += 1
|
||||
elif fg_value >= 65:
|
||||
score += -8
|
||||
factors += 1
|
||||
elif fg_value <= 20:
|
||||
score += +10
|
||||
factors += 1
|
||||
elif fg_value <= 35:
|
||||
score += +5
|
||||
factors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 归一化(考虑权重)
|
||||
if factors > 0:
|
||||
# 最大可能分数:VIX(-50~+20), DXY(-30~+30), TNX(-30~+30) = 约-110到+80
|
||||
# 归一化到-100到+100
|
||||
max_possible = 110 # 最大绝对值
|
||||
# 加上 Fear&Greed 的幅度(约 15),给点 buffer
|
||||
max_possible = 125 # 最大绝对值
|
||||
score = score / max_possible * 100
|
||||
|
||||
return max(-100, min(100, score))
|
||||
|
||||
def _score_to_decision(self, score: float) -> str:
|
||||
def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str:
|
||||
"""
|
||||
根据客观评分转换为决策
|
||||
|
||||
@@ -1566,10 +1928,13 @@ IMPORTANT:
|
||||
- -70 < score <= -40: 明显SELL
|
||||
- score <= -70: 强烈SELL
|
||||
"""
|
||||
# 使用±20作为主要阈值,大幅缩小HOLD区间
|
||||
if score >= 20:
|
||||
cfg = self._get_ai_calibration(market=market)
|
||||
buy_thr = float(cfg.get("buy_threshold") or 20.0)
|
||||
sell_thr = float(cfg.get("sell_threshold") or -20.0)
|
||||
|
||||
if score >= buy_thr:
|
||||
return "BUY"
|
||||
elif score <= -20:
|
||||
elif score <= sell_thr:
|
||||
return "SELL"
|
||||
else:
|
||||
return "HOLD"
|
||||
|
||||
Reference in New Issue
Block a user