支持手动指定品种和日期进行打分

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fish
2026-05-03 15:57:31 +08:00
parent 23776b5e96
commit a1355d91aa
12 changed files with 257 additions and 23 deletions

View File

@@ -7,21 +7,23 @@ import (
)
type Config struct {
ListenAddr string
DatabaseURL string
AuthDBPath string
JWTSecret []byte
AdminUser string
AdminPass string
ListenAddr string
DatabaseURL string
AuthDBPath string
JWTSecret []byte
AdminUser string
AdminPass string
TushareAPIURL string
}
func Load() (*Config, error) {
cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
AdminPass: os.Getenv("ADMIN_PASS"),
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
AdminPass: os.Getenv("ADMIN_PASS"),
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")

View File

@@ -11,9 +11,10 @@ import (
// Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。
type Deps struct {
Auth *store.AuthStore
Futures *store.FuturesStore
JWT *auth.Manager
Auth *store.AuthStore
Futures *store.FuturesStore
JWT *auth.Manager
TushareURL string
}
func writeJSON(w http.ResponseWriter, status int, body any) {

View File

@@ -0,0 +1,42 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type runRequest struct {
TsCode string `json:"ts_code,omitempty"`
Symbol string `json:"symbol,omitempty"`
TradeDate string `json:"trade_date,omitempty"`
}
func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
var req runRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run", "application/json", bytes.NewReader(body))
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}

View File

@@ -30,6 +30,7 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f
r.Get("/scores/{id}", d.GetScore)
r.Get("/contracts", d.ListContracts)
r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAdmin)

View File

@@ -41,7 +41,7 @@ func main() {
}
mgr := auth.NewManager(cfg.JWTSecret)
deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr}
deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr, TushareURL: cfg.TushareAPIURL}
dist, err := fs.Sub(distFS, "dist")
if err != nil {

View File

@@ -36,6 +36,7 @@ function logout() {
>
<el-menu-item index="/scores">打分列表</el-menu-item>
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
</el-menu>
</el-aside>

View File

@@ -0,0 +1,24 @@
import client from './client'
export interface RunRequest {
ts_code?: string
symbol?: string
trade_date?: string
}
export interface RunResponse {
ts_code: string
trade_date: string
close: number
oi: number
oi_chg: number
short_term: number
medium_term: number
long_term: number
composite: number
signal: string
}
export function runPipeline(req: RunRequest) {
return client.post<RunResponse>('/run', req).then((r) => r.data)
}

View File

@@ -19,6 +19,11 @@ const routes: RouteRecordRaw[] = [
name: 'chart',
component: () => import('@/views/ChartView.vue'),
},
{
path: '/run',
name: 'run',
component: () => import('@/views/RunView.vue'),
},
{
path: '/admin/users',
name: 'admin-users',

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listContracts } from '@/api/scores'
import { runPipeline, type RunResponse } from '@/api/run'
const form = reactive<{
ts_code: string
symbol: string
trade_date: string
}>({
ts_code: '',
symbol: 'FG',
trade_date: '',
})
const contracts = ref<string[]>([])
const loading = ref(false)
const result = ref<RunResponse | null>(null)
async function submit() {
if (!form.ts_code && !form.symbol) {
ElMessage.warning('请选择合约或填写品种代号')
return
}
loading.value = true
result.value = null
try {
const req: { ts_code?: string; symbol?: string; trade_date?: string } = {}
if (form.ts_code) {
req.ts_code = form.ts_code
} else {
req.symbol = form.symbol
}
if (form.trade_date) {
req.trade_date = form.trade_date.replace(/-/g, '')
}
const resp = await runPipeline(req)
result.value = resp
ElMessage.success('打分完成')
} catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg)
} finally {
loading.value = false
}
}
function signalTagType(s: string) {
if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return ''
if (s.includes('偏空')) return 'warning'
if (s.includes('强烈看空')) return 'danger'
return 'info'
}
onMounted(async () => {
contracts.value = await listContracts().catch(() => [])
})
</script>
<template>
<div class="page">
<el-card shadow="never" title="手动打分">
<template #header>
<span>手动打分</span>
</template>
<el-form :model="form" label-width="100px" style="max-width: 480px">
<el-form-item label="合约">
<el-select
v-model="form.ts_code"
placeholder="选择已有合约(优先)"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="c in contracts"
:key="c"
:label="c"
:value="c"
/>
</el-select>
</el-form-item>
<el-form-item label="品种代号">
<el-input
v-model="form.symbol"
placeholder="未选合约时按此品种选主力,如 FG"
clearable
/>
</el-form-item>
<el-form-item label="打分日期">
<el-date-picker
v-model="form.trade_date"
type="date"
placeholder="留空则对最新日期打分"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="submit">
执行打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="result" shadow="never" class="result-card">
<template #header>
<span>打分结果</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="合约">{{ result.ts_code }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ result.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ result.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ result.oi }}</el-descriptions-item>
<el-descriptions-item label="短期(7d)">{{ result.short_term }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ result.medium_term }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)">{{ result.long_term }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ result.composite }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号" :span="2">
<el-tag :type="signalTagType(result.signal)">{{ result.signal }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>