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:
dinger
2026-03-20 21:08:26 +08:00
parent b91cfcc7fa
commit 9473e50d59
27 changed files with 1197 additions and 103 deletions
+17
View File
@@ -0,0 +1,17 @@
# Used when build context is the repo root (e.g. frontend image).
# Backend builds use backend_api_python/.dockerignore with its own context.
.git
**/.git
.DS_Store
# Do not send local node_modules / build outputs (Docker runs npm ci + build)
QuantDinger-Vue-src/node_modules
QuantDinger-Vue-src/dist
# Pre-built copy used for local nginx tests only — image builds from source
frontend/dist
# Backend & DB not needed for frontend stage
backend_api_python
docker-compose.override.yml
+6
View File
@@ -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
+197 -2
View File
@@ -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 属性获取 IDexecute 内部已经处理了 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]:
+411 -46
View File
@@ -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"
+7 -5
View File
@@ -8,11 +8,13 @@
# 3. docker-compose up -d --build
# 4. Open http://localhost:8888
#
# Frontend image: multi-stage build from QuantDinger-Vue-src (context = repo root).
# To refresh static files without Docker: cd QuantDinger-Vue-src && npm run build
# && rm -rf ../frontend/dist && cp -R dist ../frontend/dist
#
# Note: The container will NOT start if SECRET_KEY is using the default value.
# This is a security measure to prevent insecure deployments.
version: '3.8'
services:
# ========================
# PostgreSQL Database
@@ -73,12 +75,12 @@ services:
retries: 3
# ========================
# Frontend (Nginx + Pre-built)
# Frontend (Nginx + Vue build from QuantDinger-Vue-src)
# ========================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
context: .
dockerfile: frontend/Dockerfile
container_name: quantdinger-frontend
restart: unless-stopped
ports:
+26 -6
View File
@@ -1,12 +1,32 @@
# QuantDinger Frontend - Pre-built Static Files
# No build step needed - dist/ is already compiled.
# QuantDinger Frontend — multi-stage build
# Build context: repository root (see docker-compose.yml)
#
# Stage 1: compile Vue app from QuantDinger-Vue-src
# Stage 2: nginx static + API reverse proxy (nginx.conf)
FROM node:18-alpine AS builder
WORKDIR /app
# Layer cache: deps only
COPY QuantDinger-Vue-src/package.json QuantDinger-Vue-src/package-lock.json ./
RUN npm ci
COPY QuantDinger-Vue-src/ ./
# Match local production build (`npm run build` in QuantDinger-Vue-src)
ENV NODE_OPTIONS=--max_old_space_size=4096
RUN npm run build
# --- runtime ---
FROM nginx:1.25-alpine
# Copy pre-built frontend files
COPY dist/ /usr/share/nginx/html/
# curl: docker-compose healthcheck
RUN apk add --no-cache curl
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html/
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,31 +33,35 @@ html{--antd-wave-shadow-color:#1890ff}
#nprogress .bar{background:#1890ff}
#nprogress .peg{-webkit-box-shadow:0 0 10px #1890ff,0 0 5px #1890ff;box-shadow:0 0 10px #1890ff,0 0 5px #1890ff}
#nprogress .spinner-icon{border-top-color:#1890ff;border-left-color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .loading-header .loading-icon-pro[data-v-110dde64]{color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-110dde64]{color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .current-step[data-v-110dde64]{color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active[data-v-110dde64]{background:#e6f7ff;color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active .step-dot[data-v-110dde64]{background:#1890ff;-webkit-box-shadow:0 0 0 3px rgba(24,144,255,.2);box-shadow:0 0 0 3px rgba(24,144,255,.2)}
.fast-analysis-report .result-container .decision-card[data-v-110dde64]{border-left:6px solid #1890ff}
.fast-analysis-report .result-container .price-info-row .price-card.current[data-v-110dde64]{border-top:3px solid #1890ff}
.fast-analysis-report .result-container .scores-row .score-item .score-header .anticon[data-v-110dde64]{color:#1890ff}
.fast-analysis-report .result-container .scores-row .score-item.overall[data-v-110dde64]{background:linear-gradient(135deg,#e6f7ff,#fff);border:1px solid #91d5ff}
.fast-analysis-report .result-container .detailed-analysis .analysis-card[data-v-110dde64]{border-left:4px solid #1890ff}
.fast-analysis-report .result-container .detailed-analysis .analysis-card.technical[data-v-110dde64]{border-left-color:#1890ff}
.fast-analysis-report .result-container .detailed-analysis .analysis-card .analysis-card-header.technical .anticon[data-v-110dde64]{color:#1890ff}
.fast-analysis-report .result-container .analysis-details .detail-section .detail-list li[data-v-110dde64]:before{color:#1890ff}
.fast-analysis-report .result-container .indicators-section .section-title .anticon[data-v-110dde64]{color:#1890ff}
.fast-analysis-report.theme-dark .decision-card[data-v-110dde64]{border-left-color:#1890ff}
.fast-analysis-report.theme-dark .detailed-analysis .analysis-card.technical[data-v-110dde64]{border-left-color:#1890ff}
.fast-analysis-report.theme-dark .score-item.overall[data-v-110dde64]{background:linear-gradient(135deg,rgba(24,144,255,.1),#2a2e39);border-color:#1890ff}
.left-panel .heatmap-box .box-header[data-v-76a3e064] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.left-panel .calendar-box .box-header .box-title .anticon[data-v-76a3e064]{color:var(--primary-color,#1890ff)}
.right-panel .analysis-toolbar .analyze-button[data-v-76a3e064]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.right-panel .analysis-main .analysis-placeholder .placeholder-content .placeholder-icon[data-v-76a3e064]{color:var(--primary-color,#1890ff)}
.watchlist-panel .watchlist-list .watchlist-item.active[data-v-76a3e064]{background:#e6f7ff;border:1px solid #91d5ff}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .watchlist-item.active[data-v-76a3e064]{background:rgba(24,144,255,.1);border-color:#1890ff}
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-76a3e064],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-76a3e064]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.1)}
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-76a3e064],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-76a3e064]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.1)}
.fast-analysis-report .loading-container .loading-content-pro .loading-header .loading-icon-pro[data-v-693144f6]{color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-693144f6]{color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .current-step[data-v-693144f6]{color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active[data-v-693144f6]{background:#e6f7ff;color:#1890ff}
.fast-analysis-report .loading-container .loading-content-pro .steps-list .step-item.active .step-dot[data-v-693144f6]{background:#1890ff;-webkit-box-shadow:0 0 0 3px rgba(24,144,255,.2);box-shadow:0 0 0 3px rgba(24,144,255,.2)}
.fast-analysis-report .result-container .decision-card[data-v-693144f6]{border-left:6px solid #1890ff}
.fast-analysis-report .result-container .decision-card .consensus-strip[data-v-693144f6]{background:rgba(24,144,255,.06);border:1px solid rgba(24,144,255,.2)}
.fast-analysis-report .result-container .decision-card .consensus-strip .consensus-strip-title[data-v-693144f6]{color:#1890ff}
.fast-analysis-report .result-container .price-info-row .price-card.current[data-v-693144f6]{border-top:3px solid #1890ff}
.fast-analysis-report .result-container .scores-row .score-item .score-header .anticon[data-v-693144f6]{color:#1890ff}
.fast-analysis-report .result-container .scores-row .score-item.overall[data-v-693144f6]{background:linear-gradient(135deg,#e6f7ff,#fff);border:1px solid #91d5ff}
.fast-analysis-report .result-container .detailed-analysis .analysis-card[data-v-693144f6]{border-left:4px solid #1890ff}
.fast-analysis-report .result-container .detailed-analysis .analysis-card.technical[data-v-693144f6]{border-left-color:#1890ff}
.fast-analysis-report .result-container .detailed-analysis .analysis-card .analysis-card-header.technical .anticon[data-v-693144f6]{color:#1890ff}
.fast-analysis-report .result-container .analysis-details .detail-section .detail-list li[data-v-693144f6]:before{color:#1890ff}
.fast-analysis-report .result-container .indicators-section .section-title .anticon[data-v-693144f6]{color:#1890ff}
.fast-analysis-report.theme-dark .decision-card[data-v-693144f6]{border-left-color:#1890ff}
.fast-analysis-report.theme-dark .decision-card .consensus-strip[data-v-693144f6]{background:rgba(24,144,255,.12);border-color:rgba(24,144,255,.35)}
.fast-analysis-report.theme-dark .decision-card .consensus-strip .consensus-strip-title[data-v-693144f6]{color:#69c0ff}
.fast-analysis-report.theme-dark .detailed-analysis .analysis-card.technical[data-v-693144f6]{border-left-color:#1890ff}
.fast-analysis-report.theme-dark .score-item.overall[data-v-693144f6]{background:linear-gradient(135deg,rgba(24,144,255,.1),#2a2e39);border-color:#1890ff}
.left-panel .heatmap-box .box-header[data-v-41eda137] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.left-panel .calendar-box .box-header .box-title .anticon[data-v-41eda137]{color:var(--primary-color,#1890ff)}
.right-panel .analysis-toolbar .analyze-button[data-v-41eda137]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.right-panel .analysis-main .analysis-placeholder .placeholder-content .placeholder-icon[data-v-41eda137]{color:var(--primary-color,#1890ff)}
.watchlist-panel .watchlist-list .watchlist-item.active[data-v-41eda137]{background:#e6f7ff;border:1px solid #91d5ff}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .watchlist-item.active[data-v-41eda137]{background:rgba(24,144,255,.1);border-color:#1890ff}
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-41eda137],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-41eda137]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.1)}
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-41eda137],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-41eda137]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.1)}
.qt-header .qt-header-left .qt-icon[data-v-0d547552]{color:#1890ff}
.theme-dark[data-v-0d547552] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
.theme-dark[data-v-0d547552] .ant-slider-track{background:#1890ff}
+1 -1
View File
@@ -421,4 +421,4 @@
.brand-text {
font-size: 20px;
}
}</style><script defer="defer" src="/js/chunk-vendors.b7f38551.js" type="module"></script><script defer="defer" src="/js/app.d094c0fc.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.ee3dda40.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.2846149e.js" nomodule></script><script defer="defer" src="/js/app-legacy.f346bd4d.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
}</style><script defer="defer" src="/js/chunk-vendors.e3ae0d1f.js" type="module"></script><script defer="defer" src="/js/app.de48b29a.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.ee3dda40.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.beabf087.js" nomodule></script><script defer="defer" src="/js/app-legacy.a9e4bd9c.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long