AI分析功能:LLM Key 改为数据库管理,支持管理员后台配置
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 环境变量未设置")
|
||||
|
||||
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
|
||||
Futures *store.FuturesStore
|
||||
TushareURL string
|
||||
AIConfig *AIConfig
|
||||
}
|
||||
|
||||
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/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user