Skip to content

Commit 9473e50

Browse files
dingerdinger
authored andcommitted
feat: AI 即时分析计费/共识/校准与 Docker 前端构建
- 即时分析:先扣费、防重入(429)、失败退款;记忆库与离线校准 worker - 多周期共识、客观分与设置项 AI_ANALYSIS_CONSENSUS_TIMEFRAMES - Docker:前端多阶段构建(QuantDinger-Vue-src)、根目录 .dockerignore、compose 调整 - 同步 frontend/dist 静态资源 Made-with: Cursor
1 parent b91cfcc commit 9473e50

27 files changed

Lines changed: 1197 additions & 103 deletions

‎.dockerignore‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Used when build context is the repo root (e.g. frontend image).
2+
# Backend builds use backend_api_python/.dockerignore with its own context.
3+
4+
.git
5+
**/.git
6+
.DS_Store
7+
8+
# Do not send local node_modules / build outputs (Docker runs npm ci + build)
9+
QuantDinger-Vue-src/node_modules
10+
QuantDinger-Vue-src/dist
11+
12+
# Pre-built copy used for local nginx tests only — image builds from source
13+
frontend/dist
14+
15+
# Backend & DB not needed for frontend stage
16+
backend_api_python
17+
docker-compose.override.yml

‎backend_api_python/app/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ def global_demo_mode_check():
273273
start_portfolio_monitor()
274274
start_usdt_order_worker()
275275
start_polymarket_worker()
276+
# Offline calibration to make AI thresholds self-tuning.
277+
try:
278+
from app.services.ai_calibration import start_ai_calibration_worker
279+
start_ai_calibration_worker()
280+
except Exception:
281+
pass
276282
restore_running_strategies()
277283

278284
return app

