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 backend ./
|
||||||
COPY --from=ui /ui/dist ./dist
|
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 && \
|
RUN go mod tidy && \
|
||||||
go build -trimpath -ldflags="-s -w" -o /out/web ./
|
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")
|
// 近30日K线数据(供支撑阻力分析)
|
||||||
sb.WriteString("1. 当前多空格局\n")
|
if len(ctx.Candles) > 0 {
|
||||||
sb.WriteString("2. 资金行为特征\n")
|
start := 0
|
||||||
sb.WriteString("3. 关键风险点\n")
|
if len(ctx.Candles) > 30 {
|
||||||
sb.WriteString("4. 短期关注价位\n")
|
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{
|
return []map[string]string{
|
||||||
{"role": "user", "content": sb.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/range", d.RunRange)
|
||||||
r.Post("/run/full", d.RunFull)
|
r.Post("/run/full", d.RunFull)
|
||||||
r.Get("/ai/analyze", d.Analyze)
|
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.Group(func(r chi.Router) {
|
||||||
r.Use(mw.RequireAdmin)
|
r.Use(mw.RequireAdmin)
|
||||||
|
|||||||
@@ -144,6 +144,126 @@ type Candle struct {
|
|||||||
PreClose float64 `json:"pre_close"`
|
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) {
|
func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
|
||||||
if tsCode == "" {
|
if tsCode == "" {
|
||||||
return nil, ErrMissingTsCode
|
return nil, ErrMissingTsCode
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ func main() {
|
|||||||
if err := auth.BootstrapLLMConfig(futures); err != nil {
|
if err := auth.BootstrapLLMConfig(futures); err != nil {
|
||||||
log.Fatalf("bootstrap llm config: %v", err)
|
log.Fatalf("bootstrap llm config: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := futures.EnsureDailyDirectionTable(); err != nil {
|
||||||
|
log.Fatalf("bootstrap daily_direction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
deps := &handlers.Deps{
|
deps := &handlers.Deps{
|
||||||
Auth: authDB,
|
Auth: authDB,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ async function handleReset() {
|
|||||||
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
|
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
|
||||||
<el-menu-item index="/contract-full">合约全景</el-menu-item>
|
<el-menu-item index="/contract-full">合约全景</el-menu-item>
|
||||||
<el-menu-item index="/run">手动打分</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="/admin/users">用户管理</el-menu-item>
|
||||||
<el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting">
|
<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="/chart">K 线 / 持仓</el-menu-item>
|
||||||
<el-menu-item index="/contract-full">合约全景</el-menu-item>
|
<el-menu-item index="/contract-full">合约全景</el-menu-item>
|
||||||
<el-menu-item index="/run">手动打分</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="/admin/users">用户管理</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</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',
|
name: 'run',
|
||||||
component: () => import('@/views/RunView.vue'),
|
component: () => import('@/views/RunView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/daily-direction',
|
||||||
|
name: 'daily-direction',
|
||||||
|
component: () => import('@/views/DailyDirectionView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/users',
|
path: '/admin/users',
|
||||||
name: '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