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]
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"] = (
|
||||
|
||||
@@ -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
@@ -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] 已退出")
|
||||
|
||||
@@ -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__)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user