AI分析功能:LLM Key 改为数据库管理,支持管理员后台配置
This commit is contained in:
@@ -6,6 +6,11 @@ import (
|
|||||||
"trade/web/internal/store"
|
"trade/web/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BootstrapLLMConfig 初始化 llm_config 表。
|
||||||
|
func BootstrapLLMConfig(s *store.FuturesStore) error {
|
||||||
|
return s.EnsureLLMConfigTable()
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
|
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
|
||||||
// 并强制首次登录后改密码。已存在 admin 时静默跳过。
|
// 并强制首次登录后改密码。已存在 admin 时静默跳过。
|
||||||
func Bootstrap(s *store.AuthStore) error {
|
func Bootstrap(s *store.AuthStore) error {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ type Config struct {
|
|||||||
ListenAddr string
|
ListenAddr string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
TushareAPIURL string
|
TushareAPIURL string
|
||||||
|
LLMBaseURL string
|
||||||
|
LLMAPIKey string
|
||||||
|
LLMModel string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -16,6 +19,9 @@ func Load() (*Config, error) {
|
|||||||
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
|
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 == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
||||||
|
|||||||
292
web/backend/internal/handlers/ai.go
Normal file
292
web/backend/internal/handlers/ai.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Deps struct {
|
|||||||
Auth *store.AuthStore
|
Auth *store.AuthStore
|
||||||
Futures *store.FuturesStore
|
Futures *store.FuturesStore
|
||||||
TushareURL string
|
TushareURL string
|
||||||
|
AIConfig *AIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
|||||||
86
web/backend/internal/handlers/llm_config.go
Normal file
86
web/backend/internal/handlers/llm_config.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
|
|||||||
r.Post("/run/batch", d.RunBatch)
|
r.Post("/run/batch", d.RunBatch)
|
||||||
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.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(mw.RequireAdmin)
|
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.Patch("/admin/users/{id}", d.AdminPatchUser)
|
||||||
r.Delete("/admin/users/{id}", d.AdminDeleteUser)
|
r.Delete("/admin/users/{id}", d.AdminDeleteUser)
|
||||||
r.Post("/admin/reset-data", d.AdminResetData)
|
r.Post("/admin/reset-data", d.AdminResetData)
|
||||||
|
r.Get("/admin/llm-config", d.GetLLMConfig)
|
||||||
|
r.Put("/admin/llm-config", d.SaveLLMConfig)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
134
web/backend/internal/store/ai.go
Normal file
134
web/backend/internal/store/ai.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -40,7 +40,20 @@ func main() {
|
|||||||
log.Fatalf("bootstrap: %v", err)
|
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")
|
dist, err := fs.Sub(distFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,3 +3,18 @@ import client from './client'
|
|||||||
export function resetAllData() {
|
export function resetAllData() {
|
||||||
return client.post('/admin/reset-data').then((r) => r.data)
|
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<LLMConfig>('/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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,34 +17,50 @@ export interface ShortDetail {
|
|||||||
export interface MediumDetail {
|
export interface MediumDetail {
|
||||||
price_return_pct: number
|
price_return_pct: number
|
||||||
price_signal: number
|
price_signal: number
|
||||||
long_up_days: number
|
accumulation_days: number
|
||||||
long_down_days: number
|
distribution_days: number
|
||||||
|
covering_days: number
|
||||||
|
liquidation_days: number
|
||||||
fund_signal: number
|
fund_signal: number
|
||||||
window: number
|
window: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LongDetail {
|
export interface LongDetail {
|
||||||
avg_oi: number
|
oi_now: number
|
||||||
oi_before: number
|
oi_before: number
|
||||||
change_pct: number
|
oi_change_pct: number
|
||||||
oi_score: number
|
oi_score: number
|
||||||
price_score: number
|
price_score: number
|
||||||
price_return_30d_pct: number
|
price_return_30d_pct: number
|
||||||
price_before_30d: number
|
price_before_30d: number
|
||||||
|
avg_oi_30d: number
|
||||||
window: number
|
window: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VolatilityDetail {
|
export interface VolatilityDetail {
|
||||||
daily_vol_pct: number
|
daily_vol_pct: number
|
||||||
atr_pct: number
|
atr_pct: number
|
||||||
|
vol_risk: number
|
||||||
vol_penalty: 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 {
|
export interface ScoreDetail {
|
||||||
short_details?: ShortDetail[]
|
short_details?: ShortDetail[]
|
||||||
medium_detail?: MediumDetail
|
medium_detail?: MediumDetail
|
||||||
long_detail?: LongDetail
|
long_detail?: LongDetail
|
||||||
volatility?: VolatilityDetail
|
volatility?: VolatilityDetail
|
||||||
|
adaptive_weights?: AdaptiveWeights
|
||||||
|
vol_penalty?: number
|
||||||
|
composite_delta?: number | null
|
||||||
|
composite_delta_5d?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Score {
|
export interface Score {
|
||||||
|
|||||||
@@ -12,6 +12,43 @@ const emit = defineEmits<{ (e: 'close'): void }>()
|
|||||||
const score = ref<Score | null>(null)
|
const score = ref<Score | null>(null)
|
||||||
const loading = ref(false)
|
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({
|
const visible = computed({
|
||||||
get: () => props.scoreId !== null,
|
get: () => props.scoreId !== null,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
@@ -22,6 +59,9 @@ const visible = computed({
|
|||||||
watch(
|
watch(
|
||||||
() => props.scoreId,
|
() => props.scoreId,
|
||||||
async (id) => {
|
async (id) => {
|
||||||
|
closeAI()
|
||||||
|
aiContent.value = ''
|
||||||
|
aiError.value = ''
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
score.value = null
|
score.value = null
|
||||||
return
|
return
|
||||||
@@ -59,7 +99,7 @@ const quadrantLabel = (q: string) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '680px'" destroy-on-close>
|
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '720px'" destroy-on-close>
|
||||||
<div v-loading="loading" v-if="score">
|
<div v-loading="loading" v-if="score">
|
||||||
<el-descriptions :column="isMobile ? 1 : 2" border>
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
|
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
|
||||||
@@ -73,11 +113,21 @@ const quadrantLabel = (q: string) => {
|
|||||||
<el-descriptions-item label="信号">
|
<el-descriptions-item label="信号">
|
||||||
<el-tag>{{ score.signal }}</el-tag>
|
<el-tag>{{ score.signal }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="短期(7d × 0.4)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
|
<el-descriptions-item label="短期(7d)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="中期(15d × 0.35)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
|
<el-descriptions-item label="中期(15d)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="长期(30d × 0.25)" :span="isMobile ? 1 : 2">
|
<el-descriptions-item label="长期(30d)" :span="isMobile ? 1 : 2">
|
||||||
{{ score.long_term.toFixed(2) }}
|
{{ score.long_term.toFixed(2) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="score.detail?.composite_delta != null" label="Δ1d">
|
||||||
|
<span :style="{ color: score.detail.composite_delta >= 0 ? '#e4393c' : '#1ca11c' }">
|
||||||
|
{{ score.detail.composite_delta >= 0 ? '+' : '' }}{{ score.detail.composite_delta.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="score.detail?.composite_delta_5d != null" label="Δ5d">
|
||||||
|
<span :style="{ color: score.detail.composite_delta_5d >= 0 ? '#e4393c' : '#1ca11c' }">
|
||||||
|
{{ score.detail.composite_delta_5d >= 0 ? '+' : '' }}{{ score.detail.composite_delta_5d.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<h4 class="section">短期 7 日逐日打分</h4>
|
<h4 class="section">短期 7 日逐日打分</h4>
|
||||||
@@ -119,7 +169,7 @@ const quadrantLabel = (q: string) => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="section">中期(15d)细节</h4>
|
<h4 class="section">中期(15d)资金意愿</h4>
|
||||||
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail">
|
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail">
|
||||||
<el-descriptions-item label="价格收益率">
|
<el-descriptions-item label="价格收益率">
|
||||||
{{ score.detail.medium_detail.price_return_pct.toFixed(2) }}%
|
{{ score.detail.medium_detail.price_return_pct.toFixed(2) }}%
|
||||||
@@ -127,19 +177,25 @@ const quadrantLabel = (q: string) => {
|
|||||||
<el-descriptions-item label="价格信号分">
|
<el-descriptions-item label="价格信号分">
|
||||||
{{ score.detail.medium_detail.price_signal.toFixed(2) }}
|
{{ score.detail.medium_detail.price_signal.toFixed(2) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="增仓上涨日">
|
<el-descriptions-item label="增仓上涨">
|
||||||
{{ score.detail.medium_detail.long_up_days }}
|
{{ score.detail.medium_detail.accumulation_days }} 天
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="增仓下跌日">
|
<el-descriptions-item label="增仓下跌">
|
||||||
{{ score.detail.medium_detail.long_down_days }}
|
{{ score.detail.medium_detail.distribution_days }} 天
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="减仓上涨">
|
||||||
|
{{ score.detail.medium_detail.covering_days }} 天
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="减仓下跌">
|
||||||
|
{{ score.detail.medium_detail.liquidation_days }} 天
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="资金意愿分" :span="isMobile ? 1 : 2">
|
<el-descriptions-item label="资金意愿分" :span="isMobile ? 1 : 2">
|
||||||
{{ score.detail.medium_detail.fund_signal.toFixed(1) }}
|
{{ score.detail.medium_detail.fund_signal.toFixed(1) }}
|
||||||
<span class="formula-hint">(50 + (增仓涨 - 增仓跌)/{{ score.detail.medium_detail.window ?? 15 }} × 50)</span>
|
<span class="formula-hint">(四象限加权合成)</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<h4 class="section">长期(30d)细节</h4>
|
<h4 class="section">长期(30d)结构</h4>
|
||||||
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail">
|
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail">
|
||||||
<el-descriptions-item label="OI 趋势分">
|
<el-descriptions-item label="OI 趋势分">
|
||||||
{{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }}
|
{{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }}
|
||||||
@@ -155,14 +211,14 @@ const quadrantLabel = (q: string) => {
|
|||||||
<el-descriptions-item label="30 日前收盘">
|
<el-descriptions-item label="30 日前收盘">
|
||||||
{{ score.detail.long_detail.price_before_30d?.toFixed(2) ?? '-' }}
|
{{ score.detail.long_detail.price_before_30d?.toFixed(2) ?? '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="30 日均持仓">
|
<el-descriptions-item label="当前 OI">
|
||||||
{{ score.detail.long_detail.avg_oi.toFixed(0) }}
|
{{ score.detail.long_detail.oi_now?.toFixed(0) ?? '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="30 日前持仓">
|
<el-descriptions-item label="30 日前 OI">
|
||||||
{{ score.detail.long_detail.oi_before.toFixed(0) }}
|
{{ score.detail.long_detail.oi_before?.toFixed(0) ?? '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="OI 变化幅度" :span="isMobile ? 1 : 2">
|
<el-descriptions-item label="OI 变化幅度" :span="isMobile ? 1 : 2">
|
||||||
{{ score.detail.long_detail.change_pct.toFixed(2) }}%
|
{{ score.detail.long_detail.oi_change_pct?.toFixed(2) ?? '-' }}%
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
@@ -174,10 +230,57 @@ const quadrantLabel = (q: string) => {
|
|||||||
<el-descriptions-item label="ATR%">
|
<el-descriptions-item label="ATR%">
|
||||||
{{ (score.detail.volatility.atr_pct * 100).toFixed(2) }}%
|
{{ (score.detail.volatility.atr_pct * 100).toFixed(2) }}%
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="波动率惩罚系数" :span="isMobile ? 1 : 2">
|
<el-descriptions-item label="综合波动风险" v-if="score.detail.volatility.vol_risk !== undefined">
|
||||||
|
{{ (score.detail.volatility.vol_risk * 100).toFixed(2) }}%
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="惩罚系数">
|
||||||
{{ score.detail.volatility.vol_penalty.toFixed(3) }}
|
{{ score.detail.volatility.vol_penalty.toFixed(3) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<template v-if="score.detail?.adaptive_weights">
|
||||||
|
<h4 class="section">自适应权重</h4>
|
||||||
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
|
<el-descriptions-item label="趋势强度">
|
||||||
|
{{ score.detail.adaptive_weights.trend_strength.toFixed(2) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="趋势因子">
|
||||||
|
{{ (score.detail.adaptive_weights.trend_factor * 100).toFixed(0) }}%
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="短期权重">
|
||||||
|
{{ (score.detail.adaptive_weights.w_short * 100).toFixed(0) }}%
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="中期权重">
|
||||||
|
{{ (score.detail.adaptive_weights.w_medium * 100).toFixed(0) }}%
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="长期权重" :span="isMobile ? 1 : 2">
|
||||||
|
{{ (score.detail.adaptive_weights.w_long * 100).toFixed(0) }}%
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<div class="ai-section">
|
||||||
|
<div v-if="!aiLoading && !aiContent && !aiError">
|
||||||
|
<el-button type="primary" :loading="aiLoading" @click="askAI">
|
||||||
|
🤖 AI 分析当前打分
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="aiLoading || aiContent" class="ai-card">
|
||||||
|
<div class="ai-header">
|
||||||
|
<span>🤖 AI 分析</span>
|
||||||
|
<el-button text size="small" @click="closeAI" v-if="aiLoading">取消</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-body">
|
||||||
|
<div v-if="aiContent" class="ai-text" v-html="aiContent.replace(/\n/g, '<br>')"></div>
|
||||||
|
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
|
||||||
|
<div v-if="aiLoading && !aiContent" class="ai-loading">
|
||||||
|
<el-icon class="is-loading"><span>⏳</span></el-icon> 正在分析...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
@@ -197,4 +300,34 @@ const quadrantLabel = (q: string) => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 6px;
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
} from '@/api/users'
|
} from '@/api/users'
|
||||||
|
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -27,6 +28,52 @@ const resetDialog = reactive({
|
|||||||
password: '',
|
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() {
|
async function reload() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +138,10 @@ async function remove(u: AdminUser) {
|
|||||||
await reload()
|
await reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(reload)
|
onMounted(() => {
|
||||||
|
reload()
|
||||||
|
loadLLMConfig()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,6 +195,38 @@ onMounted(reload)
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>🤖 LLM 配置</span>
|
||||||
|
<el-tag :type="llmCfg.has_api_key ? 'success' : 'warning'" size="small">
|
||||||
|
{{ llmCfg.has_api_key ? '已配置' : '未配置' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form label-width="100px" v-loading="llmCfg.loading" @submit.prevent>
|
||||||
|
<el-form-item label="API 地址">
|
||||||
|
<el-input v-model="llmCfg.base_url" placeholder="https://api.deepseek.com/v1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="API Key">
|
||||||
|
<el-input
|
||||||
|
v-model="llmCfg.api_key"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型">
|
||||||
|
<el-input v-model="llmCfg.model" placeholder="deepseek-chat" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="llmCfg.saving" @click="submitLLMConfig">
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
|
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
@@ -197,6 +279,11 @@ onMounted(reload)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
}
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user