From a65cca62f8d907ade5f40bec1085ce8e6059612b Mon Sep 17 00:00:00 2001 From: Lxy Date: Sat, 23 May 2026 00:22:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0AI=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=B9=B6=E8=B0=83=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/analysis_db.py | 4 + app/analysis_models.py | 13 + .../futures_analysis.cpython-311.pyc | Bin 44949 -> 50015 bytes app/api/futures_analysis.py | 112 ++++- app/services/ai_analysis.py | 459 ++++++++++++++++++ app/static/futures_analysis.css | 390 +++++++++++++++ app/static/futures_analysis.html | 33 ++ app/static/futures_analysis.js | 348 +++++++++++++ check_db.py | 55 +++ data/ai_analysis_prompt.md | 201 ++++++++ data/buffer.db | Bin 2035712 -> 2035712 bytes init_analysis_db.py | 24 + verify_db.py | 27 ++ 13 files changed, 1665 insertions(+), 1 deletion(-) create mode 100644 app/services/ai_analysis.py create mode 100644 check_db.py create mode 100644 data/ai_analysis_prompt.md create mode 100644 init_analysis_db.py create mode 100644 verify_db.py diff --git a/app/analysis_db.py b/app/analysis_db.py index 930f25f..8593935 100644 --- a/app/analysis_db.py +++ b/app/analysis_db.py @@ -33,4 +33,8 @@ def get_analysis_db(): def init_analysis_db(): """初始化期货智析数据库表""" + # 确保导入所有模型类,使其注册到 AnalysisBase + from app import analysis_models + # 直接导入 analysis_models 模块中的所有类 + from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings, AIAnalysisCache AnalysisBase.metadata.create_all(bind=analysis_engine) diff --git a/app/analysis_models.py b/app/analysis_models.py index 8a84288..eb9bfb7 100644 --- a/app/analysis_models.py +++ b/app/analysis_models.py @@ -86,3 +86,16 @@ class AnalysisSettings(AnalysisBase): def __repr__(self): return f"" + + +class AIAnalysisCache(AnalysisBase): + """AI分析缓存表""" + __tablename__ = "ai_analysis_cache" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(32), nullable=False, index=True, comment="品种合约代码") + analysis_data = Column(JSON, nullable=False, comment="AI分析结果数据") + created_at = Column(DateTime, nullable=False, default=datetime.now, index=True, comment="分析时间") + + def __repr__(self): + return f"" diff --git a/app/api/__pycache__/futures_analysis.cpython-311.pyc b/app/api/__pycache__/futures_analysis.cpython-311.pyc index 3044ec9fd4c0a792d30550d57f065d9cc5173d73..377031de042f360d3afde21348b0c0d7e3dea1d8 100644 GIT binary patch delta 10430 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 diff --git a/app/api/futures_analysis.py b/app/api/futures_analysis.py index 710c9b2..ad75ff7 100644 --- a/app/api/futures_analysis.py +++ b/app/api/futures_analysis.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from app.database import get_db from app.analysis_db import get_analysis_db -from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings +from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings, AIAnalysisCache from app.services.cache import get_cached_data, get_latest_cached, save_market_data from app.services.collector import fetch_symbol_data @@ -694,6 +694,7 @@ def delete_ai_model(model_id: int, adb: Session = Depends(get_analysis_db)): # ==================== 数据刷新接口 ==================== from app.services.cache import needs_refresh, get_symbol_timestamp +from app.services.ai_analysis import AIFuturesAnalyzer refresh_lock = threading.Lock() refresh_status = {"running": False, "progress": 0, "total": 0, "message": ""} @@ -786,3 +787,112 @@ def refresh_all_symbols_api(background_tasks: BackgroundTasks): def get_refresh_status(): """获取刷新状态""" return {"success": True, "data": refresh_status} + + +# ==================== AI智能分析接口 ==================== + +@router.post("/ai-analysis/{symbol}") +def run_ai_analysis(symbol: str, db: Session = Depends(get_db), analysis_db: Session = Depends(get_analysis_db)): + """执行AI智能分析""" + try: + analyzer = AIFuturesAnalyzer(db, analysis_db) + result = analyzer.analyze(symbol) + + if result.get("success"): + return { + "success": True, + "data": result["data"] + } + else: + return { + "success": False, + "error": result.get("error", "AI分析失败") + } + except Exception as e: + logger.error(f"AI分析失败: {e}") + return { + "success": False, + "error": f"AI分析失败: {str(e)}" + } + + +@router.get("/ai-analysis/{symbol}") +def get_ai_analysis(symbol: str, force_refresh: bool = False, db: Session = Depends(get_db), analysis_db: Session = Depends(get_analysis_db)): + """获取AI分析结果(可选择是否强制刷新)""" + try: + analyzer = AIFuturesAnalyzer(db, analysis_db) + + if force_refresh: + result = analyzer.analyze(symbol) + if result.get("success"): + return { + "success": True, + "data": result["data"], + "is_cached": False + } + else: + return { + "success": False, + "error": result.get("error", "AI分析失败") + } + + cache = analyzer.get_latest_cache(symbol) + if cache: + return { + "success": True, + "data": { + "id": cache.id, + "symbol": cache.symbol, + "analysis_time": cache.created_at.isoformat(), + "result": cache.analysis_data + }, + "is_cached": True + } + + result = analyzer.analyze(symbol) + if result.get("success"): + return { + "success": True, + "data": result["data"], + "is_cached": False + } + else: + return { + "success": False, + "error": result.get("error", "未找到分析结果") + } + except Exception as e: + logger.error(f"获取AI分析结果失败: {e}") + return { + "success": False, + "error": f"获取AI分析失败: {str(e)}" + } + + +@router.get("/ai-analysis/{symbol}/history") +def get_ai_analysis_history(symbol: str, limit: int = 10, analysis_db: Session = Depends(get_analysis_db)): + """获取AI分析历史记录""" + try: + records = analysis_db.query(AIAnalysisCache).filter( + AIAnalysisCache.symbol == symbol + ).order_by( + AIAnalysisCache.created_at.desc() + ).limit(limit).all() + + return { + "success": True, + "data": [{ + "id": r.id, + "symbol": r.symbol, + "analysis_time": r.created_at.isoformat(), + "summary": r.analysis_data.get("summary", ""), + "trading_suggestion": r.analysis_data.get("trading_suggestion", {}), + "confidence": r.analysis_data.get("trading_suggestion", {}).get("confidence", 0) + } for r in records] + } + except Exception as e: + logger.error(f"获取AI分析历史失败: {e}") + return { + "success": False, + "error": f"获取历史记录失败: {str(e)}" + } diff --git a/app/services/ai_analysis.py b/app/services/ai_analysis.py new file mode 100644 index 0000000..dddc5a3 --- /dev/null +++ b/app/services/ai_analysis.py @@ -0,0 +1,459 @@ +""" +AI分析服务 - 期货四维联合分析 +""" +import json +import re +import logging +from datetime import datetime +from typing import Dict, List, Optional +from sqlalchemy.orm import Session + +from app.analysis_models import AIAnalysisCache +from app.services.cache import get_cached_data, get_latest_cached +from pathlib import Path + +CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config" +AI_CONFIG_FILE = CONFIG_DIR / "ai_config.json" + +logger = logging.getLogger(__name__) + + +class AIAnalysisPrompt: + """AI分析提示词管理器""" + + SYSTEM_PROMPT = """你是一位拥有20年实战经验的资深金融交易分析师,精通A股市场与商品期货的技术分析。 +你的核心使命是基于提供的K线数据,执行【四维联合判断分析法(4D-XV)】,并提供包含风控红线审查的客观交易策略。 + +你的分析必须遵循以下原则: +1. 数据驱动:所有结论必须基于输入的JSON数据,严禁凭空捏造。 +2. 四维共振:任何交易建议必须经过MACD(趋势)、成交量(资金)、KDJ(时机)、多周期(方向)的交叉验证。 +3. 红线否决:如果数据触发【17条交易红线】,必须直接给出【禁止交易】或【止损】的建议。 +4. 客观中立:不使用绝对化表述,提供情景预案(概率估算)。 +5. 动态切换:遇到关键位放量突破时,立即切换右侧思维,不逆势死扛。""" + + ANALYSIS_TEMPLATE = """请严格按照以下JSON格式输出分析结果(不要输出任何其他内容): + +{ + "summary": "一句话总结当前市场状态", + "four_dimensional": { + "60min": { + "macd": {"trend": "up/down/neutral", "histogram": "放大/缩小/背离", "position": "零轴上/下"}, + "volume": {"status": "放量上涨/缩量回调/趋势量能/拐点量能", "ratio": 1.5}, + "kdj": {"k": 85, "d": 80, "j": 95, "status": "超买/超卖/中性", "signal": "金叉/死叉/钝化"}, + "conclusion": "定大势结论" + }, + "30min": { + "macd": {"trend": "up/down/neutral", "histogram": "放大/缩小/背离", "position": "零轴上/下"}, + "volume": {"status": "放量/缩量/正常", "ratio": 1.5}, + "kdj": {"k": 50, "d": 45, "j": 60, "status": "中性", "signal": "金叉/死叉"}, + "conclusion": "找拐点结论" + }, + "15min": { + "macd": {"trend": "up/down/neutral", "histogram": "放大/缩小/背离", "position": "零轴上/下"}, + "volume": {"status": "放量/缩量/正常", "ratio": 2.0}, + "kdj": {"k": 30, "d": 25, "j": 40, "status": "超卖", "signal": "金叉/死叉"}, + "conclusion": "择入场结论" + } + }, + "kdj_diagnosis": { + "current_status": "超买/超卖/中性区域", + "divergence": "是否存在顶/底背离", + "paralysis": "是否钝化(持续>6根K线)", + "recommendation": "KDJ使用建议" + }, + "pivot_points": { + "r2": 7500, + "r1": 7350, + "pp": 7200, + "s1": 7050, + "s2": 6900, + "validation": { + "test_count": 3, + "volume_confirmed": true, + "multi_period_resonance": true, + "breakback_confirmed": false + } + }, + "red_lines_check": { + "passed": true, + "violated": [], + "warnings": ["暂无红线警告"] + }, + "discipline_score": { + "total": 9, + "max": 11, + "details": { + "trend": true, + "position": true, + "signal": true, + "risk": true, + "mindset": true + } + }, + "trading_suggestion": { + "direction": "做多/做空/观望", + "confidence": 75, + "entry_range": {"min": 7050, "max": 7100}, + "stop_loss": 6950, + "take_profit": [{"price": 7200, "ratio": 50}, {"price": 7350, "ratio": 30}, {"price": 7500, "ratio": 20}], + "position_size": "轻仓/半仓/重仓", + "reason": "做多理由" + }, + "scenario_plans": { + "breakthrough": {"probability": 35, "action": "放量突破关键位,跟随右侧思维"}, + "consolidation": {"probability": 40, "action": "R1-S1区间内高抛低吸"}, + "reversal": {"probability": 15, "action": "MACD顶/底背离+量能不足,立即止损反手"}, + "news_impact": {"probability": 10, "action": "减仓50%规避不确定性"} + }, + "risk_warnings": [ + "技术指标具有滞后性,历史表现不代表未来", + "需结合基本面和市场情绪综合判断" + ], + "experience_lessons": [ + "警惕缩量创新高,可能是诱多信号", + "KDJ超买钝化中不宜逆势做空" + ] +}""" + + @classmethod + def build_prompt(cls, symbol: str, data: Dict) -> str: + """构建完整的AI分析提示词""" + prompt = f"""{cls.SYSTEM_PROMPT} + +现在请分析以下期货品种的K线数据: + +## 品种信息 +- 合约代码:{symbol} +- 当前价格:{data.get('current_price', 'N/A')} + +## 多周期K线数据 +```json +{json.dumps(data, ensure_ascii=False, indent=2)} +``` + +{cls.ANALYSIS_TEMPLATE}""" + + return prompt + + +class AIFuturesAnalyzer: + """AI期货分析器""" + + def __init__(self, db: Session, analysis_db: Session = None): + self.db = db + self.analysis_db = analysis_db or db + + def get_active_model(self) -> Optional[Dict]: + """获取当前激活的AI模型配置""" + try: + if not AI_CONFIG_FILE.exists(): + return None + + with open(AI_CONFIG_FILE, "r", encoding="utf-8") as f: + config = json.load(f) + + models = config.get("models", []) + active_model_name = config.get("active_model") + + if active_model_name: + for model in models: + if model.get("model_name") == active_model_name and model.get("enabled", True): + return model + + for model in models: + if model.get("enabled", True): + logger.warning(f"未找到匹配的激活模型,使用第一个启用的模型: {model.get('model_name')}") + return model + + return None + except Exception as e: + logger.error(f"加载AI配置失败: {e}") + return None + + def prepare_multi_period_data(self, symbol: str) -> Optional[Dict]: + """准备多周期数据用于AI分析""" + cached_data = get_cached_data( + self.db, + symbol, + "futures", + ["5min", "15min", "30min", "60min", "daily"] + ) + + if not cached_data or not cached_data.get("timeframes"): + return None + + timeframes = cached_data.get("timeframes", {}) + current_price = cached_data.get("current_price") + + result = { + "symbol": symbol, + "current_price": current_price, + "timeframes": {} + } + + for period_name, db_period in [("5min", "5min"), ("15min", "15min"), + ("30min", "30min"), ("60min", "60min"), + ("daily", "daily")]: + if period_name in timeframes and timeframes[period_name]: + candles = timeframes[period_name] + if len(candles) >= 20: + result["timeframes"][period_name] = self._analyze_timeframe(candles, period_name) + + return result + + def _analyze_timeframe(self, candles: List[Dict], period: str) -> Dict: + """分析单个周期的技术指标""" + if not candles or len(candles) < 20: + return {} + + closes = [float(c.get("close", 0)) for c in candles] + highs = [float(c.get("high", 0)) for c in candles] + lows = [float(c.get("low", 0)) for c in candles] + volumes = [float(c.get("volume", 0)) for c in candles] + + ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else None + ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else None + + macd_data = self._calc_macd(closes) + kdj_data = self._calc_kdj(highs, lows, closes) + + avg_volume = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0 + current_volume = volumes[-1] if volumes else 0 + + return { + "trend": "up" if closes[-1] > closes[0] else "down", + "ma10": round(ma10, 2) if ma10 else None, + "ma20": round(ma20, 2) if ma20 else None, + "macd_dif": round(macd_data["dif"], 4), + "macd_dea": round(macd_data["dea"], 4), + "macd_histogram": round(macd_data["histogram"], 4), + "kdj_k": kdj_data["k"], + "kdj_d": kdj_data["d"], + "kdj_j": kdj_data["j"], + "volume_avg": round(avg_volume, 2), + "volume_current": round(current_volume, 2), + "volume_ratio": round(current_volume / avg_volume, 2) if avg_volume > 0 else 1, + "candles": candles[-10:] if len(candles) > 10 else candles + } + + def _calc_macd(self, closes: List[float]) -> Dict: + """计算MACD指标""" + if len(closes) < 26: + return {"dif": 0, "dea": 0, "histogram": 0} + + ema12 = self._calc_ema(closes, 12) + ema26 = self._calc_ema(closes, 26) + + dif = ema12 - ema26 + dea = self._calc_ema([dif] * len(closes), 9) + histogram = 2 * (dif - dea) + + return {"dif": dif, "dea": dea, "histogram": histogram} + + def _calc_ema(self, data: List[float], period: int) -> float: + """计算EMA""" + if len(data) < period: + return sum(data) / len(data) if data else 0 + + multiplier = 2 / (period + 1) + ema = sum(data[:period]) / period + + for i in range(period, len(data)): + ema = (data[i] - ema) * multiplier + ema + + return ema + + def _calc_kdj(self, highs: List[float], lows: List[float], closes: List[float]) -> Dict: + """计算KDJ指标""" + if len(closes) < 9: + return {"k": 50, "d": 50, "j": 50} + + period = 9 + recent_highs = highs[-period:] + recent_lows = lows[-period:] + recent_closes = closes[-period:] + + highest = max(recent_highs) + lowest = min(recent_lows) + current = recent_closes[-1] + + if highest == lowest: + rsv = 50 + else: + rsv = (current - lowest) / (highest - lowest) * 100 + + k = rsv * 2 / 3 + 50 / 3 + d = k * 2 / 3 + 50 / 3 + j = 3 * k - 2 * d + + return {"k": round(k, 2), "d": round(d, 2), "j": round(j, 2)} + + def call_ai_model(self, prompt: str, model: Dict) -> Optional[str]: + """调用AI模型""" + try: + import requests + + api_base = model.get("api_base", "https://api.openai.com/v1") + api_key = model.get("api_key", "") + model_id = model.get("model_id") or model.get("model_name", "") + + logger.info(f"========== AI模型调用开始 ==========") + logger.info(f"API Base: {api_base}") + logger.info(f"API Key: {'已配置' if api_key else '未配置'} ({api_key[:10]}...)" if api_key else "API Key: 未配置") + logger.info(f"Model ID: {model_id}") + logger.info(f"Temperature: {model.get('temperature', 0.7)}") + logger.info(f"Max Tokens: {model.get('max_tokens', 2000)}") + + if not api_key: + logger.error("API Key 未配置,无法调用AI模型") + return None + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + + payload = { + "model": model_id, + "messages": [ + {"role": "user", "content": prompt} + ], + "temperature": model.get("temperature", 0.7), + "max_tokens": model.get("max_tokens", 2000) + } + + url = f"{api_base}/chat/completions" + logger.info(f"请求 URL: {url}") + logger.info(f"请求 Payload 大小: {len(str(payload))} 字符") + + response = requests.post( + url, + headers=headers, + json=payload, + timeout=180 # 增加到180秒(3分钟)超时 + ) + + logger.info(f"响应状态码: {response.status_code}") + + if response.status_code == 200: + result = response.json() + content = result["choices"][0]["message"]["content"] + logger.info(f"AI响应成功,内容长度: {len(content)} 字符") + logger.info(f"========== AI模型调用成功 ==========") + return content + else: + logger.error(f"AI模型调用失败:") + logger.error(f" 状态码: {response.status_code}") + logger.error(f" 响应内容: {response.text}") + logger.error(f"========== AI模型调用失败 ==========") + return None + + except requests.exceptions.Timeout: + logger.error("AI模型调用超时(超过60秒)") + logger.error(f"========== AI模型调用失败 ==========") + return None + except requests.exceptions.ConnectionError as e: + logger.error(f"AI模型连接错误: {e}") + logger.error(f"========== AI模型调用失败 ==========") + return None + except requests.exceptions.RequestException as e: + logger.error(f"AI模型请求异常: {e}") + logger.error(f"========== AI模型调用失败 ==========") + return None + except Exception as e: + logger.error(f"调用AI模型未知异常: {e}", exc_info=True) + logger.error(f"========== AI模型调用失败 ==========") + return None + + def parse_ai_response(self, response: str) -> Optional[Dict]: + """解析AI返回的JSON响应""" + try: + json_match = re.search(r'\{[\s\S]*\}', response) + if json_match: + return json.loads(json_match.group(0)) + return None + except Exception as e: + logger.error(f"解析AI响应失败: {e}") + return None + + def save_analysis_cache(self, symbol: str, analysis_data: Dict) -> AIAnalysisCache: + """保存AI分析结果到缓存""" + cache = AIAnalysisCache( + symbol=symbol, + analysis_data=analysis_data, + created_at=datetime.now() + ) + + self.analysis_db.add(cache) + self.analysis_db.commit() + self.analysis_db.refresh(cache) + + return cache + + def get_latest_cache(self, symbol: str) -> Optional[AIAnalysisCache]: + """获取最新的AI分析缓存""" + return self.analysis_db.query(AIAnalysisCache).filter( + AIAnalysisCache.symbol == symbol + ).order_by(AIAnalysisCache.created_at.desc()).first() + + def analyze(self, symbol: str) -> Dict: + """执行完整的AI分析流程""" + logger.info(f"===== 开始AI分析: {symbol} =====") + + model = self.get_active_model() + if not model: + logger.error("未找到激活的AI模型配置") + return { + "success": False, + "error": "未配置AI模型或模型未激活" + } + + logger.info(f"使用AI模型: {model.get('model_name')}") + + data = self.prepare_multi_period_data(symbol) + if not data: + logger.error(f"未找到 {symbol} 的市场数据") + return { + "success": False, + "error": f"未找到 {symbol} 的市场数据" + } + + logger.info(f"市场数据准备成功,包含周期: {list(data.get('timeframes', {}).keys())}") + + prompt = AIAnalysisPrompt.build_prompt(symbol, data) + logger.info(f"AI提示词生成完成,长度: {len(prompt)} 字符") + + response = self.call_ai_model(prompt, model) + + if not response: + logger.error("AI模型返回空响应") + return { + "success": False, + "error": "AI模型调用失败" + } + + logger.info(f"AI模型响应接收成功,长度: {len(response)} 字符") + + analysis_result = self.parse_ai_response(response) + if not analysis_result: + logger.error(f"AI响应解析失败,原始响应前100字符: {response[:100]}") + return { + "success": False, + "error": "AI响应解析失败" + } + + logger.info(f"AI响应解析成功") + + cache = self.save_analysis_cache(symbol, analysis_result) + logger.info(f"分析结果已保存到缓存,ID: {cache.id}") + logger.info(f"===== AI分析完成: {symbol} =====") + + return { + "success": True, + "data": { + "id": cache.id, + "symbol": symbol, + "analysis_time": cache.created_at.isoformat(), + "result": analysis_result + } + } diff --git a/app/static/futures_analysis.css b/app/static/futures_analysis.css index 3e5106e..d60e210 100644 --- a/app/static/futures_analysis.css +++ b/app/static/futures_analysis.css @@ -1597,6 +1597,396 @@ body.theme-minimal .sort-select select:hover { border-color: var(--text-muted); } +/* ============================================ + AI智能分析样式 + ============================================ */ + +.panel-header-actions { + display: flex; + gap: 8px; + margin-left: auto; +} + +.ai-analyze-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: linear-gradient(135deg, var(--purple), var(--cyan)); + border: none; + border-radius: 6px; + color: white; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.ai-analyze-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--purple-glow); +} + +.ai-analyze-btn:active { + transform: translateY(0); +} + +.ai-analyze-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.ai-analyze-btn i { + font-size: 11px; +} + +.ai-analysis-content { + min-height: 120px; +} + +.ai-analysis-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--text-muted); +} + +.ai-analysis-placeholder i { + font-size: 32px; + margin-bottom: 12px; + opacity: 0.5; +} + +.ai-analysis-placeholder p { + font-size: 13px; + text-align: center; +} + +.ai-analysis-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 12px; +} + +.ai-analysis-loading i { + font-size: 28px; + color: var(--purple); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.1); } +} + +.ai-analysis-result { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ai-summary { + padding: 12px; + background: rgba(139, 92, 246, 0.08); + border: 1px solid rgba(139, 92, 246, 0.15); + border-radius: 8px; + font-size: 13px; + line-height: 1.6; + color: var(--text-primary); +} + +.ai-suggestion-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; +} + +.ai-suggestion-direction { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-suggestion-direction i { + font-size: 16px; +} + +.ai-suggestion-direction.long i { + color: var(--green); +} + +.ai-suggestion-direction.short i { + color: var(--red); +} + +.ai-suggestion-direction.neutral i { + color: var(--amber); +} + +.ai-confidence { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.ai-confidence-bar { + width: 60px; + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; +} + +.ai-confidence-fill { + height: 100%; + background: linear-gradient(90deg, var(--amber), var(--green)); + border-radius: 2px; + transition: width 0.5s ease; +} + +.ai-key-metrics { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.ai-metric-item { + display: flex; + justify-content: space-between; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.02); + border-radius: 6px; + font-size: 12px; +} + +.ai-metric-item .label { + color: var(--text-muted); +} + +.ai-metric-item .value { + font-weight: 600; + color: var(--text-primary); +} + +.ai-metric-item .value.up { + color: var(--green); +} + +.ai-metric-item .value.down { + color: var(--red); +} + +.ai-timestamp { + text-align: center; + font-size: 11px; + color: var(--text-muted); + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +/* AI分析详情模态框 */ +.modal-large { + max-width: 900px; + width: 90%; +} + +.modal-large .modal-body { + max-height: 80vh; + overflow-y: auto; +} + +.ai-modal-section { + margin-bottom: 24px; +} + +.ai-modal-section:last-child { + margin-bottom: 0; +} + +.ai-modal-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + color: var(--cyan); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.ai-modal-section-title i { + font-size: 16px; +} + +.four-dimensional-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.four-dimensional-table th, +.four-dimensional-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.four-dimensional-table th { + background: rgba(139, 92, 246, 0.08); + color: var(--purple); + font-weight: 600; + font-size: 12px; +} + +.four-dimensional-table td { + color: var(--text-secondary); +} + +.four-dimensional-table tr:last-child td { + border-bottom: none; +} + +.four-dimensional-table .period-cell { + font-weight: 600; + color: var(--text-primary); +} + +.scenario-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.scenario-card { + padding: 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + border-left: 3px solid var(--cyan); +} + +.scenario-card.breakthrough { + border-left-color: var(--green); +} + +.scenario-card.consolidation { + border-left-color: var(--amber); +} + +.scenario-card.reversal { + border-left-color: var(--red); +} + +.scenario-card.news { + border-left-color: var(--purple); +} + +.scenario-probability { + display: inline-block; + padding: 2px 8px; + background: rgba(139, 92, 246, 0.15); + border-radius: 4px; + font-size: 11px; + font-weight: 600; + color: var(--purple); + margin-bottom: 6px; +} + +.scenario-action { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +.red-lines-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.red-line-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.15); + border-radius: 6px; + font-size: 12px; + color: var(--red); +} + +.red-line-item.pass { + background: rgba(16, 185, 129, 0.08); + border-color: rgba(16, 185, 129, 0.15); + color: var(--green); +} + +.discipline-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.discipline-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; +} + +.discipline-item i { + font-size: 16px; +} + +.discipline-item.pass i { + color: var(--green); +} + +.discipline-item.fail i { + color: var(--red); +} + +.discipline-label { + font-size: 12px; + color: var(--text-secondary); +} + +.ai-experience-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.experience-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.15); + border-radius: 6px; + font-size: 12px; + color: var(--amber); +} + +.experience-item i { + margin-top: 2px; +} + body.theme-minimal .sort-select select:focus { border-color: var(--cyan); box-shadow: 0 0 0 0.125rem rgba(73, 79, 223, 0.2); diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html index c1e1662..853594e 100644 --- a/app/static/futures_analysis.html +++ b/app/static/futures_analysis.html @@ -362,6 +362,26 @@ + +
+
+ + AI 四维分析 +
+ +
+
+
+
+ +

点击"智能分析"按钮获取AI分析结果

+
+
+
+
@@ -385,6 +405,19 @@
+ + +