支持手动指定品种和日期进行打分
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ app = FastAPI(title="期货数据采集与打分服务")
|
|||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
ts_code: Optional[str] = None
|
ts_code: Optional[str] = None
|
||||||
symbol: str = "FG"
|
symbol: str = "FG"
|
||||||
|
trade_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class RunResponse(BaseModel):
|
class RunResponse(BaseModel):
|
||||||
@@ -44,7 +45,7 @@ def run_pipeline(req: RunRequest):
|
|||||||
|
|
||||||
df = fetcher.fetch_contract(ts_code)
|
df = fetcher.fetch_contract(ts_code)
|
||||||
storage.save_candles(df)
|
storage.save_candles(df)
|
||||||
result = scorer.score_daily(df)
|
result = scorer.score_daily(df, req.trade_date)
|
||||||
storage.save_score(result)
|
storage.save_score(result)
|
||||||
|
|
||||||
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
|
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import sys
|
|||||||
from . import contracts, fetcher, notifier, scorer, storage
|
from . import contracts, fetcher, notifier, scorer, storage
|
||||||
|
|
||||||
|
|
||||||
def run(ts_code: str) -> int:
|
def run(ts_code: str, trade_date: Optional[str] = None) -> int:
|
||||||
storage.init_db()
|
storage.init_db()
|
||||||
|
|
||||||
print(f"[1/4] 拉取 {ts_code} 数据...")
|
print(f"[1/4] 拉取 {ts_code} 数据...")
|
||||||
df = fetcher.fetch_contract(ts_code)
|
df = fetcher.fetch_contract(ts_code)
|
||||||
print(f" 返回 {len(df)} 行")
|
print(f" 返回 {len(df)} 行")
|
||||||
|
|
||||||
print(f"[2/4] 写入/更新 SQLite...")
|
print(f"[2/4] 写入/更新 PostgreSQL...")
|
||||||
storage.save_candles(df)
|
storage.save_candles(df)
|
||||||
|
|
||||||
print(f"[3/4] 计算打分...")
|
print(f"[3/4] 计算打分...")
|
||||||
result = scorer.score_daily(df)
|
result = scorer.score_daily(df, trade_date)
|
||||||
|
|
||||||
print(f"[4/4] 保存打分结果...")
|
print(f"[4/4] 保存打分结果...")
|
||||||
storage.save_score(result)
|
storage.save_score(result)
|
||||||
@@ -61,7 +61,7 @@ def run(ts_code: str) -> int:
|
|||||||
print(f" 30日前持仓量: {ld['oi_before']:,.0f}")
|
print(f" 30日前持仓量: {ld['oi_before']:,.0f}")
|
||||||
print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%")
|
print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%")
|
||||||
|
|
||||||
print(f"\n[OK] 数据已持久化到 SQLite")
|
print(f"\n[OK] 数据已持久化到 PostgreSQL")
|
||||||
|
|
||||||
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
|
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
|
||||||
push_body = (
|
push_body = (
|
||||||
@@ -86,12 +86,18 @@ def main() -> int:
|
|||||||
default="FG",
|
default="FG",
|
||||||
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
|
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--date",
|
||||||
|
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
ts_code = args.ts_code or contracts.active_contract(args.symbol)
|
ts_code = args.ts_code or contracts.active_contract(args.symbol)
|
||||||
if not args.ts_code:
|
if not args.ts_code:
|
||||||
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
|
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
|
||||||
return run(ts_code)
|
if args.date:
|
||||||
|
print(f"[DATE] 指定打分日期: {args.date}")
|
||||||
|
return run(ts_code, args.date)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from .models import ScoreDetail, ScoreResult
|
from .models import ScoreDetail, ScoreResult
|
||||||
@@ -116,11 +118,21 @@ def _interpret(composite: float) -> str:
|
|||||||
return "强烈看空区域 — 资金主动且持续地打压价格"
|
return "强烈看空区域 — 资金主动且持续地打压价格"
|
||||||
|
|
||||||
|
|
||||||
def score_daily(df: pd.DataFrame) -> ScoreResult:
|
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
|
||||||
"""对 DataFrame 中最新一条记录打分。"""
|
"""对 DataFrame 中指定日期或最新一条记录打分。"""
|
||||||
if len(df) < 31:
|
if len(df) < 31:
|
||||||
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
|
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
|
||||||
|
|
||||||
|
if trade_date:
|
||||||
|
trade_date_str = str(trade_date)
|
||||||
|
mask = df["trade_date"].astype(str) == trade_date_str
|
||||||
|
if not mask.any():
|
||||||
|
raise ValueError(f"指定日期 {trade_date_str} 不在数据中")
|
||||||
|
pos = mask.idxmax()
|
||||||
|
df = df.iloc[:pos + 1].copy()
|
||||||
|
if len(df) < 31:
|
||||||
|
raise ValueError(f"指定日期 {trade_date_str} 之前数据不足(仅 {len(df)} 行),需要至少 31 行")
|
||||||
|
|
||||||
latest = df.iloc[-1]
|
latest = df.iloc[-1]
|
||||||
|
|
||||||
short, short_details = calc_short_term(df, 7)
|
short, short_details = calc_short_term(df, 7)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Config struct {
|
|||||||
JWTSecret []byte
|
JWTSecret []byte
|
||||||
AdminUser string
|
AdminUser string
|
||||||
AdminPass string
|
AdminPass string
|
||||||
|
TushareAPIURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -22,6 +23,7 @@ func Load() (*Config, error) {
|
|||||||
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
|
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
|
||||||
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
|
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
|
||||||
AdminPass: os.Getenv("ADMIN_PASS"),
|
AdminPass: os.Getenv("ADMIN_PASS"),
|
||||||
|
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
|
||||||
}
|
}
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Deps struct {
|
|||||||
Auth *store.AuthStore
|
Auth *store.AuthStore
|
||||||
Futures *store.FuturesStore
|
Futures *store.FuturesStore
|
||||||
JWT *auth.Manager
|
JWT *auth.Manager
|
||||||
|
TushareURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
|||||||
42
web/backend/internal/handlers/run.go
Normal file
42
web/backend/internal/handlers/run.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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("/scores/{id}", d.GetScore)
|
||||||
r.Get("/contracts", d.ListContracts)
|
r.Get("/contracts", d.ListContracts)
|
||||||
r.Get("/candles", d.ListCandles)
|
r.Get("/candles", d.ListCandles)
|
||||||
|
r.Post("/run", d.RunPipeline)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(mw.RequireAdmin)
|
r.Use(mw.RequireAdmin)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mgr := auth.NewManager(cfg.JWTSecret)
|
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")
|
dist, err := fs.Sub(distFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function logout() {
|
|||||||
>
|
>
|
||||||
<el-menu-item index="/scores">打分列表</el-menu-item>
|
<el-menu-item index="/scores">打分列表</el-menu-item>
|
||||||
<el-menu-item index="/chart">K 线 / 持仓</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-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|||||||
24
web/frontend/src/api/run.ts
Normal file
24
web/frontend/src/api/run.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'chart',
|
name: 'chart',
|
||||||
component: () => import('@/views/ChartView.vue'),
|
component: () => import('@/views/ChartView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/run',
|
||||||
|
name: 'run',
|
||||||
|
component: () => import('@/views/RunView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/users',
|
path: '/admin/users',
|
||||||
name: 'admin-users',
|
name: 'admin-users',
|
||||||
|
|||||||
139
web/frontend/src/views/RunView.vue
Normal file
139
web/frontend/src/views/RunView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user