手动打分页改为按品种选择,日期限定主力合约范围,结果自动滚动到视图

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fish
2026-05-03 16:40:15 +08:00
parent fd1c1c7330
commit 44909f04e2
6 changed files with 167 additions and 65 deletions

View File

@@ -1,5 +1,7 @@
from typing import Optional from typing import Optional
from datetime import date
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
@@ -133,6 +135,20 @@ def list_contracts():
conn.close() conn.close()
@app.get("/api/v1/contracts/active")
def get_active_contract(symbol: str = Query(...)):
"""返回某品种当前主力合约及可选打分日期范围。"""
if symbol not in contracts.ROLLOVER_RULES:
raise HTTPException(status_code=400, detail=f"未配置 {symbol} 的主力轮换规则")
today = date.today()
return {
"symbol": symbol,
"ts_code": contracts.active_contract(symbol, today),
"min_date": contracts.active_contract_start(symbol, today).isoformat(),
"max_date": today.isoformat(),
}
@app.get("/api/v1/candles") @app.get("/api/v1/candles")
def list_candles( def list_candles(
ts_code: str = Query(...), ts_code: str = Query(...),

View File

@@ -1,6 +1,10 @@
from datetime import date from datetime import date
from typing import Optional from typing import Optional
def _prev_month(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1)
# 品种主力合约轮换规则。 # 品种主力合约轮换规则。
# 每个品种维护: # 每个品种维护:
# exchange: tushare 合约后缀(交易所) # exchange: tushare 合约后缀(交易所)
@@ -72,3 +76,21 @@ def active_contract(symbol: str, today: Optional[date] = None) -> str:
contract_month, year_offset = rule["active"][today.month] contract_month, year_offset = rule["active"][today.month]
year = today.year + year_offset year = today.year + year_offset
return f"{symbol}{year % 100:02d}{contract_month:02d}.{rule['exchange']}" return f"{symbol}{year % 100:02d}{contract_month:02d}.{rule['exchange']}"
def active_contract_start(symbol: str, today: Optional[date] = None) -> date:
"""当前主力合约首次成为主力的日期(月初)。
从今天向前回溯日历月,只要 active_contract 仍指向同一合约就继续往前。
例如今天 2026-05,FG 的 09 合约活跃月份为 4-7 月,则返回 2026-04-01。
"""
today = today or date.today()
target = active_contract(symbol, today)
year, month = today.year, today.month
for _ in range(12):
py, pm = _prev_month(year, month)
if active_contract(symbol, date(py, pm, 1)) != target:
return date(year, month, 1)
year, month = py, pm
return date(year, month, 1)

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"time" "time"
) )
@@ -40,3 +41,24 @@ func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body) _, _ = io.Copy(w, resp.Body)
} }
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
writeErr(w, http.StatusBadRequest, "symbol is required")
return
}
target := fmt.Sprintf("%s/api/v1/contracts/active?symbol=%s", d.TushareURL, url.QueryEscape(symbol))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(target)
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

@@ -29,6 +29,7 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f
r.Get("/scores", d.ListScores) r.Get("/scores", d.ListScores)
r.Get("/scores/{id}", d.GetScore) r.Get("/scores/{id}", d.GetScore)
r.Get("/contracts", d.ListContracts) r.Get("/contracts", d.ListContracts)
r.Get("/contracts/active", d.GetActiveContract)
r.Get("/candles", d.ListCandles) r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline) r.Post("/run", d.RunPipeline)

View File

@@ -19,6 +19,19 @@ export interface RunResponse {
signal: string signal: string
} }
export interface ActiveContract {
symbol: string
ts_code: string
min_date: string
max_date: string
}
export function runPipeline(req: RunRequest) { export function runPipeline(req: RunRequest) {
return client.post<RunResponse>('/run', req).then((r) => r.data) return client.post<RunResponse>('/run', req).then((r) => r.data)
} }
export function getActiveContract(symbol: string) {
return client
.get<ActiveContract>('/contracts/active', { params: { symbol } })
.then((r) => r.data)
}

