refactor(paper): 影子升级为第三种运行模式(回放/实走/影子,用户拍板CTA和组合都可走影子): 模式卡三选一替代组合卡内撮合引擎单选; engine由mode推导(shadow→shadow否则eod_replay,CTA/组合统一); 日终20:30 job只遍历mode=live天然隔离影子账户; 列表徽标mode=shadow亮'影子'+组合live标'日终'; 2新测试 [vps]
This commit is contained in:
@@ -44,7 +44,7 @@ const stats = computed(() => ({
|
||||
failed: accounts.value.filter((a) => a.status === 'failed').length,
|
||||
}))
|
||||
|
||||
const modeLabel: Record<string, string> = { replay: '回放', live: '实走' }
|
||||
const modeLabel: Record<string, string> = { replay: '回放', live: '实走', shadow: '影子' }
|
||||
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' }
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
@@ -189,10 +189,8 @@ async function saveEdit(): Promise<void> {
|
||||
</el-table-column>
|
||||
<el-table-column label="模式" width="80">
|
||||
<template #default="{ row }">
|
||||
<span class="chip chip-paper">{{ modeLabel[row.mode] || row.mode }}</span>
|
||||
<span v-if="row.strategy_type === 'portfolio'" class="chip chip-shadow" title="影子柜台=盘中实时本地撮合 / 日终回放=每晚全量重放">
|
||||
{{ row.engine === 'shadow' ? '影子' : '日终' }}
|
||||
</span>
|
||||
<span class="chip chip-paper" :class="{ 'chip-shadow': row.mode === 'shadow' }">{{ modeLabel[row.mode] || row.mode }}</span>
|
||||
<span v-if="row.strategy_type === 'portfolio' && row.mode === 'live'" class="chip chip-shadow" title="日终回放=每晚 20:30 全量重放结算">日终</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="频率" width="70">
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
<el-option v-for="b in BENCH_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="撮合引擎">
|
||||
<el-radio-group v-model="engine">
|
||||
<el-radio-button value="eod_replay">日终回放</el-radio-button>
|
||||
<el-radio-button value="shadow">影子柜台</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="muted form-hint">
|
||||
{{ engine === 'shadow'
|
||||
? 'VPS 盘中实时行情本地撮合(需 VPS 影子柜台进程运行中)'
|
||||
: '每晚 20:30 全量重放结算(NAS)' }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form v-else :model="form" label-width="120px">
|
||||
<el-form-item label="标的(逗号分隔)">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user