Compare commits
8 Commits
c47735f3b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947e227d5f | ||
|
|
5dad7a6a02 | ||
|
|
b91bffdb4c | ||
|
|
e756c3f300 | ||
|
|
bd48887b88 | ||
|
|
5c30bfa472 | ||
|
|
f5615d9580 | ||
|
|
6ab310cfb3 |
@@ -22,7 +22,7 @@ WORKDIR /src
|
||||
COPY backend ./
|
||||
COPY --from=ui /ui/dist ./dist
|
||||
|
||||
ENV CGO_ENABLED=0 GOOS=linux
|
||||
ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
RUN go mod tidy && \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/web ./
|
||||
|
||||
@@ -265,11 +265,26 @@ func buildPrompt(ctx *store.AnalysisContext) []map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n请从以下4个角度简要分析(每条2-3句话,使用中文):\n")
|
||||
sb.WriteString("1. 当前多空格局\n")
|
||||
sb.WriteString("2. 资金行为特征\n")
|
||||
sb.WriteString("3. 关键风险点\n")
|
||||
sb.WriteString("4. 短期关注价位\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")
|
||||
sb.WriteString("| 日期 | 开盘 | 最高 | 最低 | 收盘 |\n")
|
||||
sb.WriteString("|------|------|------|------|------|\n")
|
||||
for _, c := range ctx.Candles[start:] {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.1f | %.1f | %.1f | %.1f |\n",
|
||||
c.TradeDate, c.Open, c.High, c.Low, c.Close))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n请从以下4个角度简要分析(使用中文):\n")
|
||||
sb.WriteString("1. 当前多空格局(2-3句话)\n")
|
||||
sb.WriteString("2. 资金行为特征(2-3句话)\n")
|
||||
sb.WriteString("3. 关键风险点(2-3句话)\n")
|
||||
sb.WriteString("4. 支撑与阻力(明确指出最近的关键支撑位和阻力位,基于近30日高低点和均线位置,给出具体价位和依据)\n")
|
||||
|
||||
return []map[string]string{
|
||||
{"role": "user", "content": sb.String()},
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -35,6 +35,8 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
|
||||
r.Post("/run/range", d.RunRange)
|
||||
r.Post("/run/full", d.RunFull)
|
||||
r.Get("/ai/analyze", d.Analyze)
|
||||
r.Post("/ai/daily-direction", d.DailyDirectionRun)
|
||||
r.Get("/ai/daily-direction", d.ListDailyDirections)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAdmin)
|
||||
|
||||
@@ -144,6 +144,126 @@ type Candle struct {
|
||||
PreClose float64 `json:"pre_close"`
|
||||
}
|
||||
|
||||
// ── Daily Direction ────────────────────────────────────────────────
|
||||
|
||||
// DailyDirection 日内方向分析结果。
|
||||
type DailyDirection struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
TradeDate string `json:"trade_date"`
|
||||
TargetDate string `json:"target_date"`
|
||||
Direction string `json:"direction"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Support string `json:"support"` // JSONB → string
|
||||
Resistance string `json:"resistance"` // JSONB → string
|
||||
Reasoning string `json:"reasoning"`
|
||||
RiskNote string `json:"risk_note"`
|
||||
PromptSnapshot string `json:"prompt_snapshot,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// EnsureDailyDirectionTable 建 daily_direction 表(幂等)。
|
||||
func (s *FuturesStore) EnsureDailyDirectionTable() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS daily_direction (
|
||||
id UUID DEFAULT uuidv7() PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
trade_date TEXT NOT NULL,
|
||||
target_date TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
confidence REAL,
|
||||
support JSONB,
|
||||
resistance JSONB,
|
||||
reasoning TEXT,
|
||||
risk_note TEXT,
|
||||
prompt_snapshot TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (symbol, trade_date)
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveDailyDirection 写入(upsert)一条方向分析。
|
||||
func (s *FuturesStore) SaveDailyDirection(dd *DailyDirection) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO daily_direction
|
||||
(symbol, trade_date, target_date, direction, confidence, support, resistance, reasoning, risk_note, prompt_snapshot)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (symbol, trade_date) DO UPDATE SET
|
||||
target_date = EXCLUDED.target_date,
|
||||
direction = EXCLUDED.direction,
|
||||
confidence = EXCLUDED.confidence,
|
||||
support = EXCLUDED.support,
|
||||
resistance = EXCLUDED.resistance,
|
||||
reasoning = EXCLUDED.reasoning,
|
||||
risk_note = EXCLUDED.risk_note,
|
||||
prompt_snapshot = EXCLUDED.prompt_snapshot,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
`, dd.Symbol, dd.TradeDate, dd.TargetDate, dd.Direction, dd.Confidence,
|
||||
dd.Support, dd.Resistance, dd.Reasoning, dd.RiskNote, dd.PromptSnapshot)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDailyDirections 查询方向分析列表。
|
||||
func (s *FuturesStore) ListDailyDirections(symbol, start, end string, limit int) ([]DailyDirection, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
q := `SELECT id, symbol, trade_date, target_date, direction, confidence,
|
||||
COALESCE(support::text, '[]'), COALESCE(resistance::text, '[]'),
|
||||
reasoning, risk_note, COALESCE(prompt_snapshot, ''),
|
||||
COALESCE(created_at::text, '')
|
||||
FROM daily_direction WHERE 1=1`
|
||||
args := []any{}
|
||||
n := 0
|
||||
next := func() string { n++; return fmt.Sprintf("$%d", n) }
|
||||
if symbol != "" {
|
||||
q += " AND symbol = " + next()
|
||||
args = append(args, symbol)
|
||||
}
|
||||
if start != "" {
|
||||
q += " AND trade_date >= " + next()
|
||||
args = append(args, start)
|
||||
}
|
||||
if end != "" {
|
||||
q += " AND trade_date <= " + next()
|
||||
args = append(args, end)
|
||||
}
|
||||
q += " ORDER BY trade_date DESC, symbol ASC LIMIT " + next()
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []DailyDirection{}
|
||||
for rows.Next() {
|
||||
var dd DailyDirection
|
||||
if err := rows.Scan(&dd.ID, &dd.Symbol, &dd.TradeDate, &dd.TargetDate,
|
||||
&dd.Direction, &dd.Confidence, &dd.Support, &dd.Resistance,
|
||||
&dd.Reasoning, &dd.RiskNote, &dd.PromptSnapshot, &dd.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dd)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetActiveTsCode 通过 scores 表查找某品种在指定日期的活跃合约代码。
|
||||
func (s *FuturesStore) GetActiveTsCode(symbol, tradeDate string) (string, error) {
|
||||
var tsCode string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT ts_code FROM scores WHERE trade_date = $1 AND ts_code LIKE $2 || '%' ORDER BY ts_code DESC LIMIT 1`,
|
||||
tradeDate, symbol,
|
||||
).Scan(&tsCode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no active contract for %s on %s: %w", symbol, tradeDate, err)
|
||||
}
|
||||
return tsCode, nil
|
||||
}
|
||||
|
||||
func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
|
||||
if tsCode == "" {
|
||||
return nil, ErrMissingTsCode
|
||||
|
||||
@@ -43,6 +43,9 @@ func main() {
|
||||
if err := auth.BootstrapLLMConfig(futures); err != nil {
|
||||
log.Fatalf("bootstrap llm config: %v", err)
|
||||
}
|
||||
if err := futures.EnsureDailyDirectionTable(); err != nil {
|
||||
log.Fatalf("bootstrap daily_direction: %v", err)
|
||||
}
|
||||
|
||||
deps := &handlers.Deps{
|
||||
Auth: authDB,
|
||||
|
||||
@@ -76,6 +76,7 @@ async function handleReset() {
|
||||
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
|
||||
<el-menu-item index="/contract-full">合约全景</el-menu-item>
|
||||
<el-menu-item index="/run">手动打分</el-menu-item>
|
||||
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
|
||||
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
|
||||
<el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting">
|
||||
数据重置
|
||||
@@ -104,6 +105,7 @@ async function handleReset() {
|
||||
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
|
||||
<el-menu-item index="/contract-full">合约全景</el-menu-item>
|
||||
<el-menu-item index="/run">手动打分</el-menu-item>
|
||||
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
|
||||
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
46
web/frontend/src/api/daily.ts
Normal file
46
web/frontend/src/api/daily.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import client from './client'
|
||||
|
||||
export interface DailyDirection {
|
||||
id: string
|
||||
symbol: string
|
||||
trade_date: string
|
||||
target_date: string
|
||||
direction: string
|
||||
confidence: number
|
||||
support: string // JSON string of number[]
|
||||
resistance: string // JSON string of number[]
|
||||
reasoning: string
|
||||
risk_note: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DailyDirectionRunRequest {
|
||||
trade_date?: string
|
||||
symbols?: string[]
|
||||
}
|
||||
|
||||
export interface DailyDirectionRunResult {
|
||||
symbol: string
|
||||
direction: string
|
||||
confidence: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DailyDirectionRunResponse {
|
||||
trade_date: string
|
||||
results: DailyDirectionRunResult[]
|
||||
errors?: DailyDirectionRunResult[]
|
||||
}
|
||||
|
||||
export function runDailyDirection(req?: DailyDirectionRunRequest) {
|
||||
return client.post<DailyDirectionRunResponse>('/ai/daily-direction', req ?? {}, { timeout: 300_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function listDailyDirections(params?: {
|
||||
symbol?: string
|
||||
start?: string
|
||||
end?: string
|
||||
limit?: number
|
||||
}) {
|
||||
return client.get<DailyDirection[]>('/ai/daily-direction', { params }).then((r) => r.data)
|
||||
}
|
||||
@@ -35,6 +35,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'run',
|
||||
component: () => import('@/views/RunView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/daily-direction',
|
||||
name: 'daily-direction',
|
||||
component: () => import('@/views/DailyDirectionView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'admin-users',
|
||||
|
||||
304
web/frontend/src/views/DailyDirectionView.vue
Normal file
304
web/frontend/src/views/DailyDirectionView.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
listDailyDirections,
|
||||
runDailyDirection,
|
||||
type DailyDirection,
|
||||
type DailyDirectionRunResponse,
|
||||
} from '@/api/daily'
|
||||
import { runBatch } from '@/api/run'
|
||||
|
||||
const items = ref<DailyDirection[]>([])
|
||||
const loading = ref(false)
|
||||
const running = ref(false)
|
||||
const runStep = ref('')
|
||||
const runResult = ref<DailyDirectionRunResponse | null>(null)
|
||||
const selectedRow = ref<DailyDirection | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
const symbolNames: Record<string, string> = {
|
||||
FG: '玻璃', SA: '纯碱', RB: '螺纹钢', MA: '甲醇', CF: '棉花', M: '豆粕',
|
||||
}
|
||||
|
||||
const directionLabel = (d: string) => {
|
||||
switch (d) {
|
||||
case 'bullish': return '看多'
|
||||
case 'bearish': return '看空'
|
||||
case 'neutral': return '震荡'
|
||||
default: return d
|
||||
}
|
||||
}
|
||||
|
||||
const directionType = (d: string): '' | 'success' | 'danger' | 'warning' => {
|
||||
switch (d) {
|
||||
case 'bullish': return 'success'
|
||||
case 'bearish': return 'danger'
|
||||
case 'neutral': return 'warning'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const runSummary = computed(() => {
|
||||
if (!runResult.value) return ''
|
||||
const parts: string[] = []
|
||||
for (const r of runResult.value.results ?? []) {
|
||||
parts.push(`${symbolNames[r.symbol] ?? r.symbol}: ${directionLabel(r.direction)}`)
|
||||
}
|
||||
return parts.join(' | ')
|
||||
})
|
||||
|
||||
const drawerTitle = computed(() => {
|
||||
if (!selectedRow.value) return ''
|
||||
const name = symbolNames[selectedRow.value.symbol] ?? selectedRow.value.symbol
|
||||
return `${name}(${selectedRow.value.symbol}) · ${selectedRow.value.trade_date}`
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await listDailyDirections({ limit: 50 })
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
running.value = true
|
||||
runResult.value = null
|
||||
try {
|
||||
runStep.value = '正在拉取数据…'
|
||||
await runBatch()
|
||||
runStep.value = '正在 AI 分析…'
|
||||
runResult.value = await runDailyDirection()
|
||||
const ok = runResult.value?.results?.length ?? 0
|
||||
const fail = runResult.value?.errors?.length ?? 0
|
||||
if (fail > 0) {
|
||||
ElMessage.warning(`分析完成:成功 ${ok} 个,失败 ${fail} 个`)
|
||||
} else {
|
||||
ElMessage.success(`已完成 ${ok} 个品种的方向分析`)
|
||||
}
|
||||
await fetchList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.error || '分析失败')
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function parseLevels(json: string): number[] {
|
||||
try {
|
||||
return JSON.parse(json) as number[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function levelAt(json: string, i: number): string {
|
||||
const arr = parseLevels(json)
|
||||
return arr[i] != null ? String(arr[i]) : '-'
|
||||
}
|
||||
|
||||
function openDrawer(row: DailyDirection) {
|
||||
selectedRow.value = row
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="daily-direction">
|
||||
<div class="toolbar">
|
||||
<h2>日内方向分析</h2>
|
||||
<el-button type="primary" :loading="running" @click="handleRun">
|
||||
{{ running ? runStep : '执行分析' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="runResult"
|
||||
:title="`${runResult.trade_date} 分析结果`"
|
||||
:description="runSummary"
|
||||
type="info"
|
||||
show-icon
|
||||
closable
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<el-table :data="items" stripe v-loading="loading" empty-text="暂无数据,请先执行分析" highlight-current-row @row-click="openDrawer">
|
||||
<el-table-column prop="symbol" label="品种" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ symbolNames[row.symbol] ?? row.symbol }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="trade_date" label="分析日" width="110" />
|
||||
<el-table-column prop="target_date" label="目标日" width="110" />
|
||||
<el-table-column prop="direction" label="方向" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="directionType(row.direction)" size="small">
|
||||
{{ directionLabel(row.direction) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="confidence" label="置信度" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.confidence >= 70 ? '#67c23a' : row.confidence >= 50 ? '#e6a23c' : '#f56c6c', fontWeight: 'bold' }">
|
||||
{{ row.confidence }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支撑位一" width="100" align="center">
|
||||
<template #default="{ row }">{{ levelAt(row.support, 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支撑位二" width="100" align="center">
|
||||
<template #default="{ row }">{{ levelAt(row.support, 1) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="阻力位一" width="100" align="center">
|
||||
<template #default="{ row }">{{ levelAt(row.resistance, 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="阻力位二" width="100" align="center">
|
||||
<template #default="{ row }">{{ levelAt(row.resistance, 1) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="明细" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click.stop="openDrawer(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="drawerOpen" :title="drawerTitle" size="480px" direction="rtl">
|
||||
<template v-if="selectedRow">
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-label">方向判断</div>
|
||||
<el-tag :type="directionType(selectedRow.direction)" size="default">
|
||||
{{ directionLabel(selectedRow.direction) }}
|
||||
</el-tag>
|
||||
<span class="drawer-confidence" :style="{ color: selectedRow.confidence >= 70 ? '#67c23a' : selectedRow.confidence >= 50 ? '#e6a23c' : '#f56c6c' }">
|
||||
置信度 {{ selectedRow.confidence }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-label">支撑位</div>
|
||||
<div class="level-rows">
|
||||
<div v-for="(v, i) in parseLevels(selectedRow.support)" :key="'ds'+i" class="level-row sup">
|
||||
<span class="level-tag">支撑{{ i + 1 }}</span>
|
||||
<span class="level-val">{{ v }}</span>
|
||||
</div>
|
||||
<div v-if="!parseLevels(selectedRow.support).length" class="level-row">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-label">阻力位</div>
|
||||
<div class="level-rows">
|
||||
<div v-for="(v, i) in parseLevels(selectedRow.resistance)" :key="'dr'+i" class="level-row res">
|
||||
<span class="level-tag">阻力{{ i + 1 }}</span>
|
||||
<span class="level-val">{{ v }}</span>
|
||||
</div>
|
||||
<div v-if="!parseLevels(selectedRow.resistance).length" class="level-row">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-label">分析逻辑</div>
|
||||
<div class="drawer-text">{{ selectedRow.reasoning || '-' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-label">风险提示</div>
|
||||
<div class="drawer-text risk">{{ selectedRow.risk_note || '-' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section meta">
|
||||
<span>分析日期:{{ selectedRow.trade_date }}</span>
|
||||
<span>目标交易日:{{ selectedRow.target_date }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.daily-direction {
|
||||
max-width: 1400px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toolbar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.drawer-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.drawer-confidence {
|
||||
margin-left: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.drawer-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.drawer-text.risk {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.level-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.level-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.level-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
min-width: 54px;
|
||||
text-align: center;
|
||||
}
|
||||
.level-row.sup .level-tag {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
.level-row.res .level-tag {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
.level-val {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.drawer-section.meta {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user