手动打分页改为按品种选择,日期限定主力合约范围,结果自动滚动到视图
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -133,6 +135,20 @@ def list_contracts():
|
||||
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")
|
||||
def list_candles(
|
||||
ts_code: str = Query(...),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from datetime import date
|
||||
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 合约后缀(交易所)
|
||||
@@ -72,3 +76,21 @@ def active_contract(symbol: str, today: Optional[date] = None) -> str:
|
||||
contract_month, year_offset = rule["active"][today.month]
|
||||
year = today.year + year_offset
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -40,3 +41,24 @@ func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = 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)
|
||||
}
|
||||
|
||||
@@ -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/{id}", d.GetScore)
|
||||
r.Get("/contracts", d.ListContracts)
|
||||
r.Get("/contracts/active", d.GetActiveContract)
|
||||
r.Get("/candles", d.ListCandles)
|
||||
r.Post("/run", d.RunPipeline)
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@ export interface RunResponse {
|
||||
signal: string
|
||||
}
|
||||
|
||||
export interface ActiveContract {
|
||||
symbol: string
|
||||
ts_code: string
|
||||
min_date: string
|
||||
max_date: string
|
||||
}
|
||||
|
||||
export function runPipeline(req: RunRequest) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,47 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listContracts } from '@/api/scores'
|
||||
import { runPipeline, type RunResponse } from '@/api/run'
|
||||
import {
|
||||
runPipeline,
|
||||
getActiveContract,
|
||||
type ActiveContract,
|
||||
type RunResponse,
|
||||
} from '@/api/run'
|
||||
import { parseTsCode } from '@/utils/contract'
|
||||
|
||||
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
|
||||
|
||||
const form = reactive<{
|
||||
ts_code: string
|
||||
symbol: string
|
||||
trade_date: string
|
||||
}>({
|
||||
ts_code: '',
|
||||
symbol: 'FG',
|
||||
trade_date: '',
|
||||
})
|
||||
|
||||
const contracts = ref<string[]>([])
|
||||
const active = ref<ActiveContract | null>(null)
|
||||
const activeLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
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() {
|
||||
if (!form.ts_code && !form.symbol) {
|
||||
ElMessage.warning('请选择合约或填写品种代号')
|
||||
if (!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 req: { symbol: string; trade_date?: string } = { 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('打分完成')
|
||||
await nextTick()
|
||||
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || err.message || '请求失败'
|
||||
ElMessage.error(msg)
|
||||
@@ -58,57 +90,52 @@ function signalTagType(s: string) {
|
||||
return 'info'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
contracts.value = await listContracts().catch(() => [])
|
||||
})
|
||||
watch(() => form.symbol, loadActive)
|
||||
onMounted(loadActive)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" title="手动打分">
|
||||
<el-card shadow="never">
|
||||
<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-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-select>
|
||||
</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-date-picker
|
||||
v-model="form.trade_date"
|
||||
type="date"
|
||||
placeholder="留空则对最新日期打分"
|
||||
:placeholder="active ? `${active.min_date} ~ ${active.max_date},留空则取最新` : '加载中…'"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disabledDate"
|
||||
:disabled="!active"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</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-form-item>
|
||||
</el-form>
|
||||
</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>
|
||||
<span>打分结果</span>
|
||||
</template>
|
||||
@@ -130,6 +157,7 @@ onMounted(async () => {
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user