From 6220fc594342b99e6c87c073f286be76e33b62bf Mon Sep 17 00:00:00 2001 From: claude_dev Date: Fri, 14 Aug 2026 12:55:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor(paper):=20=E5=BD=B1=E5=AD=90=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E4=B8=BA=E7=AC=AC=E4=B8=89=E7=A7=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F(=E5=9B=9E=E6=94=BE/=E5=AE=9E=E8=B5=B0/?= =?UTF-8?q?=E5=BD=B1=E5=AD=90,=E7=94=A8=E6=88=B7=E6=8B=8D=E6=9D=BFCTA?= =?UTF-8?q?=E5=92=8C=E7=BB=84=E5=90=88=E9=83=BD=E5=8F=AF=E8=B5=B0=E5=BD=B1?= =?UTF-8?q?=E5=AD=90):=20=E6=A8=A1=E5=BC=8F=E5=8D=A1=E4=B8=89=E9=80=89?= =?UTF-8?q?=E4=B8=80=E6=9B=BF=E4=BB=A3=E7=BB=84=E5=90=88=E5=8D=A1=E5=86=85?= =?UTF-8?q?=E6=92=AE=E5=90=88=E5=BC=95=E6=93=8E=E5=8D=95=E9=80=89;=20engin?= =?UTF-8?q?e=E7=94=B1mode=E6=8E=A8=E5=AF=BC(shadow=E2=86=92shadow=E5=90=A6?= =?UTF-8?q?=E5=88=99eod=5Freplay,CTA/=E7=BB=84=E5=90=88=E7=BB=9F=E4=B8=80)?= =?UTF-8?q?;=20=E6=97=A5=E7=BB=8820:30=20job=E5=8F=AA=E9=81=8D=E5=8E=86mod?= =?UTF-8?q?e=3Dlive=E5=A4=A9=E7=84=B6=E9=9A=94=E7=A6=BB=E5=BD=B1=E5=AD=90?= =?UTF-8?q?=E8=B4=A6=E6=88=B7;=20=E5=88=97=E8=A1=A8=E5=BE=BD=E6=A0=87mode?= =?UTF-8?q?=3Dshadow=E4=BA=AE'=E5=BD=B1=E5=AD=90'+=E7=BB=84=E5=90=88live?= =?UTF-8?q?=E6=A0=87'=E6=97=A5=E7=BB=88';=202=E6=96=B0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/paper/List.vue | 8 +++--- frontend/src/views/paper/New.vue | 34 +++++++++---------------- sanguo_api/routes_paper.py | 13 +++++----- tests/api/test_paper_routes.py | 42 +++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/frontend/src/views/paper/List.vue b/frontend/src/views/paper/List.vue index 0796593..8a3633a 100644 --- a/frontend/src/views/paper/List.vue +++ b/frontend/src/views/paper/List.vue @@ -44,7 +44,7 @@ const stats = computed(() => ({ failed: accounts.value.filter((a) => a.status === 'failed').length, })) -const modeLabel: Record = { replay: '回放', live: '实走' } +const modeLabel: Record = { replay: '回放', live: '实走', shadow: '影子' } const statusLabel: Record = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' } function pct(v: number | null | undefined): string { @@ -189,10 +189,8 @@ async function saveEdit(): Promise { diff --git a/frontend/src/views/paper/New.vue b/frontend/src/views/paper/New.vue index 8863254..a6acbae 100644 --- a/frontend/src/views/paper/New.vue +++ b/frontend/src/views/paper/New.vue @@ -84,7 +84,8 @@ watch( const modes = [ { value: 'replay', label: '回放', desc: '历史重放,立即出结果' }, - { value: 'live', label: '实走', desc: '每日定时 step,长期跟踪' }, + { value: 'live', label: '实走', desc: '每日 20:30 定时结算,长期跟踪' }, + { value: 'shadow', label: '影子', desc: 'VPS 影子柜台盘中实时本地撮合' }, ] const sessions = [ { value: 'next_open', label: '次日开盘', desc: '收盘型信号,T+1 开盘撮合' }, @@ -115,19 +116,17 @@ onMounted(async () => { const isPortfolio = computed(() => strategyType.value === 'portfolio') const portfolioStrategy = ref('all_weather') -// 撮合引擎:日终回放(NAS 每晚 20:30) / 影子柜台(VPS 盘中实时本地撮合) -const engine = ref<'eod_replay' | 'shadow'>('eod_replay') function setMode(m: string): void { - // 组合类型锁定实走:回放卡片不可选(历史回放走「组合回测」页) + // 组合类型不支持回放(历史回放走「组合回测」页) if (isPortfolio.value && m === 'replay') return form.value.mode = m } -// 组合类型锁定实走(历史回放走「组合回测」页) +// 组合类型默认实走(历史回放走「组合回测」页) watch(strategyType, (t) => { if (t === 'portfolio') { - form.value.mode = 'live' + if (form.value.mode === 'replay') form.value.mode = 'live' if (!portfolioStrategy.value && portfolioOptions.value.length) { portfolioStrategy.value = portfolioOptions.value[0] } @@ -140,7 +139,6 @@ async function onSubmit(): Promise { const payload: PaperCreate = { ...form.value } if (strategyType.value === 'portfolio') { payload.strategy_type = 'portfolio' - payload.mode = 'live' payload.symbols = [poolForm.pool] payload.strategies = [{ name: portfolioStrategy.value, @@ -151,13 +149,16 @@ async function onSubmit(): Promise { payload.pool = poolForm.pool payload.max_pool = Number(poolForm.max_pool) payload.benchmark = poolForm.benchmark - payload.engine = engine.value } + // 影子模式:engine=shadow(VPS 柜台盘中实时撮合);其余 eod_replay(回放/日终) + payload.engine = payload.mode === 'shadow' ? 'shadow' : 'eod_replay' const aid = await createPaper(payload) - ElMessage.success(strategyType.value === 'portfolio' && engine.value === 'shadow' + ElMessage.success(payload.mode === 'shadow' ? `已创建影子柜台模拟盘 #${aid}(VPS 柜台运行期间盘中实时结算)` - : `已创建模拟盘 #${aid}(今晚 20:30 起每日结算)`) - router.push(payload.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`) + : payload.mode === 'live' + ? `已创建模拟盘 #${aid}(今晚 20:30 起每日结算)` + : `已创建回放模拟盘 #${aid}`) + router.push(payload.mode === 'replay' ? `/paper/result/${aid}` : `/paper/live/${aid}`) } catch (e: unknown) { ElMessage.error(e instanceof Error ? e.message : '创建失败') } finally { @@ -250,17 +251,6 @@ function onSymbols(v: string): void { - - - 日终回放 - 影子柜台 - - - {{ engine === 'shadow' - ? 'VPS 盘中实时行情本地撮合(需 VPS 影子柜台进程运行中)' - : '每晚 20:30 全量重放结算(NAS)' }} - - diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py index b0ebe83..47267c0 100644 --- a/sanguo_api/routes_paper.py +++ b/sanguo_api/routes_paper.py @@ -71,11 +71,10 @@ def create_paper(req: PaperCreateRequest): db = _db_path["path"] or ":memory:" init_db(db) if req.strategy_type == "portfolio": - if req.mode != "live": - raise HTTPException(400, "组合策略模拟盘仅支持实走(live)模式;历史回放请用「组合回测」") - if req.engine not in ("eod_replay", "shadow"): - raise HTTPException(400, "engine 须为 eod_replay(日终回放) 或 shadow(影子柜台)") + if req.mode not in ("live", "shadow"): + raise HTTPException(400, "组合策略模拟盘仅支持实走(live)/影子(shadow)模式;历史回放请用「组合回测」") payload = req.model_dump() + payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay" payload["symbols"] = [req.pool] payload["strategies"] = [{ "name": (req.strategies[0].name if req.strategies else "all_weather"), @@ -86,7 +85,9 @@ def create_paper(req: PaperCreateRequest): update_account_status(db, aid, "running") return {"account_id": aid, "status": "running"} - aid = save_account(db, req.model_dump()) + cta_payload = req.model_dump() + cta_payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay" + aid = save_account(db, cta_payload) status = "created" if req.mode == "replay": # 回放后台线程跑,create 立即返回(避免阻塞 worker 502) def _bg(): @@ -98,7 +99,7 @@ def create_paper(req: PaperCreateRequest): update_account_status(db, aid, "failed", str(e)) threading.Thread(target=_bg, daemon=True).start() status = "running" - elif req.mode == "live": # 实走:全局 job 每日 20:30 遍历 step(不跑回放) + elif req.mode in ("live", "shadow"): # 实走:每日 20:30 step;影子:等 VPS 影子柜台进程接管 from sanguo_trader.persistence import update_account_status update_account_status(db, aid, "running") status = "running" diff --git a/tests/api/test_paper_routes.py b/tests/api/test_paper_routes.py index 4ebfdee..7424d6e 100644 --- a/tests/api/test_paper_routes.py +++ b/tests/api/test_paper_routes.py @@ -37,6 +37,48 @@ def test_create_paper(tmp_path): assert "account_id" in resp.json() +def test_create_shadow_mode(tmp_path): + """影子=第三种运行模式(CTA/组合都可):engine 由 mode 推导,不被日终 job 结算。""" + c, token = _client(tmp_path) + # CTA 影子 + r1 = c.post("/api/v1/paper/create", json={ + "mode": "shadow", + "symbols": ["600000"], + "strategies": [{"name": "DoubleMa", "symbol": "600000"}], + "start": "2024-01-01", "end": "2024-06-30", + }, headers=_auth(token)) + assert r1.status_code == 200 + # 组合影子:mode=shadow → engine=shadow + r2 = c.post("/api/v1/paper/create", json={ + "mode": "shadow", "strategy_type": "portfolio", + "symbols": ["hs300_subset"], + "strategies": [{"name": "all_weather", "symbol": "hs300_subset"}], + "start": "2024-01-01", "end": "2024-12-31", + "pool": "hs300_subset", + }, headers=_auth(token)) + assert r2.status_code == 200 + lst = c.get("/api/v1/paper", headers=_auth(token)).json() + items = lst if isinstance(lst, list) else lst.get("accounts", lst.get("papers", [])) + by_id = {a["id"]: a for a in items} + cta = by_id[r1.json()["account_id"]] + assert cta["mode"] == "shadow" + assert cta["engine"] == "shadow" + pf = by_id[r2.json()["account_id"]] + assert pf["mode"] == "shadow" and pf["engine"] == "shadow" + + +def test_create_portfolio_rejects_replay(tmp_path): + c, token = _client(tmp_path) + resp = c.post("/api/v1/paper/create", json={ + "mode": "replay", "strategy_type": "portfolio", + "symbols": ["hs300_subset"], + "strategies": [{"name": "all_weather", "symbol": "hs300_subset"}], + "start": "2024-01-01", "end": "2024-12-31", + }, headers=_auth(token)) + assert resp.status_code == 400 + assert "组合回测" in resp.json()["detail"] + + def test_get_paper_and_empty_trades(tmp_path): c, token = _client(tmp_path) aid = c.post(