diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc index 13e81c5..c52b950 100644 Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.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..9982b26 Binary files /dev/null and b/app/api/__pycache__/ai_config.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..dbb1093 Binary files /dev/null and b/app/api/__pycache__/futures_analysis.cpython-311.pyc differ diff --git a/app/api/ai_config.py b/app/api/ai_config.py new file mode 100644 index 0000000..a506da7 --- /dev/null +++ b/app/api/ai_config.py @@ -0,0 +1,177 @@ +""" +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": "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..4f4b0a8 --- /dev/null +++ b/app/api/futures_analysis.py @@ -0,0 +1,455 @@ +""" +期货智析接口 - 提供期货分析数据 +""" +import json +import logging +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.services.cache import get_cached_data, get_latest_cached + +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(high_price * 1.02, 2), + "support": round(low_price * 0.98, 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 + + resistance1 = round(high_price * 1.01, 2) + resistance2 = round(high_price * 1.03, 2) + resistance3 = round(high_price * 1.05, 2) + support1 = round(low_price * 0.99, 2) + support2 = round(low_price * 0.97, 2) + support3 = round(low_price * 0.95, 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": resistance1 if change >= 0 else support1, + "stopLoss": support1 if change >= 0 else resistance1, + "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": [resistance1, resistance2, resistance3], + "supports": [support1, support2, support3], + "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}"} diff --git a/app/main.py b/app/main.py index 9de17a8..6e7cfa9 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 # 配置日志 @@ -87,6 +87,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/static/ai_config.css b/app/static/ai_config.css new file mode 100644 index 0000000..e0678aa --- /dev/null +++ b/app/static/ai_config.css @@ -0,0 +1,517 @@ +: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); +} + +/* 设置列表 */ +.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..22e50cb --- /dev/null +++ b/app/static/ai_config.html @@ -0,0 +1,189 @@ + + + + + + AI模型配置 - 期货智析 + + + + +
+
+ + +
+ +
+
+ +
+
+

AI提供商

+
+
+ +
+
+ + +
+
+

API配置

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

模型参数

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

分析设置

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

已保存的模型

+ +
+
+ +
+
+ + +
+ + +
+
+
+
+ + + + diff --git a/app/static/ai_config.js b/app/static/ai_config.js new file mode 100644 index 0000000..320b1b6 --- /dev/null +++ b/app/static/ai_config.js @@ -0,0 +1,298 @@ +const API_BASE = '/api/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: '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', + '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'], + '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', + '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('temperature').value = activeModel.temperature || 0.7; + document.getElementById('temp-value').textContent = activeModel.temperature || 0.7; + document.getElementById('max-tokens').value = activeModel.max_tokens || 2000; + } + + 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'); + if (!models || models.length === 0) { + list.innerHTML = '
暂无已保存的模型
'; + return; + } + + 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': '阿里云', + '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 config = { + model_name: document.getElementById('model-id').value, + api_key: document.getElementById('api-key').value, + api_base: document.getElementById('api-base').value, + model_id: document.getElementById('model-id').value, + 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) + }); + 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 newModel = { + model_name: document.getElementById('model-id').value, + provider: selectedProvider, + api_key: document.getElementById('api-key').value, + api_base: document.getElementById('api-base').value, + model_id: document.getElementById('model-id').value, + 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); + } + + 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..f256196 --- /dev/null +++ b/app/static/futures_analysis.css @@ -0,0 +1,938 @@ +: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); + --red-border: rgba(239, 68, 68, 0.3); + --orange: #f59e0b; + --orange-bg: rgba(245, 158, 11, 0.15); + --blue: #3b82f6; + --purple: #8b5cf6; + --accent: #22c55e; +} + +* { + 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); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-left { + display: flex; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + width: 36px; + height: 36px; + background: var(--green-bg); + border: 1px solid var(--green-border); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--green); + font-size: 18px; +} + +.logo-text { + display: flex; + flex-direction: column; +} + +.logo-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.logo-subtitle { + font-size: 11px; + color: var(--text-secondary); +} + +.nav-center { + display: flex; + gap: 8px; +} + +.nav-item { + padding: 8px 16px; + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + font-weight: 500; + border-radius: 6px; + transition: all 0.2s; + position: relative; +} + +.nav-item:hover { + color: var(--text-primary); + background: var(--bg-card); +} + +.nav-item.active { + color: var(--green); +} + +.nav-item.active::after { + content: ''; + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + width: 24px; + height: 2px; + background: var(--green); + border-radius: 1px; +} + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +.datetime { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; +} + +.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); +} + +.notification { + position: relative; + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; + padding: 6px; +} + +.notification .badge { + position: absolute; + top: 0; + right: 0; + width: 16px; + height: 16px; + background: var(--red); + border-radius: 50%; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + color: white; +} + +/* 主内容区 */ +.main-content { + flex: 1; + padding: 20px 24px; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +.view { + display: none; +} + +.view.active { + display: block; +} + +/* 工具栏 */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.search-box { + flex: 1; + max-width: 600px; + display: flex; + align-items: center; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 14px; + gap: 10px; +} + +.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); +} + +.view-toggle { + display: flex; + gap: 4px; +} + +.toggle-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.toggle-btn.active { + background: var(--green); + border-color: var(--green); + color: white; +} + +/* 筛选栏 */ +.filter-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 16px; +} + +.filter-group, .sort-group { + display: flex; + align-items: center; + gap: 8px; +} + +.filter-label { + color: var(--text-secondary); + font-size: 13px; +} + +.filter-btn { + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.filter-btn:hover { + border-color: var(--text-muted); + color: var(--text-primary); +} + +.filter-btn.active { + background: var(--green); + border-color: var(--green); + color: white; +} + +.sort-select { + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + outline: none; +} + +/* 统计栏 */ +.stats-bar { + display: flex; + gap: 20px; + margin-bottom: 20px; + font-size: 14px; + color: var(--text-secondary); +} + +.stats-bar strong { + color: var(--text-primary); +} + +.stat-up { + color: var(--green); +} + +.stat-down { + color: var(--red); +} + +/* 品种卡片网格 */ +.futures-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 16px; +} + +.futures-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 18px; + cursor: pointer; + transition: all 0.2s; +} + +.futures-card:hover { + background: var(--bg-card-hover); + border-color: var(--text-muted); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.card-name { + font-size: 16px; + font-weight: 600; +} + +.card-code { + font-size: 12px; + color: var(--text-muted); + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 4px; +} + +.card-price { + text-align: right; +} + +.price-value { + font-size: 20px; + font-weight: 600; +} + +.price-change { + font-size: 13px; + display: flex; + align-items: center; + gap: 4px; + justify-content: flex-end; +} + +.up { + color: var(--green); +} + +.down { + color: var(--red); +} + +.suggestion-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + margin-bottom: 12px; +} + +.suggestion-badge.up { + background: var(--green-bg); + color: var(--green); + border: 1px solid var(--green-border); +} + +.suggestion-badge.down { + background: var(--red-bg); + color: var(--red); + border: 1px solid var(--red-border); +} + +.suggestion-badge.neutral { + background: rgba(107, 114, 128, 0.15); + color: var(--text-muted); + border: 1px solid rgba(107, 114, 128, 0.3); +} + +.card-section { + margin-bottom: 12px; +} + +.section-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.period-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.period-tag { + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; +} + +.period-tag.up { + background: var(--green-bg); + color: var(--green); + border: 1px solid var(--green-border); +} + +.period-tag.down { + background: var(--red-bg); + color: var(--red); + border: 1px solid var(--red-border); +} + +.period-tag.neutral { + background: rgba(107, 114, 128, 0.1); + color: var(--text-muted); + border: 1px solid rgba(107, 114, 128, 0.2); +} + +.progress-bar { + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; + margin-bottom: 4px; +} + +.progress-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} + +.progress-fill.up { + background: var(--green); +} + +.progress-fill.down { + background: var(--red); +} + +.progress-fill.orange { + background: var(--orange); +} + +.progress-info { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.progress-label { + color: var(--text-secondary); +} + +.progress-value { + font-weight: 500; +} + +.key-levels-row { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.level-label { + color: var(--text-secondary); +} + +.level-value { + font-weight: 500; +} + +.card-footer { + display: flex; + justify-content: flex-end; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.detail-link { + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; + display: flex; + align-items: center; + gap: 4px; + transition: color 0.2s; +} + +.detail-link:hover { + color: var(--green); +} + +/* 详情视图 */ +.detail-header { + margin-bottom: 20px; +} + +.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: 14px; + cursor: pointer; + margin-bottom: 16px; + transition: all 0.2s; +} + +.back-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.detail-title-bar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px 20px; +} + +.price-info { + display: flex; + flex-direction: column; +} + +.current-price { + font-size: 28px; + font-weight: 700; + color: var(--green); +} + +.price-change { + font-size: 14px; + margin-top: 4px; +} + +.quote-info { + display: flex; + gap: 24px; +} + +.quote-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.quote-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.quote-value { + font-size: 14px; + font-weight: 500; +} + +/* 周期选择 */ +.period-selector { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.period-selector i { + color: var(--green); +} + +.period-label { + color: var(--text-secondary); + font-size: 13px; + margin-right: 8px; +} + +.period-btn { + 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; +} + +.period-btn:hover { + border-color: var(--text-muted); +} + +.period-btn.active { + background: var(--green); + border-color: var(--green); + color: white; +} + +/* 详情主体 */ +.detail-body { + display: grid; + grid-template-columns: 1fr 340px; + gap: 20px; +} + +.chart-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px; +} + +.kline-chart { + width: 100%; + height: 500px; +} + +/* 分析面板 */ +.analysis-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px; +} + +.panel-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + margin-bottom: 14px; + color: var(--text-primary); +} + +.panel-title i { + color: var(--green); +} + +/* 交易建议 */ +.suggestion-box { + padding: 14px; + border-radius: 8px; + margin-bottom: 14px; + text-align: center; +} + +.suggestion-box.up { + background: var(--green-bg); + border: 1px solid var(--green-border); +} + +.suggestion-box.down { + background: var(--red-bg); + border: 1px solid var(--red-border); +} + +.suggestion-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.suggestion-action { + font-size: 18px; + font-weight: 600; + margin-bottom: 6px; +} + +.suggestion-box.up .suggestion-action { + color: var(--green); +} + +.suggestion-box.down .suggestion-action { + color: var(--red); +} + +.suggestion-reason { + font-size: 12px; + color: var(--text-secondary); +} + +.suggestion-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.detail-row { + display: flex; + justify-content: space-between; + padding: 8px 10px; + background: var(--bg-secondary); + border-radius: 6px; + font-size: 12px; +} + +.detail-label { + color: var(--text-muted); +} + +.detail-value { + font-weight: 500; +} + +/* 技术指标 */ +.indicators-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.indicator-item { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; +} + +.indicator-name { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.indicator-value { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; +} + +.indicator-detail { + font-size: 11px; + color: var(--text-secondary); +} + +/* 关键点位 */ +.levels-section { + margin-bottom: 12px; +} + +.levels-section:last-child { + margin-bottom: 0; +} + +.levels-header { + font-size: 12px; + font-weight: 600; + margin-bottom: 8px; +} + +.levels-header.resistance { + color: var(--red); +} + +.levels-header.support { + color: var(--green); +} + +.level-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; +} + +.level-row:last-child { + border-bottom: none; +} + +.level-row span:first-child { + color: var(--text-secondary); +} + +/* 多周期一致性 */ +.consistency-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); +} + +.consistency-row:last-child { + border-bottom: none; +} + +.period-name { + font-size: 13px; + color: var(--text-secondary); +} + +.consistency-badge { + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; +} + +.consistency-badge.up { + background: var(--green-bg); + color: var(--green); +} + +.consistency-badge.down { + background: var(--red-bg); + color: var(--red); +} + +.consistency-badge.neutral { + background: rgba(107, 114, 128, 0.15); + color: var(--text-muted); +} + +/* 响应式 */ +@media (max-width: 1200px) { + .detail-body { + grid-template-columns: 1fr; + } + + .analysis-panel { + display: grid; + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .top-nav { + flex-wrap: wrap; + gap: 12px; + } + + .nav-center { + order: 3; + width: 100%; + justify-content: center; + } + + .futures-grid { + grid-template-columns: 1fr; + } + + .analysis-panel { + grid-template-columns: 1fr; + } + + .detail-title-bar { + flex-direction: column; + gap: 16px; + } + + .quote-info { + width: 100%; + justify-content: space-between; + } +} diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html new file mode 100644 index 0000000..9a1e700 --- /dev/null +++ b/app/static/futures_analysis.html @@ -0,0 +1,283 @@ + + + + + + 期货智析 - 智能期货期权分析系统 + + + + +
+ +
+ + + +
+ + +
+ +
+ +
+ +
+ + +
+
+ +
+
+ 分类: + + + + + +
+
+ 排序: + +
+
+ + +
+ 8 个品种 + 7 + 1 +
+ + +
+ +
+
+ + +
+ +
+ +
+
+ ¥2,150 + + +196.00 (+10.06%) + +
+
+
+ 开盘 + 1,960 +
+
+ 最高 + 2,200 +
+
+ 最低 + 1,940 +
+
+ 持仓量 + 45,600 +
+
+
+
+ + +
+ + 周期选择 + + + + +
+ + +
+ +
+
+
+ + +
+ +
+
+ + 交易建议 +
+
+
操作建议
+
逢低做多
+
涨停突破,地缘风险推升运价
+
+
+
+ 建议入场 + 2,137.1 +
+
+ 目标价位 + 2,236 +
+
+ 止损价位 + 2,107 +
+
+ 风险等级 + +
+
+
+ + +
+
+ + 技术指标 +
+
+
+
MACD
+
金叉
+
DIF: -0.0147
+
+
+
RSI
+
47
+
正常
+
+
+
布林带
+
中轨
+
区间: 2086-2215
+
+
+
KDJ
+
中性
+
K: 71 D: 87
+
+
+
+ + +
+
+ + 关键点位 +
+
+
压力位
+
+ 压力 1 + 2,200 +
+
+ 压力 2 + 2,300 +
+
+ 压力 3 + 2,400 +
+
+
+
支撑位
+
+ 支撑 1 + 2,000 +
+
+ 支撑 2 + 1,900 +
+
+ 支撑 3 + 1,800 +
+
+
+ + +
+
+ + 多周期一致性 +
+
+
+ 5分钟 + 上涨 +
+
+ 15分钟 + 上涨 +
+
+ 30分钟 + 上涨 +
+
+ 60分钟 + 震荡 +
+
+
+
+
+
+
+
+ + + + + diff --git a/app/static/futures_analysis.js b/app/static/futures_analysis.js new file mode 100644 index 0000000..acae11e --- /dev/null +++ b/app/static/futures_analysis.js @@ -0,0 +1,825 @@ +const API_BASE = '/api/v1/futures'; + +let klineChart = null; +let currentSymbol = null; +let currentPeriod = '15'; +let allFuturesData = []; + +document.addEventListener('DOMContentLoaded', function() { + updateTime(); + setInterval(updateTime, 1000); + + initEventListeners(); + loadFuturesList(); +}); + +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.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-btn').forEach(btn => { + btn.addEventListener('click', function() { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + filterByCategory(this.dataset.category); + }); + }); + + document.getElementById('sort-select').addEventListener('change', function() { + sortFuturesList(this.value); + }); +} + +function showListView() { + document.getElementById('list-view').classList.add('active'); + document.getElementById('detail-view').classList.remove('active'); + if (klineChart) { + klineChart.dispose(); + klineChart = null; + } +} + +function showDetailView(symbol) { + currentSymbol = symbol; + document.getElementById('list-view').classList.remove('active'); + document.getElementById('detail-view').classList.add('active'); + loadFuturesDetail(symbol); + loadKlineData(symbol, currentPeriod); +} + +async function loadFuturesList() { + try { + const response = await fetch(`${API_BASE}/list`); + const data = await response.json(); + if (data.success) { + allFuturesData = data.data; + renderFuturesGrid(allFuturesData); + updateStats(allFuturesData); + } + } catch (error) { + console.error('加载品种列表失败:', error); + loadFuturesFromConfig(); + } +} + +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: 'EC', + name: '集运指数', + price: 2150, + change: 196, + changePct: 10.06, + suggestion: '逢低做多', + suggestionType: 'up', + periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, + successRate: 80, + trendScore: 90, + resistance: 2200, + support: 2000, + open: 1960, + high: 2200, + low: 1940, + volume: 45600 + }, + { + symbol: 'AU', + 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: 'AG', + 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: 'SC', + 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: 'I', + name: '铁矿石', + price: 785.5, + change: 28, + changePct: 3.7, + suggestion: '逢低做多', + suggestionType: 'up', + periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, + successRate: 68, + trendScore: 82, + resistance: 792, + support: 770, + open: 757.5, + high: 788, + low: 755, + volume: 156000 + }, + { + symbol: 'CU', + 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: 'P', + name: '棕榈油', + price: 8750, + change: 0, + changePct: 0, + suggestion: '观望等待', + suggestionType: 'neutral', + periods: { '5': 'neutral', '15': 'neutral', '30': 'neutral', '60': 'neutral' }, + successRate: 52, + trendScore: 50, + resistance: 8850, + support: 8650, + open: 8750, + high: 8780, + low: 8720, + volume: 65000 + }, + { + symbol: 'M', + 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'); + grid.innerHTML = data.map(item => ` +
+
+
+ ${item.name} + (${item.symbol}) +
+
+
¥${formatNumber(item.price)}
+
+ + +${formatNumber(item.change)} (+${item.changePct.toFixed(2)}%) +
+
+
+ ${item.suggestion} +
+ +
+ 5分 + 15分 + 30分 + 60分 +
+
+
+ +
+
+
+
+ + ${item.successRate}% +
+
+
+ +
+
+
+
+ + ${item.trendScore}/100 +
+
+
+ +
+ 压力: ${formatNumber(item.resistance)} + 支撑: ${formatNumber(item.support)} +
+
+ +
+ `).join(''); +} + +function getArrow(type) { + if (type === 'up') return 'up'; + if (type === 'down') return 'down'; + return 'right'; +} + +function formatNumber(num) { + return num.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + +function updateStats(data) { + document.getElementById('total-count').textContent = data.length; + const upCount = data.filter(d => d.change >= 0).length; + const downCount = data.length - upCount; + document.getElementById('up-count').textContent = upCount; + document.getElementById('down-count').textContent = downCount; +} + +function filterFuturesList(keyword) { + keyword = keyword.toLowerCase(); + const filtered = allFuturesData.filter(item => + item.name.toLowerCase().includes(keyword) || + item.symbol.toLowerCase().includes(keyword) + ); + renderFuturesGrid(filtered); + updateStats(filtered); +} + +function filterByCategory(category) { + if (category === 'all') { + renderFuturesGrid(allFuturesData); + updateStats(allFuturesData); + } else { + const categoryMap = { + 'energy': ['SC', 'EC', 'FU', 'LU', 'BU', 'RU', 'NR', 'ZC'], + 'metal': ['AU', 'AG', 'CU', 'AL', 'ZN', 'NI', 'SN', 'PB', 'SS', 'RB', 'HC', 'I', 'J', 'JM', 'SF', 'SM'], + 'agriculture': ['M', 'RM', 'C', 'CS', 'A', 'B', 'Y', 'P', 'OI', 'CF', 'SR', 'AP', 'JD', 'LH', 'MA', 'TA', 'EG', 'PP', 'L', 'V', 'SA', 'FG', 'UR', 'SP'], + 'finance': ['IF', 'IC', 'IH', 'IM', 'T', 'TF', 'TS', 'TL'] + }; + const symbols = categoryMap[category] || []; + const filtered = allFuturesData.filter(item => { + const symbolBase = item.symbol.replace(/[0-9]/g, '').toUpperCase(); + return symbols.includes(symbolBase); + }); + renderFuturesGrid(filtered); + updateStats(filtered); + } +} + +function sortFuturesList(sortBy) { + let sorted = [...allFuturesData]; + 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) { + updateDetailView(data.data); + } + } catch (error) { + console.error('加载详情失败:', error); + const item = allFuturesData.find(d => d.symbol === symbol); + if (item) { + updateDetailView({ + ...item, + entryPrice: item.price * 0.99, + targetPrice: item.resistance, + stopLoss: item.support, + riskLevel: item.trendScore >= 80 ? '低' : item.trendScore >= 60 ? '中' : '高', + macd: { signal: '金叉', detail: 'DIF: -0.0147' }, + rsi: { value: 47, status: '正常' }, + boll: { signal: '中轨', detail: '区间: 2086-2215' }, + kdj: { signal: '中性', detail: 'K: 71 D: 87' }, + resistances: [item.resistance, item.resistance * 1.05, item.resistance * 1.1], + supports: [item.support, item.support * 0.95, item.support * 0.9], + periodConsistency: { + '5': item.periods['5'], + '15': item.periods['15'], + '30': item.periods['30'], + '60': item.periods['60'] + }, + suggestionReason: '技术面突破,趋势明确' + }); + } + } +} + +function updateDetailView(data) { + document.getElementById('detail-price').textContent = '¥' + formatNumber(data.price); + document.getElementById('detail-price').className = 'current-price ' + (data.change >= 0 ? 'up' : 'down'); + + const changeEl = document.getElementById('detail-change'); + const changeIcon = data.change >= 0 ? 'up' : 'down'; + 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 suggestionBox = document.getElementById('suggestion-box'); + suggestionBox.className = 'suggestion-box ' + data.suggestionType; + document.getElementById('suggestion-action').textContent = data.suggestion; + document.getElementById('suggestion-reason').textContent = data.suggestionReason || ''; + + document.getElementById('entry-price').textContent = formatNumber(data.entryPrice || data.price * 0.99); + document.getElementById('target-price').textContent = formatNumber(data.targetPrice || data.resistance); + document.getElementById('stop-loss').textContent = formatNumber(data.stopLoss || data.support); + 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 < 3; i++) { + const el = document.getElementById(`resistance-${i + 1}`); + if (el && data.resistances[i]) { + el.querySelector('.level-value').textContent = formatNumber(data.resistances[i]); + } + } + } + if (data.supports) { + for (let i = 0; i < 3; i++) { + const el = document.getElementById(`support-${i + 1}`); + if (el && data.supports[i]) { + el.querySelector('.level-value').textContent = formatNumber(data.supports[i]); + } + } + } + + if (data.periodConsistency) { + const container = document.getElementById('period-consistency'); + 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(''); + } +} + +async function loadKlineData(symbol, period) { + try { + const response = await fetch(`${API_BASE}/kline/${symbol}?period=${period}`); + const data = await response.json(); + if (data.success) { + renderKlineChart(data.data); + } + } catch (error) { + console.error('加载K线数据失败:', error); + const mockKline = generateMockKlineData(); + renderKlineChart(mockKline); + } +} + +function generateMockKlineData() { + const data = []; + let basePrice = 2100; + const now = new Date(); + now.setHours(13, 0, 0, 0); + + for (let i = 0; i < 60; i++) { + const time = new Date(now.getTime() + i * 15 * 60000); + const timeStr = String(time.getHours()).padStart(2, '0') + ':' + String(time.getMinutes()).padStart(2, '0'); + + const open = basePrice + (Math.random() - 0.5) * 20; + const close = open + (Math.random() - 0.45) * 25; + const high = Math.max(open, close) + Math.random() * 10; + const low = Math.min(open, close) - Math.random() * 10; + const volume = Math.floor(Math.random() * 1000 + 200); + + data.push([timeStr, open.toFixed(2), close.toFixed(2), low.toFixed(2), high.toFixed(2), volume]); + basePrice = close; + } + + return data; +} + +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, + legend: { + data: ['K线', 'MA5', 'MA10', 'MA20', 'DIF', 'DEA', 'MACD'], + top: 10, + left: 10, + textStyle: { color: '#9aa0ab', fontSize: 11 } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + crossStyle: { color: '#999' } + }, + backgroundColor: 'rgba(26, 29, 40, 0.95)', + borderColor: '#2a2d3a', + textStyle: { color: '#e8eaed', fontSize: 12 }, + formatter: function(params) { + if (!params || params.length === 0) return ''; + let result = `
${params[0].axisValue}
`; + params.forEach(p => { + if (p.seriesName === 'K线' && p.data) { + const [o, c, l, h] = p.data; + result += `开: ${o} 收: ${c}
低: ${l} 高: ${h}`; + } else if (p.seriesName === '成交量') { + result += `
成交量: ${p.data}`; + } else if (p.seriesName === 'DIF' || p.seriesName === 'DEA') { + result += `
${p.seriesName}: ${p.data}`; + } else if (p.seriesName === 'MACD') { + result += `
MACD: ${p.data}`; + } else { + result += `
${p.seriesName}: ${p.data}`; + } + }); + return result; + } + }, + axisPointer: { + link: [{ xAxisIndex: 'all' }], + label: { + backgroundColor: '#22c55e' + } + }, + grid: [ + { left: 70, right: 20, top: 60, height: '48%' }, + { left: 70, right: 20, top: '54%', height: '14%' }, + { left: 70, right: 20, top: '73%', height: '17%' } + ], + xAxis: [ + { + type: 'category', + data: dates, + boundaryGap: true, + axisLine: { lineStyle: { color: '#2a2d3a' } }, + axisLabel: { color: '#9aa0ab', fontSize: 10 }, + splitLine: { show: false } + }, + { + type: 'category', + gridIndex: 1, + data: dates, + boundaryGap: true, + axisLine: { lineStyle: { color: '#2a2d3a' } }, + axisLabel: { show: false }, + splitLine: { show: false } + }, + { + type: 'category', + gridIndex: 2, + data: dates, + boundaryGap: true, + axisLine: { lineStyle: { color: '#2a2d3a' } }, + axisLabel: { color: '#9aa0ab', fontSize: 10 }, + splitLine: { show: false } + } + ], + yAxis: [ + { + scale: true, + axisLine: { lineStyle: { color: '#2a2d3a' } }, + axisLabel: { color: '#9aa0ab' }, + splitLine: { lineStyle: { color: '#2a2d3a', type: 'dashed' } } + }, + { + scale: true, + gridIndex: 1, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false } + }, + { + scale: true, + gridIndex: 2, + axisLine: { lineStyle: { color: '#2a2d3a' } }, + axisLabel: { color: '#9aa0ab', fontSize: 10 }, + splitLine: { lineStyle: { color: '#2a2d3a', type: 'dashed' } } + } + ], + dataZoom: [ + { + type: 'inside', + xAxisIndex: [0, 1, 2], + start: 50, + end: 100 + }, + { + show: true, + xAxisIndex: [0, 1, 2], + type: 'slider', + bottom: 5, + height: 18, + borderColor: 'transparent', + backgroundColor: '#1a1d28', + fillerColor: 'rgba(34, 197, 94, 0.15)', + handleStyle: { color: '#22c55e' }, + textStyle: { color: '#9aa0ab' } + } + ], + series: [ + { + name: 'K线', + type: 'candlestick', + data: values, + itemStyle: { + color: '#22c55e', + color0: '#ef4444', + borderColor: '#22c55e', + 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 ? '#22c55e' : '#ef4444', + opacity: 0.6 + } + })) + }, + { + 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, idx) => ({ + value: val, + itemStyle: { + color: val >= 0 ? '#22c55e' : '#ef4444', + opacity: 0.7 + } + })) + } + ] + }; + + 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 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; +} diff --git a/app/static/index.html b/app/static/index.html index 862c8f5..628ded2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -678,6 +678,15 @@ 运行日志 + + + + 期货智析 + + + + AI配置 +