‎backend_api_python/app/routes/fast_analysis.py‎

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,45 @@
44
New high-performance analysis endpoints that replace the slow multi-agent system.
55
"""
66
from flask import Blueprint, request, jsonify, g
7+
import threading
8+
import time
79

810
from app.utils.auth import login_required
911
from app.utils.logger import get_logger
1012
from app.services.fast_analysis import get_fast_analysis_service
1113
from app.services.analysis_memory import get_analysis_memory
14+
from app.services.billing_service import get_billing_service
1215

1316
logger = get_logger(__name__)
1417

1518
fast_analysis_bp = Blueprint('fast_analysis', __name__)
1619

20+
# In-memory in-flight guard to avoid duplicate analysis charges caused by rapid repeated clicks.
21+
_analysis_inflight_lock = threading.Lock()
22+
_analysis_inflight = {} # key -> expire_ts
23+
24+
25+
def _build_inflight_key(user_id: int, market: str, symbol: str, timeframe: str) -> str:
26+
return f"{int(user_id)}|{str(market or '').strip().upper()}|{str(symbol or '').strip().upper()}|{str(timeframe or '').strip().upper()}"
27+
28+
29+
def _acquire_inflight(key: str, ttl_sec: int = 90) -> bool:
30+
now = time.time()
31+
with _analysis_inflight_lock:
32+
# Cleanup stale entries
33+
stale = [k for k, exp in _analysis_inflight.items() if float(exp) <= now]
34+
for k in stale[:1024]:
35+
_analysis_inflight.pop(k, None)
36+
if key in _analysis_inflight and float(_analysis_inflight.get(key) or 0) > now:
37+
return False
38+
_analysis_inflight[key] = now + int(ttl_sec)
39+
return True
40+
41+
42+
def _release_inflight(key: str):
43+
with _analysis_inflight_lock:
44+
_analysis_inflight.pop(key, None)
45+
1746

1847
@fast_analysis_bp.route('/analyze', methods=['POST'])
1948
@login_required
@@ -51,6 +80,58 @@ def analyze():
5180

5281
# Get current user's ID to associate analysis with user
5382
user_id = getattr(g, 'user_id', None)
83+
if not user_id:
84+
return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401
85+
86+
inflight_key = _build_inflight_key(user_id, market, symbol, timeframe)
87+
if not _acquire_inflight(inflight_key, ttl_sec=90):
88+
return jsonify({
89+
'code': 0,
90+
'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.',
91+
'data': {'in_progress': True}
92+
}), 429
93+
94+
# Billing / credits (best-effort, consistent with polymarket deep analysis)
95+
credits_charged = 0
96+
remaining_credits = None
97+
billing_consumed = False
98+
billing = None
99+
try:
100+
billing = get_billing_service()
101+
if billing.is_billing_enabled():
102+
credits_charged = int(billing.get_feature_cost('ai_analysis') or 0)
103+
if credits_charged > 0:
104+
ok, msg = billing.check_and_consume(
105+
user_id=int(user_id),
106+
feature='ai_analysis',
107+
reference_id=f"fast_analysis_{market}:{symbol}:{timeframe}"
108+
)
109+
if not ok:
110+
# Standardize insufficient credits message
111+
if str(msg or "").startswith('insufficient_credits'):
112+
# Format: insufficient_credits:<current>:<cost>
113+
parts = str(msg).split(':')
114+
cur = float(parts[1]) if len(parts) >= 2 else 0.0
115+
req = float(parts[2]) if len(parts) >= 3 else float(credits_charged)
116+
return jsonify({
117+
'code': 0,
118+
'msg': 'Insufficient credits',
119+
'data': {
120+
'required': req,
121+
'current': cur,
122+
'shortage': max(0.0, req - cur),
123+
}
124+
}), 400
125+
return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500
126+
billing_consumed = True
127+
# Query remaining credits after successful consumption
128+
try:
129+
remaining_credits = float(billing.get_user_credits(int(user_id)))
130+
except Exception:
131+
remaining_credits = None
132+
except Exception as e:
133+
# Billing failure should not crash analysis by default, but should be visible in logs.
134+
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
54135

55136
service = get_fast_analysis_service()
56137
result = service.analyze(
@@ -63,6 +144,18 @@ def analyze():
63144
)
64145

65146
if result.get('error'):
147+
# Best-effort refund if we already charged but analysis failed.
148+
if billing_consumed and billing and credits_charged > 0:
149+
try:
150+
billing.add_credits(
151+
user_id=int(user_id),
152+
amount=int(credits_charged),
153+
action='refund',
154+
remark=f'Auto refund: fast-analysis failed ({market}:{symbol}:{timeframe})'
155+
)
156+
remaining_credits = float(billing.get_user_credits(int(user_id)))
157+
except Exception as re:
158+
logger.error(f"Auto refund failed: {re}", exc_info=True)
66159
return jsonify({
67160
'code': 0,
68161
'msg': result['error'],
@@ -75,16 +168,37 @@ def analyze():
75168
return jsonify({
76169
'code': 1,
77170
'msg': 'success',
78-
'data': result
171+
'data': {
172+
**(result or {}),
173+
'credits_charged': credits_charged,
174+
'remaining_credits': remaining_credits,
175+
}
79176
})
80177

81178
except Exception as e:
179+
# Best-effort refund on unexpected error after charge.
180+
try:
181+
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:
182+
billing.add_credits(
183+
user_id=int(user_id),
184+
amount=int(credits_charged),
185+
action='refund',
186+
remark=f'Auto refund: fast-analysis exception ({market}:{symbol}:{timeframe})'
187+
)
188+
except Exception:
189+
pass
82190
logger.error(f"Fast analysis API failed: {e}", exc_info=True)
83191
return jsonify({
84192
'code': 0,
85193
'msg': str(e),
86194
'data': None
87195
}), 500
196+
finally:
197+
try:
198+
if 'inflight_key' in locals() and inflight_key:
199+
_release_inflight(inflight_key)
200+
except Exception:
201+
pass
88202

89203

90204
@fast_analysis_bp.route('/analyze-legacy', methods=['POST'])
@@ -115,6 +229,56 @@ def analyze_legacy():
115229
'msg': 'market and symbol are required',
116230
'data': None
117231
}), 400
232+
233+
# Billing / credits (same behavior as /analyze)
234+
user_id = getattr(g, 'user_id', None)
235+
if not user_id:
236+
return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401
237+
238+
inflight_key = _build_inflight_key(user_id, market, symbol, timeframe)
239+
if not _acquire_inflight(inflight_key, ttl_sec=90):
240+
return jsonify({
241+
'code': 0,
242+
'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.',
243+
'data': {'in_progress': True}
244+
}), 429
245+
246+
credits_charged = 0
247+
remaining_credits = None
248+
billing_consumed = False
249+
billing = None
250+
try:
251+
billing = get_billing_service()
252+
if billing.is_billing_enabled():
253+
credits_charged = int(billing.get_feature_cost('ai_analysis') or 0)
254+
if credits_charged > 0:
255+
ok, msg = billing.check_and_consume(
256+
user_id=int(user_id),
257+
feature='ai_analysis',
258+
reference_id=f"fast_analysis_legacy_{market}:{symbol}:{timeframe}"
259+
)
260+
if not ok:
261+
if str(msg or "").startswith('insufficient_credits'):
262+
parts = str(msg).split(':')
263+
cur = float(parts[1]) if len(parts) >= 2 else 0.0
264+
req = float(parts[2]) if len(parts) >= 3 else float(credits_charged)
265+
return jsonify({
266+
'code': 0,
267+
'msg': 'Insufficient credits',
268+
'data': {
269+
'required': req,
270+
'current': cur,
271+
'shortage': max(0.0, req - cur),
272+
}
273+
}), 400
274+
return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500
275+
billing_consumed = True
276+
try:
277+
remaining_credits = float(billing.get_user_credits(int(user_id)))
278+
except Exception:
279+
remaining_credits = None
280+
except Exception as e:
281+
logger.warning(f"Billing check failed (skipped): {e}", exc_info=True)
118282

119283
service = get_fast_analysis_service()
120284
result = service.analyze_legacy_format(
@@ -126,6 +290,17 @@ def analyze_legacy():
126290
)
127291

128292
if result.get('error'):
293+
if billing_consumed and billing and credits_charged > 0:
294+
try:
295+
billing.add_credits(
296+
user_id=int(user_id),
297+
amount=int(credits_charged),
298+
action='refund',
299+
remark=f'Auto refund: fast-analysis-legacy failed ({market}:{symbol}:{timeframe})'
300+
)
301+
remaining_credits = float(billing.get_user_credits(int(user_id)))
302+
except Exception as re:
303+
logger.error(f"Auto refund failed (legacy): {re}", exc_info=True)
129304
return jsonify({
130305
'code': 0,
131306
'msg': result['error'],
@@ -135,16 +310,36 @@ def analyze_legacy():
135310
return jsonify({
136311
'code': 1,
137312
'msg': 'success',
138-
'data': result
313+
'data': {
314+
**(result or {}),
315+
'credits_charged': credits_charged,
316+
'remaining_credits': remaining_credits,
317+
}
139318
})
140319

141320
except Exception as e:
321+
try:
322+
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:
323+
billing.add_credits(
324+
user_id=int(user_id),
325+
amount=int(credits_charged),
326+
action='refund',
327+
remark=f'Auto refund: fast-analysis-legacy exception ({market}:{symbol}:{timeframe})'
328+
)
329+
except Exception:
330+
pass
142331
logger.error(f"Fast analysis legacy API failed: {e}", exc_info=True)
143332
return jsonify({
144333
'code': 0,
145334
'msg': str(e),
146335
'data': None
147336
}), 500
337+
finally:
338+
try:
339+
if 'inflight_key' in locals() and inflight_key:
340+
_release_inflight(inflight_key)
341+
except Exception:
342+
pass
148343

149344

150345
@fast_analysis_bp.route('/history', methods=['GET'])

‎backend_api_python/app/routes/settings.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@
220220
'required': False,
221221
'description': 'Custom model list in JSON format for model selector'
222222
},
223+
{
224+
'key': 'AI_ANALYSIS_CONSENSUS_TIMEFRAMES',
225+
'label': 'Consensus Timeframes',
226+
'type': 'text',
227+
'default': '1D,4H',
228+
'required': False,
229+
'description': 'Multi-timeframe consensus for fast AI analysis. Comma-separated, e.g. "1D,4H"'
230+
},
223231
]
224232
},
225233

0 commit comments

Comments
 (0)