package handlers import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" "time" "trade/web/internal/store" ) // 所有已配置品种(与 tushare/src/contracts.py 保持一致)。 var allSymbols = []string{"FG", "SA", "RB", "MA", "CF", "M"} type ddRunRequest struct { TradeDate string `json:"trade_date,omitempty"` // YYYYMMDD, 默认今天 Symbols []string `json:"symbols,omitempty"` // 默认全部 } type ddRunResponse struct { TradeDate string `json:"trade_date"` Results []ddSymbolResult `json:"results"` Errors []ddSymbolResult `json:"errors,omitempty"` } type ddSymbolResult struct { Symbol string `json:"symbol"` Direction string `json:"direction"` Confidence float64 `json:"confidence"` Error string `json:"error,omitempty"` } // DailyDirectionRun 对多个品种批量执行 AI 方向分析。 func (d *Deps) DailyDirectionRun(w http.ResponseWriter, r *http.Request) { var req ddRunRequest if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } if req.TradeDate == "" { req.TradeDate = time.Now().Format("20060102") } if len(req.Symbols) == 0 { req.Symbols = allSymbols } llmCfg := d.resolveLLMConfig() if llmCfg.APIKey == "" { writeErr(w, http.StatusServiceUnavailable, "LLM API Key 未配置,请在管理后台设置") return } resp := ddRunResponse{TradeDate: req.TradeDate} for _, sym := range req.Symbols { result, err := d.analyzeOneDirection(llmCfg, sym, req.TradeDate) if err != nil { resp.Errors = append(resp.Errors, ddSymbolResult{Symbol: sym, Error: err.Error()}) continue } resp.Results = append(resp.Results, *result) } writeJSON(w, http.StatusOK, resp) } // analyzeOneDirection 对单个品种做方向分析并持久化。 func (d *Deps) analyzeOneDirection(llmCfg *AIConfig, symbol, tradeDate string) (*ddSymbolResult, error) { // 1. 找到活跃合约 tsCode, err := d.Futures.GetActiveTsCode(symbol, tradeDate) if err != nil { return nil, fmt.Errorf("查找活跃合约: %w", err) } // 2. 拉取分析上下文 ctx, err := d.Futures.GetAnalysisContext(tsCode, tradeDate) if err != nil { return nil, fmt.Errorf("获取分析数据: %w", err) } // 3. 构建方向分析 prompt prompt := buildDirectionPrompt(ctx, symbol) promptSnapshot := prompt[len(prompt)-1]["content"] // 4. 调 LLM(非流式) rawJSON, err := callLLM(llmCfg, prompt) if err != nil { return nil, fmt.Errorf("LLM 调用失败: %w", err) } // 5. 解析 JSON parsed, err := parseDirectionJSON(rawJSON) if err != nil { log.Printf("[daily-direction] %s JSON parse failed, raw: %s", symbol, rawJSON) return nil, fmt.Errorf("AI 返回格式异常: %w", err) } // 6. 计算 target_date(简化:+1天,周五 +3天) targetDate := nextTradeDate(tradeDate) // 7. 持久化 supportJSON, _ := json.Marshal(parsed.Support) resistJSON, _ := json.Marshal(parsed.Resistance) dd := &store.DailyDirection{ Symbol: symbol, TradeDate: tradeDate, TargetDate: targetDate, Direction: parsed.Direction, Confidence: parsed.Confidence, Support: string(supportJSON), Resistance: string(resistJSON), Reasoning: parsed.Reasoning, RiskNote: parsed.RiskNote, PromptSnapshot: promptSnapshot, } if err := d.Futures.SaveDailyDirection(dd); err != nil { return nil, fmt.Errorf("保存失败: %w", err) } return &ddSymbolResult{ Symbol: symbol, Direction: parsed.Direction, Confidence: parsed.Confidence, }, nil } // directionJSON LLM 返回的期望结构。 type directionJSON struct { Direction string `json:"direction"` Confidence float64 `json:"confidence"` Support []float64 `json:"support"` Resistance []float64 `json:"resistance"` Reasoning string `json:"reasoning"` RiskNote string `json:"risk_note"` } // parseDirectionJSON 从 LLM 原始响应中提取结构化结果。 func parseDirectionJSON(raw string) (*directionJSON, error) { raw = strings.TrimSpace(raw) // 尝试直接解析 var d directionJSON if err := json.Unmarshal([]byte(raw), &d); err == nil { return &d, d.validate() } // 尝试提取 markdown code block 中的 JSON if idx := strings.Index(raw, "```json"); idx >= 0 { rest := raw[idx+7:] if end := strings.Index(rest, "```"); end > 0 { raw = strings.TrimSpace(rest[:end]) if err := json.Unmarshal([]byte(raw), &d); err == nil { return &d, d.validate() } } } if idx := strings.Index(raw, "```"); idx >= 0 { rest := raw[idx+3:] if end := strings.Index(rest, "```"); end > 0 { raw = strings.TrimSpace(rest[:end]) if err := json.Unmarshal([]byte(raw), &d); err == nil { return &d, d.validate() } } } return nil, fmt.Errorf("无法解析 AI 返回的 JSON") } func (d *directionJSON) validate() error { d.Direction = strings.TrimSpace(strings.ToLower(d.Direction)) switch d.Direction { case "bullish", "bearish", "neutral": default: return fmt.Errorf("无效的 direction 值: %s", d.Direction) } if d.Confidence < 0 || d.Confidence > 100 { d.Confidence = max(0, min(100, d.Confidence)) } return nil } // callLLM 非流式调用 LLM,返回 content 文本。 func callLLM(cfg *AIConfig, prompt []map[string]string) (string, error) { body := map[string]any{ "model": cfg.Model, "messages": prompt, "stream": false, } payload, err := json.Marshal(body) if err != nil { return "", err } client := &http.Client{Timeout: 120 * time.Second} req, err := http.NewRequest("POST", strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+cfg.APIKey) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("llm request: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("llm status %d: %s", resp.StatusCode, string(b)) } var result struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("decode llm response: %w", err) } if len(result.Choices) == 0 { return "", fmt.Errorf("llm returned empty choices") } return result.Choices[0].Message.Content, nil } // buildDirectionPrompt 构建方向分析专用 prompt。 func buildDirectionPrompt(ctx *store.AnalysisContext, symbol string) []map[string]string { s := ctx.Score var detail struct { ShortDetails []map[string]any `json:"short_details"` MediumDetail map[string]any `json:"medium_detail"` LongDetail map[string]any `json:"long_detail"` Volatility map[string]any `json:"volatility"` AdaptiveW map[string]any `json:"adaptive_weights"` VolPenalty float64 `json:"vol_penalty"` Delta1D *float64 `json:"composite_delta"` Delta5D *float64 `json:"composite_delta_5d"` } if s.Detail != nil { _ = json.Unmarshal(s.Detail, &detail) } symbolNames := map[string]string{ "FG": "玻璃", "SA": "纯碱", "RB": "螺纹钢", "MA": "甲醇", "CF": "棉花", "M": "豆粕", } symbolName := symbolNames[symbol] if symbolName == "" { symbolName = symbol } var sb strings.Builder // ── 综合概况 ── sb.WriteString(fmt.Sprintf("品种:%s(%s)\n", symbolName, symbol)) sb.WriteString(fmt.Sprintf("合约:%s 日期:%s\n", s.TsCode, s.TradeDate)) sb.WriteString(fmt.Sprintf("收盘:%.2f 持仓:%.0f (日变动 %+.0f)\n", s.Close, s.OI, s.OIChg)) sb.WriteString(fmt.Sprintf("综合分:%.1f/100 信号:%s\n", s.Composite, s.Signal)) sb.WriteString(fmt.Sprintf("分层:短期 %.1f 中期 %.1f 长期 %.1f\n", s.ShortTerm, s.MediumTerm, s.LongTerm)) // 波动率 if v, ok := detail.Volatility["vol_penalty"]; ok { risk := 0.0 if vr, ok := detail.Volatility["vol_risk"]; ok { if vv, ok := vr.(float64); ok { risk = vv } } sb.WriteString(fmt.Sprintf("波动率惩罚:%.3f (风险 %.2f%%)\n", v, risk*100)) } // 自适应权重 if ts, ok := detail.AdaptiveW["trend_strength"]; ok { sb.WriteString(fmt.Sprintf("趋势强度:%.2f → 权重 短期%.0f%%/中期%.0f%%/长期%.0f%%\n", ts, valPct(detail.AdaptiveW, "w_short"), valPct(detail.AdaptiveW, "w_medium"), valPct(detail.AdaptiveW, "w_long"))) } // 分数动量 if detail.Delta1D != nil { sb.WriteString(fmt.Sprintf("分数日变化:%+.1f", *detail.Delta1D)) } if detail.Delta5D != nil { sb.WriteString(fmt.Sprintf(" 周变化:%+.1f", *detail.Delta5D)) } if detail.Delta1D != nil || detail.Delta5D != nil { sb.WriteString("\n") } // ── 短期动力 7 日 ── sb.WriteString("\n近7日逐日打分:\n") for _, d := range detail.ShortDetails { q := fmt.Sprint(d["quadrant"]) qcn := map[string]string{ "accumulation": "增仓涨", "distribution": "增仓跌", "covering": "减仓涨", "liquidation": "减仓跌", "flat": "持平", }[q] if qcn == "" { qcn = q } sb.WriteString(fmt.Sprintf(" %s %s 涨跌%+.2f%% OI%+.2f%% 量比%.2f 得分%.1f\n", d["trade_date"], qcn, floatVal(d, "price_chg_pct")*100, floatVal(d, "oi_chg_pct")*100, floatVal(d, "vol_ratio"), floatVal(d, "score"))) } // ── 中期趋势 15 日 ── md := detail.MediumDetail sb.WriteString(fmt.Sprintf("\n中期趋势(15日):价格信号 %.1f (收益率 %+.2f%%) 资金意愿 %.1f\n", floatVal(md, "price_signal"), floatVal(md, "price_return_pct"), floatVal(md, "fund_signal"))) a, d_, c, l := intVal(md, "accumulation_days"), intVal(md, "distribution_days"), intVal(md, "covering_days"), intVal(md, "liquidation_days") sb.WriteString(fmt.Sprintf("象限分布:增仓涨 %d天 增仓跌 %d天 减仓涨 %d天 减仓跌 %d天\n", a, d_, c, l)) // ── 长期结构 30 日 ── ld := detail.LongDetail sb.WriteString(fmt.Sprintf("\n长期结构(30日):OI趋势分 %.1f (变化 %+.2f%%) 价格趋势分 %.1f (收益 %+.2f%%)\n", floatVal(ld, "oi_score"), floatVal(ld, "oi_change_pct"), floatVal(ld, "price_score"), floatVal(ld, "price_return_30d_pct"))) // ── 近 5 日分数趋势 ── if len(ctx.RecentScores) > 0 { sb.WriteString("\n近5日综合分趋势:") for i, rs := range ctx.RecentScores { if i >= 5 { break } sb.WriteString(fmt.Sprintf("%s=%.1f ", rs.TradeDate, rs.Composite)) } sb.WriteString("\n") } // ── 近 30 日 K 线(支撑阻力依据)── if len(ctx.Candles) > 0 { start := 0 if len(ctx.Candles) > 30 { start = len(ctx.Candles) - 30 } sb.WriteString("\n近30日K线(开/高/低/收):\n") for _, c := range ctx.Candles[start:] { sb.WriteString(fmt.Sprintf(" %s O%.1f H%.1f L%.1f C%.1f\n", c.TradeDate, c.Open, c.High, c.Low, c.Close)) } } systemPrompt := strings.Join([]string{ "你是一位期货日内交易分析师。基于量化打分数据,给出下一个交易日的方向判断。", "", "你必须输出严格 JSON,不要有任何额外文字:", "{", ` "direction": "bullish" | "bearish" | "neutral",`, ` "confidence": <0-100 的整数,表示对方向的确定程度>`, ` "support": [<关键支撑位1>, <关键支撑位2>]`, ` "resistance": [<关键阻力位1>, <关键阻力位2>]`, ` "reasoning": "<3-5句话,说明核心逻辑:三层信号是共振还是背离?资金在干什么?>",`, ` "risk_note": "<1-2句话,最可能打破当前判断的风险>"`, "}", "", "分析要点:", "1. 短期(7日)+中期(15日)+长期(30日) ≥2 层指向同一方向 = 有效方向信号", "2. 关注持仓变化:增仓方向才是真方向,减仓方向可能是离场", "3. 分数动量:Δ1d 和 Δ5d 连续同向 = 趋势加速,反向 = 可能转折", "4. 支撑阻力从 K 线数据中找:近期高低点、密集成交区", "5. 波动率惩罚系数 < 0.95 表示行情不稳定,降低 confidence", }, "\n") return []map[string]string{ {"role": "system", "content": systemPrompt}, {"role": "user", "content": sb.String()}, } } // nextTradeDate 计算下一个交易日(简化规则:周六→周一,周日→周一,周五→周一,其余+1天)。 func nextTradeDate(dateStr string) string { t, err := time.Parse("20060102", dateStr) if err != nil { return dateStr } next := t.AddDate(0, 0, 1) switch next.Weekday() { case time.Saturday: next = next.AddDate(0, 0, 2) case time.Sunday: next = next.AddDate(0, 0, 1) } return next.Format("20060102") } // ListDailyDirections 查询方向分析列表。 func (d *Deps) ListDailyDirections(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() symbol := q.Get("symbol") start := q.Get("start") end := q.Get("end") limit := 50 if l, err := strconv.Atoi(q.Get("limit")); err == nil { limit = l } items, err := d.Futures.ListDailyDirections(symbol, start, end, limit) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, items) }