From 44909f04e2ef692321e1540f2732425c0c7c2dd5 Mon Sep 17 00:00:00 2001 From: fish Date: Sun, 3 May 2026 16:40:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=89=93=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E5=93=81=E7=A7=8D=E9=80=89=E6=8B=A9?= =?UTF-8?q?,=E6=97=A5=E6=9C=9F=E9=99=90=E5=AE=9A=E4=B8=BB=E5=8A=9B?= =?UTF-8?q?=E5=90=88=E7=BA=A6=E8=8C=83=E5=9B=B4,=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=BB=9A=E5=8A=A8=E5=88=B0=E8=A7=86=E5=9B=BE?= 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 | 16 +++ tushare/src/contracts.py | 22 ++++ web/backend/internal/handlers/run.go | 28 ++++- web/backend/internal/router/router.go | 1 + web/frontend/src/api/run.ts | 13 +++ web/frontend/src/views/RunView.vue | 152 +++++++++++++++----------- 6 files changed, 167 insertions(+), 65 deletions(-) diff --git a/tushare/src/api.py b/tushare/src/api.py index 0cb3418..935a55e 100644 --- a/tushare/src/api.py +++ b/tushare/src/api.py @@ -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(...), diff --git a/tushare/src/contracts.py b/tushare/src/contracts.py index 998d44c..733a1ec 100644 --- a/tushare/src/contracts.py +++ b/tushare/src/contracts.py @@ -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) diff --git a/web/backend/internal/handlers/run.go b/web/backend/internal/handlers/run.go index 1ce0a66..c9ebf7a 100644 --- a/web/backend/internal/handlers/run.go +++ b/web/backend/internal/handlers/run.go @@ -6,13 +6,14 @@ import ( "fmt" "io" "net/http" + "net/url" "time" ) type runRequest struct { - TsCode string `json:"ts_code,omitempty"` - Symbol string `json:"symbol,omitempty"` - TradeDate string `json:"trade_date,omitempty"` + 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) { @@ -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) +} diff --git a/web/backend/internal/router/router.go b/web/backend/internal/router/router.go index 7f269f1..8a01854 100644 --- a/web/backend/internal/router/router.go +++ b/web/backend/internal/router/router.go @@ -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) diff --git a/web/frontend/src/api/run.ts b/web/frontend/src/api/run.ts index d2cf1d2..7e6c71a 100644 --- a/web/frontend/src/api/run.ts +++ b/web/frontend/src/api/run.ts @@ -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('/run', req).then((r) => r.data) } + +export function getActiveContract(symbol: string) { + return client + .get('/contracts/active', { params: { symbol } }) + .then((r) => r.data) +} diff --git a/web/frontend/src/views/RunView.vue b/web/frontend/src/views/RunView.vue index 407ae1a..f73b860 100644 --- a/web/frontend/src/views/RunView.vue +++ b/web/frontend/src/views/RunView.vue @@ -1,47 +1,79 @@