diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts new file mode 100644 index 0000000..64542b3 --- /dev/null +++ b/frontend/src/utils/dates.ts @@ -0,0 +1,2 @@ +// 日期控件共用:禁选未来日期(回测/回放只对历史区间有意义,后端同口径校验兜底) +export const disableFutureDate = (d: Date): boolean => d.getTime() > Date.now() diff --git a/frontend/src/views/backtest/New.vue b/frontend/src/views/backtest/New.vue index 2778ed4..3d2edc4 100644 --- a/frontend/src/views/backtest/New.vue +++ b/frontend/src/views/backtest/New.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { getStrategies, getParams, type StrategyItem } from '@/api/strategy' import { submitCta, getTaskParams } from '@/api/backtest' +import { disableFutureDate } from '@/utils/dates' import { apiClient } from '@/api/client' interface InstLike { @@ -214,10 +215,10 @@ async function onSubmit(): Promise { A 股代码,无需交易所后缀 - + - + diff --git a/frontend/src/views/backtest/Optimize.vue b/frontend/src/views/backtest/Optimize.vue index b77c94e..89a3a52 100644 --- a/frontend/src/views/backtest/Optimize.vue +++ b/frontend/src/views/backtest/Optimize.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { getStrategies, getParams, type StrategyItem } from '@/api/strategy' import { submitOptimize } from '@/api/backtest' +import { disableFutureDate } from '@/utils/dates' const router = useRouter() const strategies = ref([]) @@ -117,10 +118,10 @@ async function onSubmit(): Promise { 每行:参数名,起始,结束,步长 - + - + 提交优化 diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index 46757f6..64240ab 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -6,6 +6,7 @@ import type { EChartsCoreOption } from 'echarts' import { useChart } from '@/composables/useChart' import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark' import { getTaskParams } from '@/api/backtest' +import { disableFutureDate } from '@/utils/dates' import { INTERVAL_OPTIONS } from '@/constants/intervals' import { postPortfolioBacktest, @@ -470,10 +471,10 @@ function fmtNum(v: number | null | undefined, digits = 2): string { - + - + diff --git a/frontend/src/views/factor/New.vue b/frontend/src/views/factor/New.vue index bc8d574..8cba0d5 100644 --- a/frontend/src/views/factor/New.vue +++ b/frontend/src/views/factor/New.vue @@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { getFactors, submitFactor, type FactorItem } from '@/api/factor' +import { disableFutureDate } from '@/utils/dates' const router = useRouter() const factors = ref([]) @@ -109,10 +110,10 @@ async function onSubmit(): Promise { - + - + diff --git a/frontend/src/views/paper/New.vue b/frontend/src/views/paper/New.vue index 9e131eb..3891acd 100644 --- a/frontend/src/views/paper/New.vue +++ b/frontend/src/views/paper/New.vue @@ -6,6 +6,7 @@ import { createPaper, type PaperCreate } from '@/api/paper' import { apiClient } from '@/api/client' import { getStrategies, type StrategyItem } from '@/api/strategy' import { INTERVAL_OPTIONS } from '@/constants/intervals' +import { disableFutureDate } from '@/utils/dates' const route = useRoute() const router = useRouter() @@ -306,10 +307,10 @@ function onSymbols(v: string): void {
diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py index e92e254..e6c257f 100644 --- a/sanguo_api/routes.py +++ b/sanguo_api/routes.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Depends, WebSocket, Query, Header from fastapi.responses import FileResponse from pydantic import BaseModel from .schemas import CtaBacktestRequest, OptimizeRequest, FactorAnalysisRequest +from .validation import validate_backtest_range, validate_cta_request from .auth import ( create_token, token_expires_in, verify_password, @@ -92,6 +93,7 @@ def _build_fee_cfg(req: CtaBacktestRequest) -> dict: @router.post("/backtest/cta", dependencies=[Depends(verify_token)]) async def submit_cta(req: CtaBacktestRequest): """Submit CTA backtest task""" + validate_cta_request(req) # 400 中文提示(格式/未来/区间/资金/费率),拦在进队列前 if req.benchmark not in _BENCHMARKS: raise HTTPException(status_code=422, detail=f"Invalid benchmark: {req.benchmark}. Must be one of {_BENCHMARKS}") @@ -116,6 +118,7 @@ async def submit_cta(req: CtaBacktestRequest): @router.post("/backtest/optimize", dependencies=[Depends(verify_token)]) async def submit_optimize(req: OptimizeRequest): """Submit optimization task""" + validate_backtest_range(req.start, req.end) cls = get_strategy_class(req.strategy) if cls is None: raise HTTPException(status_code=400, detail=f"未知策略: {req.strategy}") @@ -133,6 +136,7 @@ async def submit_optimize(req: OptimizeRequest): @router.post("/factor/analyze", dependencies=[Depends(verify_token)]) async def submit_factor(req: FactorAnalysisRequest): """Submit factor analysis task""" + validate_backtest_range(req.start, req.end) tid = await get_orchestrator().submit_factor( symbols=req.symbols, factor_names=req.factor_names, diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py index 85acb3f..a5ddea0 100644 --- a/sanguo_api/routes_paper.py +++ b/sanguo_api/routes_paper.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel from .auth import verify_token as verify_token_impl +from .validation import validate_backtest_range router = APIRouter() _db_path = {"path": None} @@ -77,6 +78,9 @@ def create_paper(req: PaperCreateRequest): req.start = date.today().isoformat() req.end = "" + elif req.mode == "replay": + # 回放保留用户填的历史区间 → 校验(未来日期/超数据范围等 400,同回测口径) + validate_backtest_range(req.start, req.end) if req.strategy_type == "portfolio": if req.mode not in ("live", "shadow"): raise HTTPException(400, "组合策略模拟盘仅支持实走(live)/影子(shadow)模式;历史回放请用「组合回测」") diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index bf6b2c5..960df8f 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -16,6 +16,7 @@ from pydantic import BaseModel, Field from .auth import verify_token as verify_token_impl from .routes import get_orchestrator +from .validation import validate_portfolio_request logger = logging.getLogger(__name__) router = APIRouter() @@ -51,6 +52,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest): """异步提交组合回测任务,返回 task_id。前端轮询 GET /task/{id} 再取结果。""" if req.interval != "d": raise HTTPException(400, "组合回放暂仅支持日线(影子柜台将支持全周期分钟档)") + validate_portfolio_request(req) # 400 中文提示(格式/未来/区间/资金/费率),拦在进队列前 tid = await get_orchestrator().submit_portfolio( start=req.start_date, end=req.end_date, diff --git a/sanguo_api/validation.py b/sanguo_api/validation.py new file mode 100644 index 0000000..2976c7e --- /dev/null +++ b/sanguo_api/validation.py @@ -0,0 +1,109 @@ +"""回测参数校验(2026-08-15:此前结束时间选 2029 也能提交,零校验跑垃圾结果)。 + +L1 静态规则零 IO 秒判(格式/先后/未来/区间长度/资金/费率); +L2 数据可用性查 dbbardata 锚定标的(600000 日线)最新日期, +模块级缓存 24h——只在提交时服务端调用一次,页面加载零开销; +查库失败返回 None 自动退化为仅 L1,数据层抖动不挡正常提交。 +""" +from __future__ import annotations + +import sqlite3 +import time +from datetime import date, datetime + +from fastapi import HTTPException + +# 锚定标的:600000 浦发银行,上市以来从未长期停牌,是「数据灌到哪天」的可靠探针 +_ANCHOR_SYMBOL = "600000" +_ANCHOR_EXCHANGE = "SSE" +_CACHE_TTL_SEC = 24 * 3600 +_MIN_SPAN_DAYS = 30 +_RATE_MAX = 0.01 # 费率上限 1%:填 3 这种是百分数/万分位口径错 + +_latest_cache: tuple[float, str] | None = None # (查询时刻, 最新日期) + + +def _parse(name: str, value: str) -> date: + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except (ValueError, TypeError): + raise HTTPException(400, f"{name}格式应为 YYYY-MM-DD: {value!r}") + + +def validate_backtest_range(start: str, end: str, min_days: int = _MIN_SPAN_DAYS) -> None: + """L1+L2 区间校验,非法即抛 400(中文业务提示)。合法返回 None。""" + s = _parse("开始日期", start) + e = _parse("结束日期", end) + if s >= e: + raise HTTPException(400, f"开始日期({start})必须早于结束日期({end})") + if (e - s).days < min_days: + raise HTTPException(400, f"区间过短(不足 {min_days} 天),统计无参考意义,请至少选 {min_days} 天") + today = date.today().isoformat() + if end > today: + raise HTTPException(400, f"结束日期 {end} 在未来,请选择历史日期(今天: {today})") + latest = get_latest_daily_date() + if latest and end > latest: + raise HTTPException(400, f"结束日期 {end} 超出数据范围:日线数据最新到 {latest}") + + +def validate_capital(name: str, value: float) -> None: + if value <= 0: + raise HTTPException(400, f"{name}需大于 0,当前: {value}") + + +def validate_rate(name: str, value: float) -> None: + if not (0 <= value <= _RATE_MAX): + raise HTTPException(400, f"{name}应在 0~{_RATE_MAX:g} 之间(小数,万3=0.0003),当前: {value}") + + +def validate_cta_request(req) -> None: + """CTA 回测(个股/优化共用):区间 + 资金 + 费率。""" + validate_backtest_range(req.start, req.end) + validate_capital("初始资金", req.capital) + validate_rate("佣金率", req.commission_rate) + validate_rate("印花税率", req.stamp_duty_rate) + validate_rate("过户费率", req.transfer_fee_rate) + validate_rate("滑点", req.slippage) + + +def validate_portfolio_request(req) -> None: + """组合回测:区间 + 资金 + 费率(字段名与 CTA 不同:initial_cash/start_date)。""" + validate_backtest_range(req.start_date, req.end_date) + validate_capital("初始资金", req.initial_cash) + validate_rate("佣金率", req.commission_rate) + validate_rate("印花税率", req.stamp_duty_rate) + validate_rate("滑点", req.slippage) + + +def _query_latest_daily_date() -> str | None: + """直查 dbbardata 锚定标的日线最大日期;任何异常返回 None(退化为仅 L1)。""" + try: + from sanguo_data.config import load_config, find_config_path + + db_path = load_config(find_config_path()).data_paths.get("vnpy_db") + if not db_path: + return None + conn = sqlite3.connect(db_path, timeout=5) + try: + row = conn.execute( + "SELECT MAX(substr(datetime,1,10)) FROM dbbardata " + "WHERE symbol=? AND exchange=? AND interval='d'", + (_ANCHOR_SYMBOL, _ANCHOR_EXCHANGE), + ).fetchone() + finally: + conn.close() + return row[0] if row and row[0] else None + except Exception: + return None + + +def get_latest_daily_date() -> str | None: + """数据最新交易日(带 24h 缓存)。日线 18 点后更新,无需每次提交都查。""" + global _latest_cache + now = time.time() + if _latest_cache and now - _latest_cache[0] < _CACHE_TTL_SEC: + return _latest_cache[1] + latest = _query_latest_daily_date() + if latest: + _latest_cache = (now, latest) + return latest diff --git a/tests/api/test_validation.py b/tests/api/test_validation.py new file mode 100644 index 0000000..f09bcc2 --- /dev/null +++ b/tests/api/test_validation.py @@ -0,0 +1,100 @@ +"""回测参数校验(L1 静态规则 + L2 数据最新日)单测 + 2029 回归。 + +背景:2026-08-15 用户实况——组合回测结束时间选 2029 也能提交 +(portfolio_ef8b655a),零校验跑出垃圾结果占任务位。 +""" +import pytest +from fastapi import HTTPException + +from sanguo_api import validation as V + + +def _ok_range(): + return "2024-01-01", "2024-06-30" + + +# ===== L1 静态规则 ===== + +def test_future_end_rejected(): + """用户报的 2029 场景:结束日期在未来必须 400。""" + with pytest.raises(HTTPException) as e: + V.validate_backtest_range("2024-01-01", "2029-12-31") + assert e.value.status_code == 400 + assert "未来" in e.value.detail + + +def test_bad_format_rejected(): + with pytest.raises(HTTPException) as e: + V.validate_backtest_range("2024/01/01", "2024-06-30") + assert e.value.status_code == 400 + assert "格式" in e.value.detail + + +def test_start_after_end_rejected(): + with pytest.raises(HTTPException) as e: + V.validate_backtest_range("2024-06-30", "2024-01-01") + assert "早于" in e.value.detail + + +def test_span_too_short_rejected(): + with pytest.raises(HTTPException) as e: + V.validate_backtest_range("2024-01-01", "2024-01-15") + assert "过短" in e.value.detail + + +def test_valid_range_passes(): + s, t = _ok_range() + assert V.validate_backtest_range(s, t) is None + + +def test_capital_and_rate_checks(): + with pytest.raises(HTTPException): + V.validate_capital("初始资金", 0) + with pytest.raises(HTTPException): + V.validate_capital("初始资金", -100) + assert V.validate_capital("初始资金", 1_000_000) is None + # 费率应填小数(万3=0.0003);填成 3 明显是百分数/万分位口径错 + with pytest.raises(HTTPException): + V.validate_rate("佣金率", 3.0) + assert V.validate_rate("佣金率", 0.0003) is None + + +# ===== L2 数据最新日 ===== + +def test_end_beyond_latest_data_rejected(monkeypatch): + # end 是过去日期(过 L1)但超过 mock 的数据最新日 → L2 拦 + monkeypatch.setattr(V, "get_latest_daily_date", lambda: "2026-07-31") + with pytest.raises(HTTPException) as e: + V.validate_backtest_range("2026-01-01", "2026-08-05") + assert "数据最新到" in e.value.detail + + +def test_latest_query_failure_degrades_to_l1(monkeypatch): + """查库失败返回 None → 只做 L1,数据层抖动不挡提交。""" + monkeypatch.setattr(V, "get_latest_daily_date", lambda: None) + assert V.validate_backtest_range("2026-01-01", "2026-08-05") is None + + +def test_latest_date_cached(monkeypatch): + """24h 缓存:第二次调用不重复查库。""" + calls = [] + monkeypatch.setattr(V, "_query_latest_daily_date", lambda: calls.append(1) or "2026-08-14") + V._latest_cache = None # 清模块缓存 + assert V.get_latest_daily_date() == "2026-08-14" + assert V.get_latest_daily_date() == "2026-08-14" + assert len(calls) == 1 + V._latest_cache = None # 还原,避免污染其他测试 + + +# ===== 入口集成:组合回测 2029 必须进不了队列 ===== + +def test_portfolio_submit_2029_rejected(monkeypatch): + """回归:POST /portfolio/backtest end=2029 → 400,不产生任务。""" + monkeypatch.setattr(V, "get_latest_daily_date", lambda: "2026-08-14") + from sanguo_api.routes_portfolio import PortfolioBacktestRequest + from sanguo_api.validation import validate_portfolio_request + + req = PortfolioBacktestRequest(start_date="2024-01-01", end_date="2029-12-31") + with pytest.raises(HTTPException) as e: + validate_portfolio_request(req) + assert e.value.status_code == 400