feat(live): 组合策略实盘(R3-1): live_accounts加strategy_type/pool/max_pool/benchmark列(ALTER迁移); supervisor对组合行拉runner_live子进程(env传参+崩溃检测); runner_live重写适配bullet_trade 0.9.2新API(strategy_file+broker_factory,旧initialize=/broker=已废弃)+live_strategy.py适配文件挂StrategyTemplate; 前端live/New双卡表单+列表组合徽标; 8测试 [vps]
CI/CD / test (push) Successful in 12s
CI/CD / nas-deploy (push) Successful in 27s
CI/CD / nas-verify (push) Successful in 12s

This commit is contained in:
2026-08-13 19:57:23 +08:00
parent baaa459481
commit fd0b9d0c36
9 changed files with 640 additions and 139 deletions
+9
View File
@@ -25,6 +25,11 @@ export interface LiveAccount {
total_return?: number | null
position_count?: number
instance?: string
/** 'cta' 个股 / 'portfolio' 组合 */
strategy_type?: string
pool?: string | null
max_pool?: number | null
benchmark?: string | null
}
export interface LiveCreateRequest {
@@ -39,6 +44,10 @@ export interface LiveCreateRequest {
connect_wait_sec?: number
init_wait_sec?: number
mini_path?: string
strategy_type?: string
pool?: string
max_pool?: number
benchmark?: string
}
export interface LiveStatus {
+4 -1
View File
@@ -183,7 +183,10 @@ async function onStop(a: LiveAccount): Promise<void> {
<el-table :data="filtered" size="small" empty-text="暂无实盘实例">
<el-table-column label="名称 / ID" min-width="160">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-name">
{{ row.name }}
<span v-if="row.strategy_type === 'portfolio'" class="chip chip-portfolio">组合</span>
</div>
<div class="cell-id mono">#{{ row.id }}</div>
</template>
</el-table-column>
+188 -76
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createLive, type LiveCreateRequest } from '@/api/live'
@@ -10,6 +10,41 @@ const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
// :cta=(vnpy engine) / portfolio=(bullet_trade LiveEngine )
const strategyType = ref<'cta' | 'portfolio'>('cta')
const isPortfolio = computed(() => strategyType.value === 'portfolio')
const portfolioOptions = ref<string[]>([])
const portfolioStrategy = ref('all_weather')
const poolForm = ref({ 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 {
const { data } = await apiClient.get<{ files: { name: string; type: string }[] }>('/strategy/files')
portfolioOptions.value = data.files
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
} catch {
/* 下拉加载失败不阻塞表单 */
}
})
const form = ref<LiveCreateRequest>({
name: 'live-600000',
account: '66639661',
@@ -65,13 +100,28 @@ async function onSubmit(): Promise<void> {
ElMessage.warning('请填写交易账号')
return
}
if (!form.value.strategy_name.trim()) {
if (isPortfolio.value && !portfolioStrategy.value) {
ElMessage.warning('请选择组合策略')
return
}
if (!isPortfolio.value && !form.value.strategy_name.trim()) {
ElMessage.warning('请填写策略实例名')
return
}
loading.value = true
try {
const res = await createLive(form.value)
const payload: LiveCreateRequest = { ...form.value }
if (isPortfolio.value) {
payload.strategy_type = 'portfolio'
payload.strategy_class = portfolioStrategy.value
payload.strategy_name = `portfolio_${portfolioStrategy.value}`
payload.pool = poolForm.value.pool
payload.max_pool = Number(poolForm.value.max_pool)
payload.benchmark = poolForm.value.benchmark
} else {
payload.strategy_type = 'cta'
}
const res = await createLive(payload)
ElMessage.success(`已创建实盘实例 #${res.accountId}(stopped),请到列表点"启动"`)
router.push('/live')
} catch (e: unknown) {
@@ -86,20 +136,32 @@ async function onSubmit(): Promise<void> {
<div class="page live-new">
<div class="page-head">
<div>
<h2 class="page-title">新建实盘模拟</h2>
<p class="page-subtitle">miniQMT 直连 · A 股实盘模拟(supervisor 轮询)</p>
<h2 class="page-title">新建实盘</h2>
<p class="page-subtitle">miniQMT 直连 · 个股 CTA / 组合策略实盘(supervisor 轮询)</p>
</div>
</div>
<el-alert
type="info"
:closable="false"
title="创建后状态为 stopped,需到列表点『启动』才会启动(supervisor 轮询发现后起 engine)"
title="创建后状态为 stopped,需到列表点『启动』才会启动(supervisor 轮询发现后起)"
/>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">基本配置</span></template>
<el-form :model="form" label-width="140px">
<div class="seg-label">策略类型</div>
<div class="seg-row">
<div class="seg-card" :class="{ active: strategyType === 'cta' }" @click="strategyType = 'cta'">
<div class="seg-title">CTA 个股策略</div>
<div class="seg-desc">单标的信号型,vnpy engine 进程内跑</div>
</div>
<div class="seg-card" :class="{ active: strategyType === 'portfolio' }" @click="strategyType = 'portfolio'">
<div class="seg-title">组合策略</div>
<div class="seg-desc">选股轮动型,bullet_trade LiveEngine 子进程</div>
</div>
</div>
<el-form label-width="140px" style="margin-top:16px">
<el-form-item label="实例名" required>
<el-input v-model="form.name" placeholder="live-600000" style="width: 320px" />
<span class="muted form-hint">页面显示用</span>
@@ -108,79 +170,10 @@ async function onSubmit(): Promise<void> {
<el-input v-model="form.account" placeholder="66639661" style="width: 320px" />
<span class="muted form-hint">QMT 账号</span>
</el-form-item>
<el-form-item label="策略类">
<el-select v-model="form.strategy_class" style="width: 360px">
<el-option
v-for="opt in strategyClassOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<span class="muted form-hint">MVP 仅支持 AShareDoubleMaStrategy</span>
</el-form-item>
<el-form-item label="策略实例名" required>
<el-input v-model="form.strategy_name" placeholder="AShareDoubleMa_600000" style="width: 320px" />
<span class="muted form-hint">engine 内唯一</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与频率</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="标的(vt_symbol)">
<el-input v-model="form.vt_symbol" placeholder="600000.SSE" style="width: 320px" />
<span class="muted form-hint">交易所代码 .SSE / .SZSE</span>
</el-form-item>
<el-form-item label="K 线周期">
<el-input v-model="form.interval" placeholder="15m" style="width: 160px" />
<span class="muted form-hint"> 15m / 1m / d</span>
</el-form-item>
<el-form-item label="起始资金">
<el-input-number v-model="form.initial_capital" :min="10000" :step="100000" style="width: 240px" />
<span class="muted form-hint">单位:</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">策略参数(setting)</span></template>
<el-form :model="form.setting" label-width="140px">
<el-form-item label="fast_window">
<el-input-number v-model="form.setting.fast_window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">快均线周期</span>
</el-form-item>
<el-form-item label="slow_window">
<el-input-number v-model="form.setting.slow_window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">慢均线周期</span>
</el-form-item>
<el-form-item label="window">
<el-input-number v-model="form.setting.window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">辅助计算窗口</span>
</el-form-item>
<el-form-item label="size">
<el-input-number v-model="form.setting.size as number" :min="1" :step="100" style="width: 200px" />
<span class="muted form-hint">定寸股数(A 100 的倍数)</span>
</el-form-item>
<el-form-item label="forbid_short">
<el-switch v-model="form.setting.forbid_short as boolean" />
<span class="muted form-hint">禁止做空(A 股默认开)</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">连接参数(可选)</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="connect_wait_sec">
<el-input-number v-model="form.connect_wait_sec as number" :min="1" :step="5" style="width: 200px" />
<span class="muted form-hint">连接 QMT 等待秒数</span>
</el-form-item>
<el-form-item label="init_wait_sec">
<el-input-number v-model="form.init_wait_sec as number" :min="1" :step="10" style="width: 200px" />
<span class="muted form-hint">策略初始化等待秒数</span>
</el-form-item>
<el-form-item label="mini_path">
<el-input v-model="form.mini_path" placeholder="C:\\国金QMT交易端模拟\\userdata_mini" style="width: 480px" />
<span class="muted form-hint">miniQMT userdata_mini 路径;空时后端用 env SANGUO_QMT_PATH 或内置默认</span>
@@ -188,6 +181,109 @@ async function onSubmit(): Promise<void> {
</el-form>
</el-card>
<!-- CTA 表单 -->
<template v-if="!isPortfolio">
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与策略</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="策略类">
<el-select v-model="form.strategy_class" style="width: 360px">
<el-option
v-for="opt in strategyClassOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<span class="muted form-hint">MVP 仅支持 AShareDoubleMaStrategy</span>
</el-form-item>
<el-form-item label="策略实例名" required>
<el-input v-model="form.strategy_name" placeholder="AShareDoubleMa_600000" style="width: 320px" />
<span class="muted form-hint">engine 内唯一</span>
</el-form-item>
<el-form-item label="标的(vt_symbol)">
<el-input v-model="form.vt_symbol" placeholder="600000.SSE" style="width: 320px" />
<span class="muted form-hint">交易所代码 .SSE / .SZSE</span>
</el-form-item>
<el-form-item label="K 线周期">
<el-input v-model="form.interval" placeholder="15m" style="width: 160px" />
<span class="muted form-hint"> 15m / 1m / d</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">策略参数(setting)</span></template>
<el-form :model="form.setting" label-width="140px">
<el-form-item label="fast_window">
<el-input-number v-model="form.setting.fast_window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">快均线周期</span>
</el-form-item>
<el-form-item label="slow_window">
<el-input-number v-model="form.setting.slow_window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">慢均线周期</span>
</el-form-item>
<el-form-item label="window">
<el-input-number v-model="form.setting.window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">辅助计算窗口</span>
</el-form-item>
<el-form-item label="size">
<el-input-number v-model="form.setting.size as number" :min="1" :step="100" style="width: 200px" />
<span class="muted form-hint">定寸股数(A 100 的倍数)</span>
</el-form-item>
<el-form-item label="forbid_short">
<el-switch v-model="form.setting.forbid_short as boolean" />
<span class="muted form-hint">禁止做空(A 股默认开)</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">连接参数(可选)</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="connect_wait_sec">
<el-input-number v-model="form.connect_wait_sec as number" :min="1" :step="5" style="width: 200px" />
<span class="muted form-hint">连接 QMT 等待秒数</span>
</el-form-item>
<el-form-item label="init_wait_sec">
<el-input-number v-model="form.init_wait_sec as number" :min="1" :step="10" style="width: 200px" />
<span class="muted form-hint">策略初始化等待秒数</span>
</el-form-item>
</el-form>
</el-card>
</template>
<!-- 组合策略表单 -->
<el-card v-else class="blk" shadow="never">
<template #header><span class="section-title">组合策略与选股</span></template>
<el-form :model="poolForm" label-width="140px">
<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>
<span class="muted form-hint">supervisor 拉起 runner_live 子进程(bullet_trade LiveEngine)</span>
</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-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="loading" @click="onSubmit">
创建实盘实例
@@ -202,4 +298,20 @@ async function onSubmit(): Promise<void> {
.blk { border: 1px solid var(--border-2); }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0; display: flex; gap: 12px; }
.seg-label { font-size: 12px; color: var(--text-3); margin-bottom: 8px; }
.seg-row { display: flex; gap: 12px; }
.seg-card {
flex: 1;
max-width: 280px;
border: 1px solid var(--border-2);
border-radius: var(--r-md);
padding: 12px 14px;
cursor: pointer;
transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.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-title { font-size: 14px; font-weight: 600; color: var(--text); }
.seg-desc { font-size: 12px; color: var(--text-3); margin-top: 4px; }
</style>
+14
View File
@@ -48,6 +48,11 @@ class LiveCreateRequest(BaseModel):
connect_wait_sec: int = 10
init_wait_sec: int = 60
mini_path: str = ""
# 组合实盘(strategy_type='portfolio'):strategy_class 存组合策略名(all_weather 等)
strategy_type: str = "cta"
pool: str = ""
max_pool: int = 0
benchmark: str = ""
@router.post("/live/create", dependencies=[Depends(verify_token)])
@@ -58,6 +63,15 @@ def create_live(req: LiveCreateRequest):
db = _db_path["path"] or ":memory:"
init_db(db)
payload = req.model_dump()
if payload.get("strategy_type") == "portfolio":
# 组合实盘:vt_symbol 占位为池名;setting 存组合参数(supervisor 转发 env)
if not payload.get("strategy_class"):
raise HTTPException(400, "组合实盘需选择策略(strategy_class)")
payload.setdefault("pool", "hs300_subset")
payload.setdefault("max_pool", 30)
payload.setdefault("benchmark", "000300.XSHG")
payload["vt_symbol"] = payload["pool"]
payload["interval"] = "d"
# mini_path 兜底:req → env SANGUO_QMT_PATH → 内置默认(空值会导致 connect=-1)
if not payload.get("mini_path"):
payload["mini_path"] = (
+23 -3
View File
@@ -33,7 +33,11 @@ CREATE TABLE IF NOT EXISTS live_accounts (
mini_path TEXT,
error_msg TEXT,
created_at TEXT,
updated_at TEXT
updated_at TEXT,
strategy_type TEXT DEFAULT 'cta',
pool TEXT,
max_pool INTEGER,
benchmark TEXT
);
CREATE TABLE IF NOT EXISTS live_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -78,6 +82,17 @@ 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)
# 轻量迁移:旧库补组合实盘列(新库 CREATE 已含,ALTER 报错忽略)
for col, ddl in (
("strategy_type", "TEXT DEFAULT 'cta'"),
("pool", "TEXT"),
("max_pool", "INTEGER"),
("benchmark", "TEXT"),
):
try:
conn.execute(f"ALTER TABLE live_accounts ADD COLUMN {col} {ddl}")
except sqlite3.OperationalError:
pass # 列已存在
conn.execute("PRAGMA journal_mode=WAL")
conn.commit()
@@ -90,8 +105,9 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
"""INSERT INTO live_accounts
(name, account, vt_symbol, strategy_class, strategy_name, setting,
status, interval, initial_capital, connect_wait_sec, init_wait_sec,
mini_path, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
mini_path, created_at, updated_at,
strategy_type, pool, max_pool, benchmark)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
account.get("name", "live"),
account.get("account", ""),
@@ -106,6 +122,10 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
int(account.get("init_wait_sec", 60)),
account.get("mini_path", ""),
_now(), _now(),
account.get("strategy_type", "cta"),
account.get("pool", ""),
int(account.get("max_pool", 0) or 0),
account.get("benchmark", ""),
),
)
conn.commit()
+82 -2
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import logging
import os
import signal
import subprocess
import sys
import time
from datetime import datetime
@@ -339,6 +340,61 @@ def _stop_engine(engine: LiveTradingEngine) -> None:
logger.warning("[supervisor] engine.close 异常: %s", e)
def _portfolio_env_for(account_row: dict[str, Any], db_path: str) -> dict[str, str]:
"""live_accounts 行(组合实盘) → runner_live 子进程 env。
独立成函数便于单测(env 映射是组合实盘的唯一契约)
"""
return {
"SANGUO_QMT_ACCOUNT": account_row.get("account", ""),
"SANGUO_QMT_PATH": account_row.get("mini_path", ""),
"SANGUO_LIVE_STRATEGY": account_row.get("strategy_class", "all_weather"),
"SANGUO_LIVE_MAX_POOL": str(account_row.get("max_pool", 30) or 30),
"SANGUO_LIVE_BENCHMARK": account_row.get("benchmark", "000300.XSHG"),
"SANGUO_LIVE_CASH": str(account_row.get("initial_capital", 1_000_000)),
"SANGUO_LIVE_DB": db_path,
"SANGUO_LIVE_ACCOUNT_ID": str(account_row.get("id", "")),
}
def _start_portfolio_subprocess(
account_row: dict[str, Any], db_path: str
) -> subprocess.Popen:
"""组合实盘 = 独立子进程跑 bullet_trade LiveEngine(asyncio,与 supervisor 隔离)。"""
import json as _json
env = {**os.environ, **_portfolio_env_for(account_row, db_path)}
# setting JSON 里的额外参数(max_pool/benchmark 覆盖)并入 env
try:
setting = _json.loads(account_row.get("setting") or "{}")
if setting.get("max_pool") is not None:
env["SANGUO_LIVE_MAX_POOL"] = str(setting["max_pool"])
if setting.get("benchmark"):
env["SANGUO_LIVE_BENCHMARK"] = str(setting["benchmark"])
except (ValueError, TypeError):
pass
logger.info("[supervisor] 拉起组合实盘子进程 (account=%s strategy=%s)",
account_row.get("id"), env.get("SANGUO_LIVE_STRATEGY"))
return subprocess.Popen(
[sys.executable, "-m", "sanguo_portfolio.runner_live"],
env=env,
)
def _stop_portfolio_subprocess(proc: subprocess.Popen) -> None:
"""terminate → 等待 → kill 兜底。"""
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
logger.warning("[supervisor] 组合实盘子进程未在 10s 内退出,kill")
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
def run_supervisor(
db_path: str | None = None,
poll_interval_sec: float = 5.0,
@@ -353,6 +409,8 @@ def run_supervisor(
信号(SIGINT/SIGTERM) 停所有 engine 后退出
MVP:每实例一个 engine,表结构支持多行(多实例同时跑只是内存多 engine)
组合实盘(strategy_type='portfolio')走子进程 ``sanguo_portfolio.runner_live``
(bullet_trade LiveEngine asyncio 事件循环, supervisor 轮询线程模型隔离)
"""
from sanguo_live.persistence import (
init_db, list_running_accounts, get_account,
@@ -369,6 +427,7 @@ def run_supervisor(
db, poll_interval_sec, snapshot_interval_sec)
engines: dict[int, LiveTradingEngine] = {}
portfolio_procs: dict[int, subprocess.Popen] = {}
stop_flag = {"stop": False}
def _shutdown(signum: int, frame: Any) -> None:
@@ -387,11 +446,18 @@ def run_supervisor(
now = time.time()
# 1) 同步 status
running_ids = {r["id"] for r in list_running_accounts(db)}
# 启动新 running
for aid in running_ids - engines.keys():
# 启动新 running(CTA=进程内 engine;组合=子进程)
for aid in running_ids - engines.keys() - portfolio_procs.keys():
acc = get_account(db, aid)
if not acc:
continue
if (acc.get("strategy_type") or "cta") == "portfolio":
try:
portfolio_procs[aid] = _start_portfolio_subprocess(acc, db)
except Exception as e: # noqa: BLE001
logger.error("[supervisor] 起组合实盘失败 (account=%s): %s", aid, e)
update_account_status(db, aid, "stopped", str(e))
continue
try:
eng = _start_engine_for_account(acc)
_register_trade_handler(eng, aid, db)
@@ -404,6 +470,16 @@ def run_supervisor(
for aid in list(engines.keys() - running_ids):
logger.info("[supervisor] 停止 engine (account=%s)", aid)
_stop_engine(engines.pop(aid))
for aid in list(portfolio_procs.keys() - running_ids):
logger.info("[supervisor] 停止组合实盘子进程 (account=%s)", aid)
_stop_portfolio_subprocess(portfolio_procs.pop(aid))
# 组合子进程崩溃检测:退出即标 stopped(rc 写进 error_msg)
for aid, proc in list(portfolio_procs.items()):
rc = proc.poll()
if rc is not None:
logger.error("[supervisor] 组合实盘子进程退出 (account=%s rc=%s)", aid, rc)
portfolio_procs.pop(aid)
update_account_status(db, aid, "stopped", f"runner_live 退出 rc={rc}")
# 2) 定时 snapshot
if now - last_snapshot >= snapshot_interval_sec:
@@ -418,4 +494,8 @@ def run_supervisor(
logger.info("[supervisor] 退出清理 (account=%s)", aid)
_stop_engine(eng)
engines.clear()
for aid, proc in portfolio_procs.items():
logger.info("[supervisor] 退出清理组合实盘 (account=%s)", aid)
_stop_portfolio_subprocess(proc)
portfolio_procs.clear()
logger.info("[supervisor] 已退出")
+85
View File
@@ -0,0 +1,85 @@
"""组合策略实盘适配文件(bullet_trade LiveEngine 加载的聚宽风格 strategy_file)。
bullet_trade 0.9.x LiveEngine 只认策略文件:本文件 ``initialize(context)`` 里把
sanguo_portfolio StrategyTemplate 策略挂到 run_daily/run_monthly 定时器,
下单走 bullet_trade 顶层 API(live 模式自动路由 LiveEngine QmtBroker)
配置从 env (supervisor 注入,或手动 set 后直跑 ``python -m sanguo_portfolio.runner_live``):
SANGUO_LIVE_STRATEGY all_weather / momentum_timing / value_selection / small_cap
SANGUO_LIVE_MAX_POOL 选股池上限(默认 30)
数据 provider runner_live ``set_data_provider`` 先行注入(miniQMT live 模式)
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
def _build_live_strategy(provider):
"""env 配置 → StrategyTemplate 实例(对齐 runner_backtest._build_strategy)。"""
from sanguo_portfolio.strategies import (
AllWeatherConfig, AllWeatherStrategy,
MomentumTimingConfig, MomentumTimingStrategy,
SmallCapConfig, SmallCapStrategy,
ValueSelectionConfig, ValueSelectionStrategy,
)
name = os.environ.get("SANGUO_LIVE_STRATEGY", "all_weather")
max_pool = int(os.environ.get("SANGUO_LIVE_MAX_POOL", "30") or 30)
factories = {
"all_weather": lambda: AllWeatherStrategy(
provider=provider, config=AllWeatherConfig(max_pool=max_pool)),
"momentum_timing": lambda: MomentumTimingStrategy(
provider=provider, config=MomentumTimingConfig(max_pool=max_pool)),
"value_selection": lambda: ValueSelectionStrategy(
provider=provider, config=ValueSelectionConfig(max_pool=max_pool)),
"small_cap": lambda: SmallCapStrategy(
provider=provider, config=SmallCapConfig(max_pool=max_pool)),
}
if name not in factories:
raise ValueError(
f"未知 SANGUO_LIVE_STRATEGY: {name}"
f"(支持: {' / '.join(factories)})"
)
return factories[name]()
def initialize(context):
"""LiveEngine 启动时回调:挂策略 + 定时器 + 费用滑点。"""
from bullet_trade.core.api import ( # type: ignore
order_target_value as bt_otv,
order_value as bt_ov,
set_order_cost, set_slippage,
)
from bullet_trade.core.settings import ( # type: ignore
OrderCost, FixedSlippage, set_option as bt_set_option,
)
from bullet_trade.data.api import get_data_provider # type: ignore
from sanguo_portfolio.runner_backtest import _register_schedule
from sanguo_portfolio.strategies.all_weather import BrokerFacade
strategy = _build_live_strategy(get_data_provider())
_register_schedule(strategy)
# broker 注入(与回测同构):下单委托 bullet_trade 顶层 API,live 下路由 engine
strategy.broker = BrokerFacade(
order_target_value=lambda c, v: bt_otv(c, v),
order_value=lambda c, v: bt_ov(c, v),
set_option=lambda k, v: bt_set_option(k, v),
)
# A 股费用 + 滑点(与回测默认一致)
set_order_cost(
OrderCost(
open_tax=0.0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
min_commission=5.0,
),
type="stock",
)
set_slippage(FixedSlippage(value=0.001))
strategy.initialize(context)
logger.info("live strategy 已挂载: %s", type(strategy).__name__)
+92 -57
View File
@@ -1,13 +1,22 @@
"""全天候策略实盘入口(VPS Windows / miniQMT 直连)。
"""组合策略实盘入口(VPS Windows / miniQMT 直连)——bullet_trade 0.9.2 LiveEngine
**实盘就绪,但需在交易日+miniQMT 连接下首次跑**
supervisor(``sanguo_live.runner.run_supervisor``) strategy_type='portfolio'
live_accounts 行以**子进程**方式拉起本模块,env 传参:
用法:
set DEFAULT_DATA_PROVIDER=miniqmt
set MINIQMT_MARKET=SH
SANGUO_QMT_ACCOUNT / SANGUO_QMT_PATH miniQMT 交易账号 / userdata_mini 路径
SANGUO_LIVE_STRATEGY / _MAX_POOL / _BENCHMARK 组合策略配置
SANGUO_LIVE_CASH 初始资金(engine NAV 基准)
SANGUO_LIVE_DB / SANGUO_LIVE_ACCOUNT_ID 快照落库目标(缺省不落)
手动用法(交易日 + miniQMT 连接下):
set SANGUO_QMT_ACCOUNT=66639661
python -m sanguo_portfolio.runner_live
不在 Mac (Mac xtquant/miniQMT 客户端)
历史注记:0.2 之前的 bullet_trade LiveEngine 接受 ``initialize=/broker=`` 直传,
0.9.x 改为 strategy_file + broker_factory本模块即按新 API 装配,策略逻辑在
``sanguo_portfolio/live_strategy.py``(适配文件)
"""
from __future__ import annotations
@@ -16,10 +25,15 @@ import os
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
import logging
import threading
import time
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
ADAPTER_FILE = Path(__file__).resolve().parent / "live_strategy.py"
def build_provider(provider_config: Dict[str, Any] | None = None) -> Any:
"""构造 live 模式的 SanguoMiniQmtProvider。"""
@@ -31,73 +45,94 @@ def build_provider(provider_config: Dict[str, Any] | None = None) -> Any:
return SanguoMiniQmtProvider(cfg)
def build_broker_facade() -> Any:
"""实盘 BrokerFacade:委托 bullet_trade 顶层聚宽风格 API + QmtBroker"""
from .strategies.all_weather import BrokerFacade
from bullet_trade.core.api import ( # type: ignore
order_target_value as bt_otv,
order_value as bt_ov,
set_benchmark, set_option, set_slippage, set_order_cost,
run_daily, run_monthly,
)
def live_env() -> Dict[str, str]:
"""解析 env 实盘配置(带默认值)。独立出来便于单测"""
return {
"strategy": os.environ.get("SANGUO_LIVE_STRATEGY", "all_weather"),
"max_pool": os.environ.get("SANGUO_LIVE_MAX_POOL", "30"),
"benchmark": os.environ.get("SANGUO_LIVE_BENCHMARK", "000300.XSHG"),
"cash": os.environ.get("SANGUO_LIVE_CASH", "1000000"),
"account": os.environ.get("SANGUO_QMT_ACCOUNT", ""),
"mini_path": (os.environ.get("SANGUO_QMT_PATH")
or r"C:\国金QMT交易端模拟\userdata_mini"),
"db": os.environ.get("SANGUO_LIVE_DB", ""),
"account_id": os.environ.get("SANGUO_LIVE_ACCOUNT_ID", ""),
}
return BrokerFacade(
order_target_value=lambda c, v: bt_otv(c, v),
order_value=lambda c, v: bt_ov(c, v),
set_benchmark=set_benchmark,
set_option=set_option,
set_slippage=set_slippage,
set_order_cost=set_order_cost,
run_daily=run_daily,
run_monthly=run_monthly,
)
def _snapshot_loop(engine: Any, db: str, account_id: int,
interval_sec: float = 60.0) -> None:
"""后台线程:把 engine 组合快照落 live_positions/live_balance(供 API 读)。
LiveEngine 的账户/持仓由 broker 同步进 context.portfolio(LivePortfolioProxy),
这里只读转储;任何异常只 warning 不中断(engine 主循环不受影响)
"""
from datetime import datetime
from sanguo_live.persistence import save_balance, save_positions
while True:
time.sleep(interval_sec)
try:
portfolio = engine.context.portfolio
positions: Dict[str, Dict[str, Any]] = {}
for sym, pos in (getattr(portfolio, "positions", None) or {}).items():
vol = int(getattr(pos, "total_amount", 0) or 0)
if vol <= 0:
continue
positions[str(sym)] = {
"volume": float(vol),
"frozen": float(vol - int(getattr(pos, "closeable_amount", vol) or 0)),
"avg_price": float(getattr(pos, "avg_cost", 0) or 0),
}
save_positions(db, account_id, positions)
cash = float(getattr(portfolio, "available_cash", 0) or 0)
total = float(getattr(portfolio, "total_value", 0) or 0)
save_balance(
db, account_id, datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
cash, market_value=max(total - cash, 0.0), total=total,
)
except Exception as e: # noqa: BLE001
logger.warning("[live-snapshot] 落库失败 (account=%s): %s", account_id, e)
def run_live(provider_config: Dict[str, Any] | None = None) -> None:
"""启动 LiveEngine + AllWeatherStrategy。
LiveEngine 负责驱动 scheduler(context.current_dt 推进)和下单路由
我们负责把 provider/broker 注入 AllWeatherStrategy
"""
"""装配 LiveEngine(strategy_file=适配文件 + QmtBroker)并 run(阻塞)。"""
from bullet_trade.core.live_engine import LiveEngine # type: ignore
from bullet_trade.data.api import set_data_provider # type: ignore
from bullet_trade.broker.qmt import QmtBroker # type: ignore
from .strategies import AllWeatherConfig, AllWeatherStrategy
provider = build_provider(provider_config)
set_data_provider(provider)
broker_facade = build_broker_facade()
strategy = AllWeatherStrategy(
provider=provider,
broker=broker_facade,
config=AllWeatherConfig(),
)
def initialize(context):
strategy.initialize(context)
# QmtBroker 需要 account_id + data_path(miniQMT userdata_mini)。
# 从 env 读(与 sanguo_live 约定一致),缺 account 拒绝启动避免误下单。
account = os.environ.get("SANGUO_QMT_ACCOUNT", "")
mini_path = (os.environ.get("SANGUO_QMT_PATH")
or r"C:\国金QMT交易端模拟\userdata_mini")
if not account:
cfg = live_env()
if not cfg["account"]:
raise RuntimeError(
"缺 SANGUO_QMT_ACCOUNT(miniQMT 交易账号),实盘无法启动。"
"设 set SANGUO_QMT_ACCOUNT=66639661 后重试。"
)
broker = QmtBroker(account_id=account, data_path=mini_path)
logger.info("QmtBroker 装配 account=%s data_path=%s", account, mini_path)
# 小资金 1e6 起步,等交易日观察(9:05 prepare / 月初 9:30 monthly / 14:00 stop_loss)
provider = build_provider(provider_config)
set_data_provider(provider)
broker = QmtBroker(account_id=cfg["account"], data_path=cfg["mini_path"])
logger.info("QmtBroker 装配 account=%s data_path=%s", cfg["account"], cfg["mini_path"])
engine = LiveEngine(
initialize=initialize,
initial_cash=1_000_000.0,
broker=broker,
ADAPTER_FILE,
broker_factory=lambda: broker,
)
logger.info("AllWeather live engine 启动,等交易日触发 monthly_adjustment")
logger.info(
"组合 live engine 启动: strategy=%s max_pool=%s benchmark=%s cash=%s",
cfg["strategy"], cfg["max_pool"], cfg["benchmark"], cfg["cash"],
)
# 快照落库(supervisor 注入 db+account_id 时才开)
if cfg["db"] and cfg["account_id"]:
t = threading.Thread(
target=_snapshot_loop,
args=(engine, cfg["db"], int(cfg["account_id"])),
daemon=True, name="live-snapshot",
)
t.start()
engine.run()
+143
View File
@@ -0,0 +1,143 @@
"""Tests for 组合策略实盘(R3-1): live_accounts 组合列 + create 路由 + supervisor env 映射。
runner_live 的引擎装配依赖 miniQMT/VPS,不在单测范围;这里测的是
web 建组合实盘 DB 字段落对 supervisor 能翻译出正确 env这条契约链
直调路由函数风格与 test_paper_lifecycle.py 一致( auth)
"""
import sqlite3
import pytest
from sanguo_api import routes_live as rl
from sanguo_live import persistence as live_persistence
from sanguo_live import runner as live_runner
@pytest.fixture()
def live_db(tmp_path):
db = str(tmp_path / "live.db")
rl.set_db_path(db)
return db
def _create_portfolio(db, **kw):
req = rl.LiveCreateRequest(
name=kw.get("name", "组合实盘1"), account=kw.get("account", "66639661"),
strategy_name=kw.get("strategy_name", "portfolio_all_weather"),
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"),
initial_capital=kw.get("initial_capital", 500000),
)
return rl.create_live(req)["account_id"]
def test_save_account_portfolio_fields(live_db):
aid = live_persistence.save_account(live_db, {
"name": "portfolio-live", "account": "66639661",
"strategy_class": "all_weather", "strategy_name": "portfolio_all_weather",
"strategy_type": "portfolio", "pool": "hs300_subset",
"max_pool": 30, "benchmark": "000300.XSHG",
"status": "running", "vt_symbol": "hs300_subset",
})
acc = live_persistence.get_account(live_db, aid)
assert acc["strategy_type"] == "portfolio"
assert acc["pool"] == "hs300_subset"
assert acc["max_pool"] == 30
assert acc["benchmark"] == "000300.XSHG"
def test_init_db_migrates_old_live_accounts(tmp_path):
"""旧库(无组合列)init_db 后补列且默认 cta。"""
db = str(tmp_path / "old.db")
old_schema = """
CREATE TABLE live_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, account TEXT,
vt_symbol TEXT, strategy_class TEXT, strategy_name TEXT, setting TEXT,
status TEXT, interval TEXT, initial_capital REAL,
connect_wait_sec INTEGER, init_wait_sec INTEGER, mini_path TEXT,
error_msg TEXT, created_at TEXT, updated_at TEXT
);
"""
with sqlite3.connect(db) as conn:
conn.executescript(old_schema)
conn.execute("INSERT INTO live_accounts (name, status) VALUES ('old', 'stopped')")
live_persistence.init_db(db)
acc = live_persistence.get_account(db, 1)
assert acc["strategy_type"] == "cta" # ALTER DEFAULT 生效于旧行
def test_create_portfolio_live(live_db):
aid = _create_portfolio(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["status"] == "stopped"
def test_create_portfolio_rejects_empty_strategy(live_db):
with pytest.raises(Exception):
_create_portfolio(live_db, strategy_class="")
def test_create_cta_defaults_unchanged(live_db):
req = rl.LiveCreateRequest(account="66639661", strategy_name="dm1")
aid = rl.create_live(req)["account_id"]
acc = live_persistence.get_account(live_db, aid)
assert acc["strategy_type"] == "cta"
assert acc["interval"] == "15m"
assert acc["vt_symbol"] == "600000.SSE"
def test_portfolio_env_mapping():
"""live_accounts 行 → runner_live env(env 是组合实盘唯一参数契约)。"""
acc = {
"id": 7, "account": "66639661", "mini_path": r"C:\qmt\userdata_mini",
"strategy_class": "value_selection", "max_pool": 20,
"benchmark": "000905.XSHG", "initial_capital": 2_000_000,
}
env = live_runner._portfolio_env_for(acc, "live.db")
assert env["SANGUO_QMT_ACCOUNT"] == "66639661"
assert env["SANGUO_QMT_PATH"] == r"C:\qmt\userdata_mini"
assert env["SANGUO_LIVE_STRATEGY"] == "value_selection"
assert env["SANGUO_LIVE_MAX_POOL"] == "20"
assert env["SANGUO_LIVE_BENCHMARK"] == "000905.XSHG"
assert env["SANGUO_LIVE_CASH"] == "2000000"
assert env["SANGUO_LIVE_DB"] == "live.db"
assert env["SANGUO_LIVE_ACCOUNT_ID"] == "7"
def test_runner_live_env_defaults(monkeypatch):
"""runner_live.live_env 带默认值(手动跑不传参也不炸)。"""
from sanguo_portfolio import runner_live
for k in ("SANGUO_LIVE_STRATEGY", "SANGUO_LIVE_MAX_POOL", "SANGUO_LIVE_BENCHMARK",
"SANGUO_LIVE_CASH", "SANGUO_QMT_ACCOUNT", "SANGUO_QMT_PATH",
"SANGUO_LIVE_DB", "SANGUO_LIVE_ACCOUNT_ID"):
monkeypatch.delenv(k, raising=False)
cfg = runner_live.live_env()
assert cfg["strategy"] == "all_weather"
assert cfg["max_pool"] == "30"
assert cfg["benchmark"] == "000300.XSHG"
assert cfg["account"] == "" # 空 → run_live 拒绝启动(防误下单)
def test_live_strategy_adapter_builds_all_strategies(monkeypatch):
"""适配文件的策略工厂:env → StrategyTemplate(4 策略各识别一次)。"""
from sanguo_portfolio import live_strategy
class _FakeProvider:
pass
for name, cls_name in (
("all_weather", "AllWeatherStrategy"),
("momentum_timing", "MomentumTimingStrategy"),
("value_selection", "ValueSelectionStrategy"),
("small_cap", "SmallCapStrategy"),
):
monkeypatch.setenv("SANGUO_LIVE_STRATEGY", name)
s = live_strategy._build_live_strategy(_FakeProvider())
assert type(s).__name__ == cls_name
monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "nope")
with pytest.raises(ValueError):
live_strategy._build_live_strategy(_FakeProvider())