View File

@@ -1,47 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { listContracts } from '@/api/scores' import {
import { runPipeline, type RunResponse } from '@/api/run' runPipeline,
getActiveContract,
type ActiveContract,
type RunResponse,
} from '@/api/run'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M'] const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
const form = reactive<{ const form = reactive<{
ts_code: string
symbol: string symbol: string
trade_date: string trade_date: string
}>({ }>({
ts_code: '',
symbol: 'FG', symbol: 'FG',
trade_date: '', trade_date: '',
}) })
const contracts = ref<string[]>([]) const active = ref<ActiveContract | null>(null)
const activeLoading = ref(false)
const loading = ref(false) const loading = ref(false)
const result = ref<RunResponse | null>(null) const result = ref<RunResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null)
async function loadActive() {
activeLoading.value = true
try {
active.value = await getActiveContract(form.symbol)
// 切换品种后,如果原日期落在新合约的可选范围之外,清空它
if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) {
form.trade_date = ''
}
} catch (err: any) {
active.value = null
ElMessage.error(err?.response?.data?.error || '加载主力合约失败')
} finally {
activeLoading.value = false
}
}
function toDate(s: string) {
// s 形如 'YYYY-MM-DD'
const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d)
}
function isDateAllowed(d: Date): boolean {
if (!active.value) return true
const min = toDate(active.value.min_date).getTime()
const max = toDate(active.value.max_date).getTime()
const t = d.getTime()
return t >= min && t <= max
}
function disabledDate(d: Date) {
return !isDateAllowed(d)
}
async function submit() { async function submit() {
if (!form.ts_code && !form.symbol) { if (!form.symbol) {
ElMessage.warning('请选择合约或填写品种代号') ElMessage.warning('请选择品种')
return return
} }
loading.value = true loading.value = true
result.value = null result.value = null
try { try {
const req: { ts_code?: string; symbol?: string; trade_date?: string } = {} const req: { symbol: string; trade_date?: string } = { symbol: form.symbol }
if (form.ts_code) { if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '')
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) const resp = await runPipeline(req)
result.value = resp result.value = resp
ElMessage.success('打分完成') ElMessage.success('打分完成')
await nextTick()
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败' const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg) ElMessage.error(msg)
@@ -58,57 +90,52 @@ function signalTagType(s: string) {
return 'info' return 'info'
} }
onMounted(async () => { watch(() => form.symbol, loadActive)
contracts.value = await listContracts().catch(() => []) onMounted(loadActive)
})
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<el-card shadow="never" title="手动打分"> <el-card shadow="never">
<template #header> <template #header>
<span>手动打分</span> <span>手动打分</span>
</template> </template>
<el-form :model="form" label-width="100px" style="max-width: 480px"> <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-form-item label="品种">
<el-select v-model="form.symbol" style="width: 100%"> <el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%">
<el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" /> <el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="主力合约">
<span v-if="active">
{{ parseTsCode(active.ts_code).contract }}
<el-text type="info" size="small" style="margin-left: 8px">
({{ active.ts_code }})
</el-text>
</span>
<el-text v-else type="info">加载中</el-text>
</el-form-item>
<el-form-item label="打分日期"> <el-form-item label="打分日期">
<el-date-picker <el-date-picker
v-model="form.trade_date" v-model="form.trade_date"
type="date" type="date"
placeholder="留空则最新日期打分" :placeholder="active ? `${active.min_date} ~ ${active.max_date},留空则最新` : '加载中…'"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
:disabled="!active"
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="submit"> <el-button type="primary" :loading="loading" :disabled="!active" @click="submit">
执行打分 执行打分
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card v-if="result" shadow="never" class="result-card"> <div v-if="result" ref="resultRef">
<el-card shadow="never" class="result-card">
<template #header> <template #header>
<span>打分结果</span> <span>打分结果</span>
</template> </template>
@@ -130,6 +157,7 @@ onMounted(async () => {
</el-descriptions> </el-descriptions>
</el-card> </el-card>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>