feat(paper): 组合策略接入模拟盘实走(E1+E2): paper_accounts加strategy_type列(含迁移); portfolio_paper每晚20:30全量重放→当日成交/末日持仓/净值落paper表(幂等,回测引擎为单一真相源); run_live_step按类型分流; create支持portfolio(仅live); 前端新建模拟盘策略类型选择+组合字段(策略/池/上限/基准) [vps]
CI/CD / test (push) Successful in 11s
CI/CD / nas-deploy (push) Failing after 11s
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-08-13 18:32:09 +08:00
parent 3e7d40ad5f
commit d030cfc91f
7 changed files with 434 additions and 19 deletions
+6
View File
@@ -16,6 +16,11 @@ export interface PaperCreate {
initial_capital: number
start: string
end: string
// 组合策略实走(E1):strategy_type=portfolio 时 mode 须 live
strategy_type?: string
pool?: string
max_pool?: number
benchmark?: string
}
export interface PaperAccount {
@@ -24,6 +29,7 @@ export interface PaperAccount {
mode: string
interval: string
status: string
strategy_type?: string
symbols?: string
initial_capital?: number
start_date?: string
+115 -15
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createPaper, type PaperCreate } from '@/api/paper'
@@ -11,10 +11,38 @@ const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
const strategyOptions = ref<StrategyItem[]>([])
const portfolioOptions = ref<string[]>([])
// 策略类型:cta=个股策略 / portfolio=组合策略(组合仅支持实走,历史回放走「组合回测」)
const strategyType = ref<'cta' | 'portfolio'>('cta')
const poolForm = reactive({ pool: 'hs300_subset', max_pool: 30, benchmark: '000300.XSHG' })
const POOL_OPTIONS = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
{ label: '全市场(慢,非 MVP)', value: 'all' },
]
const BENCH_OPTIONS = [
{ label: '沪深300', value: '000300.XSHG' },
{ label: '中证500', value: '000905.XSHG' },
{ label: '中证1000', value: '000852.XSHG' },
{ label: '中证2000', value: '932000.XSHG' },
]
const PORTFOLIO_LABELS: Record<string, string> = {
all_weather: '全天候轮动',
momentum_timing: '牛熊动量',
value_selection: '价值精选',
small_cap: '小市值轮动',
}
onMounted(async () => {
try {
strategyOptions.value = await getStrategies()
const [strats, files] = await Promise.all([
getStrategies(),
apiClient.get<{ files: { name: string; type: string }[] }>('/strategy/files'),
])
strategyOptions.value = strats
portfolioOptions.value = files.data.files
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
} catch {
/* 下拉加载失败不阻塞表单 */
}
@@ -88,12 +116,40 @@ onMounted(async () => {
}
})
const isPortfolio = computed(() => strategyType.value === 'portfolio')
const portfolioStrategy = ref('all_weather')
// 组合类型锁定实走(历史回放走「组合回测」页)
watch(strategyType, (t) => {
if (t === 'portfolio') {
form.value.mode = 'live'
if (!portfolioStrategy.value && portfolioOptions.value.length) {
portfolioStrategy.value = portfolioOptions.value[0]
}
}
})
async function onSubmit(): Promise<void> {
loading.value = true
try {
const aid = await createPaper(form.value)
ElMessage.success(`已创建模拟盘 #${aid}`)
router.push(form.value.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`)
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,
params: { max_pool: poolForm.max_pool, benchmark: poolForm.benchmark },
match_session: 'next_open',
symbol: poolForm.pool,
}]
payload.pool = poolForm.pool
payload.max_pool = Number(poolForm.max_pool)
payload.benchmark = poolForm.benchmark
}
const aid = await createPaper(payload)
ElMessage.success(`已创建模拟盘 #${aid}(今晚 20:30 起每日结算)`)
router.push(payload.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
@@ -117,27 +173,70 @@ function onSymbols(v: string): void {
<el-card class="blk" shadow="never">
<template #header><span class="section-title">基本配置</span></template>
<div class="seg-label">运行模式</div>
<div class="seg-label">策略类型</div>
<div class="seg-row">
<div
v-for="m in modes" :key="m.value"
class="seg-card" :class="{ active: form.mode === m.value }"
@click="form.mode = m.value"
class="seg-card" :class="{ active: strategyType === 'cta' }"
@click="strategyType = 'cta'"
>
<div class="seg-title">{{ m.label }}</div>
<div class="seg-desc">{{ m.desc }}</div>
<div class="seg-title">CTA 个股策略</div>
<div class="seg-desc">单标的信号型回放 / 实走</div>
</div>
<div
class="seg-card" :class="{ active: strategyType === 'portfolio' }"
@click="strategyType = 'portfolio'"
>
<div class="seg-title">组合策略</div>
<div class="seg-desc">选股轮动型仅实走回放走组合回测</div>
</div>
</div>
<div class="seg-label" style="margin-top:16px">频率</div>
<el-radio-group v-model="form.interval">
<div class="seg-label" style="margin-top:16px">运行模式</div>
<div class="seg-row">
<div
v-for="m in modes" :key="m.value"
class="seg-card" :class="{ active: form.mode === m.value, disabled: isPortfolio && m.value === 'replay' }"
@click="if (!(isPortfolio && m.value === 'replay')) form.mode = m.value"
>
<div class="seg-title">{{ m.label }}</div>
<div class="seg-desc">{{ isPortfolio && m.value === 'replay' ? '组合策略不支持(请用组合回测)' : m.desc }}</div>
</div>
</div>
<div v-if="!isPortfolio" class="seg-label" style="margin-top:16px">频率</div>
<el-radio-group v-if="!isPortfolio" v-model="form.interval">
<el-radio-button v-for="i in intervals" :key="i.value" :value="i.value">{{ i.label }}</el-radio-button>
</el-radio-group>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与策略</span></template>
<el-form :model="form" label-width="120px">
<template #header><span class="section-title">{{ isPortfolio ? '组合策略与选股' : '标的与策略' }}</span></template>
<el-form v-if="isPortfolio" :model="poolForm" label-width="120px">
<el-form-item label="组合策略">
<el-select v-model="portfolioStrategy" style="width: 320px">
<el-option
v-for="p in portfolioOptions" :key="p"
:label="PORTFOLIO_LABELS[p] ? `${PORTFOLIO_LABELS[p]}${p}` : p"
:value="p"
/>
</el-select>
</el-form-item>
<el-form-item label="标的池">
<el-select v-model="poolForm.pool" style="width: 320px">
<el-option v-for="o in POOL_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="选股池上限">
<el-input-number v-model="poolForm.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
<span class="muted form-hint">0=不限, N=前N只默认30</span>
</el-form-item>
<el-form-item label="比较基准">
<el-select v-model="poolForm.benchmark" style="width: 220px">
<el-option v-for="b in BENCH_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
</el-form-item>
</el-form>
<el-form v-else :model="form" label-width="120px">
<el-form-item label="标的(逗号分隔)">
<el-input
:model-value="form.symbols.join(',')"
@@ -208,6 +307,7 @@ function onSymbols(v: string): void {
}
.seg-card:hover { border-color: var(--brand); background: var(--bg-hover); }
.seg-card.active { border-color: var(--brand); background: rgba(24, 144, 255, 0.10); }
.seg-card.disabled { opacity: 0.45; cursor: not-allowed; }
.seg-title { font-size: 14px; font-weight: 600; color: var(--text); }
.seg-desc { font-size: 12px; color: var(--text-3); margin-top: 4px; }
+20
View File
@@ -53,6 +53,12 @@ class PaperCreateRequest(BaseModel):
min_commission: float = 5.0
start: str
end: str
# 组合策略实走(E1):strategy_type=portfolio 时 mode 必须 live
# strategies[0].name=组合策略名,pool/max_pool/benchmark 进 params
strategy_type: str = "cta"
pool: str = "hs300_subset"
max_pool: int = 30
benchmark: str = "000300.XSHG"
@router.post("/paper/create", dependencies=[Depends(verify_token)])
@@ -62,6 +68,20 @@ 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)模式;历史回放请用「组合回测」")
payload = req.model_dump()
payload["symbols"] = [req.pool]
payload["strategies"] = [{
"name": (req.strategies[0].name if req.strategies else "all_weather"),
"params": {"max_pool": req.max_pool, "benchmark": req.benchmark},
}]
aid = save_account(db, payload)
from sanguo_trader.persistence import update_account_status
update_account_status(db, aid, "running")
return {"account_id": aid, "status": "running"}
aid = save_account(db, req.model_dump())
status = "created"
if req.mode == "replay": # 回放后台线程跑,create 立即返回(避免阻塞 worker 502)
+15 -1
View File
@@ -322,6 +322,7 @@ def list_live_accounts(db_path: str) -> list[int]:
def run_live_step(db_path: str) -> None:
"""每日全局 step:遍历所有 live accounts 调 live_stepscheduler 20:30 调)。
strategy_type=portfolio 的账户走组合实走全量重放portfolio_paper
lazy import _DataSourceWrapper 避免与 routes_paper 循环 import
"""
from sanguo_data.config import find_config_path, load_config
@@ -331,6 +332,19 @@ def run_live_step(db_path: str) -> None:
data_source = _DataSourceWrapper(cfg)
for aid in list_live_accounts(db_path):
try:
live_step(db_path, aid, data_source, cfg)
if _is_portfolio_account(db_path, aid):
from .portfolio_paper import run_portfolio_live_step
run_portfolio_live_step(db_path, aid)
else:
live_step(db_path, aid, data_source, cfg)
except Exception as e: # noqa: BLE001
logger.error("live_step account %s 失败: %s", aid, e)
def _is_portfolio_account(db_path: str, account_id: int) -> bool:
"""strategy_type=portfolio → 组合实走引擎。"""
with sqlite3.connect(db_path) as conn:
row = conn.execute(
"SELECT strategy_type FROM paper_accounts WHERE id=?", (account_id,)
).fetchone()
return bool(row) and (row[0] or "cta") == "portfolio"
+10 -3
View File
@@ -14,6 +14,7 @@ SCHEMA = """
CREATE TABLE IF NOT EXISTS paper_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT, owner_id TEXT DEFAULT 'admin', name TEXT,
strategy_type TEXT DEFAULT 'cta',
mode TEXT, interval TEXT,
symbols TEXT, strategies TEXT,
initial_capital REAL, rate REAL, slippage REAL, size REAL, pricetick REAL,
@@ -68,6 +69,11 @@ def init_db(db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(db_path) as conn:
conn.executescript(SCHEMA)
# 迁移:老库补 strategy_type 列(组合策略实走,E1)
try:
conn.execute("ALTER TABLE paper_accounts ADD COLUMN strategy_type TEXT DEFAULT 'cta'")
except sqlite3.OperationalError:
pass # 列已存在
conn.execute("PRAGMA journal_mode=WAL")
conn.commit()
@@ -76,14 +82,15 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
with sqlite3.connect(db_path) as conn:
cur = conn.execute(
"""INSERT INTO paper_accounts
(task_id, owner_id, name, mode, interval, symbols, strategies,
(task_id, owner_id, name, strategy_type, mode, interval, symbols, strategies,
initial_capital, rate, slippage, size, pricetick,
stamp_duty_rate, transfer_fee_rate, min_commission,
status, start_date, end_date, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
account.get("task_id"), account.get("owner_id", "admin"),
account.get("name"), account.get("mode"), account.get("interval"),
account.get("name"), account.get("strategy_type", "cta"),
account.get("mode"), account.get("interval"),
json.dumps(account.get("symbols", [])),
json.dumps(account.get("strategies", [])),
account.get("initial_capital", 0), account.get("rate", 0.0003),
+148
View File
@@ -0,0 +1,148 @@
"""组合策略模拟盘实走(E1,spec §13.2 前后端 session 职责)。
设计每晚 20:30scheduler 全局 job对每个 strategy_type=portfolio live
账户用回测引擎从 start_date **全量重放到今天**取末日持仓/当日成交/净值落
paper 不做增量引擎回测引擎是单一真相源避免增量状态与回测口径漂移
1 年区间全量重放约 2-3 分钟NAS 26G 20:30 后台跑可接受区间拉长到
数年后可优化为 checkpoint 续跑 update_checkpoint
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
from typing import Any
logger = logging.getLogger(__name__)
def _bj_today() -> str:
"""北京时间今天(NAS 容器 TZ 可能为 UTC)。"""
return (datetime.utcnow() + timedelta(hours=8)).strftime("%Y-%m-%d")
def _nas_provider_config() -> dict[str, Any]:
"""NAS 容器 unified provider 配置(与 portfolio_worker NAS 分支一致)。"""
return {
"db_path": "/volume1/stock/sanguo_vnpy_v2/data_backup/quant_trading.db",
"data_dir": "/volume1/stock/sanguo_vnpy_v2/data",
}
def _provider_config() -> dict[str, Any]:
import os
if os.path.isdir("/app"):
return _nas_provider_config()
# 开发机兜底:默认 data 目录(Mac 无全量数据,step 会因数据缺失跳过)
return {"mode": "backtest"}
def run_portfolio_live_step(db_path: str, account_id: int, today: str | None = None) -> dict[str, Any]:
"""组合实走单日 step:全量重放 → 当日成交/末日持仓/净值 落库。
幂等当日已结算checkpoint_date == today 或最新净值日期 == today则跳过
"""
import sqlite3
from sanguo_trader.persistence import (
load_checkpoint, load_last_balance, save_daily_balance, save_positions,
save_trade, update_checkpoint,
)
today = today or _bj_today()
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM paper_accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"paper account {account_id} not found")
acc = dict(row)
if (acc.get("strategy_type") or "cta") != "portfolio":
raise ValueError(f"account {account_id} 不是 portfolio 类型")
if acc["status"] != "running":
return {"account_id": account_id, "skipped": f"status={acc['status']}"}
# 幂等:今天已结算过
ck = load_checkpoint(db_path, account_id)
last_bal = load_last_balance(db_path, account_id)
if ck == today or (last_bal and last_bal.get("date") == today):
return {"account_id": account_id, "skipped": "already stepped today"}
strategies = json.loads(acc["strategies"] or "[]")
if not strategies:
raise ValueError("portfolio 账户缺少策略配置")
strategy_name = strategies[0].get("name", "all_weather")
pool = (json.loads(acc["symbols"] or "[]") or ["hs300_subset"])[0]
max_pool = int(strategies[0].get("params", {}).get("max_pool", 30))
benchmark = strategies[0].get("params", {}).get("benchmark", "000300.XSHG")
from sanguo_portfolio.runner_backtest import run_backtest_json
result = run_backtest_json({
"strategy": strategy_name,
"pool": pool,
"max_pool": max_pool,
"start_date": acc["start_date"],
"end_date": today,
"initial_cash": acc["initial_capital"],
"benchmark": benchmark,
"commission_rate": acc.get("rate") or 0.0003,
"stamp_duty_rate": acc.get("stamp_duty_rate") or 0.001,
"min_commission": acc.get("min_commission") or 5.0,
"slippage": acc.get("slippage") or 0.0,
"provider": "unified",
"provider_config": json.dumps(_provider_config()),
})
equity_curve = result.get("equity_curve") or []
if not equity_curve:
return {"account_id": account_id, "skipped": "no equity point (数据未到?)"}
last_point = equity_curve[-1]
if last_point["date"] <= _acc_last_date(last_bal):
return {"account_id": account_id, "skipped": "no new trading day"}
settle_date = last_point["date"]
# 当日成交(回放里 settle_date 发生的全部交易)
n_trades = 0
for t in result.get("trades") or []:
d = str(t.get("datetime") or t.get("date") or "")
if not d.startswith(settle_date):
continue
side = str(t.get("side") or t.get("action") or "").lower()
save_trade(db_path, account_id, {
"strategy_id": strategy_name,
"datetime": d,
"symbol": str(t.get("code", "")),
"direction": "long" if side in ("buy", "open", "") else "short",
"offset": "open" if side in ("buy", "open") else "close",
"match_session": "current_close",
"price": float(t.get("filled_price") or t.get("price") or 0),
"volume": int(float(t.get("filled_amount") or t.get("amount") or 0)),
"commission": float(t.get("commission") or 0),
"stamp_duty": 0.0,
"bar_date": settle_date,
})
n_trades += 1
# 末日持仓 + 净值
stocks = result.get("stocks_selected") or []
positions_value = sum(float(s.get("value") or 0) for s in stocks)
equity = float(last_point["equity"])
save_positions(db_path, account_id, "account", {
str(s["code"]): {"volume": int(float(s.get("amount", 0))),
"frozen": 0,
"avg_price": float(s.get("avg_cost", 0) or 0)}
for s in stocks
}, date=settle_date)
save_daily_balance(db_path, account_id, settle_date,
cash=equity - positions_value,
market_value=positions_value,
total_equity=equity)
update_checkpoint(db_path, account_id, settle_date)
logger.info("portfolio live step aid=%s date=%s trades=%s equity=%.2f",
account_id, settle_date, n_trades, equity)
return {"account_id": account_id, "date": settle_date, "trades": n_trades, "equity": equity}
def _acc_last_date(last_bal: dict | None) -> str:
"""账户已有净值的最新日期(无则空串,任何新数据都算新)。"""
return (last_bal or {}).get("date") or ""
+120
View File
@@ -0,0 +1,120 @@
"""组合策略模拟盘实走 step 单测(E1):mock run_backtest_json 验证落库/幂等/分流。"""
import json
import pytest
from sanguo_trader import portfolio_paper
from sanguo_trader.persistence import (
init_db, load_last_balance, load_positions, list_trades, save_account,
update_account_status,
)
def _mk_account(db, strategy_type="portfolio", status="running"):
aid = save_account(db, {
"name": "pf1", "mode": "live", "strategy_type": strategy_type,
"symbols": ["hs300_subset"],
"strategies": [{"name": "all_weather",
"params": {"max_pool": 30, "benchmark": "000300.XSHG"}}],
"initial_capital": 1_000_000, "start": "2026-01-01", "end": "2026-12-31",
})
update_account_status(db, aid, status)
return aid
def _fake_result(date="2026-08-13", equity=1_050_000.0):
return {
"strategy": "all_weather",
"equity_curve": [{"date": "2026-08-12", "equity": 1_040_000.0},
{"date": date, "equity": equity}],
"stocks_selected": [
{"code": "600000", "amount": 1000, "avg_cost": 10.0, "price": 10.5, "value": 10500.0},
],
"trades": [
{"date": f"{date} 14:50:00", "code": "600000", "side": "buy",
"filled_amount": 1000, "filled_price": 10.5, "commission": 5.0},
{"date": "2026-08-12 14:50:00", "code": "000001", "side": "buy",
"filled_amount": 500, "filled_price": 11.0},
],
"metrics": {},
}
def test_step_writes_balance_positions_today_trades(tmp_path, monkeypatch):
db = str(tmp_path / "paper.db")
init_db(db)
aid = _mk_account(db)
monkeypatch.setattr("sanguo_portfolio.runner_backtest.run_backtest_json",
lambda params: _fake_result())
out = portfolio_paper.run_portfolio_live_step(db, aid, today="2026-08-13")
assert out["date"] == "2026-08-13"
assert out["trades"] == 1 # 只落当日成交
bal = load_last_balance(db, aid)
assert bal["date"] == "2026-08-13"
assert bal["total_equity"] == pytest.approx(1_050_000.0)
assert bal["cash"] == pytest.approx(1_050_000.0 - 10500.0)
pos = load_positions(db, aid, "account")
assert pos["600000"]["volume"] == 1000
trades = list_trades(db, aid)
assert len(trades) == 1
assert trades[0]["symbol"] == "600000"
assert trades[0]["strategy_id"] == "all_weather"
def test_step_idempotent_same_day(tmp_path, monkeypatch):
db = str(tmp_path / "paper.db")
init_db(db)
aid = _mk_account(db)
calls = []
monkeypatch.setattr("sanguo_portfolio.runner_backtest.run_backtest_json",
lambda p: calls.append(1) or _fake_result())
r1 = portfolio_paper.run_portfolio_live_step(db, aid, today="2026-08-13")
r2 = portfolio_paper.run_portfolio_live_step(db, aid, today="2026-08-13")
assert r1.get("date") == "2026-08-13"
assert r2.get("skipped") == "already stepped today"
assert len(calls) == 1
def test_step_skips_no_new_trading_day(tmp_path, monkeypatch):
db = str(tmp_path / "paper.db")
init_db(db)
aid = _mk_account(db)
# 数据滞后:回放末日仍 08-12(账户已结算到 08-13 前先结算到 12)
monkeypatch.setattr("sanguo_portfolio.runner_backtest.run_backtest_json",
lambda p: _fake_result(date="2026-08-11", equity=1_030_000.0))
portfolio_paper.run_portfolio_live_step(db, aid, today="2026-08-12")
monkeypatch.setattr("sanguo_portfolio.runner_backtest.run_backtest_json",
lambda p: _fake_result(date="2026-08-11", equity=1_030_000.0))
r = portfolio_paper.run_portfolio_live_step(db, aid, today="2026-08-13")
assert r.get("skipped") == "no new trading day"
def test_step_stopped_account_skipped(tmp_path, monkeypatch):
db = str(tmp_path / "paper.db")
init_db(db)
aid = _mk_account(db, status="stopped")
monkeypatch.setattr("sanguo_portfolio.runner_backtest.run_backtest_json",
lambda p: (_ for _ in ()).throw(AssertionError("不应跑引擎")))
r = portfolio_paper.run_portfolio_live_step(db, aid, today="2026-08-13")
assert r.get("skipped") == "status=stopped"
def test_step_rejects_cta_account(tmp_path):
db = str(tmp_path / "paper.db")
init_db(db)
aid = _mk_account(db, strategy_type="cta")
with pytest.raises(ValueError):
portfolio_paper.run_portfolio_live_step(db, aid)
def test_is_portfolio_branch(tmp_path):
from sanguo_trader.live_orchestrator import _is_portfolio_account
db = str(tmp_path / "paper.db")
init_db(db)
p_aid = _mk_account(db, strategy_type="portfolio")
c_aid = _mk_account(db, strategy_type="cta")
assert _is_portfolio_account(db, p_aid) is True
assert _is_portfolio_account(db, c_aid) is False