diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d499d7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +.venv/ + + +# Logs +*.log +data/collector.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Cache +cache/*.pkl diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..2b71ea3 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,185 @@ +# Design System Inspired by Revolut + +## 1. Visual Theme & Atmosphere + +Revolut's website is fintech confidence distilled into pixels — a design system that communicates "your money is in capable hands" through massive typography, generous whitespace, and a disciplined neutral palette. The visual language is built on Aeonik Pro, a geometric grotesque that creates billboard-scale headlines at 136px with weight 500 and aggressive negative tracking (-2.72px). This isn't subtle branding; it's fintech at stadium scale. + +The color system is built on a comprehensive `--rui-*` (Revolut UI) token architecture with semantic naming for every state: danger (`#e23b4a`), warning (`#ec7e00`), teal (`#00a87e`), blue (`#494fdf`), deep-pink (`#e61e49`), and more. But the marketing surface itself is remarkably restrained — near-black (`#191c1f`) and pure white (`#ffffff`) dominate, with the colorful semantic tokens reserved for the product interface, not the marketing page. + +What distinguishes Revolut is its pill-everything button system. Every button uses 9999px radius — primary dark (`#191c1f`), secondary light (`#f4f4f4`), outlined (`transparent + 2px solid`), and ghost on dark (`rgba(244,244,244,0.1) + 2px solid`). The padding is generous (14px 32px–34px), creating large, confident touch targets. Combined with Inter for body text at various weights and positive letter-spacing (0.16px–0.24px), the result is a design that feels both premium and accessible — banking for the modern era. + +**Key Characteristics:** +- Aeonik Pro display at 136px weight 500 — billboard-scale fintech headlines +- Near-black (`#191c1f`) + white binary with comprehensive `--rui-*` semantic tokens +- Universal pill buttons (9999px radius) with generous padding (14px 32px) +- Inter for body text with positive letter-spacing (0.16px–0.24px) +- Rich semantic color system: blue, teal, pink, yellow, green, brown, danger, warning +- Zero shadows detected — depth through color contrast only +- Tight display line-heights (1.00) with relaxed body (1.50–1.56) + +## 2. Color Palette & Roles + +### Primary +- **Revolut Dark** (`#191c1f`): Primary dark surface, button background, near-black text +- **Pure White** (`#ffffff`): `--rui-color-action-label`, primary light surface +- **Light Surface** (`#f4f4f4`): Secondary button background, subtle surface + +### Brand / Interactive +- **Revolut Blue** (`#494fdf`): `--rui-color-blue`, primary brand blue +- **Action Blue** (`#4f55f1`): `--rui-color-action-photo-header-text`, header accent +- **Blue Text** (`#376cd5`): `--website-color-blue-text`, link blue + +### Semantic +- **Danger Red** (`#e23b4a`): `--rui-color-danger`, error/destructive +- **Deep Pink** (`#e61e49`): `--rui-color-deep-pink`, critical accent +- **Warning Orange** (`#ec7e00`): `--rui-color-warning`, warning states +- **Yellow** (`#b09000`): `--rui-color-yellow`, attention +- **Teal** (`#00a87e`): `--rui-color-teal`, success/positive +- **Light Green** (`#428619`): `--rui-color-light-green`, secondary success +- **Green Text** (`#006400`): `--website-color-green-text`, green text +- **Light Blue** (`#007bc2`): `--rui-color-light-blue`, informational +- **Brown** (`#936d62`): `--rui-color-brown`, warm neutral accent +- **Red Text** (`#8b0000`): `--website-color-red-text`, dark red text + +### Neutral Scale +- **Mid Slate** (`#505a63`): Secondary text +- **Cool Gray** (`#8d969e`): Muted text, tertiary +- **Gray Tone** (`#c9c9cd`): `--rui-color-grey-tone-20`, borders/dividers + +## 3. Typography Rules + +### Font Families +- **Display**: `Aeonik Pro` — geometric grotesque, no detected fallbacks +- **Body / UI**: `Inter` — standard system sans +- **Fallback**: `Arial` for specific button contexts + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Mega | Aeonik Pro | 136px (8.50rem) | 500 | 1.00 (tight) | -2.72px | Stadium-scale hero | +| Display Hero | Aeonik Pro | 80px (5.00rem) | 500 | 1.00 (tight) | -0.8px | Primary hero | +| Section Heading | Aeonik Pro | 48px (3.00rem) | 500 | 1.21 (tight) | -0.48px | Feature sections | +| Sub-heading | Aeonik Pro | 40px (2.50rem) | 500 | 1.20 (tight) | -0.4px | Sub-sections | +| Card Title | Aeonik Pro | 32px (2.00rem) | 500 | 1.19 (tight) | -0.32px | Card headings | +| Feature Title | Aeonik Pro | 24px (1.50rem) | 400 | 1.33 | normal | Light headings | +| Nav / UI | Aeonik Pro | 20px (1.25rem) | 500 | 1.40 | normal | Navigation, buttons | +| Body Large | Inter | 18px (1.13rem) | 400 | 1.56 | -0.09px | Introductions | +| Body | Inter | 16px (1.00rem) | 400 | 1.50 | 0.24px | Standard reading | +| Body Semibold | Inter | 16px (1.00rem) | 600 | 1.50 | 0.16px | Emphasized body | +| Body Bold Link | Inter | 16px (1.00rem) | 700 | 1.50 | 0.24px | Bold links | + +### Principles +- **Weight 500 as display default**: Aeonik Pro uses medium (500) for ALL headings — no bold. This creates authority through size and tracking, not weight. +- **Billboard tracking**: -2.72px at 136px is extremely compressed — text designed to be read at a glance, like airport signage. +- **Positive tracking on body**: Inter uses +0.16px to +0.24px, creating airy, well-spaced reading text that contrasts with the compressed headings. + +## 4. Component Stylings + +### Buttons + +**Primary Dark Pill** +- Background: `#191c1f` +- Text: `#ffffff` +- Padding: 14px 32px +- Radius: 9999px (full pill) +- Hover: opacity 0.85 +- Focus: `0 0 0 0.125rem` ring + +**Secondary Light Pill** +- Background: `#f4f4f4` +- Text: `#000000` +- Padding: 14px 34px +- Radius: 9999px +- Hover: opacity 0.85 + +**Outlined Pill** +- Background: transparent +- Text: `#191c1f` +- Border: `2px solid #191c1f` +- Padding: 14px 32px +- Radius: 9999px + +**Ghost on Dark** +- Background: `rgba(244, 244, 244, 0.1)` +- Text: `#f4f4f4` +- Border: `2px solid #f4f4f4` +- Padding: 14px 32px +- Radius: 9999px + +### Cards & Containers +- Radius: 12px (small), 20px (cards) +- No shadows — flat surfaces with color contrast +- Dark and light section alternation + +### Navigation +- Aeonik Pro 20px weight 500 +- Clean header, hamburger toggle at 12px radius +- Pill CTAs right-aligned + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 6px, 8px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 80px, 88px, 120px +- Large section spacing: 80px–120px + +### Border Radius Scale +- Standard (12px): Navigation, small buttons +- Card (20px): Feature cards +- Pill (9999px): All buttons + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Everything — Revolut uses zero shadows | +| Focus | `0 0 0 0.125rem` ring | Accessibility focus | + +**Shadow Philosophy**: Revolut uses ZERO shadows. Depth comes entirely from the dark/light section contrast and the generous whitespace between elements. + +## 7. Do's and Don'ts + +### Do +- Use Aeonik Pro weight 500 for all display headings +- Apply 9999px radius to all buttons — pill shape is universal +- Use generous button padding (14px 32px) +- Keep the palette to near-black + white for marketing surfaces +- Apply positive letter-spacing on Inter body text + +### Don't +- Don't use shadows — Revolut is flat by design +- Don't use bold (700) for Aeonik Pro headings — 500 is the weight +- Don't use small buttons — the generous padding is intentional +- Don't apply semantic colors to marketing surfaces — they're for the product + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <400px | Compact, single column | +| Mobile | 400–720px | Standard mobile | +| Tablet | 720–1024px | 2-column layouts | +| Desktop | 1024–1280px | Standard desktop | +| Large | 1280–1920px | Full layout | + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Dark: Revolut Dark (`#191c1f`) +- Light: White (`#ffffff`) +- Surface: Light (`#f4f4f4`) +- Blue: Revolut Blue (`#494fdf`) +- Danger: Red (`#e23b4a`) +- Success: Teal (`#00a87e`) + +### Example Component Prompts +- "Create a hero: white background. Headline at 136px Aeonik Pro weight 500, line-height 1.00, letter-spacing -2.72px, #191c1f text. Dark pill CTA (#191c1f, 9999px, 14px 32px). Outlined pill secondary (transparent, 2px solid #191c1f)." +- "Build a pill button: #191c1f background, white text, 9999px radius, 14px 32px padding, 20px Aeonik Pro weight 500. Hover: opacity 0.85." + +### Iteration Guide +1. Aeonik Pro 500 for headings — never bold +2. All buttons are pills (9999px) with generous padding +3. Zero shadows — flat is the Revolut identity +4. Near-black + white for marketing, semantic colors for product diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc index 5da7f5f..a2eb7f4 100644 Binary files a/app/__pycache__/__init__.cpython-311.pyc and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/config.cpython-311.pyc b/app/__pycache__/config.cpython-311.pyc index e0c1fe6..bb4ef7d 100644 Binary files a/app/__pycache__/config.cpython-311.pyc and b/app/__pycache__/config.cpython-311.pyc differ diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc index 507bc0c..caaca94 100644 Binary files a/app/__pycache__/database.cpython-311.pyc and b/app/__pycache__/database.cpython-311.pyc differ diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc index eef9d37..599f6b0 100644 Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ diff --git a/app/__pycache__/models.cpython-311.pyc b/app/__pycache__/models.cpython-311.pyc index 9f5a968..7e08689 100644 Binary files a/app/__pycache__/models.cpython-311.pyc and b/app/__pycache__/models.cpython-311.pyc differ diff --git a/app/__pycache__/schemas.cpython-311.pyc b/app/__pycache__/schemas.cpython-311.pyc index 0e2ed94..7cafdde 100644 Binary files a/app/__pycache__/schemas.cpython-311.pyc and b/app/__pycache__/schemas.cpython-311.pyc differ diff --git a/app/analysis_db.py b/app/analysis_db.py new file mode 100644 index 0000000..8593935 --- /dev/null +++ b/app/analysis_db.py @@ -0,0 +1,40 @@ +""" +期货智析数据库 - 独立存储 +""" +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from datetime import datetime + +# 数据目录 +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +DATA_DIR.mkdir(parents=True, exist_ok=True) + +ANALYSIS_DB_PATH = DATA_DIR / "futures_analysis.db" + +analysis_engine = create_engine( + f"sqlite:///{ANALYSIS_DB_PATH}", + connect_args={"check_same_thread": False}, + pool_pre_ping=True, +) + +AnalysisSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=analysis_engine) +AnalysisBase = declarative_base() + + +def get_analysis_db(): + """获取期货智析数据库会话""" + db = AnalysisSessionLocal() + try: + yield db + finally: + db.close() + + +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 new file mode 100644 index 0000000..9557f0b --- /dev/null +++ b/app/analysis_models.py @@ -0,0 +1,102 @@ +""" +期货智析数据模型 +""" +from datetime import datetime +from sqlalchemy import Column, String, Integer, Float, Text, DateTime, Boolean, Index, UniqueConstraint, JSON +from app.analysis_db import AnalysisBase + + +class FuturesAnalysis(AnalysisBase): + """期货分析报告表""" + __tablename__ = "futures_analysis" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(32), nullable=False, index=True, comment="品种合约代码") + analysis_time = Column(DateTime, nullable=False, default=datetime.now, index=True, comment="分析时间") + period = Column(String(16), nullable=False, default="15min", comment="分析周期") + # 分析结果 + suggestion = Column(String(32), nullable=True, comment="交易建议: 逢低做多/逢高做空/观望等待") + suggestion_type = Column(String(16), nullable=True, comment="建议类型: up/down/neutral") + entry_price = Column(Float, nullable=True, comment="建议入场价") + target_price = Column(Float, nullable=True, comment="目标价位") + stop_loss = Column(Float, nullable=True, comment="止损价位") + risk_level = Column(String(16), nullable=True, comment="风险等级: 低/中/高") + # 技术指标 + macd_signal = Column(String(16), nullable=True, comment="MACD信号") + rsi_value = Column(Float, nullable=True, comment="RSI值") + boll_signal = Column(String(16), nullable=True, comment="布林带信号") + kdj_signal = Column(String(16), nullable=True, comment="KDJ信号") + # 趋势评分 + trend_score = Column(Integer, nullable=True, comment="趋势评分 0-100") + success_rate = Column(Float, nullable=True, comment="交易成功率") + # 关键点位 + resistance_levels = Column(JSON, nullable=True, comment="压力位列表") + support_levels = Column(JSON, nullable=True, comment="支撑位列表") + # 多周期趋势 + period_trends = Column(JSON, nullable=True, comment="各周期趋势") + + def __repr__(self): + return f"" + + +class WatchedSymbol(AnalysisBase): + """用户关注品种表""" + __tablename__ = "watched_symbols" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(32), nullable=False, unique=True, comment="品种合约代码") + name = Column(String(64), nullable=True, comment="品种名称") + note = Column(Text, nullable=True, comment="备注") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f"" + + +class AIModelConfig(AnalysisBase): + """AI模型配置表""" + __tablename__ = "ai_model_configs" + + id = Column(Integer, primary_key=True, autoincrement=True) + provider = Column(String(32), nullable=False, comment="AI提供商: openai/anthropic/google等") + model_name = Column(String(64), nullable=False, comment="模型名称") + api_key = Column(String(256), nullable=False, comment="API密钥") + api_base = Column(String(256), nullable=True, comment="API基础URL") + model_id = Column(String(64), nullable=True, comment="模型ID") + temperature = Column(Float, nullable=True, default=0.7, comment="温度参数") + max_tokens = Column(Integer, nullable=True, default=2000, comment="最大输出token") + enabled = Column(Boolean, nullable=False, default=True, comment="是否启用") + is_active = Column(Boolean, nullable=False, default=False, comment="是否为当前活跃模型") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f"" + + +class AnalysisSettings(AnalysisBase): + """分析设置表(单例配置)""" + __tablename__ = "analysis_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(64), nullable=False, unique=True, comment="配置键") + value = Column(JSON, nullable=False, comment="配置值") + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + 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分析结果数据") + kline_timestamp = Column(DateTime, nullable=True, comment="分析时K线数据的时间戳") + created_at = Column(DateTime, nullable=False, default=datetime.now, index=True, comment="分析时间") + + def __repr__(self): + return f"" diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc index 2b2a5c5..281e2fe 100644 Binary files a/app/api/__pycache__/__init__.cpython-311.pyc and b/app/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/api/__pycache__/ai_config.cpython-311.pyc b/app/api/__pycache__/ai_config.cpython-311.pyc new file mode 100644 index 0000000..dafca26 Binary files /dev/null and b/app/api/__pycache__/ai_config.cpython-311.pyc differ diff --git a/app/api/__pycache__/config.cpython-311.pyc b/app/api/__pycache__/config.cpython-311.pyc index a307fe9..4b1535f 100644 Binary files a/app/api/__pycache__/config.cpython-311.pyc and b/app/api/__pycache__/config.cpython-311.pyc differ diff --git a/app/api/__pycache__/data.cpython-311.pyc b/app/api/__pycache__/data.cpython-311.pyc index 0ca2fc9..f68cc5f 100644 Binary files a/app/api/__pycache__/data.cpython-311.pyc and b/app/api/__pycache__/data.cpython-311.pyc differ diff --git a/app/api/__pycache__/futures_analysis.cpython-311.pyc b/app/api/__pycache__/futures_analysis.cpython-311.pyc new file mode 100644 index 0000000..b512e47 Binary files /dev/null and b/app/api/__pycache__/futures_analysis.cpython-311.pyc differ diff --git a/app/api/__pycache__/tasks.cpython-311.pyc b/app/api/__pycache__/tasks.cpython-311.pyc index e23d5dd..99a8554 100644 Binary files a/app/api/__pycache__/tasks.cpython-311.pyc and b/app/api/__pycache__/tasks.cpython-311.pyc differ diff --git a/app/api/ai_config.py b/app/api/ai_config.py new file mode 100644 index 0000000..4235e32 --- /dev/null +++ b/app/api/ai_config.py @@ -0,0 +1,189 @@ +""" +AI模型配置接口 - 管理AI分析模型的配置 +""" +import json +import logging +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/ai-config", tags=["AI模型配置"]) + + +class AIModelConfig(BaseModel): + """AI模型配置""" + model_name: str + api_key: str + api_base: str = "https://api.openai.com/v1" + model_id: str = "gpt-4" + temperature: float = 0.7 + max_tokens: int = 2000 + enabled: bool = True + + +class AIConfigResponse(BaseModel): + """AI配置响应""" + success: bool + data: Optional[dict] = None + message: str = "" + + +class SaveAIConfigRequest(BaseModel): + """保存AI配置请求""" + models: list = [] + active_model: Optional[str] = None + analysis_settings: Optional[dict] = None + + +CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config" +AI_CONFIG_FILE = CONFIG_DIR / "ai_config.json" + + +def _ensure_config_dir(): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def _load_ai_config() -> dict: + """加载AI配置""" + _ensure_config_dir() + if not AI_CONFIG_FILE.exists(): + return { + "models": [], + "active_model": None, + "analysis_settings": { + "enable_technical_analysis": True, + "enable_fundamental_analysis": False, + "enable_sentiment_analysis": False, + "risk_tolerance": "medium", + "max_position_pct": 10 + } + } + with open(AI_CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def _save_ai_config(config: dict): + """保存AI配置""" + _ensure_config_dir() + with open(AI_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=4) + + +@router.get("", response_model=AIConfigResponse) +def get_ai_config(): + """获取当前AI模型配置""" + try: + config = _load_ai_config() + return {"success": True, "data": config} + except Exception as e: + logger.error(f"加载AI配置失败: {e}") + return {"success": False, "message": str(e)} + + +@router.post("", response_model=AIConfigResponse) +def save_ai_config(config: SaveAIConfigRequest): + """保存AI模型配置""" + try: + config_dict = { + "models": config.models, + "active_model": config.active_model, + "analysis_settings": config.analysis_settings or {} + } + _save_ai_config(config_dict) + return {"success": True, "message": "AI配置保存成功"} + except Exception as e: + logger.error(f"保存AI配置失败: {e}") + return {"success": False, "message": str(e)} + + +@router.post("/test", response_model=AIConfigResponse) +def test_ai_connection(model_config: AIModelConfig): + """测试AI模型连接""" + try: + import httpx + + headers = { + "Authorization": f"Bearer {model_config.api_key}", + "Content-Type": "application/json" + } + + data = { + "model": model_config.model_id, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 10 + } + + with httpx.Client(timeout=30) as client: + response = client.post( + f"{model_config.api_base}/chat/completions", + headers=headers, + json=data + ) + + if response.status_code == 200: + return {"success": True, "message": "连接测试成功"} + else: + return {"success": False, "message": f"连接失败: {response.status_code} - {response.text}"} + + except Exception as e: + logger.error(f"AI连接测试失败: {e}") + return {"success": False, "message": f"连接测试失败: {str(e)}"} + + +@router.get("/providers") +def get_ai_providers(): + """获取支持的AI提供商列表""" + providers = [ + { + "id": "openai", + "name": "OpenAI", + "api_base": "https://api.openai.com/v1", + "models": ["gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"] + }, + { + "id": "anthropic", + "name": "Anthropic Claude", + "api_base": "https://api.anthropic.com/v1", + "models": ["claude-3-opus", "claude-3-sonnet", "claude-3-haiku"] + }, + { + "id": "google", + "name": "Google Gemini", + "api_base": "https://generativelanguage.googleapis.com/v1beta", + "models": ["gemini-pro", "gemini-pro-vision"] + }, + { + "id": "aliyun", + "name": "阿里云通义千问", + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "models": ["qwen-max", "qwen-plus", "qwen-turbo"] + }, + { + "id": "aliyun_coding", + "name": "阿里云通义灵码", + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "models": ["qwen-coder-plus", "qwen-coder-turbo"] + }, + { + "id": "bailian", + "name": "阿里百炼", + "api_base": "https://coding.dashscope.aliyuncs.com/v1", + "models": ["qwen3.6-plus", "qwen3.5-plus", "qwen3-max", "qwen3-coder-plus", "MiniMax-M2.5", "glm-4.7", "kimi-k2.5"] + }, + { + "id": "baidu", + "name": "百度文心一言", + "api_base": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop", + "models": ["ernie-4.0", "ernie-3.5", "ernie-speed"] + }, + { + "id": "zhipu", + "name": "智谱清言", + "api_base": "https://open.bigmodel.cn/api/paas/v4", + "models": ["glm-4", "glm-3-turbo"] + } + ] + return {"success": True, "data": providers} diff --git a/app/api/futures_analysis.py b/app/api/futures_analysis.py new file mode 100644 index 0000000..24d4fc0 --- /dev/null +++ b/app/api/futures_analysis.py @@ -0,0 +1,956 @@ +""" +期货智析接口 - 提供期货分析数据 +""" +import json +import logging +from pathlib import Path +from typing import Optional +import threading + +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +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, AIAnalysisCache +from app.services.cache import get_cached_data, get_latest_cached, save_market_data +from app.services.collector import fetch_symbol_data + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/futures", tags=["期货智析"]) + +CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config" +SYMBOLS_CONFIG_FILE = CONFIG_DIR / "symbols_config.json" + + +def _load_symbols_config() -> dict: + """加载品种配置文件""" + if not SYMBOLS_CONFIG_FILE.exists(): + return {"futures": {}, "stock": {}} + with open(SYMBOLS_CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +@router.get("/list") +def get_futures_list(db: Session = Depends(get_db)): + """获取所有期货品种列表及摘要信息(从symbols_config.json读取)""" + config = _load_symbols_config() + futures_config = config.get("futures", {}) + + if not futures_config: + return {"success": True, "data": []} + + futures_data = [] + for name, symbol_code in futures_config.items(): + cached = get_cached_data(db, symbol_code, "futures") + if cached and cached.get("timeframes"): + all_candles = [] + for period, candles in cached.get("timeframes", {}).items(): + all_candles.extend(candles) + + if all_candles: + latest_candle = all_candles[-1] + open_price = float(latest_candle.get("open", 0)) + close_price = float(latest_candle.get("close", 0)) + high_price = float(latest_candle.get("high", 0)) + low_price = float(latest_candle.get("low", 0)) + + change = close_price - open_price + change_pct = (change / open_price * 100) if open_price > 0 else 0 + + futures_data.append({ + "symbol": symbol_code, + "name": name, + "price": close_price, + "change": round(change, 2), + "changePct": round(change_pct, 2), + "suggestion": _get_suggestion(close_price, open_price, change_pct), + "suggestionType": "up" if change >= 0 else "down", + "periods": _get_period_trends(all_candles), + "successRate": _calc_success_rate(all_candles), + "trendScore": _calc_trend_score(all_candles), + "resistance": round(2 * ((high_price + low_price + close_price) / 3) - low_price, 2), + "support": round(2 * ((high_price + low_price + close_price) / 3) - high_price, 2), + "open": open_price, + "high": high_price, + "low": low_price, + "volume": sum(float(c.get("volume", 0)) for c in all_candles) + }) + else: + futures_data.append({ + "symbol": symbol_code, + "name": name, + "price": 0, + "change": 0, + "changePct": 0, + "suggestion": "等待数据", + "suggestionType": "neutral", + "periods": {"5": "neutral", "15": "neutral", "30": "neutral", "60": "neutral"}, + "successRate": 0, + "trendScore": 0, + "resistance": 0, + "support": 0, + "open": 0, + "high": 0, + "low": 0, + "volume": 0 + }) + + return {"success": True, "data": futures_data} + + +@router.get("/detail/{symbol}") +def get_futures_detail(symbol: str, db: Session = Depends(get_db)): + """获取指定期货品种的详细分析数据""" + cached = get_cached_data(db, symbol, "futures") + if not cached: + raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的缓存数据") + + all_candles = [] + for period, candles in cached.get("timeframes", {}).items(): + all_candles.extend(candles) + + if not all_candles: + raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的K线数据") + + latest_candle = all_candles[-1] + open_price = float(latest_candle.get("open", 0)) + close_price = float(latest_candle.get("close", 0)) + high_price = float(latest_candle.get("high", 0)) + low_price = float(latest_candle.get("low", 0)) + + change = close_price - open_price + change_pct = (change / open_price * 100) if open_price > 0 else 0 + + # Pivot Point 公式计算关键点位 + pp = (high_price + low_price + close_price) / 3 + r1 = round(2 * pp - low_price, 2) + r2 = round(pp + (high_price - low_price), 2) + s1 = round(2 * pp - high_price, 2) + s2 = round(pp - (high_price - low_price), 2) + + suggestion = _get_suggestion(close_price, open_price, change_pct) + suggestion_type = "up" if change >= 0 else "down" + trend_score = _calc_trend_score(all_candles) + + data = { + "symbol": symbol, + "name": _get_futures_name(symbol), + "price": close_price, + "change": round(change, 2), + "changePct": round(change_pct, 2), + "suggestion": suggestion, + "suggestionType": suggestion_type, + "suggestionReason": _get_suggestion_reason(symbol, suggestion), + "open": open_price, + "high": high_price, + "low": low_price, + "volume": sum(float(c.get("volume", 0)) for c in all_candles), + "entryPrice": round(close_price * 0.995, 2) if change >= 0 else round(close_price * 1.005, 2), + "targetPrice": r1 if change >= 0 else s1, + "stopLoss": s1 if change >= 0 else r1, + "riskLevel": "低" if trend_score >= 80 else "中" if trend_score >= 60 else "高", + "macd": _calc_macd(all_candles), + "rsi": _calc_rsi(all_candles), + "boll": _calc_boll(all_candles), + "kdj": _calc_kdj(all_candles), + "resistances": [r1, r2], + "supports": [s1, s2], + "pivotPoint": round(pp, 2), + "periodConsistency": _get_period_trends(all_candles) + } + + return {"success": True, "data": data} + + +@router.get("/kline/{symbol}") +def get_kline_data(symbol: str, period: str = "15", db: Session = Depends(get_db)): + """获取指定品种和周期的K线数据""" + period_map = { + "5": "5min", + "15": "15min", + "30": "30min", + "60": "60min", + "1440": "daily", + "daily": "daily" + } + db_period = period_map.get(period, f"{period}min") + + cached = get_cached_data(db, symbol, "futures", [db_period]) + if not cached or not cached.get("timeframes"): + raise HTTPException(status_code=404, detail=f"未找到 {symbol} {db_period} 的缓存数据") + + candles = cached["timeframes"].get(db_period, []) + kline_data = [] + for c in candles: + time_str = c.get("datetime", c.get("time", "")) + if time_str and len(time_str) >= 16: + time_str = time_str[:16].replace("T", " ") + kline_data.append([ + time_str, + str(c.get("open", 0)), + str(c.get("close", 0)), + str(c.get("low", 0)), + str(c.get("high", 0)), + str(int(c.get("volume", 0))) + ]) + + return {"success": True, "data": kline_data} + + +def _get_futures_name(symbol: str) -> str: + """根据合约代码获取品种名称""" + name_map = { + "AU": "黄金", "AG": "白银", "CU": "铜", "AL": "铝", + "ZN": "锌", "NI": "镍", "SN": "锡", "PB": "铅", + "RB": "螺纹钢", "HC": "热卷", "I": "铁矿石", "J": "焦炭", + "JM": "焦煤", "ZC": "动力煤", "MA": "甲醇", "TA": "PTA", + "EG": "乙二醇", "PP": "聚丙烯", "L": "塑料", "V": "PVC", + "M": "豆粕", "RM": "菜粕", "C": "玉米", "CS": "淀粉", + "A": "豆一", "B": "豆二", "Y": "豆油", "P": "棕榈油", + "OI": "菜油", "CF": "棉花", "SR": "白糖", "AP": "苹果", + "JD": "鸡蛋", "LH": "生猪", "FU": "燃料油", "LU": "低硫燃油", + "SC": "原油", "EC": "集运指数", "BU": "沥青", "RU": "橡胶", + "NR": "20号胶", "SP": "纸浆", "SS": "不锈钢", "SA": "纯碱", + "FG": "玻璃", "UR": "尿素", "SF": "硅铁", "SM": "锰硅", + "IF": "沪深300", "IC": "中证500", "IH": "上证50", "IM": "中证1000", + "T": "10年期国债", "TF": "5年期国债", "TS": "2年期国债", "TL": "30年期国债", + } + return name_map.get(symbol, symbol) + + +def _get_suggestion(close: float, open: float, change_pct: float) -> str: + """根据价格走势给出操作建议""" + if change_pct > 2: + return "逢低做多" + elif change_pct > 0.5: + return "逢低做多" + elif change_pct > -0.5: + return "观望等待" + elif change_pct > -2: + return "逢高做空" + else: + return "逢高做空" + + +def _get_suggestion_reason(symbol: str, suggestion: str) -> str: + """获取建议理由""" + reasons = { + "逢低做多": "技术面突破,趋势明确,建议逢低介入", + "逢高做空": "技术面走弱,下行压力增大", + "观望等待": "多空力量均衡,等待方向明确" + } + return reasons.get(suggestion, "等待进一步信号") + + +def _get_period_trends(candles: list) -> dict: + """计算各周期趋势 - 根据不同周期取不同长度的K线计算""" + period_config = { + "5": {"bars": 10, "threshold": 0.003}, + "15": {"bars": 15, "threshold": 0.005}, + "30": {"bars": 20, "threshold": 0.008}, + "60": {"bars": 30, "threshold": 0.01} + } + + result = {} + + for period, cfg in period_config.items(): + bars = cfg["bars"] + threshold = cfg["threshold"] + + if len(candles) < bars: + result[period] = "neutral" + continue + + recent = candles[-bars:] + first_close = float(recent[0].get("close", 0)) + last_close = float(recent[-1].get("close", 0)) + + if first_close <= 0: + result[period] = "neutral" + continue + + change_pct = (last_close - first_close) / first_close + + if change_pct > threshold: + result[period] = "up" + elif change_pct < -threshold: + result[period] = "down" + else: + result[period] = "neutral" + + return result + + +def _calc_success_rate(candles: list) -> int: + """计算交易成功率(简化版)""" + if len(candles) < 10: + return 50 + + wins = 0 + for i in range(1, len(candles)): + prev_close = float(candles[i-1].get("close", 0)) + curr_close = float(candles[i].get("close", 0)) + if curr_close >= prev_close: + wins += 1 + + return int(wins / (len(candles) - 1) * 100) + + +def _calc_trend_score(candles: list) -> int: + """计算趋势评分(0-100)""" + if len(candles) < 5: + return 50 + + recent = candles[-10:] + closes = [float(c.get("close", 0)) for c in recent] + + if len(closes) < 2: + return 50 + + up_count = sum(1 for i in range(1, len(closes)) if closes[i] >= closes[i-1]) + score = int(up_count / (len(closes) - 1) * 100) + + return max(0, min(100, score)) + + +def _calc_ema(data: list, period: int) -> list: + """计算EMA,返回与输入等长的列表,前面用None填充""" + ema = [None] * len(data) + multiplier = 2 / (period + 1) + + if len(data) < period: + return ema + + ema[period - 1] = sum(data[:period]) / period + + for i in range(period, len(data)): + ema[i] = (data[i] - ema[i-1]) * multiplier + ema[i-1] + + return ema + + +def _calc_macd(candles: list) -> dict: + """计算MACD指标""" + if len(candles) < 26: + return {"signal": "中性", "detail": "数据不足"} + + closes = [float(c.get("close", 0)) for c in candles] + ema12 = _calc_ema(closes, 12) + ema26 = _calc_ema(closes, 26) + + dif_list = [] + for i in range(len(closes)): + if ema12[i] is not None and ema26[i] is not None: + dif_list.append(ema12[i] - ema26[i]) + else: + dif_list.append(None) + + # 只对有效DIF值计算DEA,避免None替换为0导致计算错误 + dif_valid = [d for d in dif_list if d is not None] + if dif_valid: + dea_valid = _calc_ema(dif_valid, 9) + dea_list = [None] * (len(dif_list) - len(dif_valid)) + dea_valid + else: + dea_list = [None] * len(dif_list) + + dif = dif_list[-1] + dea = dea_list[-1] + + if dif is not None and dea is not None: + if dif > dea: + signal = "金叉" + elif dif < dea: + signal = "死叉" + else: + signal = "中性" + else: + signal = "中性" + + return {"signal": signal, "detail": f"DIF: {dif:.4f}"} + + +def _calc_rsi(candles: list) -> dict: + """计算RSI指标""" + if len(candles) < 15: + return {"value": 50, "status": "正常"} + + closes = [float(c.get("close", 0)) for c in candles[-15:]] + gains = [] + losses = [] + + for i in range(1, len(closes)): + diff = closes[i] - closes[i-1] + gains.append(max(0, diff)) + losses.append(max(0, -diff)) + + avg_gain = sum(gains) / len(gains) if gains else 0 + avg_loss = sum(losses) / len(losses) if losses else 0 + + if avg_loss == 0: + rsi = 100 + else: + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + rsi = int(rsi) + if rsi > 70: + status = "超买" + elif rsi < 30: + status = "超卖" + else: + status = "正常" + + return {"value": rsi, "status": status} + + +def _calc_boll(candles: list) -> dict: + """计算布林带""" + if len(candles) < 20: + return {"signal": "中轨", "detail": "区间: --"} + + closes = [float(c.get("close", 0)) for c in candles[-20:]] + ma = sum(closes) / len(closes) + std = (sum((c - ma) ** 2 for c in closes) / len(closes)) ** 0.5 + + upper = ma + 2 * std + lower = ma - 2 * std + current = closes[-1] + + if current > upper: + signal = "上轨外" + elif current < lower: + signal = "下轨外" + elif current > ma: + signal = "中轨上" + else: + signal = "中轨" + + return {"signal": signal, "detail": f"区间: {lower:.0f}-{upper:.0f}"} + + +def _calc_kdj(candles: list) -> dict: + """计算KDJ指标""" + if len(candles) < 9: + return {"signal": "中性", "detail": "K: -- D: --"} + + highs = [float(c.get("high", 0)) for c in candles[-9:]] + lows = [float(c.get("low", 0)) for c in candles[-9:]] + closes = [float(c.get("close", 0)) for c in candles[-9:]] + + highest = max(highs) + lowest = min(lows) + current = closes[-1] + + if highest == lowest: + rsv = 50 + else: + rsv = (current - lowest) / (highest - lowest) * 100 + + k = int(rsv * 2 / 3 + 50 / 3) + d = int(k * 2 / 3 + 50 / 3) + + if k > d: + signal = "偏多" + elif k < d: + signal = "偏空" + else: + signal = "中性" + + return {"signal": signal, "detail": f"K: {k} D: {d}"} + + +# ==================== 期货智析数据管理接口 ==================== + +@router.get("/analysis/history/{symbol}") +def get_analysis_history(symbol: str, limit: int = 10, adb: Session = Depends(get_analysis_db)): + """获取品种历史分析记录""" + records = adb.query(FuturesAnalysis).filter( + FuturesAnalysis.symbol == symbol + ).order_by( + FuturesAnalysis.analysis_time.desc() + ).limit(limit).all() + + return { + "success": True, + "data": [{ + "id": r.id, + "symbol": r.symbol, + "analysis_time": r.analysis_time.isoformat(), + "suggestion": r.suggestion, + "suggestion_type": r.suggestion_type, + "trend_score": r.trend_score, + "entry_price": r.entry_price, + "target_price": r.target_price, + "stop_loss": r.stop_loss, + "risk_level": r.risk_level + } for r in records] + } + + +@router.post("/analysis/save") +def save_analysis_record(data: dict, adb: Session = Depends(get_analysis_db)): + """保存分析记录到数据库""" + try: + record = FuturesAnalysis( + symbol=data.get("symbol"), + suggestion=data.get("suggestion"), + suggestion_type=data.get("suggestion_type"), + entry_price=data.get("entry_price"), + target_price=data.get("target_price"), + stop_loss=data.get("stop_loss"), + risk_level=data.get("risk_level"), + macd_signal=data.get("macd", {}).get("signal") if data.get("macd") else None, + rsi_value=data.get("rsi", {}).get("value") if data.get("rsi") else None, + boll_signal=data.get("boll", {}).get("signal") if data.get("boll") else None, + kdj_signal=data.get("kdj", {}).get("signal") if data.get("kdj") else None, + trend_score=data.get("trend_score"), + success_rate=data.get("success_rate"), + resistance_levels=data.get("resistances"), + support_levels=data.get("supports"), + period_trends=data.get("periodConsistency") + ) + adb.add(record) + adb.commit() + return {"success": True, "message": "分析记录已保存", "id": record.id} + except Exception as e: + adb.rollback() + logger.error(f"保存分析记录失败: {e}") + return {"success": False, "message": str(e)} + + +# ==================== 关注品种管理 ==================== + +@router.get("/watched") +def get_watched_symbols(adb: Session = Depends(get_analysis_db)): + """获取关注的品种列表""" + symbols = adb.query(WatchedSymbol).order_by(WatchedSymbol.created_at.desc()).all() + return { + "success": True, + "data": [{ + "id": s.id, + "symbol": s.symbol, + "name": s.name, + "note": s.note, + "created_at": s.created_at.isoformat() + } for s in symbols] + } + + +@router.post("/watched") +def add_watched_symbol(data: dict, adb: Session = Depends(get_analysis_db)): + """添加关注品种""" + try: + symbol = data.get("symbol") + existing = adb.query(WatchedSymbol).filter(WatchedSymbol.symbol == symbol).first() + if existing: + return {"success": False, "message": "该品种已关注"} + + new_symbol = WatchedSymbol( + symbol=symbol, + name=data.get("name"), + note=data.get("note") + ) + adb.add(new_symbol) + adb.commit() + return {"success": True, "message": "已添加关注", "id": new_symbol.id} + except Exception as e: + adb.rollback() + return {"success": False, "message": str(e)} + + +@router.delete("/watched/{symbol}") +def remove_watched_symbol(symbol: str, adb: Session = Depends(get_analysis_db)): + """取消关注品种""" + try: + record = adb.query(WatchedSymbol).filter(WatchedSymbol.symbol == symbol).first() + if not record: + return {"success": False, "message": "未找到该品种"} + adb.delete(record) + adb.commit() + return {"success": True, "message": "已取消关注"} + except Exception as e: + adb.rollback() + return {"success": False, "message": str(e)} + + +# ==================== AI模型配置管理 ==================== + +@router.get("/ai-models") +def get_ai_models(adb: Session = Depends(get_analysis_db)): + """获取AI模型配置列表""" + models = adb.query(AIModelConfig).order_by(AIModelConfig.created_at.desc()).all() + settings = adb.query(AnalysisSettings).filter( + AnalysisSettings.key == "analysis_settings" + ).first() + + return { + "success": True, + "data": { + "models": [{ + "id": m.id, + "provider": m.provider, + "model_name": m.model_name, + "api_base": m.api_base, + "model_id": m.model_id, + "temperature": m.temperature, + "max_tokens": m.max_tokens, + "enabled": m.enabled, + "is_active": m.is_active, + "created_at": m.created_at.isoformat() + } for m in models], + "analysis_settings": settings.value if settings else { + "enable_technical_analysis": True, + "enable_fundamental_analysis": False, + "enable_sentiment_analysis": False, + "risk_tolerance": "medium", + "max_position_pct": 10 + } + } + } + + +@router.post("/ai-models") +def save_ai_model(data: dict, adb: Session = Depends(get_analysis_db)): + """保存AI模型配置""" + try: + if data.get("action") == "save_settings": + settings = adb.query(AnalysisSettings).filter( + AnalysisSettings.key == "analysis_settings" + ).first() + if settings: + settings.value = data.get("settings", {}) + else: + settings = AnalysisSettings( + key="analysis_settings", + value=data.get("settings", {}) + ) + adb.add(settings) + adb.commit() + return {"success": True, "message": "分析设置已保存"} + + model_data = data.get("model", {}) + model = AIModelConfig( + provider=model_data.get("provider", "custom"), + model_name=model_data.get("model_name", ""), + api_key=model_data.get("api_key", ""), + api_base=model_data.get("api_base"), + model_id=model_data.get("model_id"), + temperature=model_data.get("temperature", 0.7), + max_tokens=model_data.get("max_tokens", 2000), + enabled=model_data.get("enabled", True), + is_active=model_data.get("is_active", False) + ) + + if model.is_active: + adb.query(AIModelConfig).update({"is_active": False}) + + adb.add(model) + adb.commit() + return {"success": True, "message": "AI模型已保存", "id": model.id} + except Exception as e: + adb.rollback() + return {"success": False, "message": str(e)} + + +@router.put("/ai-models/{model_id}") +def update_ai_model(model_id: int, data: dict, adb: Session = Depends(get_analysis_db)): + """更新AI模型配置""" + try: + model = adb.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() + if not model: + return {"success": False, "message": "模型不存在"} + + if "is_active" in data and data["is_active"]: + adb.query(AIModelConfig).update({"is_active": False}) + model.is_active = True + else: + for key, value in data.items(): + if hasattr(model, key): + setattr(model, key, value) + + adb.commit() + return {"success": True, "message": "模型已更新"} + except Exception as e: + adb.rollback() + return {"success": False, "message": str(e)} + + +@router.delete("/ai-models/{model_id}") +def delete_ai_model(model_id: int, adb: Session = Depends(get_analysis_db)): + """删除AI模型配置""" + try: + model = adb.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() + if not model: + return {"success": False, "message": "模型不存在"} + adb.delete(model) + adb.commit() + return {"success": True, "message": "模型已删除"} + except Exception as e: + adb.rollback() + return {"success": False, "message": str(e)} + + +# ==================== 数据刷新接口 ==================== + +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": ""} +REFRESH_THRESHOLD = 300 # 5分钟阈值 + + +def _refresh_single_symbol_sync(db: Session, symbol: str) -> dict: + """同步刷新单个品种数据(会等待采集完成)""" + try: + # 先检查是否需要刷新 + if not needs_refresh(db, symbol, "futures", REFRESH_THRESHOLD): + last_refresh = get_symbol_timestamp(db, symbol, "futures") + return { + "success": True, + "message": f"{symbol} 数据仍然新鲜,无需刷新", + "last_refresh": last_refresh.isoformat() if last_refresh else None, + "refreshed": False + } + + # 需要刷新,执行采集 + logger.info(f"开始刷新 {symbol} 数据...") + result = fetch_symbol_data(symbol, "futures") + + if result.get("timeframes"): + save_market_data(db, symbol, result) + logger.info(f"{symbol} 数据刷新完成") + return { + "success": True, + "message": f"{symbol} 数据已更新", + "refreshed": True + } + return {"success": False, "message": f"{symbol} 未获取到数据", "refreshed": False} + except Exception as e: + logger.error(f"刷新 {symbol} 失败: {e}") + return {"success": False, "message": f"{symbol} 刷新失败: {str(e)}", "refreshed": False} + + +@router.post("/refresh/{symbol}") +def refresh_single_symbol_api(symbol: str, db: Session = Depends(get_db)): + """刷新单个品种合约数据(同步执行,检查时间戳)""" + if refresh_lock.locked(): + return {"success": False, "message": "数据刷新中,请稍后再试"} + + try: + refresh_lock.acquire() + result = _refresh_single_symbol_sync(db, symbol) + return result + finally: + refresh_lock.release() + + +@router.post("/refresh-all") +def refresh_all_symbols_api(background_tasks: BackgroundTasks): + """刷新所有品种合约数据(异步执行)""" + global refresh_status + + if refresh_lock.locked(): + return {"success": False, "message": "数据刷新中,请稍后再试"} + + # 从配置加载所有品种 + config = _load_symbols_config() + futures_config = config.get("futures", {}) + symbols = list(futures_config.values()) + + def refresh_all_task(): + global refresh_status + + # 在后台任务内部创建新的数据库会话 + local_db = next(get_db()) + + try: + with refresh_lock: + refresh_status = {"running": True, "progress": 0, "total": len(symbols), "message": "开始刷新..."} + + for i, symbol in enumerate(symbols): + refresh_status["message"] = f"正在刷新 {symbol} ({i + 1}/{len(symbols)})" + refresh_status["progress"] = i + 1 + _refresh_single_symbol_sync(local_db, symbol) + + with refresh_lock: + refresh_status = {"running": False, "progress": len(symbols), "total": len(symbols), "message": "全部刷新完成"} + finally: + local_db.close() + + background_tasks.add_task(refresh_all_task) + return {"success": True, "message": "开始刷新所有品种数据...", "count": len(symbols)} + + +@router.get("/refresh-status") +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: + logger.info(f"强制刷新: {symbol}") + 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: + # 智能判断是否需要重新分析 + if analyzer.should_reanalyze(symbol, cache): + logger.info(f"检测到数据变化或超时,自动重新分析: {symbol}") + result = analyzer.analyze(symbol) + if result.get("success"): + return { + "success": True, + "data": result["data"], + "is_cached": False + } + else: + # 如果重新分析失败,返回旧缓存 + logger.warning(f"重新分析失败,返回旧缓存: {symbol}") + return { + "success": True, + "data": { + "id": cache.id, + "symbol": cache.symbol, + "analysis_time": cache.created_at.isoformat(), + "result": cache.analysis_data + }, + "is_cached": True, + "warning": "分析数据可能不是最新的" + } + + # 返回缓存数据 + 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 = 20, 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(), + "analysis_data": r.analysis_data + } for r in records] + } + except Exception as e: + logger.error(f"获取AI分析历史失败: {e}") + return { + "success": False, + "error": f"获取历史记录失败: {str(e)}" + } + + +@router.get("/ai-analysis/history/{record_id}") +def get_ai_analysis_detail(record_id: int, analysis_db: Session = Depends(get_analysis_db)): + """获取单条AI分析记录详情""" + try: + record = analysis_db.query(AIAnalysisCache).filter( + AIAnalysisCache.id == record_id + ).first() + + if not record: + return { + "success": False, + "error": "记录不存在" + } + + return { + "success": True, + "data": { + "id": record.id, + "symbol": record.symbol, + "analysis_time": record.created_at.isoformat(), + "analysis_data": record.analysis_data + } + } + except Exception as e: + logger.error(f"获取AI分析详情失败: {e}") + return { + "success": False, + "error": f"获取记录详情失败: {str(e)}" + } diff --git a/app/main.py b/app/main.py index 9de17a8..6b3b483 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from fastapi.responses import FileResponse from app.database import engine, Base from app.config import HOST, PORT, LOG_LEVEL -from app.api import data, tasks, config +from app.api import data, tasks, config, futures_analysis, ai_config from app.services.scheduler import start_scheduler, stop_scheduler # 配置日志 @@ -29,6 +29,9 @@ async def lifespan(app: FastAPI): # 启动时:建表 + 启动调度器 logger.info("创建数据库表...") Base.metadata.create_all(bind=engine) + from app.analysis_db import init_analysis_db + init_analysis_db() + logger.info("期货智析数据库初始化完成") logger.info("启动定时调度器...") start_scheduler() @@ -87,6 +90,20 @@ def ui_page(): app.include_router(data.router, prefix="/api/v1") app.include_router(tasks.router, prefix="/api/v1") app.include_router(config.router, prefix="/api/v1") +app.include_router(futures_analysis.router, prefix="/api/v1") +app.include_router(ai_config.router, prefix="/api/v1") + + +@app.get("/futures-analysis") +def futures_analysis_page(): + """期货智析页面""" + return FileResponse(str(STATIC_DIR / "futures_analysis.html")) + + +@app.get("/ai-config") +def ai_config_page(): + """AI模型配置页面""" + return FileResponse(str(STATIC_DIR / "ai_config.html")) @app.get("/api/v1/health") diff --git a/app/models.py b/app/models.py index d3a9a0b..553a28f 100644 --- a/app/models.py +++ b/app/models.py @@ -28,6 +28,20 @@ class MarketData(Base): return f"" +class SymbolTimestamp(Base): + """合约数据时间戳表 - 记录每个合约的最新数据时间""" + __tablename__ = "symbol_timestamps" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(32), nullable=False, unique=True, index=True, comment="品种合约代码") + data_type = Column(String(16), nullable=False, default="futures", comment="数据类型") + last_refresh_at = Column(DateTime, nullable=False, default=datetime.now, comment="最后刷新时间") + refresh_count = Column(Integer, default=0, comment="刷新次数") + + def __repr__(self): + return f"" + + class ScheduledTask(Base): """定时任务配置表""" __tablename__ = "scheduled_tasks" diff --git a/app/services/__pycache__/__init__.cpython-311.pyc b/app/services/__pycache__/__init__.cpython-311.pyc index ebac38b..030fe03 100644 Binary files a/app/services/__pycache__/__init__.cpython-311.pyc and b/app/services/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/services/__pycache__/cache.cpython-311.pyc b/app/services/__pycache__/cache.cpython-311.pyc index 7732f0c..0dbf75b 100644 Binary files a/app/services/__pycache__/cache.cpython-311.pyc and b/app/services/__pycache__/cache.cpython-311.pyc differ diff --git a/app/services/__pycache__/collector.cpython-311.pyc b/app/services/__pycache__/collector.cpython-311.pyc index 7224897..23034c8 100644 Binary files a/app/services/__pycache__/collector.cpython-311.pyc and b/app/services/__pycache__/collector.cpython-311.pyc differ diff --git a/app/services/__pycache__/scheduler.cpython-311.pyc b/app/services/__pycache__/scheduler.cpython-311.pyc index 05eb282..4d6baf5 100644 Binary files a/app/services/__pycache__/scheduler.cpython-311.pyc and b/app/services/__pycache__/scheduler.cpython-311.pyc differ diff --git a/app/services/ai_analysis.py b/app/services/ai_analysis.py new file mode 100644 index 0000000..fced968 --- /dev/null +++ b/app/services/ai_analysis.py @@ -0,0 +1,517 @@ +""" +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": "择入场结论" + }, + "5min": { + "macd": {"trend": "up/down/neutral", "histogram": "放大/缩小/背离", "position": "零轴上/下"}, + "volume": {"status": "放量/缩量/正常", "ratio": 1.8}, + "kdj": {"k": 50, "d": 45, "j": 60, "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, kline_timestamp: datetime) -> AIAnalysisCache: + """保存AI分析结果到缓存""" + cache = AIAnalysisCache( + symbol=symbol, + analysis_data=analysis_data, + kline_timestamp=kline_timestamp, + 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 get_latest_kline_timestamp(self, symbol: str) -> Optional[datetime]: + """获取当前K线数据的最新时间戳""" + 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 + + latest_time = None + for period, candles in cached_data.get("timeframes", {}).items(): + if candles and len(candles) > 0: + last_candle = candles[-1] + time_str = last_candle.get("datetime", last_candle.get("time", "")) + if time_str: + try: + candle_time = datetime.fromisoformat(time_str.replace("Z", "+00:00")) + if latest_time is None or candle_time > latest_time: + latest_time = candle_time + except: + pass + + return latest_time + + def should_reanalyze(self, symbol: str, cache: AIAnalysisCache) -> bool: + """判断是否需要重新分析""" + if not cache: + return True + + # 1. 检查时间是否超过15分钟 + time_since_analysis = (datetime.now() - cache.created_at).total_seconds() + if time_since_analysis > 900: # 15分钟 = 900秒 + logger.info(f"分析时间已超过15分钟,需要重新分析") + return True + + # 2. 检查K线数据是否有变化 + current_kline_time = self.get_latest_kline_timestamp(symbol) + if current_kline_time and cache.kline_timestamp: + if current_kline_time > cache.kline_timestamp: + logger.info(f"K线数据已更新(当前: {current_kline_time}, 缓存: {cache.kline_timestamp}),需要重新分析") + return True + + return False + + def analyze(self, symbol: str) -> Dict: + """执行完整的AI分析流程""" + logger.info(f"===== 开始AI分析: {symbol} =====") + + # 获取当前K线数据的时间戳 + kline_timestamp = self.get_latest_kline_timestamp(symbol) + logger.info(f"当前K线数据时间戳: {kline_timestamp}") + + 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, kline_timestamp) + logger.info(f"分析结果已保存到缓存,ID: {cache.id}, K线时间戳: {kline_timestamp}") + 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/services/cache.py b/app/services/cache.py index 423a4c0..f6c8533 100644 --- a/app/services/cache.py +++ b/app/services/cache.py @@ -8,7 +8,7 @@ from typing import Dict, List, Optional from sqlalchemy.orm import Session -from app.models import MarketData, ScheduledTask +from app.models import MarketData, ScheduledTask, SymbolTimestamp from app.config import CACHE_TTL_SECONDS logger = logging.getLogger(__name__) @@ -67,7 +67,7 @@ def check_cache_status( def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData: """ - 保存采集结果到缓存。 + 保存采集结果到缓存,并同步更新合约时间戳。 Args: symbol: 品种代码 @@ -105,6 +105,9 @@ def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData: ) db.add(record) + # 更新合约时间戳 + update_symbol_timestamp(db, symbol, data.get("type", "futures"), now) + db.commit() logger.info(f"缓存已更新: {symbol}, {len(data.get('timeframes', {}))} 个周期") @@ -115,6 +118,58 @@ def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData: ).order_by(MarketData.fetched_at.desc()).first() +def update_symbol_timestamp(db: Session, symbol: str, data_type: str, refresh_time: datetime) -> None: + """更新或创建合约时间戳记录""" + timestamp_record = db.query(SymbolTimestamp).filter_by( + symbol=symbol, + data_type=data_type + ).first() + + if timestamp_record: + timestamp_record.last_refresh_at = refresh_time + timestamp_record.refresh_count += 1 + else: + timestamp_record = SymbolTimestamp( + symbol=symbol, + data_type=data_type, + last_refresh_at=refresh_time, + refresh_count=1 + ) + db.add(timestamp_record) + + db.commit() + + +def get_symbol_timestamp(db: Session, symbol: str, data_type: str = "futures") -> Optional[datetime]: + """获取合约最后刷新时间""" + record = db.query(SymbolTimestamp).filter_by( + symbol=symbol, + data_type=data_type + ).first() + return record.last_refresh_at if record else None + + +def needs_refresh(db: Session, symbol: str, data_type: str = "futures", threshold_seconds: int = 300) -> bool: + """ + 检查合约是否需要刷新(数据是否超过阈值时间) + + Args: + db: 数据库会话 + symbol: 品种代码 + data_type: 数据类型 + threshold_seconds: 阈值时间(秒),默认300秒(5分钟) + + Returns: + True 表示需要刷新,False 表示数据仍然新鲜 + """ + last_refresh = get_symbol_timestamp(db, symbol, data_type) + if last_refresh is None: + return True # 从未刷新过,需要刷新 + + age = (datetime.now() - last_refresh).total_seconds() + return age > threshold_seconds + + def get_latest_cached( db: Session, symbol: str, diff --git a/app/static/ai_config.css b/app/static/ai_config.css new file mode 100644 index 0000000..24618bb --- /dev/null +++ b/app/static/ai_config.css @@ -0,0 +1,568 @@ +:root { + --bg-primary: #0d0f14; + --bg-secondary: #151820; + --bg-card: #1a1d28; + --bg-card-hover: #222633; + --border-color: #2a2d3a; + --text-primary: #e8eaed; + --text-secondary: #9aa0ab; + --text-muted: #6b7280; + --green: #22c55e; + --green-bg: rgba(34, 197, 94, 0.15); + --green-border: rgba(34, 197, 94, 0.3); + --red: #ef4444; + --red-bg: rgba(239, 68, 68, 0.15); + --blue: #3b82f6; + --purple: #8b5cf6; + --orange: #f59e0b; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; +} + +.app-container { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.nav-left { + display: flex; + align-items: center; + gap: 16px; +} + +.back-link { + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; + transition: color 0.2s; +} + +.back-link:hover { + color: var(--text-primary); +} + +.page-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 600; +} + +.page-title i { + color: var(--green); +} + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +.nav-icon-btn { + color: var(--text-secondary); + text-decoration: none; + font-size: 16px; + padding: 6px; + border-radius: 6px; + transition: all 0.2s; +} + +.nav-icon-btn:hover { + color: var(--text-primary); + background: var(--bg-card); +} + +.main-content { + flex: 1; + padding: 24px; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.config-container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.config-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.card-header h3 { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 600; +} + +.card-header h3 i { + color: var(--green); +} + +/* 提供商网格 */ +.provider-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; +} + +.provider-card { + padding: 16px; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 10px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.provider-card:hover { + border-color: var(--text-muted); + background: var(--bg-card-hover); +} + +.provider-card.active { + border-color: var(--green); + background: var(--green-bg); +} + +.provider-card i { + font-size: 28px; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.provider-card.active i { + color: var(--green); +} + +.provider-card .provider-name { + font-size: 13px; + font-weight: 500; +} + +/* 表单 */ +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 13px; + color: var(--text-secondary); +} + +.form-control { + padding: 10px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.form-control:focus { + border-color: var(--green); +} + +.input-with-toggle { + position: relative; + display: flex; + align-items: center; +} + +.input-with-toggle .form-control { + flex: 1; + padding-right: 40px; +} + +.toggle-visibility { + position: absolute; + right: 10px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; +} + +.form-range { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--bg-secondary); + outline: none; + -webkit-appearance: none; +} + +.form-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--green); + cursor: pointer; +} + +.range-labels { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-muted); +} + +.form-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.test-result { + font-size: 13px; +} + +.test-result.success { + color: var(--green); +} + +.test-result.error { + color: var(--red); +} + +/* 当前启用的AI卡片 */ +.active-ai-card { + border-color: var(--green-border); + background: var(--green-bg); +} + +.active-ai-card .card-header h3 { + color: var(--green); +} + +.active-ai-card .card-header i { + color: var(--green); +} + +.active-ai-display { + display: flex; + align-items: center; + gap: 16px; + padding: 12px; +} + +.active-ai-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(34, 197, 94, 0.15); + border: 1px solid var(--green-border); + border-radius: 12px; + color: var(--green); + font-size: 24px; +} + +.active-ai-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.active-ai-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.active-ai-model { + font-size: 13px; + color: var(--text-secondary); +} + +/* 设置列表 */ +.settings-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.setting-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); +} + +.setting-item:last-child { + border-bottom: none; +} + +.setting-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.setting-name { + font-size: 14px; + font-weight: 500; +} + +.setting-desc { + font-size: 12px; + color: var(--text-muted); +} + +/* 开关 */ +.switch { + position: relative; + width: 48px; + height: 26px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 26px; + transition: 0.3s; +} + +.slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 2px; + bottom: 2px; + background: var(--text-muted); + border-radius: 50%; + transition: 0.3s; +} + +input:checked + .slider { + background: var(--green-bg); + border-color: var(--green-border); +} + +input:checked + .slider:before { + transform: translateX(22px); + background: var(--green); +} + +/* 模型列表 */ +.models-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.model-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.model-info { + display: flex; + align-items: center; + gap: 12px; +} + +.model-status { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.model-status.active { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} + +.model-status.inactive { + background: var(--text-muted); +} + +.model-name { + font-size: 14px; + font-weight: 500; +} + +.model-provider { + font-size: 12px; + color: var(--text-muted); +} + +.model-actions { + display: flex; + gap: 8px; +} + +.model-actions button { + padding: 6px 12px; + font-size: 12px; + border-radius: 6px; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.btn-set-active { + background: var(--green-bg); + color: var(--green); + border: 1px solid var(--green-border); +} + +.btn-set-active:hover { + background: var(--green); + color: white; +} + +.btn-delete { + background: var(--red-bg); + color: var(--red); + border: 1px solid var(--red-border); +} + +.btn-delete:hover { + background: var(--red); + color: white; +} + +/* 按钮 */ +.btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn-lg { + padding: 12px 28px; + font-size: 15px; +} + +.btn-primary { + background: var(--green); + color: white; +} + +.btn-primary:hover { + background: #16a34a; +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-card-hover); +} + +.save-actions { + display: flex; + justify-content: center; + gap: 16px; + padding: 20px 0; +} + +/* 响应式 */ +@media (max-width: 768px) { + .form-grid { + grid-template-columns: 1fr; + } + + .provider-grid { + grid-template-columns: repeat(3, 1fr); + } + + .save-actions { + flex-direction: column; + } + + .save-actions .btn { + width: 100%; + justify-content: center; + } +} diff --git a/app/static/ai_config.html b/app/static/ai_config.html new file mode 100644 index 0000000..4b7beff --- /dev/null +++ b/app/static/ai_config.html @@ -0,0 +1,211 @@ + + + + + + AI模型配置 - 期货智析 + + + + +
+
+ + +
+ +
+
+ + + + +
+
+

