新增日内方向分析功能:基于三层打分数据由 AI 批量生成下一个交易日方向判断
This commit is contained in:
409
web/backend/internal/handlers/daily_direction.go
Normal file
409
web/backend/internal/handlers/daily_direction.go
Normal file
@@ -0,0 +1,409 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user