feat: AI analysis engine refactor, dark theme polish & virtual position management

Core changes:
- Refactor FastAnalysisService: single LLM multi-factor analysis replaces
  7-agent pipeline; add multi-timeframe consensus, threshold calibration,
  confidence calibration, multi-model ensemble voting
- Add RAG memory injection and reflection validation (analysis_memory +
  reflection worker)
- Simplify billing config: remove unused strategy_run/backtest/portfolio_monitor,
  add ai_code_gen separate billing (different token consumption scale)
- Settings hot-reload after save, no backend restart needed

Frontend:
- Global dark theme overhaul: pure black palette replacing blue-tinted colors
  across sidebar/header/dashboard/analysis/K-line/user-manage/profile/settings/billing
- Fix USDT payment modal dark theme (portal rendering broke CSS selectors)
- Refactor position modal: direction + quantity + entry price, remove add/reduce
  logic, show raw DB values on re-open, save exactly what user inputs
- Fix Polymarket prediction market dark text
- i18n for position modal title

Backend:
- Position management: one record per symbol (DELETE+INSERT replacing
  ON CONFLICT with side), fixes PnL showing 0 when switching long/short
- MarketDataCollector data fetching optimization
- portfolio_monitor scheduled monitoring improvements
- env.example reorganized: common config first, advanced config last

Documentation:
- README architecture diagram updated to FastAnalysisService flow
- Add virtual position, AI tuning config, billing items documentation
- Add INDICATOR_DEFINITIONS_CN.md, FRONTEND_FAST_ANALYSIS.md