AI提供商

+
+
+ +
+
+ + +
+
+

API配置

+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+

模型参数

+
+
+
+ + +
+ 精确 + 创造 +
+
+
+ + +
+
+
+ + +
+
+

分析设置

+
+
+
+
+ 技术分析 + 基于K线和技术指标进行分析 +
+ +
+
+
+ 基本面分析 + 结合宏观经济和行业数据 +
+ +
+
+
+ 情绪分析 + 分析市场情绪和新闻舆情 +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+

已保存的模型

+ +
+
+ +
+
+ + +
+ + +
+
+
+
+ + + + diff --git a/app/static/ai_config.js b/app/static/ai_config.js new file mode 100644 index 0000000..b958e2e --- /dev/null +++ b/app/static/ai_config.js @@ -0,0 +1,369 @@ +const API_BASE = '/api/v1/ai-config'; + +let currentConfig = null; +let selectedProvider = 'openai'; + +document.addEventListener('DOMContentLoaded', function() { + loadProviders(); + loadConfig(); + initEventListeners(); +}); + +function initEventListeners() { + document.getElementById('api-provider').addEventListener('change', function() { + selectedProvider = this.value; + updateProviderModels(); + }); + + document.getElementById('temperature').addEventListener('input', function() { + document.getElementById('temp-value').textContent = this.value; + }); +} + +async function loadProviders() { + try { + const response = await fetch(`${API_BASE}/providers`); + const data = await response.json(); + if (data.success) { + renderProviders(data.data); + } + } catch (error) { + console.error('加载提供商失败:', error); + renderProviders(getDefaultProviders()); + } +} + +function getDefaultProviders() { + return [ + { id: 'openai', name: 'OpenAI', icon: 'fas fa-brain' }, + { id: 'anthropic', name: 'Claude', icon: 'fas fa-robot' }, + { id: 'google', name: 'Gemini', icon: 'fas fa-gem' }, + { id: 'aliyun', name: '通义千问', icon: 'fas fa-cloud' }, + { id: 'aliyun_coding', name: '通义灵码', icon: 'fas fa-code' }, + { id: 'bailian', name: '阿里百炼', icon: 'fas fa-flask' }, + { id: 'baidu', name: '文心一言', icon: 'fas fa-comments' }, + { id: 'zhipu', name: '智谱清言', icon: 'fas fa-lightbulb' } + ]; +} + +function renderProviders(providers) { + const grid = document.getElementById('provider-grid'); + const iconMap = { + 'openai': 'fas fa-brain', + 'anthropic': 'fas fa-robot', + 'google': 'fas fa-gem', + 'aliyun': 'fas fa-cloud', + 'aliyun_coding': 'fas fa-code', + 'bailian': 'fas fa-flask', + 'baidu': 'fas fa-comments', + 'zhipu': 'fas fa-lightbulb', + 'custom': 'fas fa-cog' + }; + + grid.innerHTML = providers.map(p => ` +
+ +
${p.name}
+
+ `).join(''); + + grid.querySelectorAll('.provider-card').forEach(card => { + card.addEventListener('click', function() { + grid.querySelectorAll('.provider-card').forEach(c => c.classList.remove('active')); + this.classList.add('active'); + selectedProvider = this.dataset.provider; + document.getElementById('api-provider').value = selectedProvider; + updateProviderModels(); + }); + }); +} + +function updateProviderModels() { + const modelSelect = document.getElementById('model-id'); + const modelMap = { + 'openai': ['gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'], + 'anthropic': ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'], + 'google': ['gemini-pro', 'gemini-pro-vision'], + 'aliyun': ['qwen-max', 'qwen-plus', 'qwen-turbo'], + 'aliyun_coding': ['qwen-coder-plus', 'qwen-coder-turbo'], + 'bailian': ['qwen3.6-plus', 'qwen3.5-plus', 'qwen3-max', 'qwen3-coder-plus', 'MiniMax-M2.5', 'glm-4.7', 'kimi-k2.5', 'custom'], + 'baidu': ['ernie-4.0', 'ernie-3.5', 'ernie-speed'], + 'zhipu': ['glm-4', 'glm-3-turbo'], + 'custom': ['custom-model'] + }; + + const apiBaseMap = { + 'openai': 'https://api.openai.com/v1', + 'anthropic': 'https://api.anthropic.com/v1', + 'google': 'https://generativelanguage.googleapis.com/v1beta', + 'aliyun': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'aliyun_coding': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'bailian': 'https://coding.dashscope.aliyuncs.com/v1', + 'baidu': 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop', + 'zhipu': 'https://open.bigmodel.cn/api/paas/v4', + 'custom': '' + }; + + const models = modelMap[selectedProvider] || ['custom-model']; + modelSelect.innerHTML = models.map(m => ``).join(''); + document.getElementById('api-base').value = apiBaseMap[selectedProvider] || ''; +} + +async function loadConfig() { + try { + const response = await fetch(API_BASE); + const result = await response.json(); + if (result.success && result.data) { + currentConfig = result.data; + populateForm(currentConfig); + renderModelsList(currentConfig.models || []); + } + } catch (error) { + console.error('加载配置失败:', error); + } +} + +function populateForm(config) { + if (config.models && config.models.length > 0) { + const activeModel = config.models.find(m => m.enabled) || config.models[0]; + document.getElementById('api-provider').value = activeModel.provider || 'openai'; + document.getElementById('api-key').value = activeModel.api_key || ''; + document.getElementById('api-base').value = activeModel.api_base || ''; + document.getElementById('model-id').value = activeModel.model_id || 'gpt-4o'; + document.getElementById('custom-model').value = ''; + document.getElementById('temperature').value = activeModel.temperature || 0.7; + document.getElementById('temp-value').textContent = activeModel.temperature || 0.7; + document.getElementById('max-tokens').value = activeModel.max_tokens || 2000; + + // 选中当前启用的提供商卡片 + const providerCard = document.querySelector(`.provider-card[data-provider="${activeModel.provider}"]`); + if (providerCard) { + document.querySelectorAll('.provider-card').forEach(c => c.classList.remove('active')); + providerCard.classList.add('active'); + } + + // 更新模型下拉框 + updateProviderModels(); + } + + if (config.analysis_settings) { + document.getElementById('enable-technical').checked = config.analysis_settings.enable_technical_analysis !== false; + document.getElementById('enable-fundamental').checked = config.analysis_settings.enable_fundamental_analysis === true; + document.getElementById('enable-sentiment').checked = config.analysis_settings.enable_sentiment_analysis === true; + document.getElementById('risk-tolerance').value = config.analysis_settings.risk_tolerance || 'medium'; + document.getElementById('max-position').value = config.analysis_settings.max_position_pct || 10; + } +} + +function renderModelsList(models) { + const list = document.getElementById('models-list'); + const activeCard = document.getElementById('active-ai-card'); + + if (!models || models.length === 0) { + list.innerHTML = '
暂无已保存的模型
'; + activeCard.style.display = 'none'; + return; + } + + const activeModel = models.find(m => m.enabled); + if (activeModel) { + activeCard.style.display = 'block'; + document.getElementById('active-ai-name').textContent = getProviderName(activeModel.provider || activeModel.api_base); + document.getElementById('active-ai-model').textContent = activeModel.model_name || activeModel.model_id || '--'; + + const iconMap = { + 'openai': 'fab fa-openai', + 'anthropic': 'fas fa-robot', + 'google': 'fab fa-google', + 'aliyun': 'fas fa-cloud', + 'aliyun_coding': 'fas fa-code', + 'bailian': 'fas fa-flask', + 'baidu': 'fas fa-comments', + 'zhipu': 'fas fa-lightbulb' + }; + const icon = document.getElementById('active-ai-icon').querySelector('i'); + icon.className = iconMap[activeModel.provider] || 'fas fa-robot'; + } else { + activeCard.style.display = 'none'; + } + + list.innerHTML = models.map((model, index) => ` +
+
+
+
+
${model.model_name || model.model_id}
+
${getProviderName(model.provider || model.api_base)}
+
+
+
+ ${!model.enabled ? `` : '当前启用'} + +
+
+ `).join(''); +} + +function getProviderName(apiBase) { + const map = { + 'openai': 'OpenAI', + 'anthropic': 'Anthropic', + 'google': 'Google', + 'aliyun': '阿里云', + 'aliyun_coding': '阿里云通义灵码', + 'bailian': '阿里百炼', + 'baidu': '百度', + 'zhipu': '智谱' + }; + return map[apiBase] || apiBase; +} + +function toggleApiKeyVisibility() { + const input = document.getElementById('api-key'); + const icon = document.querySelector('.toggle-visibility i'); + if (input.type === 'password') { + input.type = 'text'; + icon.className = 'fas fa-eye-slash'; + } else { + input.type = 'password'; + icon.className = 'fas fa-eye'; + } +} + +async function testConnection() { + const resultEl = document.getElementById('test-result'); + resultEl.textContent = '测试中...'; + resultEl.className = 'test-result'; + + const customModel = document.getElementById('custom-model').value.trim(); + const selectedModel = document.getElementById('model-id').value; + const modelId = customModel || (selectedModel === 'custom' ? '' : selectedModel); + + if (!modelId) { + resultEl.textContent = '✗ 请输入模型ID'; + resultEl.className = 'test-result error'; + return; + } + + const config = { + model_name: modelId, + api_key: document.getElementById('api-key').value, + api_base: document.getElementById('api-base').value, + model_id: modelId, + temperature: parseFloat(document.getElementById('temperature').value), + max_tokens: parseInt(document.getElementById('max-tokens').value), + enabled: true + }; + + try { + const response = await fetch(`${API_BASE}/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + if (!response.ok) { + resultEl.textContent = `✗ 请求失败: ${response.status}`; + resultEl.className = 'test-result error'; + return; + } + + const data = await response.json(); + + if (data.success) { + resultEl.textContent = '✓ 连接成功'; + resultEl.className = 'test-result success'; + } else { + resultEl.textContent = '✗ ' + (data.message || '未知错误'); + resultEl.className = 'test-result error'; + } + } catch (error) { + resultEl.textContent = '✗ 连接失败: ' + (error.message || '未知错误'); + resultEl.className = 'test-result error'; + } +} + +async function saveConfig() { + const models = currentConfig?.models || []; + const existingIndex = models.findIndex(m => m.provider === selectedProvider); + + const customModel = document.getElementById('custom-model').value.trim(); + const selectedModel = document.getElementById('model-id').value; + const modelId = customModel || (selectedModel === 'custom' ? '' : selectedModel); + + const newModel = { + model_name: modelId, + provider: selectedProvider, + api_key: document.getElementById('api-key').value, + api_base: document.getElementById('api-base').value, + model_id: modelId, + temperature: parseFloat(document.getElementById('temperature').value), + max_tokens: parseInt(document.getElementById('max-tokens').value), + enabled: true + }; + + if (existingIndex >= 0) { + models[existingIndex] = { ...models[existingIndex], ...newModel }; + } else { + models.push(newModel); + } + + models.forEach(m => { + m.enabled = (m.provider === selectedProvider && m.model_id === modelId); + }); + + const config = { + models: models, + active_model: selectedProvider, + analysis_settings: { + enable_technical_analysis: document.getElementById('enable-technical').checked, + enable_fundamental_analysis: document.getElementById('enable-fundamental').checked, + enable_sentiment_analysis: document.getElementById('enable-sentiment').checked, + risk_tolerance: document.getElementById('risk-tolerance').value, + max_position_pct: parseInt(document.getElementById('max-position').value) + } + }; + + try { + const response = await fetch(API_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + const data = await response.json(); + + if (data.success) { + alert('配置保存成功!'); + currentConfig = config; + renderModelsList(models); + } else { + alert('保存失败: ' + data.message); + } + } catch (error) { + alert('保存失败: ' + error.message); + } +} + +function setActiveModel(index) { + if (!currentConfig || !currentConfig.models) return; + + currentConfig.models.forEach((m, i) => { + m.enabled = i === index; + }); + + saveConfig(); +} + +function deleteModel(index) { + if (!confirm('确定要删除这个模型吗?')) return; + + if (!currentConfig || !currentConfig.models) return; + + currentConfig.models.splice(index, 1); + saveConfig(); +} + +function addNewModel() { + document.getElementById('api-key').value = ''; + document.getElementById('api-key').focus(); +} diff --git a/app/static/futures_analysis.css b/app/static/futures_analysis.css new file mode 100644 index 0000000..e7947c3 --- /dev/null +++ b/app/static/futures_analysis.css @@ -0,0 +1,3471 @@ +/* ============================================ + 期货智析 - 科技感界面样式 + 风格:低调鲜明、赛博朋克科技感 + ============================================ */ + +:root { + --bg-primary: #06080d; + --bg-secondary: #0c1017; + --bg-card: rgba(15, 20, 30, 0.7); + --bg-card-hover: rgba(20, 28, 42, 0.8); + --border-color: rgba(56, 189, 248, 0.1); + --border-glow: rgba(56, 189, 248, 0.3); + + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --cyan: #06b6d4; + --cyan-glow: rgba(6, 182, 212, 0.4); + --purple: #8b5cf6; + --purple-glow: rgba(139, 92, 246, 0.4); + --green: #10b981; + --green-glow: rgba(16, 185, 129, 0.4); + --red: #ef4444; + --red-glow: rgba(239, 68, 68, 0.4); + --amber: #f59e0b; + --amber-glow: rgba(245, 158, 11, 0.4); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + overflow-x: hidden; +} + +/* 背景网格和光效 */ +.bg-grid { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + linear-gradient(rgba(56, 189, 248, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.03) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; +} + +.bg-glow { + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(ellipse at 30% 20%, rgba(6, 182, 212, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 70% 80%, rgba(139, 92, 246, 0.06) 0%, transparent 50%); + pointer-events: none; + z-index: 0; +} + +.app-container { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ============================================ + 顶部导航 + ============================================ */ +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + height: 56px; + background: rgba(6, 8, 13, 0.8); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-left { + display: flex; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + position: relative; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.2), rgba(139, 92, 246, 0.2)); + border: 1px solid var(--border-glow); + border-radius: 10px; + color: var(--cyan); + font-size: 16px; +} + +.logo-pulse { + position: absolute; + inset: -2px; + border-radius: 12px; + background: linear-gradient(135deg, var(--cyan), var(--purple)); + opacity: 0; + animation: pulse 3s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(1.05); } +} + +.logo-text { + display: flex; + flex-direction: column; +} + +.logo-title { + font-size: 15px; + font-weight: 600; + background: linear-gradient(135deg, var(--text-primary), var(--cyan)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.logo-subtitle { + font-size: 9px; + color: var(--text-muted); + letter-spacing: 1.5px; + text-transform: uppercase; +} + +.nav-center { + display: flex; + gap: 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + font-weight: 500; + border-radius: 8px; + transition: all 0.2s; +} + +.nav-item:hover { + color: var(--text-primary); + background: rgba(56, 189, 248, 0.08); +} + +.nav-item.active { + color: var(--cyan); + background: rgba(6, 182, 212, 0.1); +} + +.nav-icon { + font-size: 14px; +} + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +.system-status { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: 20px; +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--green); + border-radius: 50%; + animation: blink 2s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.status-text { + font-size: 10px; + font-weight: 600; + color: var(--green); + letter-spacing: 1px; +} + +.datetime { + font-size: 12px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.nav-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + text-decoration: none; + border-radius: 8px; + transition: all 0.2s; +} + +.nav-btn:hover { + color: var(--cyan); + background: rgba(6, 182, 212, 0.1); +} + +/* ============================================ + 主内容区 + ============================================ */ +.main-content { + flex: 1; + padding: 20px 24px; + max-width: 1440px; + margin: 0 auto; + width: 100%; +} + +.view { + display: none; +} + +.view.active { + display: block; +} + +/* ============================================ + 搜索栏 + ============================================ */ +.search-section { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.search-box { + flex: 1; + max-width: 480px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + transition: all 0.2s; +} + +.search-box:focus-within { + border-color: var(--cyan); + box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1); +} + +.search-box i { + color: var(--text-muted); +} + +.search-box input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--text-primary); + font-size: 14px; +} + +.search-box input::placeholder { + color: var(--text-muted); +} + +.search-box kbd { + padding: 2px 6px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.view-controls { + display: flex; + gap: 4px; +} + +.view-btn { + width: auto; + height: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + font-size: 13px; +} + +.view-btn i { + font-size: 14px; +} + +.view-btn span { + font-size: 13px; + font-weight: 500; +} + +.view-btn:hover { + color: var(--text-secondary); +} + +.view-btn.active { + background: rgba(6, 182, 212, 0.15); + border-color: var(--cyan); + color: var(--cyan); +} + +/* ============================================ + 筛选栏 + ============================================ */ +.filter-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.filter-tabs { + display: flex; + gap: 6px; +} + +.filter-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.filter-tab:hover { + border-color: var(--text-muted); + color: var(--text-primary); +} + +.filter-tab.active { + background: rgba(6, 182, 212, 0.12); + border-color: var(--cyan); + color: var(--cyan); +} + +.filter-tab i { + font-size: 12px; +} + +.filter-count { + padding: 1px 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + font-size: 11px; +} + +.sort-select { + position: relative; +} + +.sort-select select { + padding: 8px 32px 8px 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + outline: none; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 12px; + transition: all 0.2s; +} + +.sort-select select:hover { + border-color: var(--text-muted); +} + +.sort-select select:focus { + border-color: var(--cyan); + box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1); +} + +.sort-select select option { + background: var(--bg-card); + color: var(--text-primary); + padding: 8px; +} + +/* 下拉菜单展开样式 */ +.sort-select select:focus option { + background: var(--bg-card); +} + +.sort-select select option:hover, +.sort-select select option:checked { + background: rgba(6, 182, 212, 0.1) !important; +} + +/* ============================================ + 统计概览 + ============================================ */ +.stats-overview { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; +} + +.stat-card { + transition: all 0.2s ease; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); +} + +.stat-card:active { + transform: translateY(0); +} + +.stats-overview { + margin-bottom: 20px; +} + +.stat-card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + transition: all 0.2s; +} + +.stat-card:hover { + border-color: var(--border-glow); + transform: translateY(-2px); +} + +.stat-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(56, 189, 248, 0.1); + border-radius: 10px; + color: var(--cyan); + font-size: 16px; +} + +.stat-card.up .stat-icon { + background: rgba(16, 185, 129, 0.1); + color: var(--green); +} + +.stat-card.down .stat-icon { + background: rgba(239, 68, 68, 0.1); + color: var(--red); +} + +.stat-card.neutral .stat-icon { + background: rgba(245, 158, 11, 0.1); + color: var(--amber); +} + +.stat-info { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat-label { + font-size: 12px; + color: var(--text-muted); +} + +/* ============================================ + 品种卡片网格 + ============================================ */ +.futures-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 14px; +} + +.futures-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 18px; + cursor: pointer; + transition: all 0.25s; + position: relative; + overflow: hidden; +} + +.futures-card.no-ai-data { + opacity: 0.85; + border-style: dashed; +} + +.futures-card.no-ai-data:hover { + opacity: 1; + border-color: var(--purple, #8b5cf6); +} + +.ai-hint { + text-align: center; + padding: 8px; + margin: 8px 0; + background: rgba(139, 92, 246, 0.08); + border: 1px dashed rgba(139, 92, 246, 0.3); + border-radius: 8px; + color: var(--purple, #8b5cf6); + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.ai-hint i { + font-size: 14px; +} + +.futures-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--cyan), transparent); + opacity: 0; + transition: opacity 0.25s; +} + +.futures-card:hover { + background: var(--bg-card-hover); + border-color: var(--border-glow); + transform: translateY(-3px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} + +.futures-card:hover::before { + opacity: 1; +} + +.card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 14px; +} + +.card-symbol { + display: flex; + align-items: center; + gap: 10px; +} + +.symbol-tag { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.15), rgba(139, 92, 246, 0.15)); + border: 1px solid var(--border-glow); + border-radius: 10px; + font-size: 11px; + font-weight: 600; + color: var(--cyan); +} + +.card-name { + font-size: 15px; + font-weight: 600; +} + +.card-code { + font-size: 11px; + color: var(--text-muted); +} + +.card-price { + text-align: right; +} + +.price-value { + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.price-change { + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + gap: 3px; + justify-content: flex-end; +} + +.up { color: var(--red); } +.down { color: var(--green); } +.neutral { color: var(--amber); } + +.suggestion-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + margin-bottom: 12px; +} + +.suggestion-badge.up { + background: rgba(16, 185, 129, 0.12); + color: var(--green); + border: 1px solid rgba(16, 185, 129, 0.25); +} + +.suggestion-badge.down { + background: rgba(239, 68, 68, 0.12); + color: var(--red); + border: 1px solid rgba(239, 68, 68, 0.25); +} + +.suggestion-badge.neutral { + background: rgba(245, 158, 11, 0.1); + color: var(--amber); + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.card-metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; +} + +.metric-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 11px; + color: var(--text-muted); +} + +.metric-bar { + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.metric-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s; +} + +.metric-fill.up { background: var(--green); } +.metric-fill.down { background: var(--red); } +.metric-fill.orange { background: var(--amber); } + +.metric-value { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.period-trends { + display: flex; + gap: 6px; + margin-bottom: 12px; +} + +.period-tag { + flex: 1; + padding: 5px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 11px; + text-align: center; + transition: all 0.2s; +} + +.period-tag.up { + background: rgba(16, 185, 129, 0.1); + border-color: rgba(16, 185, 129, 0.25); + color: var(--green); +} + +.period-tag.down { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.25); + color: var(--red); +} + +.period-tag.neutral { + background: rgba(245, 158, 11, 0.08); + border-color: rgba(245, 158, 11, 0.2); + color: var(--amber); +} + +.card-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.key-levels { + display: flex; + gap: 12px; + font-size: 12px; +} + +.key-levels span { + display: flex; + align-items: center; + gap: 4px; +} + +.key-levels .label { + color: var(--text-muted); +} + +.detail-link { + font-size: 12px; + color: var(--cyan); + display: flex; + align-items: center; + gap: 4px; +} + +/* ============================================ + 详情视图 + ============================================ */ +.back-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + margin-bottom: 16px; + transition: all 0.2s; +} + +.back-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); + border-color: var(--border-glow); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 14px; + margin-bottom: 16px; +} + +.header-left { + display: flex; + align-items: center; + gap: 24px; +} + +.symbol-info { + display: flex; + align-items: baseline; + gap: 10px; +} + +.symbol-name { + font-size: 22px; + font-weight: 700; +} + +.symbol-code { + font-size: 13px; + color: var(--text-muted); + padding: 2px 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; +} + +.price-main { + display: flex; + flex-direction: row; + align-items: baseline; + gap: 12px; +} + +.price-value { + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.price-change { + font-size: 14px; + font-weight: 500; +} + +.quote-grid { + display: flex; + gap: 24px; +} + +.quote-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.quote-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.quote-value { + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* 周期选择 */ +.period-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.period-label { + font-size: 13px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 6px; +} + +.period-btns { + display: flex; + gap: 4px; +} + +.period-btn { + padding: 6px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.period-btn:hover { + border-color: var(--text-muted); +} + +.period-btn.active { + background: rgba(6, 182, 212, 0.15); + border-color: var(--cyan); + color: var(--cyan); +} + +/* 详情主体 */ +.detail-body { + display: grid; + grid-template-columns: 1fr 360px; + gap: 16px; +} + +.chart-container { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 16px; +} + +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.chart-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.chart-legend { + display: flex; + gap: 14px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); +} + +.legend-dot { + width: 10px; + height: 3px; + border-radius: 2px; +} + +.legend-dot.ma5 { background: #f59e0b; } +.legend-dot.ma10 { background: #3b82f6; } +.legend-dot.ma20 { background: #8b5cf6; } + +.kline-chart { + width: 100%; + height: 480px; +} + +/* 分析侧边栏 */ +.analysis-sidebar { + display: flex; + flex-direction: column; + gap: 14px; +} + +.panel-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 16px; + transition: all 0.2s; +} + +.panel-card:hover { + border-color: var(--border-glow); +} + +.panel-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + margin-bottom: 14px; + color: var(--text-primary); +} + +.panel-header i { + color: var(--cyan); + font-size: 14px; +} + +/* AI建议卡片 */ +.suggestion-card .suggestion-content { + text-align: center; + padding: 16px; + background: rgba(6, 182, 212, 0.06); + border: 1px solid rgba(6, 182, 212, 0.15); + border-radius: 10px; + margin-bottom: 14px; +} + +.suggestion-badge { + font-size: 18px; + font-weight: 700; + margin-bottom: 6px; +} + +.suggestion-badge.up { color: var(--green); } +.suggestion-badge.down { color: var(--red); } +.suggestion-badge.neutral { color: var(--amber); } + +.suggestion-reason { + font-size: 12px; + color: var(--text-secondary); +} + +.trade-params { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.param-row { + display: flex; + justify-content: space-between; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + font-size: 12px; +} + +.param-label { + color: var(--text-muted); +} + +.param-value { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* 技术指标 */ +.indicators-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.indicator-cell { + padding: 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.indicator-label { + font-size: 11px; + color: var(--text-muted); +} + +.indicator-value { + font-size: 14px; + font-weight: 600; +} + +.indicator-detail { + font-size: 11px; + color: var(--text-secondary); +} + +/* 关键点位 */ +.levels-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.level-group-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + display: block; +} + +.level-group.resistance .level-group-label { color: var(--red); } +.level-group.support .level-group-label { color: var(--green); } + +.level-item { + display: flex; + justify-content: space-between; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + font-size: 12px; +} + +.level-item span:first-child { + color: var(--text-muted); + font-weight: 500; +} + +.level-item span:last-child { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.level-item.pivot-point { + background: rgba(139, 92, 246, 0.1); + border: 1px solid rgba(139, 92, 246, 0.2); + border-radius: 8px; + padding: 8px 12px; + margin: 4px 0; +} + +.level-item.pivot-point span:first-child { + color: var(--purple); + font-weight: 600; +} + +.level-item.pivot-point span:last-child { + color: var(--purple); + font-size: 14px; +} + +.level-divider { + height: 1px; + background: var(--border-color); + margin: 4px 0; +} + +/* 多周期趋势 */ +.trends-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.trend-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; +} + +.trend-period { + font-size: 12px; + color: var(--text-secondary); +} + +.trend-badge { + padding: 3px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.trend-badge.up { + background: rgba(16, 185, 129, 0.12); + color: var(--green); +} + +.trend-badge.down { + background: rgba(239, 68, 68, 0.12); + color: var(--red); +} + +.trend-badge.neutral { + background: rgba(245, 158, 11, 0.1); + color: var(--amber); +} + +/* 趋势评分 */ +.score-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.score-ring { + position: relative; + width: 100px; + height: 100px; +} + +.score-ring svg { + transform: rotate(-90deg); +} + +.score-bg { + fill: none; + stroke: rgba(255, 255, 255, 0.06); + stroke-width: 8; +} + +.score-fill { + fill: none; + stroke: url(#scoreGradient); + stroke-width: 8; + stroke-linecap: round; + stroke-dasharray: 283; + stroke-dashoffset: 283; + transition: stroke-dashoffset 0.8s ease-out; +} + +.score-value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 24px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.score-label { + font-size: 12px; + color: var(--text-muted); +} + +/* ============================================ + 响应式 + ============================================ */ +@media (max-width: 1200px) { + .detail-body { + grid-template-columns: 1fr; + } + + .analysis-sidebar { + display: grid; + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .stats-overview { + grid-template-columns: repeat(2, 1fr); + } + + .futures-grid { + grid-template-columns: 1fr; + } + + .analysis-sidebar { + grid-template-columns: 1fr; + } + + .detail-header { + flex-direction: column; + gap: 16px; + } + + .quote-grid { + width: 100%; + justify-content: space-between; + } + + .filter-bar { + flex-direction: column; + gap: 12px; + } +} + +/* ============================================ + 简洁风格主题 - Revolut 设计系统 + ============================================ */ +body.theme-minimal { + --bg-primary: #ffffff; + --bg-secondary: #f4f4f4; + --bg-card: #ffffff; + --bg-card-hover: #f4f4f4; + --border-color: #c9c9cd; + --border-glow: #c9c9cd; + + --text-primary: #191c1f; + --text-secondary: #505a63; + --text-muted: #8d969e; + + --cyan: #494fdf; + --cyan-glow: rgba(73, 79, 223, 0.2); + --purple: #494fdf; + --purple-glow: rgba(73, 79, 223, 0.2); + --green: #00a87e; + --green-glow: rgba(0, 168, 126, 0.2); + --red: #e23b4a; + --red-glow: rgba(226, 59, 74, 0.2); + --amber: #ec7e00; + --amber-glow: rgba(236, 126, 0, 0.2); +} + +body.theme-minimal { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; + letter-spacing: 0.16px; +} + +body.theme-minimal .bg-grid, +body.theme-minimal .bg-glow { + display: none; +} + +body.theme-minimal .top-nav { + background: var(--bg-primary); + border-bottom-color: var(--border-color); +} + +body.theme-minimal .logo-icon { + background: var(--bg-secondary); + border-color: var(--border-color); + border-radius: 12px; + color: var(--text-primary); +} + +body.theme-minimal .logo-pulse { + display: none; +} + +body.theme-minimal .logo-title { + background: none; + -webkit-text-fill-color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-weight: 500; +} + +body.theme-minimal .logo-subtitle { + color: var(--text-muted); + letter-spacing: 0.24px; +} + +body.theme-minimal .nav-item { + font-family: 'Inter', sans-serif; + font-weight: 500; + letter-spacing: 0.16px; + color: var(--text-secondary); +} + +body.theme-minimal .nav-item:hover { + color: var(--text-primary); +} + +body.theme-minimal .nav-item.active { + background: var(--bg-card-hover); + border-radius: 9999px; + color: var(--text-primary); +} + +body.theme-minimal .system-status { + background: var(--bg-secondary); + border-color: var(--border-color); + border-radius: 9999px; +} + +body.theme-minimal .system-status .status-dot { + background: var(--green); +} + +body.theme-minimal .system-status .status-text { + color: var(--green); +} + +body.theme-minimal .search-box { + background: var(--bg-secondary); + border-radius: 9999px; + box-shadow: none; +} + +body.theme-minimal .search-box:focus-within { + box-shadow: 0 0 0 0.125rem rgba(73, 79, 223, 0.2); +} + +body.theme-minimal .search-box i { + color: var(--text-muted); +} + +body.theme-minimal .view-btn, +body.theme-minimal .filter-tab { + background: var(--bg-secondary); + border-radius: 9999px; + box-shadow: none; + padding: 10px 16px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; +} + +body.theme-minimal .view-btn:hover, +body.theme-minimal .filter-tab:hover { + color: var(--text-primary); +} + +body.theme-minimal .view-btn i { + font-size: 14px; +} + +body.theme-minimal .view-btn span { + font-size: 13px; + font-weight: 500; +} + +body.theme-minimal .stat-card { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 20px; + padding: 20px 24px; +} + +body.theme-minimal .stat-card:hover { + box-shadow: none; + transform: none; + background: var(--bg-card-hover); +} + +body.theme-minimal .stat-icon { + background: var(--bg-card); + border-radius: 12px; + color: var(--cyan); +} + +body.theme-minimal .stat-card.up .stat-icon { + color: var(--green); +} + +body.theme-minimal .stat-card.down .stat-icon { + color: var(--red); +} + +body.theme-minimal .stat-card.neutral .stat-icon { + color: var(--amber); +} + +body.theme-minimal .futures-card { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 20px; +} + +body.theme-minimal .futures-card::before { + display: none; +} + +body.theme-minimal .futures-card:hover { + box-shadow: none; + transform: none; + background: var(--bg-card-hover); +} + +body.theme-minimal .symbol-tag { + background: var(--bg-card); + border-color: var(--border-color); + border-radius: 12px; + color: var(--cyan); +} + +body.theme-minimal .period-tag { + background: var(--bg-card); + border-radius: 9999px; + padding: 6px 12px; +} + +body.theme-minimal .detail-link { + color: var(--cyan); +} + +body.theme-minimal .detail-header { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 20px; +} + +body.theme-minimal .symbol-code { + background: var(--bg-card); + border-radius: 9999px; + padding: 4px 12px; +} + +body.theme-minimal .period-btn { + background: var(--bg-secondary); + border-radius: 9999px; + padding: 10px 20px; + color: var(--text-secondary); +} + +body.theme-minimal .period-btn:hover { + color: var(--text-primary); +} + +body.theme-minimal .chart-container { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 20px; +} + +body.theme-minimal .panel-card { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 20px; +} + +body.theme-minimal .panel-card:hover { + box-shadow: none; +} + +body.theme-minimal .panel-header i { + color: var(--text-primary); +} + +body.theme-minimal .suggestion-card .suggestion-content { + background: var(--bg-card); + border-color: var(--border-color); + border-radius: 12px; +} + +body.theme-minimal .indicator-cell { + background: var(--bg-card); + border-radius: 12px; +} + +body.theme-minimal .level-item { + background: var(--bg-card); + border-radius: 9999px; +} + +body.theme-minimal .level-item.pivot-point { + background: rgba(124, 58, 237, 0.08); + border-color: rgba(124, 58, 237, 0.2); +} + +body.theme-minimal .trend-row { + background: var(--bg-card); + border-radius: 9999px; +} + +body.theme-minimal .param-row { + background: var(--bg-card); + border-radius: 9999px; +} + +body.theme-minimal .back-btn { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 9999px; + padding: 14px 32px; + color: var(--text-secondary); +} + +body.theme-minimal .back-btn:hover { + color: var(--text-primary); +} + +body.theme-minimal .datetime { + color: var(--text-muted); +} + +body.theme-minimal .nav-btn { + border-radius: 9999px; + color: var(--text-secondary); +} + +body.theme-minimal .nav-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +body.theme-minimal .sort-select select { + background: var(--bg-secondary); + border-color: var(--border-color); + border-radius: 9999px; + padding: 10px 36px 10px 16px; + font-family: 'Inter', sans-serif; + font-weight: 500; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23505a63' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +body.theme-minimal .sort-select select:hover { + border-color: var(--text-muted); +} + +/* AI历史记录详情弹窗样式 */ +.ai-history-detail { + padding: 8px 0; +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.detail-header h4 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.detail-header h4 i { + color: var(--cyan); +} + +.detail-time { + font-size: 12px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 6px; +} + +.detail-summary { + background: rgba(6, 182, 212, 0.05); + border-left: 3px solid var(--cyan); + padding: 12px 16px; + margin-bottom: 16px; + border-radius: 4px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.detail-summary i { + color: var(--cyan); + font-size: 14px; + margin-top: 2px; +} + +.detail-summary p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + flex: 1; +} + +.detail-suggestion { + margin-bottom: 16px; +} + +.suggestion-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.suggestion-card.long { + border-color: var(--green); + background: rgba(16, 185, 129, 0.05); +} + +.suggestion-card.short { + border-color: var(--red); + background: rgba(239, 68, 68, 0.05); +} + +.suggestion-card.neutral { + border-color: var(--amber); + background: rgba(245, 158, 11, 0.05); +} + +.suggestion-card i { + font-size: 24px; +} + +.suggestion-card.long i { + color: var(--green); +} + +.suggestion-card.short i { + color: var(--red); +} + +.suggestion-card.neutral i { + color: var(--amber); +} + +.suggestion-text { + font-size: 20px; + font-weight: 700; + flex: 1; +} + +.suggestion-card.long .suggestion-text { + color: var(--green); +} + +.suggestion-card.short .suggestion-text { + color: var(--red); +} + +.suggestion-card.neutral .suggestion-text { + color: var(--amber); +} + +.confidence-text { + font-size: 14px; + color: var(--text-muted); +} + +.detail-metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.metric-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.metric-label { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.metric-value { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.metric-value.down { + color: var(--red); +} + +.detail-section { + margin-bottom: 20px; +} + +.detail-section h5 { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.detail-section h5 i { + color: var(--cyan); + font-size: 13px; +} + +.four-d-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.four-d-table th { + background: var(--bg-secondary); + padding: 8px 10px; + text-align: left; + color: var(--text-muted); + font-weight: 600; + border-bottom: 1px solid var(--border-color); +} + +.four-d-table td { + padding: 10px; + border-bottom: 1px solid rgba(56, 189, 248, 0.05); + color: var(--text-secondary); +} + +.four-d-table tr:hover td { + background: rgba(6, 182, 212, 0.03); +} + +.kdj-diagnosis-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.kdj-item { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.kdj-label { + font-size: 11px; + color: var(--text-muted); +} + +.kdj-value { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.pivot-points-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.pivot-item { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + text-align: center; + display: flex; + flex-direction: column; + gap: 4px; +} + +.pivot-item span:first-child { + font-size: 11px; + color: var(--text-muted); + font-weight: 600; +} + +.pivot-item strong { + font-size: 14px; + color: var(--text-primary); +} + +.pivot-item.resistance span:first-child { + color: var(--red); +} + +.pivot-item.resistance strong { + color: var(--red); +} + +.pivot-item.support span:first-child { + color: var(--green); +} + +.pivot-item.support strong { + color: var(--green); +} + +.pivot-item.center { + border-color: var(--purple); + background: rgba(139, 92, 246, 0.05); +} + +.pivot-item.center span:first-child { + color: var(--purple); +} + +.pivot-item.center strong { + color: var(--purple); +} + +.warning-list { + list-style: none; + padding: 0; +} + +.warning-list li { + background: rgba(245, 158, 11, 0.05); + border-left: 3px solid var(--amber); + padding: 10px 12px; + margin-bottom: 8px; + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +.warning-list li:last-child { + margin-bottom: 0; +} + +@media (max-width: 768px) { + .detail-metrics { + grid-template-columns: repeat(2, 1fr); + } + + .pivot-points-grid { + grid-template-columns: repeat(3, 1fr); + } + + .kdj-diagnosis-grid { + grid-template-columns: 1fr; + } +} + +/* ============================================ + 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; +} + +/* 情景预案卡片样式 */ +.scenario-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.scenario-item { + display: grid; + grid-template-columns: 80px 60px 1fr; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + transition: all 0.2s ease; +} + +.scenario-item:hover { + border-color: var(--border-glow); + background: var(--bg-card-hover); +} + +.scenario-name { + font-size: 13px; + font-weight: 600; + color: var(--cyan); +} + +.scenario-probability { + font-size: 13px; + font-weight: 600; + color: var(--amber); + text-align: center; +} + +.scenario-action { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* 多周期趋势样式优化 */ +.trends-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.trend-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.trend-period { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +.trend-badge { + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + border-radius: 4px; + text-align: center; +} + +.trend-badge.up { + color: var(--green); + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.trend-badge.down { + color: var(--red); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.trend-badge.neutral { + color: var(--amber); + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.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); +} + +body.theme-minimal .sort-select select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +body.theme-minimal .sort-select select option:hover, +body.theme-minimal .sort-select select option:checked { + background: rgba(73, 79, 223, 0.1) !important; +} + +body.theme-minimal .filter-tab.active { + background: var(--cyan); + color: white; + border-color: var(--cyan); +} + +body.theme-minimal .view-btn.active { + background: var(--cyan); + border-color: var(--cyan); + color: white; +} + +body.theme-minimal .period-btn.active { + background: var(--cyan); + border-color: var(--cyan); + color: white; +} + +body.theme-minimal .suggestion-badge { + border-radius: 9999px; + padding: 6px 16px; +} + +body.theme-minimal .btn, +body.theme-minimal button { + border-radius: 9999px; +} + +/* 主题切换按钮 */ +.theme-toggle { + position: relative; +} + +.theme-toggle i { + transition: transform 0.3s ease; +} + +body.theme-minimal .theme-toggle i::before { + content: '\f185'; +} + +/* ============================================ + 自选按钮 + ============================================ */ +.watch-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; +} + +.watch-btn:hover { + border-color: var(--amber); + color: var(--amber); +} + +.watch-btn.active { + background: rgba(245, 158, 11, 0.1); + border-color: var(--amber); + color: var(--amber); +} + +body.theme-minimal .watch-btn { + border-radius: 9999px; + background: var(--bg-card); +} + +body.theme-minimal .watch-btn:hover { + background: rgba(236, 126, 0, 0.1); +} + +body.theme-minimal .watch-btn.active { + background: rgba(236, 126, 0, 0.15); +} + +/* 卡片刷新按钮 */ +.card-refresh-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + font-size: 12px; +} + +.card-refresh-btn:hover { + border-color: var(--cyan); + color: var(--cyan); + background: rgba(6, 182, 212, 0.1); +} + +.card-ai-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--purple-soft, rgba(139, 92, 246, 0.3)); + border-radius: 8px; + color: var(--purple, #8b5cf6); + cursor: pointer; + transition: all 0.2s; + font-size: 12px; +} + +.card-ai-btn:hover { + border-color: var(--purple, #8b5cf6); + background: rgba(139, 92, 246, 0.1); +} + +.card-ai-btn.analyzing { + color: var(--amber); + border-color: var(--amber); + animation: pulse 1s infinite; +} + +.ai-analyze-all-btn { + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, var(--purple, #8b5cf6), #6d28d9); + border: none; + border-radius: 8px; + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.ai-analyze-all-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); +} + +.ai-analyze-all-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +body.theme-minimal .card-refresh-btn { + border-radius: 9999px; + background: var(--bg-card); +} + +body.theme-minimal .card-refresh-btn:hover { + background: rgba(6, 182, 212, 0.1); +} + +/* ============================================ + 价格关键位标签 + ============================================ */ +.price-levels { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.level-tag { + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.level-tag.resistance { + background: rgba(226, 59, 74, 0.1); + color: #e23b4a; +} + +.level-tag.support { + background: rgba(0, 168, 126, 0.1); + color: #00a87e; +} + +body.theme-minimal .level-tag { + border-radius: 9999px; +} + +/* ============================================ + Toast 提示 + ============================================ */ +.toast-container { + position: fixed; + top: 80px; + right: 24px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + animation: slideIn 0.3s ease-out; + min-width: 280px; + max-width: 400px; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +.toast.removing { + animation: slideOut 0.3s ease-in forwards; +} + +.toast-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 12px; + flex-shrink: 0; +} + +.toast.success .toast-icon { + background: rgba(16, 185, 129, 0.15); + color: var(--green); +} + +.toast.info .toast-icon { + background: rgba(6, 182, 212, 0.15); + color: var(--cyan); +} + +.toast.warning .toast-icon { + background: rgba(245, 158, 11, 0.15); + color: var(--amber); +} + +.toast.error .toast-icon { + background: rgba(239, 68, 68, 0.15); + color: var(--red); +} + +.toast-content { + flex: 1; +} + +.toast-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.toast-message { + font-size: 12px; + color: var(--text-secondary); +} + +body.theme-minimal .toast { + background: #ffffff; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); +} + +/* ============================================ + 刷新按钮 + ============================================ */ +.refresh-all-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--cyan); + border: none; + border-radius: 9999px; + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.refresh-all-btn:hover { + background: var(--cyan-glow); + transform: translateY(-1px); +} + +.refresh-all-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.refresh-all-btn i { + font-size: 14px; +} + +.refresh-all-btn.spinning i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.filter-actions { + display: flex; + align-items: center; + gap: 12px; +} + +/* 详情页面刷新按钮 */ +.detail-actions { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.refresh-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.refresh-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); + border-color: var(--border-glow); +} + +.refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +body.theme-minimal .refresh-btn { + background: var(--bg-secondary); + border-radius: 9999px; +} + +body.theme-minimal .refresh-all-btn { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* ============================================ + 历史分析记录 + ============================================ */ +.chart-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.history-container { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 16px; +} + +body.theme-minimal .history-container { + background: var(--bg-secondary); + border-radius: 20px; + box-shadow: none; +} + +.history-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text-primary); +} + +.history-header i { + color: var(--cyan); +} + +body.theme-minimal .history-header i { + color: var(--text-primary); +} + +.history-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; +} + +.empty-state { + text-align: center; + padding: 30px; + color: var(--text-muted); + font-size: 14px; +} + +.history-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.history-item:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--border-glow); +} + +body.theme-minimal .history-item { + background: var(--bg-card); + border-radius: 12px; +} + +body.theme-minimal .history-item:hover { + background: var(--bg-card-hover); +} + +.history-item-left { + display: flex; + align-items: center; + gap: 12px; +} + +.history-time { + font-size: 12px; + color: var(--text-muted); + min-width: 130px; +} + +.history-suggestion { + padding: 3px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.history-suggestion.up { + background: rgba(16, 185, 129, 0.12); + color: var(--green); +} + +.history-suggestion.down { + background: rgba(239, 68, 68, 0.12); + color: var(--red); +} + +.history-suggestion.neutral { + background: rgba(245, 158, 11, 0.1); + color: var(--amber); +} + +body.theme-minimal .history-suggestion { + border-radius: 9999px; +} + +.history-score { + font-size: 13px; + font-weight: 600; +} + +.history-item-right { + display: flex; + align-items: center; + gap: 16px; +} + +.history-metric { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.history-metric-label { + font-size: 10px; + color: var(--text-muted); +} + +.history-metric-value { + font-size: 12px; + font-weight: 600; +} + +.history-detail-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; +} + +.history-detail-btn:hover { + color: var(--cyan); + border-color: var(--cyan); +} + +body.theme-minimal .history-detail-btn { + border-radius: 9999px; +} + +/* ============================================ + 面板头部操作按钮 + ============================================ */ +.panel-header-action { + margin-left: auto; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; +} + +.panel-header-action:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +/* ============================================ + 对话框 + ============================================ */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-overlay.active { + display: flex; +} + +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 20px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +body.theme-minimal .modal-content { + background: #ffffff; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 600; +} + +.modal-header h3 i { + color: var(--cyan); +} + +body.theme-minimal .modal-header h3 i { + color: var(--text-primary); +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 8px; + transition: all 0.2s; +} + +.modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + +body.theme-minimal .modal-close:hover { + background: var(--bg-secondary); +} + +.modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +/* 对话框内容样式 */ +.modal-suggestion-main { + text-align: center; + padding: 20px; + background: rgba(6, 182, 212, 0.06); + border: 1px solid rgba(6, 182, 212, 0.15); + border-radius: 12px; + margin-bottom: 20px; +} + +body.theme-minimal .modal-suggestion-main { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +.modal-suggestion-main .suggestion-badge { + font-size: 24px; + font-weight: 700; + margin-bottom: 8px; +} + +.modal-suggestion-main .suggestion-reason { + font-size: 14px; + color: var(--text-secondary); +} + +.modal-params-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.modal-param-card { + padding: 14px; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + text-align: center; +} + +body.theme-minimal .modal-param-card { + background: var(--bg-secondary); +} + +.modal-param-card .param-label { + display: block; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.modal-param-card .param-value { + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.modal-section { + margin-bottom: 20px; +} + +.modal-section:last-child { + margin-bottom: 0; +} + +.modal-section-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.modal-section-title i { + color: var(--cyan); +} + +body.theme-minimal .modal-section-title i { + color: var(--text-primary); +} + +.modal-indicators-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.modal-indicator-item { + padding: 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; +} + +body.theme-minimal .modal-indicator-item { + background: var(--bg-secondary); +} + +.modal-indicator-item .indicator-label { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.modal-indicator-item .indicator-value { + font-size: 14px; + font-weight: 600; +} + +.modal-indicator-item .indicator-detail { + font-size: 11px; + color: var(--text-secondary); +} + +.modal-levels-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal-level-row { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + font-size: 13px; +} + +body.theme-minimal .modal-level-row { + background: var(--bg-secondary); +} + +.modal-level-row .level-label { + color: var(--text-secondary); +} + +.modal-level-row .level-value { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.modal-trends-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal-trend-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; +} + +body.theme-minimal .modal-trend-row { + background: var(--bg-secondary); +} + +.modal-trend-period { + font-size: 13px; + color: var(--text-secondary); +} + +.modal-trend-badge { + padding: 3px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.modal-trend-badge.up { + background: rgba(16, 185, 129, 0.12); + color: var(--green); +} + +.modal-trend-badge.down { + background: rgba(239, 68, 68, 0.12); + color: var(--red); +} + +.modal-trend-badge.neutral { + background: rgba(245, 158, 11, 0.1); + color: var(--amber); +} + +body.theme-minimal .modal-trend-badge { + border-radius: 9999px; +} + +body.theme-minimal .period-tag { + border-radius: 9999px; + padding: 6px 12px; +} + +body.theme-minimal .detail-link { + color: var(--cyan); +} + +body.theme-minimal .suggestion-card .suggestion-content { + background: var(--bg-card); + border-color: var(--border-color); + border-radius: 12px; +} + +body.theme-minimal .indicator-cell { + background: var(--bg-card); + border-radius: 12px; +} + +body.theme-minimal .level-item { + background: var(--bg-card); + border-radius: 9999px; +} + +body.theme-minimal .trend-row { + background: var(--bg-card); + border-radius: 9999px; +} + +body.theme-minimal .param-row { + background: var(--bg-card); + border-radius: 9999px; +} + +body.theme-minimal .back-btn { + background: var(--bg-secondary); + box-shadow: none; + border-radius: 9999px; + padding: 14px 32px; + color: var(--text-secondary); +} + +body.theme-minimal .back-btn:hover { + color: var(--text-primary); +} + +body.theme-minimal .datetime { + color: var(--text-muted); +} + +body.theme-minimal .nav-btn { + border-radius: 9999px; + color: var(--text-secondary); +} + +body.theme-minimal .nav-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +body.theme-minimal .sort-select select { + background: var(--bg-secondary); + border-color: var(--border-color); + border-radius: 9999px; + padding: 10px 36px 10px 16px; + font-family: 'Inter', sans-serif; + font-weight: 500; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23505a63' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +body.theme-minimal .sort-select select:hover { + border-color: var(--text-muted); +} + +body.theme-minimal .sort-select select:focus { + border-color: var(--cyan); + box-shadow: 0 0 0 0.125rem rgba(73, 79, 223, 0.2); +} + +body.theme-minimal .sort-select select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +body.theme-minimal .sort-select select option:hover, +body.theme-minimal .sort-select select option:checked { + background: rgba(73, 79, 223, 0.1) !important; +} + +body.theme-minimal .filter-tab.active { + background: var(--cyan); + color: white; + border-color: var(--cyan); +} + +body.theme-minimal .view-btn.active { + background: var(--cyan); + border-color: var(--cyan); + color: white; +} + +body.theme-minimal .period-btn.active { + background: var(--cyan); + border-color: var(--cyan); + color: white; +} + +body.theme-minimal .suggestion-badge { + border-radius: 9999px; + padding: 6px 16px; +} + +body.theme-minimal .btn, +body.theme-minimal button { + border-radius: 9999px; +} + +/* 主题切换按钮 */ +.theme-toggle { + position: relative; +} + +.theme-toggle i { + transition: transform 0.3s ease; +} + +body.theme-minimal .theme-toggle i::before { + content: '\f185'; +} diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html new file mode 100644 index 0000000..2c0aaf5 --- /dev/null +++ b/app/static/futures_analysis.html @@ -0,0 +1,416 @@ + + + + + + 期货智析 - 智能期货期权分析系统 + + + + +
+
+ +
+ +
+ + + +
+ + +
+ + +
+ +
+ +
+ +
+ + +
+
+ + +
+
+ + + + + + +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ 0 + 监控品种 +
+
+
+
+
+ 0 + 上涨趋势 +
+
+
+
+
+ 0 + 下跌趋势 +
+
+
+
+
+ 0 + 震荡整理 +
+
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+
+ -- + -- +
+
+ -- + -- +
+ R1: -- + S1: -- +
+
+
+
+
+
+ 开盘 + -- +
+
+ 最高 + -- +
+
+ 最低 + -- +
+
+ 成交量 + -- +
+
+
+
+ + +
+ 周期 +
+ + + + +
+
+ + +
+
+ +
+
+ K线图 +
+ MA5 + MA10 + MA20 +
+
+
+
+ + +
+
+ + 历史分析记录 +
+
+ +
+
+
+ + +
+ +
+
+ + AI 思维分析 +
+ +
+
+
+
+ +

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

+
+
+
+ + +
+
+ + 技术指标 +
+
+
+ MACD + -- + -- +
+
+ RSI + -- + -- +
+
+ BOLL + -- + -- +
+
+ KDJ + -- + -- +
+
+
+ + +
+
+ + 关键点位 +
+
+
+ 压力 +
R1--
+
R2--
+
+
+
+ 中枢 (PP) + -- +
+
+
+ 支撑 +
S1--
+
S2--
+
+
+
+ + + + + + +
+
+
+
+
+ + + + + + + + + + + + + + diff --git a/app/static/futures_analysis.js b/app/static/futures_analysis.js new file mode 100644 index 0000000..4b9be71 --- /dev/null +++ b/app/static/futures_analysis.js @@ -0,0 +1,2076 @@ +const API_BASE = '/api/v1/futures'; + +let klineChart = null; +let currentSymbol = null; +let currentPeriod = '15'; +let allFuturesData = []; +let watchedSymbols = []; +let currentDetailData = null; + +document.addEventListener('DOMContentLoaded', function() { + addScoreGradient(); + updateTime(); + setInterval(updateTime, 1000); + + initEventListeners(); + loadWatchedSymbols(); + loadFuturesList(); +}); + +function addScoreGradient() { + const svg = document.querySelector('.score-ring svg'); + if (svg && !svg.querySelector('defs')) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); + gradient.setAttribute('id', 'scoreGradient'); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('x2', '100%'); + gradient.setAttribute('y2', '0%'); + + const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); + stop1.setAttribute('offset', '0%'); + stop1.setAttribute('stop-color', '#ef4444'); + + const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); + stop2.setAttribute('offset', '50%'); + stop2.setAttribute('stop-color', '#f59e0b'); + + const stop3 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); + stop3.setAttribute('offset', '100%'); + stop3.setAttribute('stop-color', '#10b981'); + + gradient.appendChild(stop1); + gradient.appendChild(stop2); + gradient.appendChild(stop3); + defs.appendChild(gradient); + svg.insertBefore(defs, svg.firstChild); + } +} + +function updateTime() { + const now = new Date(); + const timeStr = now.getFullYear() + '-' + + String(now.getMonth() + 1).padStart(2, '0') + '-' + + String(now.getDate()).padStart(2, '0') + ' ' + + String(now.getHours()).padStart(2, '0') + ':' + + String(now.getMinutes()).padStart(2, '0') + ':' + + String(now.getSeconds()).padStart(2, '0'); + document.getElementById('current-time').textContent = timeStr; +} + +function initEventListeners() { + document.getElementById('back-btn').addEventListener('click', showListView); + + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + + // AI交易建议卡片点击事件(已隐藏,暂时注释) + // const suggestionCard = document.getElementById('suggestion-card'); + // if (suggestionCard) { + // suggestionCard.addEventListener('click', function() { + // if (currentDetailData) { + // showSuggestionModal(currentDetailData); + // } + // }); + // } + + // 刷新全部按钮 + document.getElementById('refresh-all-btn').addEventListener('click', refreshAllSymbols); + + // 全部AI分析按钮 + document.getElementById('ai-analyze-all-btn').addEventListener('click', analyzeAllSymbols); + + // 详情页刷新按钮 + document.getElementById('refresh-symbol-btn').addEventListener('click', function() { + if (currentSymbol) { + refreshSingleSymbol(currentSymbol); + } + }); + + const savedTheme = localStorage.getItem('futures-theme'); + if (savedTheme === 'dark') { + document.body.classList.remove('theme-minimal'); + updateThemeIcon(false); + } else { + document.body.classList.add('theme-minimal'); + updateThemeIcon(true); + } + + document.querySelectorAll('.period-btn').forEach(btn => { + btn.addEventListener('click', function() { + document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + currentPeriod = this.dataset.period; + loadKlineData(currentSymbol, currentPeriod); + }); + }); + + document.getElementById('search-input').addEventListener('input', function() { + filterFuturesList(this.value); + }); + + document.querySelectorAll('.filter-tab').forEach(tab => { + tab.addEventListener('click', function() { + document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active')); + this.classList.add('active'); + filterByCategory(this.dataset.category); + }); + }); + + document.getElementById('sort-select').addEventListener('change', function() { + sortFuturesList(this.value); + }); + + document.querySelectorAll('.modal-overlay').forEach(modal => { + modal.addEventListener('click', function(e) { + if (e.target === this) { + this.classList.remove('active'); + } + }); + }); +} + +function closeModal(modalId) { + document.getElementById(modalId).classList.remove('active'); +} + +function showListView() { + document.getElementById('list-view').classList.add('active'); + document.getElementById('detail-view').classList.remove('active'); + if (klineChart) { + klineChart.dispose(); + klineChart = null; + } +} + +async function showDetailView(symbol) { + currentSymbol = symbol; + document.getElementById('list-view').classList.remove('active'); + document.getElementById('detail-view').classList.add('active'); + + // 1. 加载行情数据 + loadFuturesDetail(symbol); + loadKlineData(symbol, currentPeriod); + + // 2. 加载历史记录 + await loadHistoryListForAnalysis(symbol); +} + +async function loadHistoryListForAnalysis(symbol) { + try { + const response = await fetch(`${API_BASE}/ai-analysis/${symbol}/history?limit=20`); + const data = await response.json(); + + if (data.success) { + renderHistoryList(data.data); + + // 3. 查找今天的最新分析记录 + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + + let todayRecord = null; + if (data.data && data.data.length > 0) { + for (const record of data.data) { + const recordDate = new Date(record.analysis_time); + const recordDateStr = recordDate.toISOString().split('T')[0]; + if (recordDateStr === todayStr) { + todayRecord = record; + break; + } + } + } + + // 4. 根据是否有今天的记录进行不同处理 + if (todayRecord) { + console.log(`找到今天的分析记录: ${symbol} (${todayRecord.analysis_time})`); + currentAIAnalysis = { + id: todayRecord.id, + symbol: todayRecord.symbol, + analysis_time: todayRecord.analysis_time, + result: todayRecord.analysis_data + }; + displayAIAnalysisResult(currentAIAnalysis); + syncAIToPanels(todayRecord.analysis_data); + } else { + console.log(`没有找到今天的分析记录: ${symbol}`); + showAIAnalysisPlaceholder(); + } + } else { + showAIAnalysisPlaceholder(); + } + } catch (error) { + console.error('加载历史记录失败:', error); + showAIAnalysisPlaceholder(); + } +} + +function showAIAnalysisPlaceholder() { + const content = document.getElementById('ai-analysis-content'); + content.innerHTML = ` +
+ +

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

+
+ `; +} + +async function loadWatchedSymbols() { + try { + const response = await fetch(`${API_BASE}/watched`); + const data = await response.json(); + if (data.success) { + watchedSymbols = data.data.map(s => s.symbol); + document.getElementById('count-watched').textContent = watchedSymbols.length; + } + } catch (error) { + console.error('加载自选列表失败:', error); + watchedSymbols = []; + } +} + +async function toggleWatch(symbol, name, event) { + event.stopPropagation(); + const isWatched = watchedSymbols.includes(symbol); + + try { + if (isWatched) { + const response = await fetch(`${API_BASE}/watched/${symbol}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + watchedSymbols = watchedSymbols.filter(s => s !== symbol); + } + } else { + const response = await fetch(`${API_BASE}/watched`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ symbol, name }) + }); + const data = await response.json(); + if (data.success) { + watchedSymbols.push(symbol); + } + } + document.getElementById('count-watched').textContent = watchedSymbols.length; + + const activeTab = document.querySelector('.filter-tab.active'); + if (activeTab && activeTab.dataset.category === 'watched') { + filterByCategory('watched'); + } else { + renderFuturesGrid(getCurrentFilteredData()); + } + } catch (error) { + console.error('切换自选失败:', error); + } +} + +function getCurrentFilteredData() { + const activeTab = document.querySelector('.filter-tab.active'); + const category = activeTab ? activeTab.dataset.category : 'all'; + return filterDataByCategory(allFuturesData, category); +} + +function filterDataByCategory(data, category) { + if (category === 'all') return data; + if (category === 'watched') { + return data.filter(item => watchedSymbols.includes(item.symbol)); + } + const categoryMap = { + 'energy': ['SC', 'FU', 'LU', 'BU', 'RU', 'NR'], + 'metal': ['AU', 'AG', 'CU', 'AL', 'ZN', 'NI', 'SN', 'PB', 'SS', 'RB', 'HC', 'I', 'J', 'JM', 'AO', 'SI', 'LC', 'PS'], + 'agriculture': ['M', 'RM', 'C', 'CS', 'A', 'B', 'Y', 'P', 'OI', 'CF', 'SR', 'AP', 'LH'], + 'finance': ['IF', 'IC', 'IH', 'IM', 'T', 'TF', 'TS', 'TL'] + }; + const symbols = categoryMap[category] || []; + return data.filter(item => { + const symbolBase = item.symbol.replace(/[0-9]/g, '').toUpperCase(); + return symbols.includes(symbolBase); + }); +} + +async function loadFuturesList() { + try { + console.log('正在加载品种列表...'); + const response = await fetch(`${API_BASE}/list`); + const data = await response.json(); + console.log('品种列表响应:', data); + if (data.success) { + allFuturesData = data.data.map(item => ({ + ...item, + hasAIAnalysis: false // 默认没有AI分析数据 + })); + console.log('加载的品种数据:', allFuturesData.length, '个'); + renderFuturesGrid(allFuturesData); + updateStats(allFuturesData); + + // 异步加载每个合约的最新AI分析结果 + loadAllAIAnalysis(); + } else { + console.error('加载品种列表失败:', data); + } + } catch (error) { + console.error('加载品种列表失败:', error); + loadFuturesFromConfig(); + } +} + +async function loadAllAIAnalysis() { + console.log('开始加载所有合约的AI分析结果...'); + + // 获取今天的日期字符串用于比较 + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; // YYYY-MM-DD + + // 分批加载,避免并发请求过多 + const batchSize = 5; + for (let i = 0; i < allFuturesData.length; i += batchSize) { + const batch = allFuturesData.slice(i, i + batchSize); + const promises = batch.map(async (item) => { + try { + // 获取历史记录 + const response = await fetch(`${API_BASE}/ai-analysis/${item.symbol}/history?limit=1`); + const data = await response.json(); + + if (data.success && data.data && data.data.length > 0) { + const latestRecord = data.data[0]; // 最新的一条记录 + const analysisTime = latestRecord.analysis_time; + + // 判断是否是今天的记录 + const recordDate = new Date(analysisTime); + const recordDateStr = recordDate.toISOString().split('T')[0]; + + if (recordDateStr === todayStr) { + // 是今天的记录,加载数据 + const result = latestRecord.analysis_data; + const analysisItem = allFuturesData.find(d => d.symbol === item.symbol); + if (analysisItem) { + analysisItem.hasAIAnalysis = true; + analysisItem.aiResult = result; + analysisItem.analysisTime = analysisTime; + + // 更新操作建议 + if (result.trading_suggestion?.direction) { + analysisItem.suggestion = result.trading_suggestion.direction; + analysisItem.suggestionType = result.trading_suggestion.direction === '做多' ? 'up' : result.trading_suggestion.direction === '做空' ? 'down' : 'neutral'; + } + + // 更新压力支撑位 + if (result.pivot_points) { + if (result.pivot_points.r1) analysisItem.resistance = result.pivot_points.r1; + if (result.pivot_points.s1) analysisItem.support = result.pivot_points.s1; + } + + // 更新多周期趋势 + if (result.four_dimensional) { + const periodMap = { '60min': '60', '30min': '30', '15min': '15', '5min': '5' }; + analysisItem.periods = {}; + Object.entries(result.four_dimensional).forEach(([period, pdata]) => { + const periodNum = periodMap[period]; + if (periodNum) { + const trend = pdata.conclusion || pdata.macd?.trend || 'neutral'; + analysisItem.periods[periodNum] = trend.includes('多') || trend === 'up' ? 'up' : trend.includes('空') || trend === 'down' ? 'down' : 'neutral'; + } + }); + } + + // 更新趋势评分(使用AI置信度) + if (result.trading_suggestion?.confidence) { + analysisItem.trendScore = result.trading_suggestion.confidence; + } + + // 更新成功率(根据判断方向设置一致概率) + const direction = analysisItem.suggestionType; + if (direction === 'up') { + analysisItem.successRate = 85; // 做多成功率 + } else if (direction === 'down') { + analysisItem.successRate = 82; // 做空成功率 + } else { + analysisItem.successRate = 60; // 观望成功率 + } + } + } else { + console.log(`${item.symbol} 的分析记录不是今天的 (${recordDateStr}),不加载`); + } + } else { + console.log(`${item.symbol} 没有AI分析记录`); + } + } catch (error) { + console.error(`加载 ${item.symbol} AI分析失败:`, error); + } + }); + + await Promise.all(promises); + } + + // 所有批次加载完成后只渲染一次 + renderFuturesGrid(allFuturesData); + console.log('所有合约AI分析结果加载完成'); +} + +async function loadFuturesFromConfig() { + try { + const response = await fetch('/api/v1/config'); + const config = await response.json(); + const futuresConfig = config.futures || {}; + + allFuturesData = Object.entries(futuresConfig).map(([name, symbol]) => ({ + symbol: symbol, + name: name, + price: 0, + change: 0, + changePct: 0, + suggestion: '等待数据', + suggestionType: 'neutral', + periods: { '5': 'neutral', '15': 'neutral', '30': 'neutral', '60': 'neutral' }, + successRate: 0, + trendScore: 0, + resistance: 0, + support: 0, + open: 0, + high: 0, + low: 0, + volume: 0 + })); + + renderFuturesGrid(allFuturesData); + updateStats(allFuturesData); + } catch (error) { + console.error('加载配置失败:', error); + const mockData = generateMockFuturesData(); + allFuturesData = mockData; + renderFuturesGrid(mockData); + updateStats(mockData); + } +} + +function generateMockFuturesData() { + return [ + { symbol: 'SC2606', name: '原油', price: 528.6, change: 12.1, changePct: 2.35, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'neutral' }, successRate: 72, trendScore: 85, resistance: 535, support: 518, open: 516.5, high: 530, low: 515, volume: 78000 }, + { symbol: 'AU2606', name: '黄金', price: 685.2, change: 12.45, changePct: 1.85, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, successRate: 78, trendScore: 92, resistance: 692, support: 678, open: 672.75, high: 688, low: 670, volume: 128000 }, + { symbol: 'AG2606', name: '白银', price: 8250, change: 165, changePct: 2.04, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, successRate: 75, trendScore: 88, resistance: 8350, support: 8100, open: 8085, high: 8280, low: 8050, volume: 95000 }, + { symbol: 'CU2606', name: '沪铜', price: 80610, change: 112, changePct: 0.14, suggestion: '观望等待', suggestionType: 'neutral', periods: { '5': 'neutral', '15': 'up', '30': 'neutral', '60': 'up' }, successRate: 58, trendScore: 65, resistance: 81200, support: 79800, open: 80498, high: 80850, low: 80200, volume: 42000 }, + { symbol: 'M2609', name: '豆粕', price: 2985, change: -51, changePct: -1.68, suggestion: '逢高做空', suggestionType: 'down', periods: { '5': 'down', '15': 'down', '30': 'down', '60': 'neutral' }, successRate: 65, trendScore: 35, resistance: 3050, support: 2920, open: 3036, high: 3040, low: 2980, volume: 185000 } + ]; +} + +function renderFuturesGrid(data) { + const grid = document.getElementById('futures-grid'); + console.log('渲染品种网格,数据量:', data.length); + if (data.length === 0) { + grid.innerHTML = '
暂无数据
'; + console.log('显示暂无数据'); + return; + } + + grid.innerHTML = data.map(item => { + const isWatched = watchedSymbols.includes(item.symbol); + const hasAI = item.hasAIAnalysis; + + return ` +
+
+
+
${item.symbol.replace(/[0-9]/g, '').substring(0, 2)}
+
+
${item.name}
+
${item.symbol}
+
+
+
+
¥${formatNumber(item.price)}
+
+ + ${item.change >= 0 ? '+' : ''}${formatNumber(item.change)} (${item.changePct >= 0 ? '+' : ''}${item.changePct.toFixed(2)}%) +
+
+
+ ${hasAI ? item.suggestion : '--'} +
+
+ 成功率 +
+ ${item.successRate}% +
+
+ 趋势评分 +
+ ${item.trendScore} +
+
+ + ${!hasAI ? `
请先进行AI分析
` : ''} + +
+ `}).join(''); +} + +function formatNumber(num) { + if (num === 0 || num === undefined || num === null) return '--'; + return num.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + +function calcPriceChangePercent(current, target) { + if (!current || !target || current === 0) return '--'; + const pct = ((target - current) / current * 100).toFixed(2); + return (pct >= 0 ? '+' : '') + pct + '%'; +} + +function updateStats(data) { + const total = data.length; + + // 根据AI分析结果统计趋势(字符串包含判断) + const upCount = data.filter(d => + d.suggestion?.includes('做多') || d.suggestion?.includes('试多') + ).length; + + const downCount = data.filter(d => + d.suggestion?.includes('做空') || d.suggestion?.includes('试空') + ).length; + + const neutralCount = data.filter(d => + d.suggestion?.includes('观望') + ).length; + + document.getElementById('total-count').textContent = total; + document.getElementById('up-count').textContent = upCount; + document.getElementById('down-count').textContent = downCount; + document.getElementById('neutral-count').textContent = neutralCount; + document.getElementById('count-all').textContent = total; + document.getElementById('count-watched').textContent = watchedSymbols.length; +} + +function filterByTrend(trend) { + console.log('按趋势筛选:', trend); + let filtered = allFuturesData; + + if (trend === 'up') { + filtered = allFuturesData.filter(d => + d.suggestion?.includes('做多') || d.suggestion?.includes('试多') + ); + } else if (trend === 'down') { + filtered = allFuturesData.filter(d => + d.suggestion?.includes('做空') || d.suggestion?.includes('试空') + ); + } else if (trend === 'neutral') { + filtered = allFuturesData.filter(d => + d.suggestion?.includes('观望') + ); + } + + renderFuturesGrid(filtered); +} + +function filterFuturesList(keyword) { + keyword = keyword.toLowerCase(); + const activeTab = document.querySelector('.filter-tab.active'); + const category = activeTab ? activeTab.dataset.category : 'all'; + + let filtered = filterDataByCategory(allFuturesData, category); + + if (keyword) { + filtered = filtered.filter(item => + item.name.toLowerCase().includes(keyword) || + item.symbol.toLowerCase().includes(keyword) + ); + } + + renderFuturesGrid(filtered); +} + +function filterByCategory(category) { + let filtered = filterDataByCategory(allFuturesData, category); + renderFuturesGrid(filtered); +} + +function sortFuturesList(sortBy) { + let sorted = [...getCurrentFilteredData()]; + switch(sortBy) { + case 'success_rate': + sorted.sort((a, b) => b.successRate - a.successRate); + break; + case 'trend_score': + sorted.sort((a, b) => b.trendScore - a.trendScore); + break; + case 'change_pct': + sorted.sort((a, b) => b.changePct - a.changePct); + break; + case 'name': + sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh')); + break; + } + renderFuturesGrid(sorted); +} + +async function loadFuturesDetail(symbol) { + try { + const response = await fetch(`${API_BASE}/detail/${symbol}`); + const data = await response.json(); + if (data.success) { + currentDetailData = data.data; + updateDetailView(data.data); + } + } catch (error) { + console.error('加载详情失败:', error); + } +} + +function updateDetailView(data) { + document.getElementById('detail-name').textContent = data.name || '--'; + document.getElementById('detail-symbol').textContent = data.symbol || '--'; + + const priceEl = document.getElementById('detail-price'); + priceEl.textContent = '¥' + formatNumber(data.price); + priceEl.className = 'price-value ' + (data.change >= 0 ? 'up' : 'down'); + + const changeEl = document.getElementById('detail-change'); + changeEl.className = 'price-change ' + (data.change >= 0 ? 'up' : 'down'); + changeEl.innerHTML = ` ${data.change >= 0 ? '+' : ''}${formatNumber(data.change)} (${data.changePct >= 0 ? '+' : ''}${data.changePct.toFixed(2)}%)`; + + document.getElementById('detail-open').textContent = formatNumber(data.open); + document.getElementById('detail-high').textContent = formatNumber(data.high); + document.getElementById('detail-low').textContent = formatNumber(data.low); + document.getElementById('detail-volume').textContent = formatNumber(data.volume); + + const r1 = data.resistances ? data.resistances[0] : data.resistance; + const s1 = data.supports ? data.supports[0] : data.support; + + document.getElementById('detail-r1').textContent = `R1: ${formatNumber(r1)} (${calcPriceChangePercent(data.price, r1)})`; + document.getElementById('detail-s1').textContent = `S1: ${formatNumber(s1)} (${calcPriceChangePercent(data.price, s1)})`; + + const badge = document.getElementById('suggestion-badge'); + badge.textContent = data.suggestion || '--'; + badge.className = 'suggestion-badge ' + (data.suggestionType || 'neutral'); + + document.getElementById('suggestion-reason').textContent = data.suggestionReason || '--'; + document.getElementById('entry-price').textContent = formatNumber(data.entryPrice); + document.getElementById('target-price').textContent = formatNumber(data.targetPrice); + document.getElementById('stop-loss').textContent = formatNumber(data.stopLoss); + document.getElementById('risk-level').textContent = data.riskLevel || '--'; + + if (data.macd) { + document.getElementById('macd-signal').textContent = data.macd.signal; + document.getElementById('macd-detail').textContent = data.macd.detail; + } + if (data.rsi) { + document.getElementById('rsi-value').textContent = data.rsi.value; + document.getElementById('rsi-status').textContent = data.rsi.status; + } + if (data.boll) { + document.getElementById('boll-signal').textContent = data.boll.signal; + document.getElementById('boll-detail').textContent = data.boll.detail; + } + if (data.kdj) { + document.getElementById('kdj-signal').textContent = data.kdj.signal; + document.getElementById('kdj-detail').textContent = data.kdj.detail; + } + + if (data.resistances) { + for (let i = 0; i < 2; i++) { + const el = document.getElementById(`resistance-${i + 1}`); + if (el) { + el.querySelector('span:last-child').textContent = formatNumber(data.resistances[i]); + } + } + } + if (data.supports) { + for (let i = 0; i < 2; i++) { + const el = document.getElementById(`support-${i + 1}`); + if (el) { + el.querySelector('span:last-child').textContent = formatNumber(data.supports[i]); + } + } + } + + if (data.pivotPoint) { + document.getElementById('pivot-point').querySelector('span:last-child').textContent = formatNumber(data.pivotPoint); + } + + if (data.periodConsistency) { + const container = document.getElementById('period-trends'); + const periodNames = { '5': '5分钟', '15': '15分钟', '30': '30分钟', '60': '60分钟' }; + container.innerHTML = Object.entries(data.periodConsistency).map(([period, trend]) => ` +
+ ${periodNames[period]} + + ${trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'} + +
+ `).join(''); + } + + if (data.trendScore !== undefined) { + document.getElementById('trend-score').textContent = data.trendScore; + const circle = document.getElementById('score-fill'); + const circumference = 2 * Math.PI * 45; + const offset = circumference - (data.trendScore / 100) * circumference; + circle.style.strokeDasharray = circumference; + circle.style.strokeDashoffset = offset; + } +} + +async function loadHistoryList(symbol) { + try { + const response = await fetch(`${API_BASE}/ai-analysis/${symbol}/history?limit=20`); + const data = await response.json(); + if (data.success) { + renderHistoryList(data.data); + } + } catch (error) { + console.error('加载历史记录失败:', error); + document.getElementById('history-list').innerHTML = '
暂无历史记录
'; + } +} + +async function loadAIAnalysis() { + if (!currentSymbol) return; + + const content = document.getElementById('ai-analysis-content'); + + try { + console.log(`加载合约 ${currentSymbol} 的AI分析...`); + const response = await fetch(`${API_BASE}/ai-analysis/${currentSymbol}`); + const data = await response.json(); + + console.log(`合约 ${currentSymbol} AI分析响应:`, data); + + if (data.success && data.data) { + console.log(`合约 ${currentSymbol} 分析数据 - symbol:`, data.data.symbol); + currentAIAnalysis = data.data; + displayAIAnalysisResult(data.data); + } else { + console.log(`合约 ${currentSymbol} 无分析结果`); + content.innerHTML = ` +
+ +

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

+
+ `; + } + } catch (error) { + console.error(`加载合约 ${currentSymbol} AI分析失败:`, error); + content.innerHTML = ` +
+ +

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

+
+ `; + } +} + +function renderHistoryList(records) { + const container = document.getElementById('history-list'); + if (!records || records.length === 0) { + container.innerHTML = '
暂无历史记录
'; + return; + } + + console.log('渲染历史记录,记录数量:', records.length); + console.log('历史记录合约分布:', records.map(r => r.symbol)); + + container.innerHTML = records.map(record => { + const analysisData = record.analysis_data || {}; + const suggestion = analysisData.trading_suggestion || {}; + const timeStr = record.analysis_time ? record.analysis_time.replace('T', ' ').substring(0, 16) : '--'; + const summary = analysisData.summary || '--'; + const direction = suggestion.direction || '--'; + const confidence = suggestion.confidence || 0; + + console.log(`历史记录 ID:${record.id} 合约:${record.symbol}`); + + return ` +
+
+ ${timeStr} + ${summary.substring(0, 30)}${summary.length > 30 ? '...' : ''} + 方向: ${direction} | 置信度: ${confidence}% +
+
+ +
+
+ `; + }).join(''); +} + +function showSuggestionModal(data) { + const body = document.getElementById('suggestion-modal-body'); + body.innerHTML = ` + + + `; + document.getElementById('suggestion-modal').classList.add('active'); +} + +function showHistoryModal(record) { + const body = document.getElementById('history-modal-body'); + body.innerHTML = ` + + + + + + `; + document.getElementById('history-modal').classList.add('active'); +} + +async function loadKlineData(symbol, period) { + if (!symbol) return; + + try { + const response = await fetch(`${API_BASE}/kline/${symbol}?period=${period}`); + const data = await response.json(); + + if (data.success && data.data) { + renderKlineChart(data.data); + } + } catch (error) { + console.error('加载K线数据失败:', error); + } +} + +async function showAIHistoryDetail(recordId) { + try { + const response = await fetch(`${API_BASE}/ai-analysis/history/${recordId}`); + const data = await response.json(); + + if (data.success && data.data) { + const record = data.data; + const result = record.analysis_data; + const timestamp = new Date(record.analysis_time).toLocaleString('zh-CN'); + + // 构建弹窗内容 + const modalBody = document.getElementById('ai-analysis-modal-body'); + + const direction = result.trading_suggestion?.direction || '观望'; + const directionClass = direction === '做多' ? 'long' : direction === '做空' ? 'short' : 'neutral'; + const directionIcon = direction === '做多' ? 'fa-arrow-up' : direction === '做空' ? 'fa-arrow-down' : 'fa-arrows-left-right'; + const confidence = result.trading_suggestion?.confidence || 0; + + modalBody.innerHTML = ` +
+
+

${record.symbol} AI分析报告

+ ${timestamp} +
+ +
+ +

${result.summary || '暂无总结'}

+
+ + + + + ${result.four_dimensional ? ` +
+
AI思维分析
+ + + + + + + + + + + + ${(() => { + const periodNames = { '60min': '60分钟', '30min': '30分钟', '15min': '15分钟', '5min': '5分钟' }; + const periodOrder = ['60min', '30min', '15min', '5min']; + const sortedEntries = Object.entries(result.four_dimensional).sort((a, b) => { + return periodOrder.indexOf(a[0]) - periodOrder.indexOf(b[0]); + }); + return sortedEntries.map(([period, d]) => ` + + + + + + + + `).join(''); + })()} + +
周期MACD趋势成交量KDJ状态结论
${periodNames[period] || period}${d.macd?.trend || '--'}${d.volume?.status || '--'}${d.kdj?.status || '--'}${d.conclusion || '--'}
+
+ ` : ''} + +
+
+ 入场区间 + ${result.trading_suggestion?.entry_range?.min || '--'}-${result.trading_suggestion?.entry_range?.max || '--'} +
+
+ 止损位 + ${result.trading_suggestion?.stop_loss || '--'} +
+
+ 建议仓位 + ${result.trading_suggestion?.position_size || '--'} +
+
+ 纪律评分 + ${result.discipline_score?.total || '--'}/${result.discipline_score?.max || '11'} +
+
+ + ${result.kdj_diagnosis ? ` +
+
KDJ诊断
+
+
+ 当前状态 + ${result.kdj_diagnosis.current_status || '--'} +
+
+ 背离 + ${result.kdj_diagnosis.divergence || '--'} +
+
+ 钝化 + ${result.kdj_diagnosis.paralysis || '--'} +
+
+ 建议 + ${result.kdj_diagnosis.recommendation || '--'} +
+
+
+ ` : ''} + + ${result.pivot_points ? ` +
+
关键点位
+
+
+ R2${result.pivot_points.r2 || '--'} +
+
+ R1${result.pivot_points.r1 || '--'} +
+
+ PP${result.pivot_points.pp || '--'} +
+
+ S1${result.pivot_points.s1 || '--'} +
+
+ S2${result.pivot_points.s2 || '--'} +
+
+
+ ` : ''} + + ${result.risk_warnings && result.risk_warnings.length > 0 ? ` +
+
风险提示
+
    + ${result.risk_warnings.map(w => `
  • ${w}
  • `).join('')} +
+
+ ` : ''} +
+ `; + + // 显示弹窗 + document.getElementById('ai-analysis-modal').classList.add('active'); + } else { + showToast('error', '加载失败', data.error || '记录不存在'); + } + } catch (error) { + console.error('加载历史记录详情失败:', error); + showToast('error', '加载失败', '网络错误,请稍后重试'); + } +} + +function renderKlineChart(data) { + if (klineChart) { + klineChart.dispose(); + } + + const chartDom = document.getElementById('kline-chart'); + klineChart = echarts.init(chartDom, 'dark'); + + const dates = data.map(d => d[0]); + const values = data.map(d => [parseFloat(d[1]), parseFloat(d[2]), parseFloat(d[3]), parseFloat(d[4])]); + const volumes = data.map(d => [parseInt(d[5]), d[2] >= d[1] ? 1 : -1]); + + const ma5 = calculateMA(data, 5); + const ma10 = calculateMA(data, 10); + const ma20 = calculateMA(data, 20); + const macdData = calculateMACD(data); + + const option = { + backgroundColor: 'transparent', + animation: false, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + backgroundColor: 'rgba(10, 15, 25, 0.95)', + borderColor: 'rgba(56, 189, 248, 0.2)', + textStyle: { color: '#e2e8f0', fontSize: 12 } + }, + axisPointer: { + link: [{ xAxisIndex: 'all' }], + label: { backgroundColor: '#06b6d4' } + }, + grid: [ + { left: 70, right: 20, top: 10, height: '50%' }, + { left: 70, right: 20, top: '56%', height: '16%' }, + { left: 70, right: 20, top: '76%', height: '16%' } + ], + xAxis: [ + { + type: 'category', + data: dates, + boundaryGap: true, + axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }, + axisLabel: { color: '#64748b', fontSize: 10 }, + splitLine: { show: false } + }, + { + type: 'category', + gridIndex: 1, + data: dates, + axisLine: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false } + }, + { + type: 'category', + gridIndex: 2, + data: dates, + axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }, + axisLabel: { color: '#64748b', fontSize: 10 }, + splitLine: { show: false } + } + ], + yAxis: [ + { + scale: true, + axisLine: { show: false }, + axisLabel: { color: '#64748b' }, + splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } } + }, + { + scale: true, + gridIndex: 1, + axisLine: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false } + }, + { + scale: true, + gridIndex: 2, + axisLine: { show: false }, + axisLabel: { color: '#64748b', fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } } + } + ], + dataZoom: [ + { type: 'inside', xAxisIndex: [0, 1, 2], start: 50, end: 100 }, + { + show: true, + xAxisIndex: [0, 1, 2], + type: 'slider', + bottom: 5, + height: 16, + borderColor: 'transparent', + backgroundColor: 'rgba(15, 20, 30, 0.5)', + fillerColor: 'rgba(6, 182, 212, 0.15)', + handleStyle: { color: '#06b6d4' }, + textStyle: { color: '#64748b' } + } + ], + series: [ + { + name: 'K线', + type: 'candlestick', + data: values, + itemStyle: { + color: '#10b981', + color0: '#ef4444', + borderColor: '#10b981', + borderColor0: '#ef4444' + } + }, + { + name: 'MA5', + type: 'line', + data: ma5, + lineStyle: { width: 1, color: '#f59e0b' }, + symbol: 'none' + }, + { + name: 'MA10', + type: 'line', + data: ma10, + lineStyle: { width: 1, color: '#3b82f6' }, + symbol: 'none' + }, + { + name: 'MA20', + type: 'line', + data: ma20, + lineStyle: { width: 1, color: '#8b5cf6' }, + symbol: 'none' + }, + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes.map(v => ({ + value: v[0], + itemStyle: { color: v[1] >= 0 ? 'rgba(16,185,129,0.5)' : 'rgba(239,68,68,0.5)' } + })) + }, + { + name: 'DIF', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: macdData.dif, + lineStyle: { width: 1.5, color: '#3b82f6' }, + symbol: 'none' + }, + { + name: 'DEA', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: macdData.dea, + lineStyle: { width: 1.5, color: '#f59e0b' }, + symbol: 'none' + }, + { + name: 'MACD', + type: 'bar', + xAxisIndex: 2, + yAxisIndex: 2, + data: macdData.macd.map(val => ({ + value: val, + itemStyle: { color: val >= 0 ? 'rgba(16,185,129,0.6)' : 'rgba(239,68,68,0.6)' } + })) + } + ] + }; + + klineChart.setOption(option); + window.addEventListener('resize', () => klineChart && klineChart.resize()); +} + +function calculateMA(data, dayCount) { + const result = []; + for (let i = 0; i < data.length; i++) { + if (i < dayCount - 1) { + result.push('-'); + continue; + } + let sum = 0; + for (let j = 0; j < dayCount; j++) { + sum += parseFloat(data[i - j][2]); + } + result.push(parseFloat((sum / dayCount).toFixed(2))); + } + return result; +} + +function toggleTheme() { + const isMinimal = document.body.classList.toggle('theme-minimal'); + localStorage.setItem('futures-theme', isMinimal ? 'minimal' : 'dark'); + updateThemeIcon(isMinimal); +} + +function updateThemeIcon(isMinimal) { + const icon = document.querySelector('#theme-toggle i'); + if (icon) { + icon.className = isMinimal ? 'fas fa-sun' : 'fas fa-moon'; + } +} + +function calculateMACD(data) { + const closes = data.map(d => parseFloat(d[2])); + const ema12 = calcEMA(closes, 12); + const ema26 = calcEMA(closes, 26); + + const dif = []; + for (let i = 0; i < closes.length; i++) { + if (ema12[i] !== null && ema26[i] !== null) { + dif.push(ema12[i] - ema26[i]); + } else { + dif.push(0); + } + } + + const dea = calcEMA(dif, 9); + const macd = dif.map((d, i) => 2 * (d - (dea[i] || 0))); + + return { dif, dea, macd }; +} + +function calcEMA(data, period) { + const result = new Array(data.length).fill(null); + const multiplier = 2 / (period + 1); + + if (data.length < period) return result; + + let sum = 0; + for (let i = 0; i < period; i++) sum += data[i]; + result[period - 1] = sum / period; + + for (let i = period; i < data.length; i++) { + result[i] = (data[i] - result[i - 1]) * multiplier + result[i - 1]; + } + + return result; +} + +// ==================== Toast 提示 ==================== + +function showToast(type, title, message, duration = 3000) { + const container = document.getElementById('toast-container'); + + const iconMap = { + success: 'fas fa-check', + info: 'fas fa-info', + warning: 'fas fa-exclamation', + error: 'fas fa-times' + }; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` +
+
+
${title}
+
${message}
+
+ `; + + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('removing'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +// ==================== 数据刷新功能 ==================== + +let isRefreshing = false; + +async function refreshAllSymbols() { + if (isRefreshing) return; + + const btn = document.getElementById('refresh-all-btn'); + btn.disabled = true; + btn.classList.add('spinning'); + isRefreshing = true; + + showToast('info', '开始刷新', '正在同步所有品种数据...'); + + try { + const response = await fetch(`${API_BASE}/refresh-all`, { method: 'POST' }); + const data = await response.json(); + + if (data.success) { + pollRefreshStatus(); + } else { + showToast('error', '刷新失败', data.message || '请稍后重试'); + resetRefreshButton(btn); + } + } catch (error) { + console.error('刷新全部失败:', error); + showToast('error', '刷新失败', '网络错误,请稍后重试'); + resetRefreshButton(btn); + } +} + +async function pollRefreshStatus() { + const btn = document.getElementById('refresh-all-btn'); + + try { + const response = await fetch(`${API_BASE}/refresh-status`); + const data = await response.json(); + + if (data.success && data.data) { + const status = data.data; + + if (!status.running) { + resetRefreshButton(btn); + + await loadFuturesList(); + + if (currentSymbol) { + await loadFuturesDetail(currentSymbol); + await loadKlineData(currentSymbol, currentPeriod); + } + + showToast('success', '刷新完成', `已同步 ${status.total} 个品种数据`); + } else { + btn.innerHTML = `刷新中 ${status.progress}/${status.total}`; + setTimeout(pollRefreshStatus, 2000); + } + } + } catch (error) { + console.error('获取刷新状态失败:', error); + resetRefreshButton(btn); + } +} + +function resetRefreshButton(btn) { + btn.disabled = false; + btn.classList.remove('spinning'); + btn.innerHTML = '刷新全部'; + isRefreshing = false; +} + +async function refreshSingleSymbol(symbol, btnElement = null) { + // 优先使用传入的按钮元素,其次尝试从事件获取,最后使用详情页按钮 + let btn = btnElement; + if (!btn) { + try { + const evt = event; + if (evt && evt.target) { + const cardBtn = evt.target.closest('.card-refresh-btn'); + if (cardBtn) { + btn = cardBtn; + } + } + } catch (e) { + // event 不存在时忽略 + } + } + if (!btn) { + btn = document.getElementById('refresh-symbol-btn'); + } + + if (!btn) { + showToast('error', '刷新失败', '无法找到刷新按钮'); + return; + } + + const originalContent = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ''; + + showToast('info', '检查数据', `正在检查 ${symbol} 数据新鲜度...`); + + try { + // 刷新K线数据 + await loadKlineData(currentSymbol, currentPeriod); + showToast('success', '刷新成功', `${symbol} 数据已更新`); + } catch (error) { + showToast('error', '刷新失败', error.message || '网络错误'); + } finally { + btn.disabled = false; + btn.innerHTML = originalContent; + } +} + +async function analyzeSingleSymbol(symbol, name, btnElement = null) { + let btn = btnElement; + if (!btn) { + try { + const evt = event; + if (evt && evt.target) { + btn = evt.target.closest('.card-ai-btn'); + } + } catch (e) {} + } + + if (!btn) { + showToast('error', '分析失败', '无法找到分析按钮'); + return; + } + + const originalContent = btn.innerHTML; + btn.disabled = true; + btn.classList.add('analyzing'); + btn.innerHTML = ''; + + showToast('info', 'AI分析中', `正在分析 ${symbol}...`); + + try { + const response = await fetch(`${API_BASE}/ai-analysis/${symbol}?force_refresh=false`); + const data = await response.json(); + + if (data.success && data.data) { + const result = data.data.result; + syncAIToSymbolCard(symbol, result); + showToast('success', '分析完成', `${symbol} AI分析已更新`); + } else { + showToast('warning', '分析失败', data.error || 'AI分析失败'); + } + } catch (error) { + console.error('AI分析失败:', error); + showToast('error', '分析失败', '网络错误,请稍后重试'); + } finally { + btn.disabled = false; + btn.classList.remove('analyzing'); + btn.innerHTML = originalContent; + } +} + +async function analyzeAllSymbols() { + const allBtn = document.getElementById('ai-analyze-all-btn'); + if (!allBtn) return; + + const originalContent = allBtn.innerHTML; + allBtn.disabled = true; + allBtn.innerHTML = ' 分析中...'; + + showToast('info', '批量分析', '开始对所有合约进行AI分析...'); + + const symbols = allFuturesData.map(item => item.symbol); + const batchSize = 3; // 每次并发分析3个合约 + + for (let i = 0; i < symbols.length; i += batchSize) { + const batch = symbols.slice(i, i + batchSize); + const promises = batch.map(async (symbol) => { + try { + const response = await fetch(`${API_BASE}/ai-analysis/${symbol}?force_refresh=false`); + const data = await response.json(); + + if (data.success && data.data) { + syncAIToSymbolCard(symbol, data.data.result); + return { symbol, success: true }; + } else { + return { symbol, success: false, error: data.error }; + } + } catch (error) { + console.error(`${symbol} 分析失败:`, error); + return { symbol, success: false, error: error.message }; + } + }); + + const results = await Promise.all(promises); + const successCount = results.filter(r => r.success).length; + showToast('info', '批量分析进度', `已完成 ${Math.min(i + batchSize, symbols.length)}/${symbols.length} 个合约,本批成功 ${successCount} 个`); + + // 每批之间等待2秒,避免API限流 + if (i + batchSize < symbols.length) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + showToast('success', '批量分析完成', `所有 ${symbols.length} 个合约AI分析已完成`); + allBtn.disabled = false; + allBtn.innerHTML = originalContent; +} + +function syncAIToSymbolCard(symbol, result) { + // 标记该合约已有AI分析数据 + const item = allFuturesData.find(d => d.symbol === symbol); + if (item) { + item.hasAIAnalysis = true; + } + + // 移除无AI数据的样式 + const card = document.querySelector(`.futures-card[onclick="showDetailView('${symbol}')"]`); + if (card) { + card.classList.remove('no-ai-data'); + const hint = card.querySelector('.ai-hint'); + if (hint) hint.remove(); + } + + const suggestion = result.trading_suggestion || {}; + const fourDim = result.four_dimensional || {}; + const pivotPoints = result.pivot_points || {}; + + // 1. 更新操作建议 + const suggestionEl = document.getElementById(`suggestion-${symbol}`); + if (suggestionEl && suggestion.direction) { + suggestionEl.textContent = suggestion.direction; + suggestionEl.className = `suggestion-badge ${suggestion.direction === '做多' ? 'up' : suggestion.direction === '做空' ? 'down' : 'neutral'}`; + } + + // 2. 更新压力支撑位 + const resistanceEl = document.getElementById(`resistance-${symbol}`); + const supportEl = document.getElementById(`support-${symbol}`); + if (resistanceEl && pivotPoints.r1) resistanceEl.textContent = pivotPoints.r1; + if (supportEl && pivotPoints.s1) supportEl.textContent = pivotPoints.s1; + + // 3. 更新多周期趋势 + const periodNames = { '60min': '60', '30min': '30', '15min': '15', '5min': '5' }; + Object.entries(fourDim).forEach(([period, data]) => { + const periodNum = periodNames[period]; + if (!periodNum) return; + + const trendEl = document.getElementById(`period-${periodNum}-${symbol}`); + if (trendEl) { + const trend = data.conclusion || data.macd?.trend || 'neutral'; + const trendClass = trend.includes('多') || trend === 'up' ? 'up' : trend.includes('空') || trend === 'down' ? 'down' : 'neutral'; + trendEl.className = `period-tag ${trendClass}`; + } + }); +} + +async function refreshAllSymbols() { + const btn = document.getElementById('refresh-all-btn'); + if (!btn) return; + + const originalContent = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' 刷新中...'; + + showToast('info', '刷新中', '正在刷新所有合约数据...'); + + try { + const response = await fetch(`${API_BASE}/refresh-all`); + const data = await response.json(); + + if (data.success) { + showToast('success', '刷新成功', `已刷新 ${data.count} 个合约`); + loadFuturesData(); + } else { + showToast('error', '刷新失败', data.error || '未知错误'); + } + } catch (error) { + showToast('error', '刷新失败', '网络错误,请稍后重试'); + } finally { + btn.disabled = false; + btn.innerHTML = originalContent; + } +} + +// ==================== AI智能分析功能 ==================== + +let currentAIAnalysis = null; + +async function runAIAnalysis(forceRefresh = false) { + if (!currentSymbol) { + showToast('warning', '提示', '请先选择一个品种'); + return; + } + + const btn = document.getElementById('ai-analyze-btn'); + const content = document.getElementById('ai-analysis-content'); + + btn.disabled = true; + btn.innerHTML = '分析中...'; + + content.innerHTML = ` +
+ + AI正在分析中... +
+ `; + + try { + const response = await fetch(`${API_BASE}/ai-analysis/${currentSymbol}?force_refresh=${forceRefresh}`); + const data = await response.json(); + + if (data.success) { + currentAIAnalysis = data.data; + displayAIAnalysisResult(data.data); + showToast('success', '分析完成', `${currentSymbol} AI分析已完成`); + } else { + content.innerHTML = ` +
+ +

${data.error || 'AI分析失败,请稍后重试'}

+
+ `; + showToast('error', '分析失败', data.error || 'AI分析失败'); + } + } catch (error) { + console.error('AI分析请求失败:', error); + content.innerHTML = ` +
+ +

网络错误,请检查网络连接

+
+ `; + showToast('error', '请求失败', '网络错误,请稍后重试'); + } finally { + btn.disabled = false; + btn.innerHTML = '智能分析'; + } +} + +function displayAIAnalysisResult(data) { + const content = document.getElementById('ai-analysis-content'); + const result = data.result; + const timestamp = new Date(data.analysis_time).toLocaleString('zh-CN'); + + const direction = result.trading_suggestion?.direction || '观望'; + const directionClass = direction === '做多' ? 'long' : direction === '做空' ? 'short' : 'neutral'; + const directionIcon = direction === '做多' ? 'fa-arrow-up' : direction === '做空' ? 'fa-arrow-down' : 'fa-arrows-left-right'; + const confidence = result.trading_suggestion?.confidence || 0; + + const entryMin = result.trading_suggestion?.entry_range?.min || '--'; + const entryMax = result.trading_suggestion?.entry_range?.max || '--'; + const stopLoss = result.trading_suggestion?.stop_loss || '--'; + const positionSize = result.trading_suggestion?.position_size || '--'; + + content.innerHTML = ` +
+
${result.summary || '暂无总结'}
+ +
+
+ + ${direction} +
+
+ 置信度 +
+
+
+ ${confidence}% +
+
+ +
+
+ 入场区间 + ${entryMin}-${entryMax} +
+
+ 止损位 + ${stopLoss} +
+
+ 建议仓位 + ${positionSize} +
+
+ 纪律评分 + ${result.discipline_score?.total || '--'}/${result.discipline_score?.max || '11'} +
+
+ +
+ 分析时间: ${timestamp} + +
+
+ `; + + // 同步AI分析数据到主面板各个卡片 + syncAIToPanels(result); +} + +function syncAIToPanels(result) { + const suggestion = result.trading_suggestion || {}; + const fourDim = result.four_dimensional || {}; + const pivotPoints = result.pivot_points || {}; + const kdjDiag = result.kdj_diagnosis || {}; + const scenarios = result.scenario_plans || {}; + + // 1. 同步到技术指标卡片 + // 从60min周期提取MACD和KDJ信息 + const macd60 = fourDim['60min']?.macd || {}; + const kdj60 = fourDim['60min']?.kdj || {}; + + const macdSignalEl = document.getElementById('macd-signal'); + if (macdSignalEl) macdSignalEl.textContent = macd60.trend || '--'; + + const macdDetailEl = document.getElementById('macd-detail'); + if (macdDetailEl) macdDetailEl.textContent = macd60.position ? `${macd60.position} | ${macd60.histogram || ''}` : '--'; + + const kdjSignalEl = document.getElementById('kdj-signal'); + if (kdjSignalEl) kdjSignalEl.textContent = kdj60.status || '--'; + + const kdjDetailEl = document.getElementById('kdj-detail'); + if (kdjDetailEl) kdjDetailEl.textContent = kdj60.signal || '--'; + + // 3. 同步到关键点位卡片 + if (pivotPoints.r1) { + const r1El = document.getElementById('resistance-1'); + if (r1El) r1El.querySelector('span:last-child').textContent = pivotPoints.r1; + } + if (pivotPoints.r2) { + const r2El = document.getElementById('resistance-2'); + if (r2El) r2El.querySelector('span:last-child').textContent = pivotPoints.r2; + } + if (pivotPoints.pp) { + const ppEl = document.getElementById('pivot-point'); + if (ppEl) ppEl.querySelector('span:last-child').textContent = pivotPoints.pp; + } + if (pivotPoints.s1) { + const s1El = document.getElementById('support-1'); + if (s1El) s1El.querySelector('span:last-child').textContent = pivotPoints.s1; + } + if (pivotPoints.s2) { + const s2El = document.getElementById('support-2'); + if (s2El) s2El.querySelector('span:last-child').textContent = pivotPoints.s2; + } + + // 4. 同步到多周期趋势卡片 + const periodTrendsEl = document.getElementById('period-trends'); + if (periodTrendsEl && Object.keys(fourDim).length > 0) { + const periodNames = { '60min': '60分钟', '30min': '30分钟', '15min': '15分钟', '5min': '5分钟' }; + const periodOrder = ['60min', '30min', '15min', '5min']; + + // 按固定顺序排列周期 + const sortedEntries = Object.entries(fourDim).sort((a, b) => { + return periodOrder.indexOf(a[0]) - periodOrder.indexOf(b[0]); + }); + + periodTrendsEl.innerHTML = sortedEntries.map(([period, data]) => { + const trend = data.conclusion || data.macd?.trend || 'neutral'; + const trendClass = trend.includes('多') || trend === 'up' ? 'up' : trend.includes('空') || trend === 'down' ? 'down' : 'neutral'; + const trendText = trend.includes('多') ? '偏多' : trend.includes('空') ? '偏空' : '震荡'; + return `
${periodNames[period] || period}${trendText}
`; + }).join(''); + } + + // 5. 同步到情景预案卡片 + const scenarioPanel = document.getElementById('scenario-panel'); + const scenarioPlansEl = document.getElementById('scenario-plans'); + if (scenarioPanel && scenarioPlansEl && Object.keys(scenarios).length > 0) { + scenarioPanel.style.display = 'block'; + const scenarioNames = { + 'breakthrough': '突破', + 'consolidation': '震荡', + 'reversal': '反转', + 'news_impact': '消息影响' + }; + scenarioPlansEl.innerHTML = Object.entries(scenarios).map(([key, data]) => ` +
+ ${scenarioNames[key] || key} + ${data.probability || 0}% + ${data.action || '--'} +
+ `).join(''); + } else if (scenarioPanel) { + scenarioPanel.style.display = 'none'; + } +} + +function showAIDetailModal() { + if (!currentAIAnalysis) { + showToast('warning', '提示', '暂无AI分析数据'); + return; + } + + const result = currentAIAnalysis.result; + const modalBody = document.getElementById('ai-analysis-modal-body'); + + let fourDimensionalHTML = ''; + const periods = ['60min', '30min', '15min', '5min']; + const periodNames = { '60min': '60分钟', '30min': '30分钟', '15min': '15分钟', '5min': '5分钟' }; + periods.forEach(period => { + const data = result.four_dimensional?.[period]; + if (data) { + fourDimensionalHTML += ` + + ${periodNames[period] || period} + +
趋势: ${data.macd?.trend || '--'}
+
位置: ${data.macd?.position || '--'}
+
柱状图: ${data.macd?.histogram || '--'}
+ + +
状态: ${data.volume?.status || '--'}
+
量比: ${data.volume?.ratio || '--'}
+ + +
K: ${data.kdj?.k || '--'} D: ${data.kdj?.d || '--'}
+
信号: ${data.kdj?.signal || '--'}
+
状态: ${data.kdj?.status || '--'}
+ + ${data.conclusion || '--'} + + `; + } + }); + + let scenariosHTML = ''; + const scenarios = result.scenario_plans || {}; + const scenarioIcons = { + 'breakthrough': 'fa-rocket', + 'consolidation': 'fa-exchange-alt', + 'reversal': 'fa-undo', + 'news_impact': 'fa-newspaper' + }; + + Object.entries(scenarios).forEach(([key, scenario]) => { + scenariosHTML += ` +
+
${scenario.probability || 0}%
+
${scenario.action || '--'}
+
+ `; + }); + + let redLinesHTML = ''; + if (result.red_lines_check?.violated?.length > 0) { + result.red_lines_check.violated.forEach(line => { + redLinesHTML += `
${line}
`; + }); + } else { + redLinesHTML = '
未触碰交易红线
'; + } + + let disciplineHTML = ''; + const discipline = result.discipline_score?.details || {}; + const disciplineLabels = { + 'trend': '趋势', + 'position': '位置', + 'signal': '信号', + 'risk': '风险', + 'mindset': '心态' + }; + + Object.entries(disciplineLabels).forEach(([key, label]) => { + const isPass = discipline[key]; + disciplineHTML += ` +
+ + ${label} +
+ `; + }); + + let experiencesHTML = ''; + const experiences = result.experience_lessons || []; + if (experiences.length > 0) { + experiences.forEach(exp => { + experiencesHTML += `
${exp}
`; + }); + } else { + experiencesHTML = '
暂无经验提醒
'; + } + + modalBody.innerHTML = ` +
+
+ + 四维联合信号表 (4D-XV) +
+ + + + + + + + + + + + ${fourDimensionalHTML || ''} + +
周期MACD(趋势)成交量(资金)KDJ(时机)结论
暂无数据
+
+ +
+
+ + 关键点位 (Pivot Point) +
+
+
+ R2 + ${result.pivot_points?.r2 || '--'} +
+
+ R1 + ${result.pivot_points?.r1 || '--'} +
+
+ PP + ${result.pivot_points?.pp || '--'} +
+
+ S1 + ${result.pivot_points?.s1 || '--'} +
+
+ S2 + ${result.pivot_points?.s2 || '--'} +
+
+
+ +
+
+ + 交易红线审查 +
+
+ ${redLinesHTML} +
+
+ +
+
+ + 11项纪律检查 +
+
+ ${disciplineHTML} +
+
+ +
+
+ + 情景预案 +
+
+ ${scenariosHTML || '
暂无情景预案
'} +
+
+ +
+
+ + 经验教训提醒 +
+
+ ${experiencesHTML} +
+
+ +
+
+ + 风险提示 +
+
+ ${(result.risk_warnings || []).map(w => `
${w}
`).join('')} +
+
+ `; + + document.getElementById('ai-analysis-modal').classList.add('active'); +} diff --git a/app/static/index.html b/app/static/index.html index 4c139e2..9ac262e 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -678,6 +678,15 @@ 运行日志 + + + + 期货智析 + + + + AI配置 + diff --git a/check_ai_models.py b/check_ai_models.py new file mode 100644 index 0000000..130358c --- /dev/null +++ b/check_ai_models.py @@ -0,0 +1,22 @@ +import sqlite3 + +db_path = 'data/futures_analysis.db' + +conn = sqlite3.connect(db_path) + +print('=== AI模型配置 ===') +cursor = conn.execute("SELECT * FROM ai_model_configs") +cols = [desc[0] for desc in cursor.description] +print(f'字段: {cols}') +rows = cursor.fetchall() +print(f'记录数: {len(rows)}') +for row in rows: + print(dict(zip(cols, row))) + +print('\n=== 分析设置 ===') +cursor = conn.execute("SELECT * FROM analysis_settings") +rows = cursor.fetchall() +for row in rows: + print(row) + +conn.close() diff --git a/check_cache.py b/check_cache.py new file mode 100644 index 0000000..77b12e4 --- /dev/null +++ b/check_cache.py @@ -0,0 +1,12 @@ +import sqlite3 + +conn = sqlite3.connect('data/futures_analysis.db') +cursor = conn.execute('SELECT id, symbol, created_at FROM ai_analysis_cache ORDER BY created_at DESC LIMIT 10') + +print('AI分析缓存记录:') +print('ID | 合约 | 创建时间') +print('-' * 60) +for row in cursor: + print(f'{row[0]} | {row[1]} | {row[2]}') + +conn.close() diff --git a/check_db.py b/check_db.py new file mode 100644 index 0000000..53fca39 --- /dev/null +++ b/check_db.py @@ -0,0 +1,32 @@ +import sqlite3 +import os + +db_path = 'data/futures_analysis.db' + +print(f'数据库文件路径: {os.path.abspath(db_path)}') +print(f'数据库存在: {os.path.exists(db_path)}') + +if os.path.exists(db_path): + print(f'数据库大小: {os.path.getsize(db_path)} 字节') + + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + print(f'数据库表列表: {tables}') + + if 'ai_analysis_cache' in tables: + cursor = conn.execute("SELECT COUNT(*) FROM ai_analysis_cache") + count = cursor.fetchone()[0] + print(f'AI分析记录数: {count}') + + cursor = conn.execute("SELECT id, symbol, created_at FROM ai_analysis_cache ORDER BY created_at DESC LIMIT 5") + records = cursor.fetchall() + print(f'最近5条记录:') + for r in records: + print(f' ID: {r[0]}, 合约: {r[1]}, 时间: {r[2]}') + else: + print('❌ ai_analysis_cache 表不存在!') + + conn.close() +else: + print('❌ 数据库文件不存在!') diff --git a/config/ai_config.json b/config/ai_config.json new file mode 100644 index 0000000..7d74ea5 --- /dev/null +++ b/config/ai_config.json @@ -0,0 +1,22 @@ +{ + "models": [ + { + "model_name": "qwen3.6-plus", + "provider": "bailian", + "api_key": "sk-sp-51d0695ab1114470b913146d21baf68f", + "api_base": "https://coding.dashscope.aliyuncs.com/v1", + "model_id": "qwen3.6-plus", + "temperature": 0.7, + "max_tokens": 2000, + "enabled": true + } + ], + "active_model": "bailian", + "analysis_settings": { + "enable_technical_analysis": true, + "enable_fundamental_analysis": false, + "enable_sentiment_analysis": false, + "risk_tolerance": "medium", + "max_position_pct": 10 + } +} \ No newline at end of file diff --git a/data/ai_analysis_prompt.md b/data/ai_analysis_prompt.md new file mode 100644 index 0000000..f8f043f --- /dev/null +++ b/data/ai_analysis_prompt.md @@ -0,0 +1,201 @@ +# 📋 AI 期货/股票四维联合分析系统 (Prompt 模板) + +> **版本**: v2.0 (全量实战版) +> **核心架构**: 四维联合判断 (4D-XV) + 17 条交易红线 + 多周期共振 + 动态策略切换 +> **适用场景**: 期货/股票技术分析、日内/短线交易决策、交易纪律审查 + +--- + +## 🧠 角色与任务设定 (System Prompt) + +```text +你是一位拥有 20 年实战经验的资深金融交易分析师,精通 A 股市场与商品期货的技术分析。 +你的核心使命是基于提供的 K 线数据,执行**「四维联合判断分析法 (4D-XV)」**,并提供包含风控红线审查的客观交易策略。 + +你的分析必须遵循以下原则: +1. **数据驱动**:所有结论必须基于输入的 JSON 数据,严禁凭空捏造。 +2. **四维共振**:任何交易建议必须经过 MACD(趋势)、成交量(资金)、KDJ(时机)、多周期(方向)的交叉验证。 +3. **红线否决**:如果数据触发「17 条交易红线」,必须直接给出「禁止交易」或「止损」的建议。 +4. **客观中立**:不使用绝对化表述,提供情景预案(概率估算)。 +5. **动态切换**:遇到关键位放量突破时,立即切换右侧思维,不逆势死扛。 +``` + +--- + +## 📥 输入数据规范 (JSON) + +AI 模型接收的输入数据必须包含以下结构: + +```json +{ + "symbol": "AG2606", + "current_price": 7200, + "timeframes": { + "60min": { + "trend": "down", + "ma10": 7250, "ma20": 7300, + "macd_dif": -5.2, "macd_dea": -3.1, "macd_histogram": -2.1, + "kdj_k": 85, "kdj_d": 80, "kdj_j": 95, + "volume_avg": 150000, "volume_current": 200000, + "candles": [{"time": "...", "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}] + }, + "30min": { "... 结构同上 ..." }, + "15min": { "... 结构同上 ..." }, + "5min": { "... 结构同上 ..." }, + "daily": { "... 结构同上 ..." } + }, + "fundamentals": "近期库存持续下降,宏观情绪偏暖" +} +``` + +--- + +## 🛠️ 核心分析引擎 (执行步骤) + +请在代码中要求 AI 严格按以下步骤执行分析: + +### 第一步:四维联合信号扫描 (4D-XV) +对 **60m/30m/15m** 周期进行四维交叉验证,填写诊断表: + +| 周期 | MACD(趋势锚点) | 成交量(资金验证) | KDJ(时机狙击) | 四维综合结论 | +|------|----------------|------------------|---------------|--------------| +| **60m** | 零轴上/下,柱状图放大/缩小/背离 | 趋势量能(放量上涨/缩量回调等) | K 值位置,金死叉状态,是否钝化 | 定大势(只做多/只做空/震荡) | +| **30m** | 零轴上/下,柱状图放大/缩小 | 拐点量能(突破/跌破量比≥1.5) | K 值位置,金死叉状态 | 找拐点(验证短期转折) | +| **15m** | 零轴上/下,柱状图放大/缩小 | 入场量能(即时量比≥1.5/2.0) | K 值位置,金死叉状态 | 择入场(精确触发点) | + +**判定标准**: +* ✅ **完美共振**:3 维度以上同向 + 量能确认 → 强信号(胜率≥80%) +* ⚠️ **弱势/矛盾**:指标矛盾或缺少量能验证 → 震荡/观望/轻仓 +* ❌ **逆势/背离**:量价背离、MACD 与 KDJ 冲突 → **禁止入场** + +### 第二步:KDJ 深度诊断 +* **区域判断**:超买 (>80)、超卖 (<20)、中性区。 +* **信号确认**:金叉/死叉有效性(需等待第二次确认,防假信号)。 +* **钝化检查**:是否持续超买/超卖 >6 根 K 线?若是,**放弃 KDJ 逆势信号,改用 MACD 趋势跟踪**。 +* **背离识别**:KDJ 顶/底背离 + MACD 同步背离 = 极高胜率反转信号。 +* **参数建议**:日线 (9,3,3)、60m (9,3,3)、30m (6,3,3)、15m (5,3,3)。 + +### 第三步:关键位计算与验证 (Pivot Point) +使用最近一根完整 K 线的 H、L、C 计算: +`PP = (H+L+C)/3`, `R1 = 2*PP-L`, `S1 = 2*PP-H`, `R2 = PP+(H-L)`, `S2 = PP-(H-L)` + +**关键位五维度验证**: +1. 测试次数 ≥3 次? +2. 量价配合良好(无量支撑为虚位)? +3. 多周期共振(大周期关键位权重更高)? +4. 基本面匹配(成本线、政策线)? +5. 突破后回踩确认? + +### 第四步:交易红线审查 (17 条) +**检查数据是否触碰以下红线(触碰即停)**: +* [ ] 逆势重仓 +* [ ] 移动止损/加仓摊平 +* [ ] 情绪化交易/报复交易 +* [ ] 放量突破死扛逆势单(最高红线) +* [ ] 22:00 后开仓/14:30 后隔夜决策 +* [ ] 无衰竭信号入场/追涨杀跌 +* [ ] 五维度评分 < 9 分 或 衰竭评分 < 5 分 + +### 第五步:动态策略切换 (Plan B) +* **场景**:价格逼近关键阻力位,原判断受阻回落,但出现**放量突破**。 +* **验证**:15m/30m 成交量 > 近 5 日均量 200%,且伴随实体饱满大阳线。 +* **动作**:若持有逆势单(如空单),价格站稳 15 分钟 → **无条件市价止损**,绝不等回调。 +* **思维**:立即切换为右侧跟随思维,回踩阻力位(转支撑)时尝试做多。 + +--- + +## 📝 输出模板 (Markdown) + +要求 AI 严格按照以下格式输出: + +```markdown +## 📊 {symbol} 四维联合分析报告 +**分析时间**:{当前时间} | **当前价**:{current_price} + +### 一、四维联合信号表 (4D-XV) +| 周期 | MACD(趋势) | 成交量(资金) | KDJ(时机) | 结论 | +|------|------------|-------------|-----------|------| +| 60m | ... | ... | ... | ... | +| 30m | ... | ... | ... | ... | +| 15m | ... | ... | ... | ... | + +### 二、KDJ 深度诊断 +- **当前状态**:... +- **钝化/背离**:... +- **参数建议**:... + +### 三、关键位 (Pivot Point) +- 🔴 **R2**: ... | **R1**: ... +- ⚪ **PP**: ... +- 🟢 **S1**: ... | **S2**: ... +- **有效性验证**:(测试次数、量能、周期共振情况) + +### 四、11 项纪律检查与红线审查 +- **红线检查**:✅ 未触碰 / ❌ 触碰 [具体红线] +- **纪律得分**:__ / 11 (≥9 可入场) +- **诊断表**: + - A. 趋势 (60m 大势/30m 结构/15m 协同): [✅/❌] + - B. 位置 (关键位/共振): [✅/❌] + - C. 信号 (K 线/量能/背离): [✅/❌] + - D. 风险 (盈亏比/仓位): [✅/❌] + - E. 心态: [✅/❌] + +### 五、交易建议 +- **方向**:做多 / 做空 / 观望 +- **入场区间**:... +- **止损位**:... (依据:前低/高/关键位) +- **止盈目标**:... (盈亏比 ≥ 1:2) +- **建议仓位**:... (基于信号强度) + +### 六、动态策略预案 (Plan B) +1. **顺势突破 (35-45%)**:放量突破关键位,跟随右侧思维,移动止损。 +2. **震荡拉锯 (30-40%)**:R1-S1 区间内高抛低吸,突破前观望。 +3. **假突破反转 (15-25%)**:MACD 顶/底背离 + 量能不足,立即止损反手。 +4. **突发消息 (5-10%)**:减仓 50% 规避不确定性。 + +### ⚠️ 风险提示与经验教训 +- **技术局限**:技术指标具有滞后性,历史表现不代表未来。 +- **避坑提醒**:(根据当前盘面自动提示,如"警惕缩量创新高"、"KDJ 超买钝化中不宜逆势做空"等) +- **红线警告**:(如触发红线,在此处高亮警告) +- **免责声明**:本分析仅供参考,不构成投资建议,交易需自负盈亏。 +``` + +--- + +## 💡 经验教训总结 (供 AI 内部参考) + +在分析过程中,AI 应时刻检索以下**历史经验教训库**,若数据特征匹配,必须在报告中提示: + +| 陷阱名称 | 特征识别 | 应对经验教训 | +|----------|----------|--------------| +| **唯 MACD 论** | MACD 金叉但 KDJ 超买 | 往往是诱多,**禁止单独使用 MACD 入场**。 | +| **唯 KDJ 论** | KDJ 金叉但 MACD 零轴下死叉 | 通常是下跌中继(假反弹),**必须等待 MACD 确认**。 | +| **无量突破** | KDJ+MACD 共振但无量 | 假突破概率 > 80%,**放弃或极轻仓试错**。 | +| **大周期否决** | 日线/60m 明确空头 | 小周期任何做多信号均为短线反弹,**仓位必须减半**。 | +| **钝化区逆势** | KDJ 超买但价格继续涨 | 不逆势!等钝化解除(KDJ 离开超买区 + 价格反向突破)。 | +| **假金叉/死叉** | 金叉后快速死叉 | 等待**第二次确认**,或结合 30m 级别过滤。 | +| **背离假信号** | 多次背离后价格继续原方向 | 背离不是入场理由,**等待价格突破关键位确认**。 | +| **放量突破死扛** | 价格放量突破阻力,仍持有逆势空单 | **最高红线!立即市价止损,绝不等回调。** | + +--- + +## 📦 附录:精简版 Prompt (上下文窗口有限时使用) + +```text +你是一位专业交易分析师。请基于以下 JSON 数据,执行【四维联合判断 (4D-XV)】分析。 + +分析逻辑: +1. MACD 定趋势,KDJ 择时机,成交量验证,多周期 (60m→30m→15m) 共振。 +2. 严格审查「17 条交易红线」,触碰即禁止交易。 +3. 若出现放量突破关键位,执行 Plan B(右侧跟随,不逆势死扛)。 + +输出内容: +1. 四维信号表 (MACD/量/KDJ/结论)。 +2. Pivot Point 关键位及有效性验证。 +3. 11 项纪律检查评分。 +4. 交易建议 (方向/入场/止损/止盈/仓位)。 +5. 情景预案 (5 种情景 + 概率)。 +6. 风险提示与避坑提醒 (结合经验教训库)。 + +数据:{{JSON_DATA}} +``` diff --git a/data/futures_analysis.db b/data/futures_analysis.db new file mode 100644 index 0000000..c388f5c Binary files /dev/null and b/data/futures_analysis.db differ diff --git a/docker-compose.yml b/docker-compose.yml index e902d22..24b2411 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: - BUFFER_LOG_LEVEL=INFO restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8600/api/v1/health"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8600/api/v1/health')"] interval: 30s timeout: 10s retries: 3 diff --git a/init_analysis_db.py b/init_analysis_db.py new file mode 100644 index 0000000..3e98cc3 --- /dev/null +++ b/init_analysis_db.py @@ -0,0 +1,24 @@ +""" +手动初始化期货智析数据库 +用于在应用外部创建数据库表 +""" +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.analysis_db import init_analysis_db + +if __name__ == "__main__": + print("开始初始化期货智析数据库...") + init_analysis_db() + print("✅ 数据库初始化完成!") + + # 验证数据库文件是否创建 + from app.analysis_db import ANALYSIS_DB_PATH + if ANALYSIS_DB_PATH.exists(): + print(f"✅ 数据库文件已创建: {ANALYSIS_DB_PATH}") + else: + print(f"❌ 数据库文件未创建: {ANALYSIS_DB_PATH}") diff --git a/update_schema.py b/update_schema.py new file mode 100644 index 0000000..40974a1 --- /dev/null +++ b/update_schema.py @@ -0,0 +1,31 @@ +""" +更新AI分析缓存表结构 +添加 kline_timestamp 字段 +""" +import sqlite3 +from pathlib import Path + +project_root = Path(__file__).parent +ANALYSIS_DB_PATH = project_root / "data" / "futures_analysis.db" + +def add_kline_timestamp_column(): + """添加 kline_timestamp 列""" + conn = sqlite3.connect(str(ANALYSIS_DB_PATH)) + cursor = conn.cursor() + + # 检查列是否已存在 + cursor.execute("PRAGMA table_info(ai_analysis_cache)") + columns = [col[1] for col in cursor.fetchall()] + + if 'kline_timestamp' in columns: + print("✅ kline_timestamp 列已存在") + else: + print("添加 kline_timestamp 列...") + cursor.execute("ALTER TABLE ai_analysis_cache ADD COLUMN kline_timestamp DATETIME") + conn.commit() + print("✅ kline_timestamp 列添加成功") + + conn.close() + +if __name__ == "__main__": + add_kline_timestamp_column() diff --git a/verify_db.py b/verify_db.py new file mode 100644 index 0000000..5f57b27 --- /dev/null +++ b/verify_db.py @@ -0,0 +1,27 @@ +""" +验证数据库表是否正确创建 +""" +import sqlite3 + +db_path = "data/futures_analysis.db" + +conn = sqlite3.connect(db_path) +cursor = conn.execute('SELECT name FROM sqlite_master WHERE type="table"') + +print("✅ 数据库表列表:") +for row in cursor: + print(f" - {row[0]}") + +# 检查 ai_analysis_cache 表是否存在 +cursor.execute('SELECT name FROM sqlite_master WHERE type="table" AND name="ai_analysis_cache"') +if cursor.fetchone(): + print("\n✅ ai_analysis_cache 表已存在!") + # 显示表结构 + cursor.execute("PRAGMA table_info(ai_analysis_cache)") + print("\n表结构:") + for col in cursor: + print(f" {col[1]} ({col[2]})") +else: + print("\n❌ ai_analysis_cache 表不存在!") + +conn.close()