From 99c2a5bcbf80c182397848a8ac5b867a421e03d9 Mon Sep 17 00:00:00 2001 From: fish Date: Sun, 10 May 2026 16:21:15 +0800 Subject: [PATCH] =?UTF-8?q?AI=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?LLM=20Key=20=E6=94=B9=E4=B8=BA=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/backend/internal/auth/bootstrap.go | 5 + web/backend/internal/config/config.go | 6 + web/backend/internal/handlers/ai.go | 292 ++++++++++++++++++ web/backend/internal/handlers/deps.go | 1 + web/backend/internal/handlers/llm_config.go | 86 ++++++ web/backend/internal/router/router.go | 3 + web/backend/internal/store/ai.go | 134 ++++++++ web/backend/main.go | 15 +- web/frontend/src/api/admin.ts | 15 + web/frontend/src/api/scores.ts | 24 +- .../src/components/ScoreDetailDrawer.vue | 167 +++++++++- web/frontend/src/views/AdminUsersView.vue | 89 +++++- 12 files changed, 814 insertions(+), 23 deletions(-) create mode 100644 web/backend/internal/handlers/ai.go create mode 100644 web/backend/internal/handlers/llm_config.go create mode 100644 web/backend/internal/store/ai.go diff --git a/web/backend/internal/auth/bootstrap.go b/web/backend/internal/auth/bootstrap.go index 22d42c2..91e0a7f 100644 --- a/web/backend/internal/auth/bootstrap.go +++ b/web/backend/internal/auth/bootstrap.go @@ -6,6 +6,11 @@ import ( "trade/web/internal/store" ) +// BootstrapLLMConfig 初始化 llm_config 表。 +func BootstrapLLMConfig(s *store.FuturesStore) error { + return s.EnsureLLMConfigTable() +} + // Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin; // 并强制首次登录后改密码。已存在 admin 时静默跳过。 func Bootstrap(s *store.AuthStore) error { diff --git a/web/backend/internal/config/config.go b/web/backend/internal/config/config.go index 3d657a8..2830fe8 100644 --- a/web/backend/internal/config/config.go +++ b/web/backend/internal/config/config.go @@ -9,6 +9,9 @@ type Config struct { ListenAddr string DatabaseURL string TushareAPIURL string + LLMBaseURL string + LLMAPIKey string + LLMModel string } func Load() (*Config, error) { @@ -16,6 +19,9 @@ func Load() (*Config, error) { ListenAddr: getenv("LISTEN_ADDR", ":8080"), DatabaseURL: os.Getenv("DATABASE_URL"), TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"), + LLMBaseURL: getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"), + LLMAPIKey: os.Getenv("LLM_API_KEY"), + LLMModel: getenv("LLM_MODEL", "deepseek-chat"), } if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL 环境变量未设置") diff --git a/web/backend/internal/handlers/ai.go b/web/backend/internal/handlers/ai.go new file mode 100644 index 0000000..f346c7a --- /dev/null +++ b/web/backend/internal/handlers/ai.go @@ -0,0 +1,292 @@ +package handlers + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "trade/web/internal/store" +) + +// AIConfig LLM 调用配置。 +type AIConfig struct { + BaseURL string + APIKey string + Model string +} + +// resolveLLMConfig 返回实际使用的 LLM 配置:DB 优先,环境变量作 fallback。 +func (d *Deps) resolveLLMConfig() *AIConfig { + cfg := &AIConfig{ + BaseURL: "https://api.deepseek.com/v1", + Model: "deepseek-chat", + } + + // 尝试从 DB 读取 + if dbCfg, err := d.Futures.GetLLMConfig(); err == nil && dbCfg != nil && dbCfg.APIKey != "" { + cfg.APIKey = dbCfg.APIKey + if dbCfg.BaseURL != "" { + cfg.BaseURL = dbCfg.BaseURL + } + if dbCfg.Model != "" { + cfg.Model = dbCfg.Model + } + return cfg + } + + // fallback 到环境变量 + if d.AIConfig != nil { + if d.AIConfig.APIKey != "" { + cfg.APIKey = d.AIConfig.APIKey + } + if d.AIConfig.BaseURL != "" { + cfg.BaseURL = d.AIConfig.BaseURL + } + if d.AIConfig.Model != "" { + cfg.Model = d.AIConfig.Model + } + } + return cfg +} + +// Analyze 接收 ts_code + trade_date,查库拼 prompt,调 LLM 并以 SSE 流式返回。 +func (d *Deps) Analyze(w http.ResponseWriter, r *http.Request) { + llmCfg := d.resolveLLMConfig() + if llmCfg.APIKey == "" { + writeErr(w, http.StatusServiceUnavailable, "LLM API Key 未配置,请在管理后台设置") + return + } + + q := r.URL.Query() + tsCode := q.Get("ts_code") + tradeDate := q.Get("trade_date") + if tsCode == "" || tradeDate == "" { + writeErr(w, http.StatusBadRequest, "缺少 ts_code 或 trade_date") + return + } + + ctx, err := d.Futures.GetAnalysisContext(tsCode, tradeDate) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + + prompt := buildPrompt(ctx) + + // SSE + flusher, ok := w.(http.Flusher) + if !ok { + writeErr(w, http.StatusInternalServerError, "不支持 SSE") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + if err := streamLLM(d.AIConfig, prompt, w, flusher); err != nil { + log.Printf("[ai] stream error: %v", err) + sendSSE(w, flusher, "error", err.Error()) + } + sendSSE(w, flusher, "done", "") +} + +func sendSSE(w io.Writer, flusher http.Flusher, event, data string) { + if event != "" { + fmt.Fprintf(w, "event: %s\n", event) + } + for _, line := range strings.Split(data, "\n") { + fmt.Fprintf(w, "data: %s\n", line) + } + fmt.Fprint(w, "\n") + flusher.Flush() +} + +// streamLLM 调用 LLM chat/completions,逐 token 推送 SSE。 +func streamLLM(cfg *AIConfig, prompt []map[string]string, w io.Writer, flusher http.Flusher) error { + body := map[string]any{ + "model": cfg.Model, + "messages": prompt, + "stream": true, + } + payload, _ := json.Marshal(body) + + 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 := http.DefaultClient.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, 1024)) + return fmt.Errorf("llm status %d: %s", resp.StatusCode, string(b)) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + return nil + } + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue // 忽略解析失败的行 + } + for _, c := range chunk.Choices { + if c.Delta.Content != "" { + sendSSE(w, flusher, "token", c.Delta.Content) + } + } + } + return scanner.Err() +} + +// buildPrompt 用 AnalysisContext 构建 system + user 消息。 +func buildPrompt(ctx *store.AnalysisContext) []map[string]string { + s := ctx.Score + + // 解析 detail_json 中的关键字段 + 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) + } + + var sb strings.Builder + sb.WriteString("你是一位资深期货技术分析师,擅长从量化打分系统中解读市场信号。\n") + + // 综合 + sb.WriteString(fmt.Sprintf("\n## 合约 %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\n", s.Composite)) + sb.WriteString(fmt.Sprintf("- 分层: 短期 %.1f 中期 %.1f 长期 %.1f\n", s.ShortTerm, s.MediumTerm, s.LongTerm)) + sb.WriteString(fmt.Sprintf("- 信号: %s\n", s.Signal)) + + // 波动率 + if v, ok := detail.Volatility["vol_penalty"]; ok { + dp := 0.0 + if vv, ok := v.(float64); ok { + dp = vv + } + 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", dp, risk*100)) + } + + // 自适应权重 + if aw, ok := detail.AdaptiveW["trend_strength"]; ok { + sb.WriteString(fmt.Sprintf("- 趋势强度: %.2f → 权重 短期%.0f%%/中期%.0f%%/长期%.0f%%\n", + aw, valPct(detail.AdaptiveW, "w_short"), valPct(detail.AdaptiveW, "w_medium"), valPct(detail.AdaptiveW, "w_long"))) + } + + // 分数动量 + if detail.Delta1D != nil { + sb.WriteString(fmt.Sprintf("- 分数动量: Δ1d %+.1f", *detail.Delta1D)) + } + if detail.Delta5D != nil { + sb.WriteString(fmt.Sprintf(" Δ5d %+.1f", *detail.Delta5D)) + } + if detail.Delta1D != nil || detail.Delta5D != nil { + sb.WriteString("\n") + } + + // 短期细节 + sb.WriteString("\n## 短期动力 (近7日逐日)\n") + sb.WriteString("| 日期 | 象限 | 涨跌% | OI变化% | 得分 |\n") + sb.WriteString("|------|------|-------|---------|------|\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%% | %+.2f%% | %.1f |\n", + d["trade_date"], qcn, floatVal(d, "price_chg_pct")*100, floatVal(d, "oi_chg_pct")*100, floatVal(d, "score"))) + } + + // 中期细节 + md := detail.MediumDetail + sb.WriteString("\n## 中期趋势 (15日)\n") + sb.WriteString(fmt.Sprintf("- 价格信号: %.1f (收益率 %+.2f%%)\n", floatVal(md, "price_signal"), floatVal(md, "price_return_pct"))) + sb.WriteString(fmt.Sprintf("- 资金意愿: %.1f\n", 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)) + + // 长期细节 + ld := detail.LongDetail + sb.WriteString("\n## 长期结构 (30日)\n") + sb.WriteString(fmt.Sprintf("- OI 趋势分: %.1f (端点变化 %+.2f%%)\n", floatVal(ld, "oi_score"), floatVal(ld, "oi_change_pct"))) + sb.WriteString(fmt.Sprintf("- 价格趋势分: %.1f (30日收益 %+.2f%%)\n", floatVal(ld, "price_score"), floatVal(ld, "price_return_30d_pct"))) + + // 近 5 日分数趋势 + if len(ctx.RecentScores) > 0 { + sb.WriteString("\n## 近5日分数趋势\n") + for i, rs := range ctx.RecentScores { + if i >= 5 { + break + } + sb.WriteString(fmt.Sprintf("- %s 综合 %.1f\n", rs.TradeDate, rs.Composite)) + } + } + + sb.WriteString("\n请从以下4个角度简要分析(每条2-3句话,使用中文):\n") + sb.WriteString("1. 当前多空格局\n") + sb.WriteString("2. 资金行为特征\n") + sb.WriteString("3. 关键风险点\n") + sb.WriteString("4. 短期关注价位\n") + + return []map[string]string{ + {"role": "user", "content": sb.String()}, + } +} + +func floatVal(m map[string]any, key string) float64 { + v, _ := m[key].(float64) + return v +} + +func intVal(m map[string]any, key string) int { + v, _ := m[key].(float64) + return int(v) +} + +func valPct(m map[string]any, key string) float64 { + v, _ := m[key].(float64) + return v * 100 +} diff --git a/web/backend/internal/handlers/deps.go b/web/backend/internal/handlers/deps.go index afaff03..493f65f 100644 --- a/web/backend/internal/handlers/deps.go +++ b/web/backend/internal/handlers/deps.go @@ -13,6 +13,7 @@ type Deps struct { Auth *store.AuthStore Futures *store.FuturesStore TushareURL string + AIConfig *AIConfig } func writeJSON(w http.ResponseWriter, status int, body any) { diff --git a/web/backend/internal/handlers/llm_config.go b/web/backend/internal/handlers/llm_config.go new file mode 100644 index 0000000..c252cc1 --- /dev/null +++ b/web/backend/internal/handlers/llm_config.go @@ -0,0 +1,86 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "trade/web/internal/store" +) + +type llmConfigReq struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` +} + +type llmConfigResp struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` // 脱敏:仅显示首尾 + Model string `json:"model"` + HasAPIKey bool `json:"has_api_key"` // 前端据此判断是否已配置 +} + +// maskKey 将 sk-abc123xyz 脱敏为 sk-a...xyz。 +func maskKey(key string) string { + if len(key) <= 8 { + return strings.Repeat("*", len(key)) + } + return key[:4] + "..." + key[len(key)-4:] +} + +// GetLLMConfig 返回当前 LLM 配置(API Key 脱敏)。 +func (d *Deps) GetLLMConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := d.Futures.GetLLMConfig() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + resp := llmConfigResp{ + BaseURL: cfg.BaseURL, + Model: cfg.Model, + HasAPIKey: cfg.APIKey != "", + } + if cfg.APIKey != "" { + resp.APIKey = maskKey(cfg.APIKey) + } + writeJSON(w, http.StatusOK, resp) +} + +// SaveLLMConfig 保存 LLM 配置。api_key 为脱敏占位时保留原值。 +func (d *Deps) SaveLLMConfig(w http.ResponseWriter, r *http.Request) { + var req llmConfigReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + req.BaseURL = strings.TrimSpace(req.BaseURL) + req.Model = strings.TrimSpace(req.Model) + if req.BaseURL == "" { + req.BaseURL = "https://api.deepseek.com/v1" + } + if req.Model == "" { + req.Model = "deepseek-chat" + } + + // 如果前端传的是脱敏值(含 ...),说明未修改 Key,保留旧值 + if strings.Contains(req.APIKey, "...") { + old, err := d.Futures.GetLLMConfig() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + req.APIKey = old.APIKey + } + + cfg := &store.LLMConfig{ + BaseURL: req.BaseURL, + APIKey: req.APIKey, + Model: req.Model, + } + if err := d.Futures.SaveLLMConfig(cfg); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/web/backend/internal/router/router.go b/web/backend/internal/router/router.go index 9758ed3..587a81f 100644 --- a/web/backend/internal/router/router.go +++ b/web/backend/internal/router/router.go @@ -34,6 +34,7 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler { r.Post("/run/batch", d.RunBatch) r.Post("/run/range", d.RunRange) r.Post("/run/full", d.RunFull) + r.Get("/ai/analyze", d.Analyze) r.Group(func(r chi.Router) { r.Use(mw.RequireAdmin) @@ -42,6 +43,8 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler { r.Patch("/admin/users/{id}", d.AdminPatchUser) r.Delete("/admin/users/{id}", d.AdminDeleteUser) r.Post("/admin/reset-data", d.AdminResetData) + r.Get("/admin/llm-config", d.GetLLMConfig) + r.Put("/admin/llm-config", d.SaveLLMConfig) }) }) }) diff --git a/web/backend/internal/store/ai.go b/web/backend/internal/store/ai.go new file mode 100644 index 0000000..3758d57 --- /dev/null +++ b/web/backend/internal/store/ai.go @@ -0,0 +1,134 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" +) + +// AnalysisContext 汇总一次 AI 分析所需的全部数据。 +type AnalysisContext struct { + Score Score `json:"score"` + Candles []Candle `json:"candles"` + RecentScores []Score `json:"recent_scores"` +} + +// GetAnalysisContext 拉取指定合约某日的 score + 近 60 日 K 线 + 近 10 日 scores。 +func (s *FuturesStore) GetAnalysisContext(tsCode, tradeDate string) (*AnalysisContext, error) { + ctx := &AnalysisContext{} + + // 1) 目标日 score(含 detail_json) + row := s.db.QueryRow(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term, + long_term, composite, signal, detail_json, created_at FROM scores + WHERE ts_code = $1 AND trade_date = $2`, tsCode, tradeDate) + var detail sql.NullString + if err := row.Scan(&ctx.Score.ID, &ctx.Score.TsCode, &ctx.Score.TradeDate, + &ctx.Score.Close, &ctx.Score.OI, &ctx.Score.OIChg, + &ctx.Score.ShortTerm, &ctx.Score.MediumTerm, &ctx.Score.LongTerm, + &ctx.Score.Composite, &ctx.Score.Signal, &detail, &ctx.Score.CreatedAt); err != nil { + return nil, fmt.Errorf("score not found: %w", err) + } + if detail.Valid { + ctx.Score.Detail = json.RawMessage(detail.String) + } + + // 2) 近 60 日 K 线 + rows, err := s.db.Query(`SELECT ts_code, trade_date, + COALESCE(NULLIF(open, 'NaN'::real), 0), COALESCE(NULLIF(high, 'NaN'::real), 0), + COALESCE(NULLIF(low, 'NaN'::real), 0), COALESCE(NULLIF(close, 'NaN'::real), 0), + COALESCE(NULLIF(vol, 'NaN'::real), 0), COALESCE(NULLIF(amount, 'NaN'::real), 0), + COALESCE(NULLIF(oi, 'NaN'::real), 0), COALESCE(NULLIF(oi_chg, 'NaN'::real), 0), + COALESCE(NULLIF(pre_close, 'NaN'::real), 0) + FROM candles WHERE ts_code = $1 AND trade_date <= $2 + ORDER BY trade_date DESC LIMIT 60`, tsCode, tradeDate) + if err != nil { + return nil, fmt.Errorf("candles: %w", err) + } + defer rows.Close() + for rows.Next() { + var c Candle + if err := rows.Scan(&c.TsCode, &c.TradeDate, &c.Open, &c.High, &c.Low, &c.Close, + &c.Vol, &c.Amount, &c.OI, &c.OIChg, &c.PreClose); err != nil { + return nil, err + } + ctx.Candles = append(ctx.Candles, c) + } + // 反转回升序 + for i, j := 0, len(ctx.Candles)-1; i < j; i, j = i+1, j-1 { + ctx.Candles[i], ctx.Candles[j] = ctx.Candles[j], ctx.Candles[i] + } + + // 3) 近 10 日 scores(不含 detail_json 以减体积) + scoreRows, err := s.db.Query(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term, + long_term, composite, signal, created_at FROM scores + WHERE ts_code = $1 AND trade_date <= $2 + ORDER BY trade_date DESC LIMIT 10`, tsCode, tradeDate) + if err != nil { + return nil, fmt.Errorf("recent scores: %w", err) + } + defer scoreRows.Close() + for scoreRows.Next() { + var sc Score + if err := scoreRows.Scan(&sc.ID, &sc.TsCode, &sc.TradeDate, &sc.Close, &sc.OI, &sc.OIChg, + &sc.ShortTerm, &sc.MediumTerm, &sc.LongTerm, &sc.Composite, &sc.Signal, &sc.CreatedAt); err != nil { + return nil, err + } + ctx.RecentScores = append(ctx.RecentScores, sc) + } + // 反转回升序 + for i, j := 0, len(ctx.RecentScores)-1; i < j; i, j = i+1, j-1 { + ctx.RecentScores[i], ctx.RecentScores[j] = ctx.RecentScores[j], ctx.RecentScores[i] + } + + return ctx, nil +} + +// LLMConfig 数据库中的 LLM 配置单例。 +type LLMConfig struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` +} + +// EnsureLLMConfigTable 建 llm_config 表(幂等)。 +func (s *FuturesStore) EnsureLLMConfigTable() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS llm_config ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK(id = 1), + base_url TEXT NOT NULL DEFAULT 'https://api.deepseek.com/v1', + api_key TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT 'deepseek-chat', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO llm_config (id) VALUES (1) ON CONFLICT DO NOTHING; + `) + return err +} + +// GetLLMConfig 读取 LLM 配置,无记录返回零值。 +func (s *FuturesStore) GetLLMConfig() (*LLMConfig, error) { + cfg := &LLMConfig{} + err := s.db.QueryRow(`SELECT base_url, api_key, model FROM llm_config WHERE id = 1`). + Scan(&cfg.BaseURL, &cfg.APIKey, &cfg.Model) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return cfg, nil + } + return nil, err + } + return cfg, nil +} + +// SaveLLMConfig 写入 LLM 配置(upsert)。 +func (s *FuturesStore) SaveLLMConfig(cfg *LLMConfig) error { + _, err := s.db.Exec(` + INSERT INTO llm_config (id, base_url, api_key, model, updated_at) + VALUES (1, $1, $2, $3, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO UPDATE SET + base_url = EXCLUDED.base_url, + api_key = EXCLUDED.api_key, + model = EXCLUDED.model, + updated_at = CURRENT_TIMESTAMP + `, cfg.BaseURL, cfg.APIKey, cfg.Model) + return err +} diff --git a/web/backend/main.go b/web/backend/main.go index 8cdca60..3c1d998 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -40,7 +40,20 @@ func main() { log.Fatalf("bootstrap: %v", err) } - deps := &handlers.Deps{Auth: authDB, Futures: futures, TushareURL: cfg.TushareAPIURL} + if err := auth.BootstrapLLMConfig(futures); err != nil { + log.Fatalf("bootstrap llm config: %v", err) + } + + deps := &handlers.Deps{ + Auth: authDB, + Futures: futures, + TushareURL: cfg.TushareAPIURL, + AIConfig: &handlers.AIConfig{ + BaseURL: cfg.LLMBaseURL, + APIKey: cfg.LLMAPIKey, + Model: cfg.LLMModel, + }, + } dist, err := fs.Sub(distFS, "dist") if err != nil { diff --git a/web/frontend/src/api/admin.ts b/web/frontend/src/api/admin.ts index e30f81c..87dfebe 100644 --- a/web/frontend/src/api/admin.ts +++ b/web/frontend/src/api/admin.ts @@ -3,3 +3,18 @@ import client from './client' export function resetAllData() { return client.post('/admin/reset-data').then((r) => r.data) } + +export interface LLMConfig { + base_url: string + api_key: string + model: string + has_api_key: boolean +} + +export function getLLMConfig() { + return client.get('/admin/llm-config').then((r) => r.data) +} + +export function saveLLMConfig(cfg: { base_url: string; api_key: string; model: string }) { + return client.put('/admin/llm-config', cfg).then((r) => r.data) +} diff --git a/web/frontend/src/api/scores.ts b/web/frontend/src/api/scores.ts index 5eb0c43..9ac55af 100644 --- a/web/frontend/src/api/scores.ts +++ b/web/frontend/src/api/scores.ts @@ -17,34 +17,50 @@ export interface ShortDetail { export interface MediumDetail { price_return_pct: number price_signal: number - long_up_days: number - long_down_days: number + accumulation_days: number + distribution_days: number + covering_days: number + liquidation_days: number fund_signal: number window: number } export interface LongDetail { - avg_oi: number + oi_now: number oi_before: number - change_pct: number + oi_change_pct: number oi_score: number price_score: number price_return_30d_pct: number price_before_30d: number + avg_oi_30d: number window: number } export interface VolatilityDetail { daily_vol_pct: number atr_pct: number + vol_risk: number vol_penalty: number } +export interface AdaptiveWeights { + trend_strength: number + trend_factor: number + w_short: number + w_medium: number + w_long: number +} + export interface ScoreDetail { short_details?: ShortDetail[] medium_detail?: MediumDetail long_detail?: LongDetail volatility?: VolatilityDetail + adaptive_weights?: AdaptiveWeights + vol_penalty?: number + composite_delta?: number | null + composite_delta_5d?: number | null } export interface Score { diff --git a/web/frontend/src/components/ScoreDetailDrawer.vue b/web/frontend/src/components/ScoreDetailDrawer.vue index 28756a2..c3e1022 100644 --- a/web/frontend/src/components/ScoreDetailDrawer.vue +++ b/web/frontend/src/components/ScoreDetailDrawer.vue @@ -12,6 +12,43 @@ const emit = defineEmits<{ (e: 'close'): void }>() const score = ref(null) const loading = ref(false) +// AI 分析 +const aiLoading = ref(false) +const aiContent = ref('') +const aiError = ref('') +let es: EventSource | null = null + +function closeAI() { + es?.close() + es = null + aiLoading.value = false +} + +async function askAI() { + if (!score.value) return + closeAI() + aiLoading.value = true + aiContent.value = '' + aiError.value = '' + + const url = `/api/v1/ai/analyze?ts_code=${encodeURIComponent(score.value.ts_code)}&trade_date=${encodeURIComponent(score.value.trade_date)}` + es = new EventSource(url) + es.addEventListener('token', (e) => { + aiContent.value += e.data + }) + es.addEventListener('error', (e) => { + aiError.value = (e as any)?.data || '请求失败' + closeAI() + }) + es.addEventListener('done', () => { + closeAI() + }) + es.onerror = () => { + if (!aiContent.value) aiError.value = '连接中断' + closeAI() + } +} + const visible = computed({ get: () => props.scoreId !== null, set: (v) => { @@ -22,6 +59,9 @@ const visible = computed({ watch( () => props.scoreId, async (id) => { + closeAI() + aiContent.value = '' + aiError.value = '' if (id === null) { score.value = null return @@ -59,7 +99,7 @@ const quadrantLabel = (q: string) => { @@ -197,4 +300,34 @@ const quadrantLabel = (q: string) => { font-size: 12px; margin-left: 6px; } +.ai-section { + margin-top: 4px; +} +.ai-card { + border: 1px solid var(--el-border-color); + border-radius: 6px; + margin-top: 8px; + overflow: hidden; +} +.ai-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--el-fill-color-light); + font-weight: 600; +} +.ai-body { + padding: 12px; +} +.ai-text { + line-height: 1.8; + white-space: pre-wrap; +} +.ai-error { + color: var(--el-color-danger); +} +.ai-loading { + color: var(--el-text-color-secondary); +} diff --git a/web/frontend/src/views/AdminUsersView.vue b/web/frontend/src/views/AdminUsersView.vue index 7a277d1..6fe2508 100644 --- a/web/frontend/src/views/AdminUsersView.vue +++ b/web/frontend/src/views/AdminUsersView.vue @@ -8,6 +8,7 @@ import { updateUser, type AdminUser, } from '@/api/users' +import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin' import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() @@ -27,6 +28,52 @@ const resetDialog = reactive({ password: '', }) +// LLM 配置 +const llmCfg = reactive({ + base_url: 'https://api.deepseek.com/v1', + api_key: '', + model: 'deepseek-chat', + has_api_key: false, + saving: false, + loading: false, +}) + +async function loadLLMConfig() { + llmCfg.loading = true + try { + const cfg = await getLLMConfig() + llmCfg.base_url = cfg.base_url + llmCfg.api_key = cfg.api_key || '' + llmCfg.model = cfg.model + llmCfg.has_api_key = cfg.has_api_key + } catch { + // ignore + } finally { + llmCfg.loading = false + } +} + +async function submitLLMConfig() { + if (!llmCfg.base_url.trim()) { + ElMessage.warning('API 地址不能为空') + return + } + llmCfg.saving = true + try { + await saveLLMConfig({ + base_url: llmCfg.base_url.trim(), + api_key: llmCfg.api_key.trim(), + model: llmCfg.model.trim(), + }) + ElMessage.success('LLM 配置已保存') + await loadLLMConfig() + } catch (e: any) { + ElMessage.error(e?.response?.data?.error || '保存失败') + } finally { + llmCfg.saving = false + } +} + async function reload() { loading.value = true try { @@ -91,7 +138,10 @@ async function remove(u: AdminUser) { await reload() } -onMounted(reload) +onMounted(() => { + reload() + loadLLMConfig() +})