Made-with: Cursor
This commit is contained in:
Dinger
2026-03-23 23:01:04 +08:00
parent 05f07ee544
commit 2e9c7cd69e
96 changed files with 2131 additions and 780 deletions
@@ -9,6 +9,7 @@ import json
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from app.utils.db import get_db_connection
@@ -16,6 +17,7 @@ from app.utils.logger import get_logger
from app.services.fast_analysis import get_fast_analysis_service
from app.services.signal_notifier import SignalNotifier
from app.services.kline import KlineService
from app.services.billing_service import get_billing_service
logger = get_logger(__name__)
@@ -154,98 +156,92 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No
return []
MAX_PARALLEL_ANALYSIS = 5
def _analyze_single_position(pos: Dict[str, Any], language: str) -> Dict[str, Any]:
"""Analyze a single position (designed to run inside a thread pool)."""
market = pos.get('market')
symbol = pos.get('symbol')
name = pos.get('name') or symbol
group_name = pos.get('group_name')
if not market or not symbol:
return {'market': market, 'symbol': symbol, 'name': name, 'error': 'missing market/symbol'}
try:
logger.info(f"Running fast AI analysis for {market}:{symbol}")
service = get_fast_analysis_service()
analysis_result = service.analyze(
market=market, symbol=symbol, language=language, timeframe='1D'
)
detailed = analysis_result.get('detailed_analysis', {})
trading_plan = analysis_result.get('trading_plan', {})
scores = analysis_result.get('scores', {})
risks = analysis_result.get('risks', [])
risk_report = '\n'.join([f"{r}" for r in risks]) if risks else ''
result = {
'market': market, 'symbol': symbol, 'name': name, 'group_name': group_name,
'entry_price': pos.get('entry_price'),
'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'),
'pnl': pos.get('pnl'), 'pnl_percent': pos.get('pnl_percent'),
'quantity': pos.get('quantity'), 'side': pos.get('side'),
'final_decision': analysis_result.get('decision', 'HOLD'),
'confidence': analysis_result.get('confidence', 50),
'reasoning': analysis_result.get('summary', ''),
'trader_decision': analysis_result.get('decision', 'HOLD'),
'trader_reasoning': analysis_result.get('summary', ''),
'overview_report': detailed.get('technical', ''),
'fundamental_report': detailed.get('fundamental', ''),
'sentiment_report': detailed.get('sentiment', ''),
'risk_report': risk_report,
'suggested_entry': trading_plan.get('entry_price'),
'suggested_stop_loss': trading_plan.get('stop_loss'),
'suggested_take_profit': trading_plan.get('take_profit'),
'technical_score': scores.get('technical', 50),
'fundamental_score': scores.get('fundamental', 50),
'sentiment_score': scores.get('sentiment', 50),
'key_reasons': analysis_result.get('reasons', []),
'error': analysis_result.get('error')
}
logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}")
return result
except Exception as e:
logger.error(f"Failed to analyze {market}:{symbol}: {e}")
return {'market': market, 'symbol': symbol, 'name': name, 'error': str(e)}
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
"""
Run fast AI analysis on positions.
Uses the new FastAnalysisService (single LLM call, faster and more stable).
Run fast AI analysis on positions **in parallel** using a thread pool.
"""
try:
language = config.get('language', 'en-US')
custom_prompt = config.get('prompt', '')
# Get the fast analysis service
service = get_fast_analysis_service()
# Analyze each position
position_analyses = []
for pos in positions:
market = pos.get('market')
symbol = pos.get('symbol')
name = pos.get('name') or symbol
group_name = pos.get('group_name')
if not market or not symbol:
continue
try:
logger.info(f"Running fast AI analysis for {market}:{symbol}")
# Use the new FastAnalysisService (single LLM call)
analysis_result = service.analyze(
market=market,
symbol=symbol,
language=language,
timeframe='1D'
)
# Extract information from the new format
detailed = analysis_result.get('detailed_analysis', {})
trading_plan = analysis_result.get('trading_plan', {})
scores = analysis_result.get('scores', {})
# Build risk report from risks list
risks = analysis_result.get('risks', [])
risk_report = '\n'.join([f"{r}" for r in risks]) if risks else ''
position_analysis = {
'market': market,
'symbol': symbol,
'name': name,
'group_name': group_name,
'entry_price': pos.get('entry_price'),
'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'),
'pnl': pos.get('pnl'),
'pnl_percent': pos.get('pnl_percent'),
'quantity': pos.get('quantity'),
'side': pos.get('side'),
# New fast analysis results
'final_decision': analysis_result.get('decision', 'HOLD'),
'confidence': analysis_result.get('confidence', 50),
'reasoning': analysis_result.get('summary', ''),
'trader_decision': analysis_result.get('decision', 'HOLD'), # Same as final for fast analysis
'trader_reasoning': analysis_result.get('summary', ''),
'overview_report': detailed.get('technical', ''),
'fundamental_report': detailed.get('fundamental', ''),
'sentiment_report': detailed.get('sentiment', ''),
'risk_report': risk_report,
# Trading plan
'suggested_entry': trading_plan.get('entry_price'),
'suggested_stop_loss': trading_plan.get('stop_loss'),
'suggested_take_profit': trading_plan.get('take_profit'),
# Scores
'technical_score': scores.get('technical', 50),
'fundamental_score': scores.get('fundamental', 50),
'sentiment_score': scores.get('sentiment', 50),
'key_reasons': analysis_result.get('reasons', []),
'error': analysis_result.get('error')
}
position_analyses.append(position_analysis)
logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}")
except Exception as e:
logger.error(f"Failed to analyze {market}:{symbol}: {e}")
position_analyses.append({
'market': market,
'symbol': symbol,
'name': name,
'error': str(e)
})
# Build comprehensive report
workers = min(len(positions), MAX_PARALLEL_ANALYSIS)
position_analyses: List[Dict[str, Any]] = [None] * len(positions)
with ThreadPoolExecutor(max_workers=workers) as executor:
future_to_idx = {
executor.submit(_analyze_single_position, pos, language): idx
for idx, pos in enumerate(positions)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
position_analyses[idx] = future.result()
except Exception as e:
pos = positions[idx]
position_analyses[idx] = {
'market': pos.get('market'), 'symbol': pos.get('symbol'),
'name': pos.get('name') or pos.get('symbol'), 'error': str(e)
}
analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt)
return {
'success': True,
'analysis': analysis_report,
@@ -254,15 +250,11 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) ->
'analyzed_count': len([p for p in position_analyses if not p.get('error')]),
'timestamp': _now_ts()
}
except Exception as e:
logger.error(f"_run_ai_analysis failed: {e}")
logger.error(traceback.format_exc())
return {
'success': False,
'error': str(e),
'timestamp': _now_ts()
}
return {'success': False, 'error': str(e), 'timestamp': _now_ts()}
def _build_comprehensive_report(
@@ -914,22 +906,67 @@ def run_single_monitor(monitor_id: int, override_language: str = None, user_id:
if override_language:
config['language'] = override_language
# Get positions for this user
# Resolve interval (frontend sends run_interval_minutes, legacy uses interval_minutes)
interval_minutes = int(
config.get('run_interval_minutes')
or config.get('interval_minutes')
or 60
)
# Get positions (or build from config.symbol if no position_ids)
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
# If monitor was created without positions but has symbol in config, build a virtual position
if not positions and config.get('symbol'):
positions = [{
'market': config.get('market', ''),
'symbol': config.get('symbol', ''),
'name': config.get('symbol', ''),
'side': 'long',
'quantity': 0,
'entry_price': 0,
'current_price': 0,
'pnl': 0,
'pnl_percent': 0,
}]
if not positions:
return {'success': False, 'error': 'No positions to analyze'}
# ── Billing: charge per symbol analyzed ──
billing = get_billing_service()
symbol_count = len(positions)
per_symbol_cost = billing.get_feature_cost('ai_analysis')
total_cost = per_symbol_cost * symbol_count
if total_cost > 0 and billing.is_billing_enabled():
user_credits = billing.get_user_credits(monitor_user_id)
if user_credits < total_cost:
logger.warning(
f"Monitor #{monitor_id} skipped: insufficient credits "
f"({user_credits} < {total_cost} for {symbol_count} symbols)"
)
return {
'success': False,
'error': f'Insufficient credits: need {total_cost}, have {user_credits}'
}
for i in range(symbol_count):
pos = positions[i]
ok, msg = billing.check_and_consume(
user_id=monitor_user_id,
feature='ai_analysis',
reference_id=f"monitor_{monitor_id}_{pos.get('symbol', '')}"
)
if not ok:
logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i+1}: {msg}")
break
# Run analysis based on type
if monitor_type == 'ai':
result = _run_ai_analysis(positions, config)
else:
# For other types, we can add price_alert, pnl_alert logic later
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
# Update monitor record
interval_minutes = int(config.get('interval_minutes') or 60)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(