feat(paper): 组合策略接入模拟盘实走(E1+E2): paper_accounts加strategy_type列(含迁移); portfolio_paper每晚20:30全量重放→当日成交/末日持仓/净值落paper表(幂等,回测引擎为单一真相源); run_live_step按类型分流; create支持portfolio(仅live); 前端新建模拟盘策略类型选择+组合字段(策略/池/上限/基准) [vps]
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_step(scheduler 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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""组合策略模拟盘实走(E1,spec §13.2 前后端 session 职责)。
|
||||
|
||||
设计:每晚 20:30(scheduler 全局 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 ""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user