From 861c7faa8c42eba83828517e5f2060325397cc17 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Fri, 14 Aug 2026 12:05:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20P1-a=20=E7=BB=84=E5=90=88?= =?UTF-8?q?=E7=AD=96=E7=95=A5K=E7=BA=BF=E5=91=A8=E6=9C=9F=E5=AD=97?= =?UTF-8?q?=E6=AE=B5(=E5=89=8D=E5=90=8E=E7=AB=AF):=20=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E7=9B=98=E6=96=B0=E5=BB=BA=E7=BB=84=E5=90=88=E5=8D=A1=E6=98=BE?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E4=B8=8B=E6=8B=89(=E5=8E=9Fv-if=E8=97=8F?= =?UTF-8?q?=E8=B5=B7,=E5=AE=9E=E8=B5=B0/=E5=BD=B1=E5=AD=90=E7=94=A8miniQMT?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D);=20live=E7=BB=84=E5=90=88=E5=88=86=E6=94=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=99=E6=AD=BBd(=E7=A9=BA=3Dd=E9=BB=98=E8=AE=A4,ct?= =?UTF-8?q?a=E7=A9=BA=3D15m);=20=E7=BB=84=E5=90=88=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E5=8A=A0interval(d=3Dday,=E9=9D=9Ed=E6=8B=92400+runner?= =?UTF-8?q?=E6=8B=92ValueError,=E5=88=86=E9=92=9F=E6=A1=A3=E7=AD=89?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B1=82);=20worker=20params=E5=AD=98interva?= =?UTF-8?q?l=E4=BE=9B=E5=9B=9E=E6=94=BE=E9=A2=84=E5=A1=AB;=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=BB=84=E5=90=88=E5=9B=9E=E6=B5=8B=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E5=8A=A0=E4=B8=8B=E6=8B=89(=E4=BB=85=E6=97=A5=E7=BA=BF?= =?UTF-8?q?=E5=8F=AF=E9=80=89,=E5=85=B6=E4=BD=99disabled=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=E5=BD=B1=E5=AD=90=E5=8F=AF=E7=94=A8)=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/backtest/PortfolioBacktest.vue | 13 +++++++++++++ frontend/src/views/live/New.vue | 9 +++++++++ frontend/src/views/paper/New.vue | 8 ++++---- sanguo_api/routes_live.py | 6 ++++-- sanguo_api/routes_portfolio.py | 4 ++++ sanguo_orchestrator/portfolio_worker.py | 1 + sanguo_orchestrator/runner.py | 4 +++- sanguo_portfolio/runner_backtest.py | 8 +++++++- tests/api/test_portfolio_live.py | 10 +++++++++- 9 files changed, 54 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index 8d98f17..8f420bd 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 { INTERVAL_OPTIONS } from '@/constants/intervals' import { postPortfolioBacktest, getPortfolioResult, @@ -60,6 +61,7 @@ const form = reactive({ stamp_duty_rate: 0.001, min_commission: 5, slippage: 0.001, + interval: 'd', }) watch( () => [form.start, form.end], @@ -360,6 +362,7 @@ async function onSubmit(): Promise { stamp_duty_rate: Number(form.stamp_duty_rate), min_commission: Number(form.min_commission), slippage: Number(form.slippage), + interval: form.interval, }) ElMessage.success('回测已提交,后台运行中') router.push('/backtest/history') // 跳任务中心(历史任务页)看进度/结果 @@ -398,6 +401,7 @@ async function prefillFromTask(tid: string): Promise { if (p.stamp_duty_rate != null) form.stamp_duty_rate = Number(p.stamp_duty_rate) if (p.min_commission != null) form.min_commission = Number(p.min_commission) if (p.slippage != null) form.slippage = Number(p.slippage) + if (p.interval) form.interval = String(p.interval) } catch { /* 预填失败,走默认 */ } @@ -444,6 +448,15 @@ function fmtNum(v: number | null | undefined, digits = 2): string { 0=全市场不限, N=前N只(MVP验证用, 默认30) + + + + {{ o.label }} + 影子柜台实时可用·组合回放暂仅日线 + + + 组合回放暂仅日线(本地数据);分钟档影子柜台/实盘可用 + diff --git a/frontend/src/views/live/New.vue b/frontend/src/views/live/New.vue index c597655..53ab1bd 100644 --- a/frontend/src/views/live/New.vue +++ b/frontend/src/views/live/New.vue @@ -287,6 +287,15 @@ async function onSubmit(): Promise { + + + + {{ o.label }} + 影子柜台实时可用·回放暂无本地数据 + + + miniQMT 成品K线,默认日线 + diff --git a/frontend/src/views/paper/New.vue b/frontend/src/views/paper/New.vue index cfdea17..fd96ddb 100644 --- a/frontend/src/views/paper/New.vue +++ b/frontend/src/views/paper/New.vue @@ -206,15 +206,15 @@ function onSymbols(v: string): void { -
K 线周期
- - K 线周期 + + {{ i.label }} 影子柜台实时可用·回放暂无本地数据 -
+
{{ INTERVAL_OPTIONS.find(o => o.value === form.interval)?.matchHint || (form.interval === 'd' ? '' : '分钟级周期需标的分钟数据') }}
diff --git a/sanguo_api/routes_live.py b/sanguo_api/routes_live.py index 5f27af8..96fc4fb 100644 --- a/sanguo_api/routes_live.py +++ b/sanguo_api/routes_live.py @@ -43,7 +43,7 @@ class LiveCreateRequest(BaseModel): strategy_class: str = "AShareDoubleMaStrategy" strategy_name: str setting: dict = {} - interval: str = "15m" + interval: str = "" # 空=按类型给默认(cta→15m, portfolio→d);前端下拉显式传 initial_capital: float = 1_000_000 connect_wait_sec: int = 10 init_wait_sec: int = 60 @@ -83,9 +83,11 @@ def create_live(req: LiveCreateRequest): payload.setdefault("max_pool", 30) payload.setdefault("benchmark", "000300.XSHG") payload["vt_symbol"] = payload["pool"] - payload["interval"] = "d" + # 周期由前端下拉传(miniQMT 成品K线档位);空=默认日线 + payload["interval"] = payload.get("interval") or "d" else: # CTA 实盘:标的允许只写 6 位码,后端自动补交易所后缀 + payload["interval"] = payload.get("interval") or "15m" payload["vt_symbol"] = _normalize_vt_symbol(payload.get("vt_symbol", "")) # mini_path 兜底:req → env SANGUO_QMT_PATH → 内置默认(空值会导致 connect=-1) if not payload.get("mini_path"): diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index a574f55..bf6b2c5 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -43,11 +43,14 @@ class PortfolioBacktestRequest(BaseModel): stamp_duty_rate: float = Field(default=0.001, description="印花税率卖出(千1=0.001)") min_commission: float = Field(default=5.0, description="单笔最低佣金(元)") slippage: float = Field(default=0.0, description="滑点比率(万10=0.001,0=不加)") + interval: str = Field(default="d", description="K线周期:组合回放暂仅日线(d)") @router.post("/portfolio/backtest", dependencies=[Depends(verify_token)]) async def run_portfolio_backtest(req: PortfolioBacktestRequest): """异步提交组合回测任务,返回 task_id。前端轮询 GET /task/{id} 再取结果。""" + if req.interval != "d": + raise HTTPException(400, "组合回放暂仅支持日线(影子柜台将支持全周期分钟档)") tid = await get_orchestrator().submit_portfolio( start=req.start_date, end=req.end_date, @@ -60,6 +63,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest): stamp_duty_rate=req.stamp_duty_rate, min_commission=req.min_commission, slippage=req.slippage, + interval=req.interval, ) return {"task_id": tid} diff --git a/sanguo_orchestrator/portfolio_worker.py b/sanguo_orchestrator/portfolio_worker.py index 0f6301c..0c9187b 100644 --- a/sanguo_orchestrator/portfolio_worker.py +++ b/sanguo_orchestrator/portfolio_worker.py @@ -119,6 +119,7 @@ def run_portfolio_task(spec: dict) -> Any: "stamp_duty_rate": spec.get("stamp_duty_rate", 0.001), "min_commission": spec.get("min_commission", 5.0), "slippage": spec.get("slippage", 0.0), + "interval": spec.get("interval", "d"), }, start=period.get("start", ""), end=period.get("end", ""), diff --git a/sanguo_orchestrator/runner.py b/sanguo_orchestrator/runner.py index bbc3c1a..4809ec9 100644 --- a/sanguo_orchestrator/runner.py +++ b/sanguo_orchestrator/runner.py @@ -141,7 +141,8 @@ class Orchestrator: commission_rate: float = 0.0003, stamp_duty_rate: float = 0.001, min_commission: float = 5.0, - slippage: float = 0.0) -> str: + slippage: float = 0.0, + interval: str = "d") -> str: """Submit a portfolio backtest task asynchronously. Runs runner_backtest as a subprocess (3600s hard cap) inside the @@ -165,6 +166,7 @@ class Orchestrator: stamp_duty_rate=stamp_duty_rate, min_commission=min_commission, slippage=slippage, + interval=interval, db_path=self.db_path, file_dir=file_dir, ) diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py index 8bfb328..d6e6d5e 100644 --- a/sanguo_portfolio/runner_backtest.py +++ b/sanguo_portfolio/runner_backtest.py @@ -375,6 +375,10 @@ def _write_result_md(result: Dict[str, Any], path: str, args: argparse.Namespace logger.warning("写结果文件失败: %s", exc) +def _raise_interval(interval: Any) -> str: + raise ValueError(f"组合回测暂仅支持日线(interval=d),收到: {interval}") + + def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]: """JSON 入口(供 SSH 触发,前端 MVP 用)。 @@ -402,7 +406,9 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]: end=params.get("end_date", "2024-02-29"), cash=float(params.get("initial_cash", 1_000_000.0)), benchmark=params.get("benchmark", "000300.XSHG"), - frequency="day", + # 周期:目前组合回放只有日线有本地数据;分钟级等数据层补齐后放开 + frequency="day" if params.get("interval", "d") in ("", "d", "day") else ( + _raise_interval(params.get("interval"))), strategy=strategy_name, provider=params.get("provider", "local"), provider_config=params.get("provider_config", "{}"), diff --git a/tests/api/test_portfolio_live.py b/tests/api/test_portfolio_live.py index ce2836a..972dbb5 100644 --- a/tests/api/test_portfolio_live.py +++ b/tests/api/test_portfolio_live.py @@ -27,6 +27,7 @@ def _create_portfolio(db, **kw): strategy_type="portfolio", strategy_class=kw.get("strategy_class", "all_weather"), pool=kw.get("pool", "hs300_subset"), max_pool=kw.get("max_pool", 30), benchmark=kw.get("benchmark", "000300.XSHG"), + interval=kw.get("interval", ""), initial_capital=kw.get("initial_capital", 500000), ) return rl.create_live(req)["account_id"] @@ -72,10 +73,17 @@ def test_create_portfolio_live(live_db): acc = live_persistence.get_account(live_db, aid) assert acc["strategy_type"] == "portfolio" assert acc["vt_symbol"] == "hs300_subset" # 组合行 vt_symbol=池名 - assert acc["interval"] == "d" + assert acc["interval"] == "d" # 未传周期 → 组合默认日线 assert acc["status"] == "stopped" +def test_create_portfolio_interval_passthrough(live_db): + """组合实盘周期由前端下拉传(miniQMT 档位),后端不写死。""" + aid = _create_portfolio(live_db, interval="15m") + acc = live_persistence.get_account(live_db, aid) + assert acc["interval"] == "15m" + + def test_create_portfolio_rejects_empty_strategy(live_db): with pytest.raises(Exception): _create_portfolio(live_db, strategy_class="")