diff --git a/tushare/src/api.py b/tushare/src/api.py index ad92415..0cb3418 100644 --- a/tushare/src/api.py +++ b/tushare/src/api.py @@ -11,6 +11,7 @@ app = FastAPI(title="期货数据采集与打分服务") class RunRequest(BaseModel): ts_code: Optional[str] = None symbol: str = "FG" + trade_date: Optional[str] = None class RunResponse(BaseModel): @@ -44,7 +45,7 @@ def run_pipeline(req: RunRequest): df = fetcher.fetch_contract(ts_code) storage.save_candles(df) - result = scorer.score_daily(df) + result = scorer.score_daily(df, req.trade_date) storage.save_score(result) push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}" diff --git a/tushare/src/main.py b/tushare/src/main.py index 8d61877..c0b0be2 100644 --- a/tushare/src/main.py +++ b/tushare/src/main.py @@ -4,18 +4,18 @@ import sys from . import contracts, fetcher, notifier, scorer, storage -def run(ts_code: str) -> int: +def run(ts_code: str, trade_date: Optional[str] = None) -> int: storage.init_db() print(f"[1/4] 拉取 {ts_code} 数据...") df = fetcher.fetch_contract(ts_code) print(f" 返回 {len(df)} 行") - print(f"[2/4] 写入/更新 SQLite...") + print(f"[2/4] 写入/更新 PostgreSQL...") storage.save_candles(df) print(f"[3/4] 计算打分...") - result = scorer.score_daily(df) + result = scorer.score_daily(df, trade_date) print(f"[4/4] 保存打分结果...") storage.save_score(result) @@ -61,7 +61,7 @@ def run(ts_code: str) -> int: print(f" 30日前持仓量: {ld['oi_before']:,.0f}") print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%") - print(f"\n[OK] 数据已持久化到 SQLite") + print(f"\n[OK] 数据已持久化到 PostgreSQL") push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}" push_body = ( @@ -86,12 +86,18 @@ def main() -> int: default="FG", help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG", ) + parser.add_argument( + "--date", + help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分", + ) args = parser.parse_args() ts_code = args.ts_code or contracts.active_contract(args.symbol) if not args.ts_code: print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}") - return run(ts_code) + if args.date: + print(f"[DATE] 指定打分日期: {args.date}") + return run(ts_code, args.date) if __name__ == "__main__": diff --git a/tushare/src/scorer.py b/tushare/src/scorer.py index 2b1d6f8..a93a4ea 100644 --- a/tushare/src/scorer.py +++ b/tushare/src/scorer.py @@ -1,3 +1,5 @@ +from typing import Optional + import pandas as pd from .models import ScoreDetail, ScoreResult @@ -116,11 +118,21 @@ def _interpret(composite: float) -> str: return "强烈看空区域 — 资金主动且持续地打压价格" -def score_daily(df: pd.DataFrame) -> ScoreResult: - """对 DataFrame 中最新一条记录打分。""" +def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult: + """对 DataFrame 中指定日期或最新一条记录打分。""" if len(df) < 31: raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行") + if trade_date: + trade_date_str = str(trade_date) + mask = df["trade_date"].astype(str) == trade_date_str + if not mask.any(): + raise ValueError(f"指定日期 {trade_date_str} 不在数据中") + pos = mask.idxmax() + df = df.iloc[:pos + 1].copy() + if len(df) < 31: + raise ValueError(f"指定日期 {trade_date_str} 之前数据不足(仅 {len(df)} 行),需要至少 31 行") + latest = df.iloc[-1] short, short_details = calc_short_term(df, 7) diff --git a/web/backend/internal/config/config.go b/web/backend/internal/config/config.go index 82e514d..6e92ea9 100644 --- a/web/backend/internal/config/config.go +++ b/web/backend/internal/config/config.go @@ -7,21 +7,23 @@ import ( ) type Config struct { - ListenAddr string - DatabaseURL string - AuthDBPath string - JWTSecret []byte - AdminUser string - AdminPass string + ListenAddr string + DatabaseURL string + AuthDBPath string + JWTSecret []byte + AdminUser string + AdminPass string + TushareAPIURL string } func Load() (*Config, error) { cfg := &Config{ - ListenAddr: getenv("LISTEN_ADDR", ":8080"), - DatabaseURL: os.Getenv("DATABASE_URL"), - AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"), - AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")), - AdminPass: os.Getenv("ADMIN_PASS"), + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"), + AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")), + AdminPass: os.Getenv("ADMIN_PASS"), + TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"), } if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL 环境变量未设置") diff --git a/web/backend/internal/handlers/deps.go b/web/backend/internal/handlers/deps.go index eb9af13..2ed8e17 100644 --- a/web/backend/internal/handlers/deps.go +++ b/web/backend/internal/handlers/deps.go @@ -11,9 +11,10 @@ import ( // Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。 type Deps struct { - Auth *store.AuthStore - Futures *store.FuturesStore - JWT *auth.Manager + Auth *store.AuthStore + Futures *store.FuturesStore + JWT *auth.Manager + TushareURL string } func writeJSON(w http.ResponseWriter, status int, body any) { diff --git a/web/backend/internal/handlers/run.go b/web/backend/internal/handlers/run.go new file mode 100644 index 0000000..1ce0a66 --- /dev/null +++ b/web/backend/internal/handlers/run.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type runRequest struct { + 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) { + var req runRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + + body, err := json.Marshal(req) + if err != nil { + writeErr(w, http.StatusInternalServerError, "encode request failed") + return + } + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Post(d.TushareURL+"/api/v1/run", "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) +} diff --git a/web/backend/internal/router/router.go b/web/backend/internal/router/router.go index 03ff5e7..7f269f1 100644 --- a/web/backend/internal/router/router.go +++ b/web/backend/internal/router/router.go @@ -30,6 +30,7 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f r.Get("/scores/{id}", d.GetScore) r.Get("/contracts", d.ListContracts) r.Get("/candles", d.ListCandles) + r.Post("/run", d.RunPipeline) r.Group(func(r chi.Router) { r.Use(mw.RequireAdmin) diff --git a/web/backend/main.go b/web/backend/main.go index 9b506ae..fa72912 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -41,7 +41,7 @@ func main() { } mgr := auth.NewManager(cfg.JWTSecret) - deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr} + deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr, TushareURL: cfg.TushareAPIURL} dist, err := fs.Sub(distFS, "dist") if err != nil { diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index 2aaf73d..c50c068 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -36,6 +36,7 @@ function logout() { > 打分列表 K 线 / 持仓 + 手动打分 用户管理 diff --git a/web/frontend/src/api/run.ts b/web/frontend/src/api/run.ts new file mode 100644 index 0000000..d2cf1d2 --- /dev/null +++ b/web/frontend/src/api/run.ts @@ -0,0 +1,24 @@ +import client from './client' + +export interface RunRequest { + ts_code?: string + symbol?: string + trade_date?: string +} + +export interface RunResponse { + ts_code: string + trade_date: string + close: number + oi: number + oi_chg: number + short_term: number + medium_term: number + long_term: number + composite: number + signal: string +} + +export function runPipeline(req: RunRequest) { + return client.post('/run', req).then((r) => r.data) +} diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts index 24a0268..8dea98d 100644 --- a/web/frontend/src/router/index.ts +++ b/web/frontend/src/router/index.ts @@ -19,6 +19,11 @@ const routes: RouteRecordRaw[] = [ name: 'chart', component: () => import('@/views/ChartView.vue'), }, + { + path: '/run', + name: 'run', + component: () => import('@/views/RunView.vue'), + }, { path: '/admin/users', name: 'admin-users', diff --git a/web/frontend/src/views/RunView.vue b/web/frontend/src/views/RunView.vue new file mode 100644 index 0000000..2c15e63 --- /dev/null +++ b/web/frontend/src/views/RunView.vue @@ -0,0 +1,139 @@ + + + + +