新增合约全历史拉取与 K 线打分叠加功能

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fish
2026-05-07 22:29:00 +08:00
parent 9d2997a3cb
commit ee3acd1c4d
8 changed files with 274 additions and 52 deletions

View File

@@ -22,6 +22,10 @@ class RunRangeRequest(BaseModel):
end_date: str end_date: str
class RunFullRequest(BaseModel):
ts_code: str
class RunResponse(BaseModel): class RunResponse(BaseModel):
ts_code: str ts_code: str
trade_date: str trade_date: str
@@ -131,6 +135,37 @@ def run_range(req: RunRangeRequest):
} }
@app.post("/api/v1/run/full")
def run_full(req: RunFullRequest):
"""拉取指定合约全部历史数据,保存 candles对所有可打分日期逐日打分并保存。"""
df = fetcher.fetch_contract(req.ts_code)
storage.save_candles(df)
results, warnings, total_days, scored_count = scorer.score_all(df)
for r in results:
storage.save_score(r)
skipped_count = total_days - scored_count
return {
"ts_code": req.ts_code,
"total_days": total_days,
"scored_count": scored_count,
"skipped_count": skipped_count,
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.get("/api/v1/scores") @app.get("/api/v1/scores")
def list_scores( def list_scores(
ts_code: Optional[str] = Query(None), ts_code: Optional[str] = Query(None),

View File

@@ -254,3 +254,29 @@ def score_range(
warnings.append(str(e)) warnings.append(str(e))
return results, warnings return results, warnings
def score_all(df: pd.DataFrame) -> tuple[list[ScoreResult], list[str], int, int]:
"""对 DataFrame 中所有有足够前置数据的交易日逐日打分。
Returns:
(results, warnings, total_days, scored_count)
"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
for idx in range(30, len(df)):
subset = df.iloc[: idx + 1].copy()
trade_date = str(df.iloc[idx]["trade_date"])
try:
result = score_daily(subset)
results.append(result)
except ValueError as e:
warnings.append(f"{trade_date}: {e}")
total_days = len(df) - 30
scored_count = len(results)
return results, warnings, total_days, scored_count

View File

@@ -88,6 +88,40 @@ func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body) _, _ = io.Copy(w, resp.Body)
} }
type runFullRequest struct {
TsCode string `json:"ts_code"`
}
func (d *Deps) RunFull(w http.ResponseWriter, r *http.Request) {
var req runFullRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.TsCode == "" {
writeErr(w, http.StatusBadRequest, "ts_code is required")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 300 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/full", "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)
}
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) { func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol") symbol := r.URL.Query().Get("symbol")
if symbol == "" { if symbol == "" {

View File

@@ -33,6 +33,7 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
r.Post("/run", d.RunPipeline) r.Post("/run", d.RunPipeline)
r.Post("/run/batch", d.RunBatch) r.Post("/run/batch", d.RunBatch)
r.Post("/run/range", d.RunRange) r.Post("/run/range", d.RunRange)
r.Post("/run/full", d.RunFull)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)

View File

@@ -75,7 +75,7 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
args = append(args, "%"+f.Signal+"%") args = append(args, "%"+f.Signal+"%")
} }
q += " ORDER BY trade_date DESC, id DESC" q += " ORDER BY trade_date DESC, id DESC"
if f.Limit <= 0 || f.Limit > 500 { if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200 f.Limit = 200
} }
q += " LIMIT " + next() q += " LIMIT " + next()

View File

@@ -53,10 +53,34 @@ export interface RunRangeResponse {
results: RunRangeResult[] results: RunRangeResult[]
} }
export interface RunFullRequest {
ts_code: string
}
export interface RunFullResult {
trade_date: string
close: number
composite: number
signal: string
}
export interface RunFullResponse {
ts_code: string
total_days: number
scored_count: number
skipped_count: number
warnings: string[]
results: RunFullResult[]
}
export function runRange(req: RunRangeRequest) { export function runRange(req: RunRangeRequest) {
return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data) return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data)
} }
export function runFull(req: RunFullRequest) {
return client.post<RunFullResponse>('/run/full', req, { timeout: 300_000 }).then((r) => r.data)
}
export function runBatch() { export function runBatch() {
return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data) return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data)
} }

View File

@@ -5,7 +5,7 @@ import type { Candle } from '@/api/candles'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
const props = defineProps<{ data: Candle[] }>() const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
const theme = useThemeStore() const theme = useThemeStore()
const { isMobile } = useMobile() const { isMobile } = useMobile()
@@ -28,51 +28,86 @@ function render() {
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high]) const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
const oi = props.data.map((c) => c.oi) const oi = props.data.map((c) => c.oi)
const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
const hasScores = props.scores && props.scores.length > 0
const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
const grids: any[] = [
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
]
const xAxes: any[] = [
{ type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
]
const yAxes: any[] = [
{ scale: true, splitArea: { show: true } },
{ gridIndex: 1, scale: true, splitNumber: 3 },
]
const series: any[] = [
{
name: 'K 线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#ec3a3a',
color0: '#26a69a',
borderColor: '#ec3a3a',
borderColor0: '#26a69a',
},
},
{
name: '持仓量',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
data: oi,
smooth: true,
showSymbol: false,
lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' },
},
]
if (hasScores) {
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2 })
series.push({
name: '综合分',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: compositeData,
barWidth: '60%',
itemStyle: {
color: (params: any) => {
const val = params.value as number | null
if (val == null) return 'transparent'
if (val >= 80) return '#ec3a3a'
if (val >= 50) return '#f89898'
if (val >= 40) return '#89d6c7'
return '#26a69a'
},
},
})
}
chart.setOption( chart.setOption(
{ {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['K 线', '持仓量'], top: 0 }, legend: { data: legendData, top: 0 },
grid: [ grid: grids,
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: '60%' }, xAxis: xAxes,
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '78%', height: '18%' }, yAxis: yAxes,
],
xAxis: [
{ type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
],
yAxis: [
{ scale: true, splitArea: { show: true } },
{ gridIndex: 1, scale: true, splitNumber: 3 },
],
dataZoom: [ dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1] }, { type: 'inside', xAxisIndex: xAxisIndices },
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 }, { type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
],
series: [
{
name: 'K 线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#ec3a3a',
color0: '#26a69a',
borderColor: '#ec3a3a',
borderColor0: '#26a69a',
},
},
{
name: '持仓量',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
data: oi,
smooth: true,
showSymbol: false,
lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' },
},
], ],
series,
}, },
true, true,
) )

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { listContracts } from '@/api/scores' import { listContracts } from '@/api/scores'
import { listCandles, type Candle } from '@/api/candles' import { listCandles, type Candle } from '@/api/candles'
import { listScores, type Score } from '@/api/scores'
import { runFull, type RunFullResponse } from '@/api/run'
import KLineChart from '@/components/KLineChart.vue' import KLineChart from '@/components/KLineChart.vue'
import { useMobile } from '@/composables/useMobile' import { useMobile } from '@/composables/useMobile'
@@ -15,7 +17,10 @@ const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
const contracts = ref<string[]>([]) const contracts = ref<string[]>([])
const candles = ref<Candle[]>([]) const candles = ref<Candle[]>([])
const scores = ref<Score[]>([])
const loading = ref(false) const loading = ref(false)
const fullLoading = ref(false)
const fullResult = ref<RunFullResponse | null>(null)
async function reload() { async function reload() {
if (!filter.ts_code) { if (!filter.ts_code) {
@@ -25,12 +30,48 @@ async function reload() {
loading.value = true loading.value = true
try { try {
const [start, end] = filter.range || [] const [start, end] = filter.range || []
candles.value = await listCandles(filter.ts_code, start, end) const [candleData, scoreData] = await Promise.all([
listCandles(filter.ts_code, start, end),
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
])
candles.value = candleData
scores.value = scoreData
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function handleFetchAndScore() {
if (!filter.ts_code) {
ElMessage.warning('请输入或选择合约')
return
}
try {
await ElMessageBox.confirm(
`即将拉取 ${filter.ts_code} 的全部历史数据并逐日打分,这可能需要一些时间。`,
'拉取并打分',
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
fullLoading.value = true
fullResult.value = null
try {
const resp = await runFull({ ts_code: filter.ts_code })
fullResult.value = resp
ElMessage.success(`完成: ${resp.scored_count} 天已打分, ${resp.skipped_count} 天跳过`)
await reload()
} catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg)
} finally {
fullLoading.value = false
}
}
onMounted(async () => { onMounted(async () => {
contracts.value = await listContracts().catch(() => []) contracts.value = await listContracts().catch(() => [])
if (contracts.value.length > 0) { if (contracts.value.length > 0) {
@@ -45,14 +86,22 @@ onMounted(async () => {
<el-card shadow="never" class="filter-card"> <el-card shadow="never" class="filter-card">
<el-form :inline="!isMobile"> <el-form :inline="!isMobile">
<el-form-item label="合约"> <el-form-item label="合约">
<el-select <div style="display: flex; gap: 8px;">
v-model="filter.ts_code" <el-input
placeholder="选择合约" v-model="filter.ts_code"
filterable placeholder="输入合约代码如 FG2509.ZCE"
:style="{ width: isMobile ? '100%' : '200px' }" clearable
> :style="{ width: isMobile ? '100%' : '200px' }"
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" /> />
</el-select> <el-select
v-model="filter.ts_code"
placeholder="已存合约"
clearable
style="width: 120px"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</div>
</el-form-item> </el-form-item>
<el-form-item label="日期"> <el-form-item label="日期">
<el-date-picker <el-date-picker
@@ -67,12 +116,27 @@ onMounted(async () => {
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="reload">刷新</el-button> <el-button type="primary" :loading="loading" @click="reload">刷新</el-button>
<el-button type="warning" :loading="fullLoading" @click="handleFetchAndScore">
拉取并打分
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card v-if="fullResult" shadow="never" class="result-card">
<el-descriptions :column="isMobile ? 2 : 4" border>
<el-descriptions-item label="合约">{{ fullResult.ts_code }}</el-descriptions-item>
<el-descriptions-item label="总天数">{{ fullResult.total_days }}</el-descriptions-item>
<el-descriptions-item label="已打分">{{ fullResult.scored_count }}</el-descriptions-item>
<el-descriptions-item label="跳过">{{ fullResult.skipped_count }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="chart-card" v-loading="loading"> <el-card shadow="never" class="chart-card" v-loading="loading">
<KLineChart :data="candles" /> <KLineChart
:data="candles"
:scores="scores.map((s) => ({ trade_date: s.trade_date, composite: s.composite }))"
/>
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -86,6 +150,9 @@ onMounted(async () => {
.filter-card :deep(.el-card__body) { .filter-card :deep(.el-card__body) {
padding: 12px 16px; padding: 12px 16px;
} }
.result-card :deep(.el-card__body) {
padding: 12px 16px;
}
.chart-card :deep(.el-card__body) { .chart-card :deep(.el-card__body) {
padding: 8px; padding: 8px;
} }