From ee3acd1c4dd62ab6784d8a8a32746ff326dacea5 Mon Sep 17 00:00:00 2001 From: fish Date: Thu, 7 May 2026 22:29:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=88=E7=BA=A6=E5=85=A8?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=8B=89=E5=8F=96=E4=B8=8E=20K=20=E7=BA=BF?= =?UTF-8?q?=E6=89=93=E5=88=86=E5=8F=A0=E5=8A=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- tushare/src/api.py | 35 +++++++ tushare/src/scorer.py | 26 +++++ web/backend/internal/handlers/run.go | 34 ++++++ web/backend/internal/router/router.go | 1 + web/backend/internal/store/futures.go | 2 +- web/frontend/src/api/run.ts | 24 +++++ web/frontend/src/components/KLineChart.vue | 115 ++++++++++++++------- web/frontend/src/views/ChartView.vue | 89 ++++++++++++++-- 8 files changed, 274 insertions(+), 52 deletions(-) diff --git a/tushare/src/api.py b/tushare/src/api.py index 2d00e69..0e11f0e 100644 --- a/tushare/src/api.py +++ b/tushare/src/api.py @@ -22,6 +22,10 @@ class RunRangeRequest(BaseModel): end_date: str +class RunFullRequest(BaseModel): + ts_code: str + + class RunResponse(BaseModel): ts_code: 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") def list_scores( ts_code: Optional[str] = Query(None), diff --git a/tushare/src/scorer.py b/tushare/src/scorer.py index b43ada5..c558794 100644 --- a/tushare/src/scorer.py +++ b/tushare/src/scorer.py @@ -254,3 +254,29 @@ def score_range( warnings.append(str(e)) 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 diff --git a/web/backend/internal/handlers/run.go b/web/backend/internal/handlers/run.go index 34b58b6..9b6f8a5 100644 --- a/web/backend/internal/handlers/run.go +++ b/web/backend/internal/handlers/run.go @@ -88,6 +88,40 @@ func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) { _, _ = 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) { symbol := r.URL.Query().Get("symbol") if symbol == "" { diff --git a/web/backend/internal/router/router.go b/web/backend/internal/router/router.go index 7dd191c..9758ed3 100644 --- a/web/backend/internal/router/router.go +++ b/web/backend/internal/router/router.go @@ -33,6 +33,7 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler { r.Post("/run", d.RunPipeline) r.Post("/run/batch", d.RunBatch) r.Post("/run/range", d.RunRange) + r.Post("/run/full", d.RunFull) r.Group(func(r chi.Router) { r.Use(mw.RequireAdmin) diff --git a/web/backend/internal/store/futures.go b/web/backend/internal/store/futures.go index 23de2bf..da8b100 100644 --- a/web/backend/internal/store/futures.go +++ b/web/backend/internal/store/futures.go @@ -75,7 +75,7 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) { args = append(args, "%"+f.Signal+"%") } 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 } q += " LIMIT " + next() diff --git a/web/frontend/src/api/run.ts b/web/frontend/src/api/run.ts index 95521f6..34f2514 100644 --- a/web/frontend/src/api/run.ts +++ b/web/frontend/src/api/run.ts @@ -53,10 +53,34 @@ export interface RunRangeResponse { 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) { return client.post('/run/range', req, { timeout: 180_000 }).then((r) => r.data) } +export function runFull(req: RunFullRequest) { + return client.post('/run/full', req, { timeout: 300_000 }).then((r) => r.data) +} + export function runBatch() { return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data) } diff --git a/web/frontend/src/components/KLineChart.vue b/web/frontend/src/components/KLineChart.vue index 7469461..a3e02d8 100644 --- a/web/frontend/src/components/KLineChart.vue +++ b/web/frontend/src/components/KLineChart.vue @@ -5,7 +5,7 @@ import type { Candle } from '@/api/candles' import { useThemeStore } from '@/stores/theme' 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 { isMobile } = useMobile() @@ -28,51 +28,86 @@ function render() { const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high]) 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( { backgroundColor: 'transparent', tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, - legend: { data: ['K 线', '持仓量'], top: 0 }, - grid: [ - { left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: '60%' }, - { left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '78%', height: '18%' }, - ], - 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 }, - ], + legend: { data: legendData, top: 0 }, + grid: grids, + xAxis: xAxes, + yAxis: yAxes, dataZoom: [ - { type: 'inside', xAxisIndex: [0, 1] }, - { type: 'slider', xAxisIndex: [0, 1], 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' }, - }, + { type: 'inside', xAxisIndex: xAxisIndices }, + { type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 }, ], + series, }, true, ) diff --git a/web/frontend/src/views/ChartView.vue b/web/frontend/src/views/ChartView.vue index 0fc766b..0310873 100644 --- a/web/frontend/src/views/ChartView.vue +++ b/web/frontend/src/views/ChartView.vue @@ -1,8 +1,10 @@