feat: 实盘模拟(live) + 组合回测MVP(portfolio)

[live] 实盘模拟 vnpy+miniQMT 直连(supervisor 轮询, 前后端):
- sanguo_live: LiveTradingEngine + AShareCtaTemplate(定寸/禁做空) + runner_supervisor(DB驱动) + persistence(4表WAL)
- sanguo_api/routes_live: 9路由(create/start/stop/positions/trades/account/status)
- frontend live: New/List/Monitor + api/live.ts; config/live.yaml

[portfolio] 组合回测 MVP(BulletTrade, 链路代码完成待验证):
- runner_backtest 加 JSON 入口(--json, BacktestEngine 顶层 import)
- sanguo_api/routes_portfolio: POST /portfolio/backtest SSH 触发 VPS 跑
- frontend PortfolioBacktest.vue + api/portfolio.ts: 表单+结果+净值曲线
- 路由/菜单注册(/backtest/portfolio 组合回测)
- 已知: MVP 链路未端到端验证, agent 改至中途被停; 待 Mac 起服务联调
This commit is contained in:
2026-07-18 20:04:16 +08:00
parent a68cf4905e
commit 96b1924fd5
24 changed files with 3550 additions and 9 deletions
+9
View File
@@ -49,3 +49,12 @@ live:
mode_b: false # D-4c 模式B: bridge回报校正账本(默认关,切实盘再开)
# 和 Windows bridge 同值;不进 git。占位空值,真实值部署时填实际 config
bridge_token:
# 实盘模拟(task #4)— supervisor 常驻进程 + API 共享 DB
# db_path 留空则 fallback 到 data_paths.vnpy_db(与回测主库同)
# supervisor 用法: python -m sanguo_live --supervisor [db_path]
live_trading:
enabled: false # 总开关
db_path: # 留空 → 用 data_paths.vnpy_db
poll_interval_sec: 5 # supervisor 轮询 live_accounts.status 间隔
snapshot_interval_sec: 30 # 持仓/账户快照落库间隔
+34
View File
@@ -0,0 +1,34 @@
# 实盘模拟交易配置
# 用法:
# python -m sanguo_live
# SANGUO_QMT_ACCOUNT=66639661 python -m sanguo_live
#
# env SANGUO_QMT_ACCOUNT / SANGUO_QMT_PATH 优先于此文件。
# miniQMT 交易账号(可用 env SANGUO_QMT_ACCOUNT 覆盖)
account: "66639661"
# userdata_mini 路径;留空则由 vnpy_qmt/md.py 自动扫描 C:\
# (避免中文路径字面量编码问题,推荐留空或用 env SANGUO_QMT_PATH)
mini_path: ""
# 策略实例名(唯一,用于 CTA 引擎路由)
strategy_name: "dm_15min_600000"
# 策略类名(必须在 sanguo_live.runner._STRATEGY_REGISTRY 注册)
strategy_class: "AShareDoubleMaStrategy"
# 标的 vt_symbol(SYMBOL.EXCHANGE)。600000.SSE = 浦发银行
vt_symbol: "600000.SSE"
# 各阶段等待秒数
connect_wait_sec: 10
init_wait_sec: 60
# 策略参数(透传给 CtaTemplate.update_setting)
setting:
fast_window: 10
slow_window: 20
window: 15 # BarGenerator 分钟窗口(A 股 15min)
size: 100 # 1 手 = 100 股
forbid_short: true # A 股不可做空 → short() 拦截
+125
View File
@@ -0,0 +1,125 @@
import { apiClient } from './client'
/** 实盘模拟账户(API 返回行) */
export interface LiveAccount {
id: number
name: string
account: string
vt_symbol: string
strategy_class: string
strategy_name: string
/** JSON 字符串,前端 JSON.parse 得到策略参数 */
setting: string
status: string
interval: string
initial_capital: number
connect_wait_sec?: number
init_wait_sec?: number
mini_path?: string
error_msg?: string | null
created_at?: string
updated_at?: string
/** 列表端点附带(单账户 GET 不含) */
latest_equity?: number | null
latest_date?: string | null
total_return?: number | null
position_count?: number
}
export interface LiveCreateRequest {
name: string
account: string
vt_symbol: string
strategy_class: string
strategy_name: string
setting: Record<string, unknown>
interval: string
initial_capital: number
connect_wait_sec?: number
init_wait_sec?: number
mini_path?: string
}
export interface LiveStatus {
account_id: number
status: string
name: string
account: string
vt_symbol: string
strategy_name: string
updated_at?: string
error_msg?: string
}
export interface LivePosition {
symbol: string
volume: number
frozen: number
avg_price: number
updated_at?: string
}
export interface LiveTrade {
account_id: number
strategy_name: string
symbol: string
direction: string
offset: string
price: number
volume: number
traded_at: string
vt_tradeid?: string
}
export interface LiveBalance {
account_id?: number
date?: string
cash?: number
market_value?: number
total?: number
}
export async function createLive(req: LiveCreateRequest): Promise<{ accountId: number; status: string }> {
const { data } = await apiClient.post<{ account_id: number; status: string }>('/live/create', req)
return { accountId: data.account_id, status: data.status }
}
export async function listLives(): Promise<LiveAccount[]> {
const { data } = await apiClient.get<{ accounts: LiveAccount[] }>('/live')
return data.accounts
}
export async function getLive(aid: number): Promise<LiveAccount> {
const { data } = await apiClient.get<LiveAccount>(`/live/${aid}`)
return data
}
export async function startLive(aid: number): Promise<{ accountId: number; status: string }> {
const { data } = await apiClient.post<{ account_id: number; status: string }>(`/live/${aid}/start`)
return { accountId: data.account_id, status: data.status }
}
export async function stopLive(aid: number): Promise<{ accountId: number; status: string }> {
const { data } = await apiClient.post<{ account_id: number; status: string }>(`/live/${aid}/stop`)
return { accountId: data.account_id, status: data.status }
}
export async function getLivePositions(aid: number): Promise<LivePosition[]> {
const { data } = await apiClient.get<LivePosition[]>(`/live/${aid}/positions`)
return data
}
export async function getLiveTrades(aid: number): Promise<LiveTrade[]> {
const { data } = await apiClient.get<LiveTrade[]>(`/live/${aid}/trades`)
return data
}
export async function getLiveAccountBalance(aid: number): Promise<LiveBalance> {
const { data } = await apiClient.get<LiveBalance>(`/live/${aid}/account`)
return data ?? {}
}
export async function getLiveStatus(aid: number): Promise<LiveStatus> {
const { data } = await apiClient.get<LiveStatus>(`/live/${aid}/status`)
return data
}
+68
View File
@@ -0,0 +1,68 @@
import { apiClient } from './client'
export interface PortfolioBacktestReq {
pool: string
start_date: string
end_date: string
initial_cash: number
benchmark?: string
}
export interface EquityPoint {
date: string
equity: number
}
export interface StockPicked {
code: string
name: string
amount: number
avg_cost: number
price: number
value: number
}
export interface PortfolioTrade {
datetime?: string
date?: string
code?: string
side?: string
action?: string
amount?: number
filled_amount?: number
price?: number
filled_price?: number
commission?: number
status?: string
}
export interface PortfolioMetrics {
total_return: number | null
annual_return: number | null
max_drawdown: number | null
sharpe: number | null
win_rate_daily: number | null
win_rate_trade: number | null
trading_days: number | null
}
export interface PortfolioBacktestResult {
strategy: string
period: { start: string; end: string; trading_days: number }
stocks_selected: StockPicked[]
trades: PortfolioTrade[]
equity_curve: EquityPoint[]
metrics: PortfolioMetrics
raw_summary?: Record<string, unknown>
}
export async function postPortfolioBacktest(
req: PortfolioBacktestReq,
): Promise<PortfolioBacktestResult> {
const { data } = await apiClient.post<PortfolioBacktestResult>(
'/portfolio/backtest',
req,
{ timeout: 600000 },
)
return data
}
+4
View File
@@ -10,6 +10,7 @@ const routes: RouteRecordRaw[] = [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/Dashboard.vue') },
{ path: 'backtest/new', name: 'bt-new', component: () => import('@/views/backtest/New.vue') },
{ path: 'backtest/portfolio', name: 'bt-portfolio', component: () => import('@/views/backtest/PortfolioBacktest.vue') },
{ path: 'backtest/progress/:id', name: 'bt-progress', component: () => import('@/views/backtest/Progress.vue') },
{ path: 'backtest/result/:id', name: 'bt-result', component: () => import('@/views/backtest/Result.vue') },
{ path: 'backtest/optimize', name: 'bt-optimize', component: () => import('@/views/backtest/Optimize.vue') },
@@ -22,6 +23,9 @@ const routes: RouteRecordRaw[] = [
{ path: 'paper', name: 'paper-list', component: () => import('@/views/paper/List.vue') },
{ path: 'paper/result/:id', name: 'paper-result', component: () => import('@/views/paper/Result.vue') },
{ path: 'paper/live/:aid', name: 'paper-live', component: () => import('@/views/paper/Live.vue') },
{ path: 'live/new', name: 'live-new', component: () => import('@/views/live/New.vue') },
{ path: 'live', name: 'live-list', component: () => import('@/views/live/List.vue') },
{ path: 'live/monitor/:id', name: 'live-monitor', component: () => import('@/views/live/Monitor.vue') },
],
},
]
+12 -3
View File
@@ -18,6 +18,7 @@ const activeMenu = computed(() => {
const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/dashboard$/, group: '工作台', title: '工作台' },
{ match: /^\/backtest\/new$/, group: '回测', title: '新建回测' },
{ match: /^\/backtest\/portfolio$/, group: '回测', title: '组合回测' },
{ match: /^\/backtest\/optimize$/, group: '回测', title: '参数优化' },
{ match: /^\/backtest\/history$/, group: '回测', title: '历史任务' },
{ match: /^\/backtest\/progress\//, group: '回测', title: '任务进度' },
@@ -29,6 +30,9 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/paper\/result\//, group: '模拟', title: '模拟盘结果' },
{ match: /^\/paper\/live\//, group: '模拟', title: '实走监控' },
{ match: /^\/paper$/, group: '模拟', title: '模拟交易' },
{ match: /^\/live\/new$/, group: '实盘模拟', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘模拟', title: '实盘监控' },
{ match: /^\/live$/, group: '实盘模拟', title: '实盘模拟' },
]
const pageMeta = computed(() => {
const p = route.path
@@ -59,6 +63,7 @@ function onLogout(): void {
<span class="nav-label">回测</span>
</template>
<el-menu-item index="/backtest/new">新建回测</el-menu-item>
<el-menu-item index="/backtest/portfolio">组合回测</el-menu-item>
<el-menu-item index="/backtest/optimize">参数优化</el-menu-item>
<el-menu-item index="/backtest/history">历史任务</el-menu-item>
</el-sub-menu>
@@ -78,9 +83,13 @@ function onLogout(): void {
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
</el-sub-menu>
<el-menu-item index="live" disabled>
<span class="nav-label">实盘 <em class="nav-tip">国金 QMT · D </em></span>
</el-menu-item>
<el-sub-menu index="live">
<template #title>
<span class="nav-label">实盘模拟</span>
</template>
<el-menu-item index="/live">实盘列表</el-menu-item>
<el-menu-item index="/live/new">新建实盘</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="sidebar-foot">v2 · 量化研究台</div>
</aside>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
import {
postPortfolioBacktest,
type PortfolioBacktestResult,
type EquityPoint,
type StockPicked,
type PortfolioTrade,
type PortfolioMetrics,
} from '@/api/portfolio'
const submitting = ref(false)
const result = ref<PortfolioBacktestResult | null>(null)
const equityCurve = ref<EquityPoint[]>([])
const stocks = ref<StockPicked[]>([])
const trades = ref<PortfolioTrade[]>([])
const metrics = ref<PortfolioMetrics | null>(null)
const period = ref<{ start: string; end: string; trading_days: number } | null>(null)
const errorMsg = ref('')
const form = reactive({
pool: 'hs300_subset',
start: '2024-01-01',
end: '2024-02-29',
cash: 1_000_000,
benchmark: '000300.XSHG',
})
const poolOptions = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
{ label: '全市场(慢,非 MVP)', value: 'all' },
]
// 净值曲线
const equityEl = ref<HTMLDivElement>()
const { setOption: setEquityOption } = useChart(equityEl)
function renderEquity(): void {
if (!equityCurve.value.length) return
const dates = equityCurve.value.map((p) => p.date)
const values = equityCurve.value.map((p) => p.equity)
const option: EChartsCoreOption = {
title: darkTitle('净值曲线'),
tooltip: darkTooltip(),
grid: darkGrid(),
xAxis: { type: 'category', data: dates, ...darkAxis() },
yAxis: { type: 'value', scale: true, name: '净值(元)', ...darkAxis() },
series: [
{
type: 'line',
name: '策略净值',
smooth: true,
showSymbol: false,
lineStyle: { color: '#c23531', width: 1.6 },
areaStyle: { color: '#c23531', opacity: 0.12 },
data: values,
},
],
}
setEquityOption(option)
}
onMounted(() => {
// 等 dom 挂载后渲染(若有初始数据)
})
watch(equityCurve, renderEquity, { deep: true })
function fmtPct(v: number | null | undefined): string {
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
return `${v.toFixed(2)}%`
}
function fmtNum(v: number | null | undefined, digits = 2): string {
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
return v.toFixed(digits)
}
async function onSubmit(): Promise<void> {
submitting.value = true
errorMsg.value = ''
result.value = null
equityCurve.value = []
stocks.value = []
trades.value = []
metrics.value = null
try {
const r = await postPortfolioBacktest({
pool: form.pool,
start_date: form.start,
end_date: form.end,
initial_cash: form.cash,
benchmark: form.benchmark,
})
result.value = r
equityCurve.value = r.equity_curve || []
stocks.value = r.stocks_selected || []
trades.value = r.trades || []
metrics.value = r.metrics || null
period.value = r.period || null
ElMessage.success(
`回测完成: ${r.period?.trading_days ?? 0} 交易日, 选股 ${(r.stocks_selected || []).length}`,
)
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } }; message?: string }
errorMsg.value = err.response?.data?.detail || err.message || '提交失败'
ElMessage.error('回测失败,详见页面提示')
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="page bt-portfolio">
<div class="page-head">
<div>
<h2 class="page-title">组合策略回测</h2>
<p class="page-subtitle">
BulletTrade + 全天候轮动策略 · 触发 VPS 跑回测 · MVP 验证链路
</p>
</div>
</div>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">回测参数</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="标的池">
<el-select v-model="form.pool" style="width: 320px">
<el-option
v-for="opt in poolOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<span class="muted form-hint">MVP 默认 HS300 子集(20-30 ),快速验证链路</span>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="form.start"
type="date"
value-format="YYYY-MM-DD"
style="width: 220px"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="form.end"
type="date"
value-format="YYYY-MM-DD"
style="width: 220px"
/>
</el-form-item>
<el-form-item label="初始资金">
<el-input-number
v-model="form.cash"
:min="10000"
:step="100000"
:controls="false"
style="width: 220px"
/>
<span class="muted form-hint"></span>
</el-form-item>
<el-form-item label="基准">
<el-input v-model="form.benchmark" style="width: 220px" />
</el-form-item>
</el-form>
<div class="submit-bar">
<el-button
type="primary"
size="large"
:loading="submitting"
@click="onSubmit"
>
{{ submitting ? '回测中(VPS 执行,请等待...)' : '开始回测' }}
</el-button>
<span v-if="submitting" class="muted form-hint">
最长 600s,期间请勿关闭页面
</span>
</div>
</el-card>
<el-alert
v-if="errorMsg"
type="error"
:title="`回测失败:${errorMsg}`"
:closable="false"
show-icon
class="blk"
/>
<template v-if="result">
<!-- 指标卡片 -->
<el-card class="blk" shadow="never">
<template #header>
<span class="section-title">关键指标</span>
<span class="muted period">
({{ period?.start }} ~ {{ period?.end }} · {{ period?.trading_days ?? 0 }} 交易日)
</span>
</template>
<div class="metric-row">
<div class="metric-cell">
<div class="metric-label">总收益</div>
<div class="metric-value" :class="(metrics?.total_return ?? 0) >= 0 ? 'up' : 'down'">
{{ fmtPct(metrics?.total_return) }}
</div>
</div>
<div class="metric-cell">
<div class="metric-label">年化收益</div>
<div class="metric-value" :class="(metrics?.annual_return ?? 0) >= 0 ? 'up' : 'down'">
{{ fmtPct(metrics?.annual_return) }}
</div>
</div>
<div class="metric-cell">
<div class="metric-label">最大回撤</div>
<div class="metric-value down">{{ fmtPct(metrics?.max_drawdown) }}</div>
</div>
<div class="metric-cell">
<div class="metric-label">夏普比率</div>
<div class="metric-value">{{ fmtNum(metrics?.sharpe) }}</div>
</div>
<div class="metric-cell">
<div class="metric-label">日胜率</div>
<div class="metric-value">{{ fmtPct(metrics?.win_rate_daily) }}</div>
</div>
<div class="metric-cell">
<div class="metric-label">交易胜率</div>
<div class="metric-value">{{ fmtPct(metrics?.win_rate_trade) }}</div>
</div>
</div>
</el-card>
<!-- 净值曲线 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">净值曲线</span></template>
<div ref="equityEl" class="chart-box" />
</el-card>
<!-- 选股名单 -->
<el-card class="blk" shadow="never">
<template #header>
<span class="section-title">选股名单(末日持仓)</span>
</template>
<el-table :data="stocks" stripe size="small" empty-text="无持仓数据">
<el-table-column label="代码" prop="code" width="140">
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
</el-table-column>
<el-table-column label="数量" prop="amount" align="right" width="140">
<template #default="{ row }"><span class="mono">{{ row.amount.toFixed(0) }}</span></template>
</el-table-column>
<el-table-column label="均价" align="right" width="120">
<template #default="{ row }"><span class="mono">{{ row.avg_cost.toFixed(3) }}</span></template>
</el-table-column>
<el-table-column label="现价" align="right" width="120">
<template #default="{ row }"><span class="mono">{{ row.price.toFixed(3) }}</span></template>
</el-table-column>
<el-table-column label="市值" align="right" width="160">
<template #default="{ row }"><span class="mono">{{ row.value.toFixed(2) }}</span></template>
</el-table-column>
</el-table>
</el-card>
<!-- 成交明细 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">成交明细({{ trades.length }} )</span></template>
<el-table :data="trades" stripe size="small" max-height="500" empty-text="无成交">
<el-table-column label="时间" width="180">
<template #default="{ row }"><span class="mono">{{ row.datetime || row.date || '-' }}</span></template>
</el-table-column>
<el-table-column label="代码" prop="code" width="140">
<template #default="{ row }"><span class="mono">{{ row.code || '-' }}</span></template>
</el-table-column>
<el-table-column label="方向" width="100">
<template #default="{ row }">{{ row.side || row.action || '-' }}</template>
</el-table-column>
<el-table-column label="数量" align="right" width="120">
<template #default="{ row }"><span class="mono">{{ (row.filled_amount ?? row.amount ?? 0).toFixed(0) }}</span></template>
</el-table-column>
<el-table-column label="价格" align="right" width="120">
<template #default="{ row }"><span class="mono">{{ (row.filled_price ?? row.price ?? 0).toFixed(3) }}</span></template>
</el-table-column>
</el-table>
</el-card>
</template>
</div>
</template>
<style scoped>
.bt-portfolio { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.section-title { font-size: 14px; font-weight: 600; color: var(--text); }
.period { margin-left: 12px; font-size: 12px; }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0 8px; }
.chart-box { width: 100%; height: 360px; }
.metric-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.metric-cell {
background: var(--bg-hover);
border: 1px solid var(--border-2);
border-radius: 6px;
padding: 12px 14px;
}
.metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.metric-value { font-size: 20px; font-weight: 600; color: var(--text); font-family: var(--mono); }
.metric-value.up { color: #f56c6c; }
.metric-value.down { color: #67c23a; }
/* A 股惯例涨红跌绿;回撤是负值用绿色(表示"少亏方向")但习惯 down class 显绿 */
.muted { color: var(--text-3); font-size: 13px; }
.mono { font-family: var(--mono); }
</style>
+213
View File
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listLives, startLive, stopLive,
type LiveAccount,
} from '@/api/live'
const router = useRouter()
const accounts = ref<LiveAccount[]>([])
const loading = ref(false)
const kw = ref('')
const statusFilter = ref('')
const actionLoading = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
void refresh()
timer = setInterval(refreshSilent, 15000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
async function refresh(): Promise<void> {
loading.value = true
try {
await refreshSilent()
} finally {
loading.value = false
}
}
async function refreshSilent(): Promise<void> {
try {
accounts.value = await listLives()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
}
}
const filtered = computed(() =>
accounts.value.filter((a) => {
if (kw.value) {
const hay = `${a.name} ${a.id} ${a.vt_symbol} ${a.strategy_name}`.toLowerCase()
if (!hay.includes(kw.value.toLowerCase())) return false
}
if (statusFilter.value && a.status !== statusFilter.value) return false
return true
}),
)
const stats = computed(() => ({
total: accounts.value.length,
running: accounts.value.filter((a) => a.status === 'running').length,
stopped: accounts.value.filter((a) => a.status === 'stopped').length,
}))
function pct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return (v * 100).toFixed(2) + '%'
}
function num(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function goMonitor(a: LiveAccount): void {
router.push(`/live/monitor/${a.id}`)
}
function goNew(): void {
router.push('/live/new')
}
async function onStart(a: LiveAccount): Promise<void> {
actionLoading.value = a.id
try {
await startLive(a.id)
ElMessage.success(`#${a.id} 已下发启动(supervisor 将轮询启动 engine)`)
await refreshSilent()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '启动失败')
} finally {
actionLoading.value = null
}
}
async function onStop(a: LiveAccount): Promise<void> {
try {
await ElMessageBox.confirm(`确认停止实例 #${a.id}(${a.name})?`, '停止确认', {
type: 'warning',
})
} catch {
return
}
actionLoading.value = a.id
try {
await stopLive(a.id)
ElMessage.success(`#${a.id} 已下发停止`)
await refreshSilent()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '停止失败')
} finally {
actionLoading.value = null
}
}
</script>
<template>
<div class="page live-list" v-loading="loading">
<div class="page-head">
<div>
<h2 class="page-title">实盘模拟 <span class="count-badge">{{ stats.total }}</span></h2>
<p class="page-subtitle">miniQMT 直连 · supervisor 轮询 · 15s 自动刷新</p>
</div>
<el-button type="primary" @click="goNew">+ 新建实盘</el-button>
</div>
<!-- 统计 -->
<div class="stat-row">
<div class="stat-card"><div class="stat-label">总数</div><div class="stat-value mono">{{ stats.total }}</div></div>
<div class="stat-card"><div class="stat-label">运行中</div><div class="stat-value mono" style="color:var(--brand)">{{ stats.running }}</div></div>
<div class="stat-card"><div class="stat-label">已停止</div><div class="stat-value mono muted">{{ stats.stopped }}</div></div>
</div>
<!-- 筛选 -->
<div class="filter-row">
<el-input v-model="kw" placeholder="搜索 名称 / ID / 标的 / 策略" clearable style="width: 280px" />
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 130px">
<el-option label="运行中" value="running" />
<el-option label="已停止" value="stopped" />
</el-select>
<el-button link type="primary" @click="refresh">刷新</el-button>
</div>
<el-card class="table-card" shadow="never">
<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-id mono">#{{ row.id }}</div>
</template>
</el-table-column>
<el-table-column label="标的" min-width="110">
<template #default="{ row }"><span class="mono">{{ row.vt_symbol }}</span></template>
</el-table-column>
<el-table-column label="策略实例" min-width="160">
<template #default="{ row }">
<div class="cell-name">{{ row.strategy_name }}</div>
<div class="cell-id mono muted">{{ row.strategy_class }}</div>
</template>
</el-table-column>
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
</el-table-column>
<el-table-column label="收益率" width="100" align="right">
<template #default="{ row }">
<span class="mono" :class="(row.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ pct(row.total_return) }}</span>
</template>
</el-table-column>
<el-table-column label="最新净值" width="130" align="right">
<template #default="{ row }">
<div class="mono">{{ num(row.latest_equity) }}</div>
<div class="cell-id mono muted">{{ row.latest_date ?? '—' }}</div>
</template>
</el-table-column>
<el-table-column label="持仓数" width="76" align="right">
<template #default="{ row }"><span class="mono">{{ row.position_count ?? 0 }}</span></template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<span class="chip" :class="row.status === 'running' ? 'st-running' : 'st-pending'">
{{ row.status === 'running' ? '运行中' : '已停止' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button
v-if="row.status !== 'running'"
link type="success" size="small"
:loading="actionLoading === row.id"
@click="onStart(row)"
>启动</el-button>
<el-button
v-else
link type="warning" size="small"
:loading="actionLoading === row.id"
@click="onStop(row)"
>停止</el-button>
<el-button link type="primary" size="small" @click="goMonitor(row)">监控</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.live-list { display: flex; flex-direction: column; gap: 16px; }
.stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 14px 16px; }
.stat-label { font-size: 12px; color: var(--text-3); }
.stat-value { margin-top: 6px; font-size: 26px; font-weight: 700; color: var(--text); }
.filter-row { display: flex; align-items: center; gap: 10px; }
.table-card { border: 1px solid var(--border-2); }
.cell-name { font-size: 13px; color: var(--text); font-weight: 500; }
.cell-id { font-size: 11px; color: var(--text-3); margin-top: 2px; }
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getLive, getLivePositions, getLiveTrades, getLiveAccountBalance, getLiveStatus,
startLive, stopLive,
type LiveAccount, type LivePosition, type LiveTrade, type LiveBalance, type LiveStatus,
} from '@/api/live'
const route = useRoute()
const aid = Number(route.params.id)
const loading = ref(true)
const account = ref<LiveAccount | null>(null)
const status = ref<LiveStatus | null>(null)
const balance = ref<LiveBalance>({})
const positions = ref<LivePosition[]>([])
const trades = ref<LiveTrade[]>([])
const actionLoading = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const statusType = computed<'success' | 'info' | 'warning' | 'danger'>(() => {
const s = status.value?.status ?? account.value?.status ?? ''
if (s === 'running') return 'success'
if (s === 'error') return 'danger'
return 'info'
})
const statusText = computed(() => {
const s = status.value?.status ?? account.value?.status ?? ''
if (s === 'running') return '运行中'
if (s === 'stopped') return '已停止'
if (s === 'error') return '错误'
return s || '—'
})
/** 初始 setting 是 JSON 字符串 */
const settingParsed = computed<Record<string, unknown>>(() => {
const raw = account.value?.setting
if (!raw) return {}
try {
return JSON.parse(raw) as Record<string, unknown>
} catch {
return {}
}
})
const totalReturnPct = computed(() => {
const total = balance.value.total
const cap = account.value?.initial_capital
if (total == null || !cap) return null
return (total - cap) / cap
})
const todayStr = new Date().toISOString().slice(0, 10)
const todayTrades = computed(() =>
trades.value.filter((t) => String(t.traded_at).slice(0, 10) === todayStr),
)
function orDash(v: string | null | undefined): string {
return v && v !== 'None' ? v : '—'
}
function num(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function pct(v: number | null): string {
if (v == null || !Number.isFinite(v)) return '—'
return (v * 100).toFixed(2) + '%'
}
async function load(): Promise<void> {
try {
const [acc, st, bal, pos, tr] = await Promise.all([
getLive(aid),
getLiveStatus(aid).catch(() => null),
getLiveAccountBalance(aid).catch(() => ({})),
getLivePositions(aid),
getLiveTrades(aid),
])
account.value = acc
status.value = st
balance.value = bal ?? {}
positions.value = pos
trades.value = tr
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
timer = setInterval(load, 10000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
async function onStart(): Promise<void> {
actionLoading.value = true
try {
await startLive(aid)
ElMessage.success('已下发启动(supervisor 将轮询启动 engine)')
await load()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '启动失败')
} finally {
actionLoading.value = false
}
}
async function onStop(): Promise<void> {
try {
await ElMessageBox.confirm(`确认停止实例 #${aid}?`, '停止确认', { type: 'warning' })
} catch {
return
}
actionLoading.value = true
try {
await stopLive(aid)
ElMessage.success('已下发停止')
await load()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '停止失败')
} finally {
actionLoading.value = false
}
}
</script>
<template>
<div v-loading="loading" class="page live-monitor">
<div class="page-head">
<div>
<h2 class="page-title">
实盘监控 <span class="muted">#{{ aid }}</span>
<span v-if="account" class="title-sub"> · {{ account.name }} · {{ account.vt_symbol }}</span>
</h2>
<p class="page-subtitle">miniQMT 直连 · 10s 自动刷新</p>
</div>
<div class="head-right">
<el-tag :type="statusType" effect="dark" size="large">{{ statusText }}</el-tag>
<el-button
v-if="(status?.status ?? account?.status) !== 'running'"
type="success" :loading="actionLoading"
@click="onStart"
>启动</el-button>
<el-button
v-else
type="warning" :loading="actionLoading"
@click="onStop"
>停止</el-button>
</div>
</div>
<el-alert
v-if="status?.error_msg || account?.error_msg"
type="error"
:title="status?.error_msg || account?.error_msg || ''"
:closable="false"
/>
<!-- 状态卡片 -->
<div class="stat-row">
<div class="stat">
<span class="stat-label">策略实例</span>
<span class="stat-value">{{ account?.strategy_name ?? '—' }}</span>
<span class="stat-sub mono muted">{{ account?.strategy_class ?? '' }}</span>
</div>
<div class="stat">
<span class="stat-label">标的</span>
<span class="stat-value mono">{{ account?.vt_symbol ?? '—' }}</span>
</div>
<div class="stat">
<span class="stat-label">周期 / 资金</span>
<span class="stat-value mono">{{ account?.interval ?? '—' }} / {{ num(account?.initial_capital) }}</span>
</div>
<div class="stat">
<span class="stat-label">最新账户总额</span>
<span class="stat-value mono">{{ num(balance.total) }}</span>
<span class="stat-sub mono muted">{{ balance.date ?? '—' }}</span>
</div>
<div class="stat">
<span class="stat-label">累计收益率</span>
<span class="stat-value mono" :class="(totalReturnPct ?? 0) >= 0 ? 'up' : 'down'">{{ pct(totalReturnPct) }}</span>
</div>
<div class="stat">
<span class="stat-label">上次更新</span>
<span class="stat-value num">{{ orDash(status?.updated_at ?? account?.updated_at) }}</span>
</div>
</div>
<!-- 净值三件套 -->
<el-row :gutter="16">
<el-col :span="8">
<el-card shadow="never">
<div class="metric">
<div class="metric-label">现金</div>
<div class="metric-value mono">{{ num(balance.cash) }}</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="metric">
<div class="metric-label">市值</div>
<div class="metric-value mono">{{ num(balance.market_value) }}</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="metric">
<div class="metric-label">总资产</div>
<div class="metric-value mono">{{ num(balance.total) }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 持仓 -->
<el-card shadow="never">
<template #header><span class="section-title">当前持仓</span></template>
<el-table :data="positions" size="small" empty-text="无持仓">
<el-table-column prop="symbol" label="标的" min-width="100" />
<el-table-column prop="volume" label="总持仓" width="100" class-name="num" />
<el-table-column prop="frozen" label="冻结" width="90" class-name="num" />
<el-table-column prop="avg_price" label="均价" width="100" class-name="num" />
<el-table-column prop="updated_at" label="更新时间" min-width="160" />
</el-table>
</el-card>
<!-- 今日成交 -->
<el-card shadow="never">
<template #header><span class="section-title">今日成交</span></template>
<el-table :data="todayTrades" size="small" empty-text="今日无成交">
<el-table-column prop="traded_at" label="时间" min-width="160" />
<el-table-column prop="strategy_name" label="策略" min-width="160" />
<el-table-column prop="symbol" label="标的" width="100" />
<el-table-column label="方向" width="70">
<template #default="{ row }">
<span :class="row.direction === '多' || row.direction === 'buy' ? 'up' : 'down'">
{{ row.direction }}
</span>
</template>
</el-table-column>
<el-table-column prop="offset" label="开平" width="70" />
<el-table-column prop="price" label="价格" width="100" class-name="num" />
<el-table-column prop="volume" label="数量" width="90" class-name="num" />
</el-table>
</el-card>
<!-- 全部成交(最新在上) -->
<el-card shadow="never">
<template #header><span class="section-title">全部成交记录</span></template>
<el-table :data="[...trades].reverse()" size="small" empty-text="无成交" max-height="400">
<el-table-column prop="traded_at" label="时间" min-width="160" />
<el-table-column prop="strategy_name" label="策略" min-width="160" />
<el-table-column prop="symbol" label="标的" width="100" />
<el-table-column label="方向" width="70">
<template #default="{ row }">
<span :class="row.direction === '多' || row.direction === 'buy' ? 'up' : 'down'">
{{ row.direction }}
</span>
</template>
</el-table-column>
<el-table-column prop="offset" label="开平" width="70" />
<el-table-column prop="price" label="价格" width="100" class-name="num" />
<el-table-column prop="volume" label="数量" width="90" class-name="num" />
</el-table>
</el-card>
<!-- 策略参数 -->
<el-card shadow="never">
<template #header><span class="section-title">策略参数</span></template>
<pre class="setting-pre mono">{{ JSON.stringify(settingParsed, null, 2) }}</pre>
</el-card>
</div>
</template>
<style scoped>
.live-monitor { display: flex; flex-direction: column; gap: 16px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.head-right { display: flex; align-items: center; gap: 12px; }
.title-sub { font-size: 13px; color: var(--text-3); font-weight: 500; }
.stat-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--sp-3);
}
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-label { font-size: 11px; color: var(--text-3); letter-spacing: 0.3px; }
.stat-value { font-size: 15px; color: var(--text); font-weight: 600; }
.stat-sub { font-size: 11px; }
.metric { padding: 4px 0; }
.metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.metric-value { font-size: 22px; font-weight: 700; color: var(--text); }
.setting-pre {
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
padding: 10px 14px;
margin: 0;
font-size: 12px;
color: var(--text-2);
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 1200px) {
.stat-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.stat-row { grid-template-columns: repeat(2, 1fr); }
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createLive, type LiveCreateRequest } from '@/api/live'
const router = useRouter()
const loading = ref(false)
const form = ref<LiveCreateRequest>({
name: 'live-600000',
account: '66639661',
vt_symbol: '600000.SSE',
strategy_class: 'AShareDoubleMaStrategy',
strategy_name: 'AShareDoubleMa_600000',
setting: {
fast_window: 5,
slow_window: 20,
window: 15,
size: 100,
forbid_short: true,
},
interval: '15m',
initial_capital: 1_000_000,
connect_wait_sec: 10,
init_wait_sec: 60,
mini_path: 'C:\\国金QMT交易端模拟\\userdata_mini',
})
const strategyClassOptions = [
{ value: 'AShareDoubleMaStrategy', label: 'AShareDoubleMaStrategy(双均线 A 股策略)' },
]
async function onSubmit(): Promise<void> {
if (!form.value.name.trim()) {
ElMessage.warning('请填写实例名')
return
}
if (!form.value.account.trim()) {
ElMessage.warning('请填写交易账号')
return
}
if (!form.value.strategy_name.trim()) {
ElMessage.warning('请填写策略实例名')
return
}
loading.value = true
try {
const res = await createLive(form.value)
ElMessage.success(`已创建实盘实例 #${res.accountId}(stopped),请到列表点"启动"`)
router.push('/live')
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page live-new">
<div class="page-head">
<div>
<h2 class="page-title">新建实盘模拟</h2>
<p class="page-subtitle">miniQMT 直连 · A 股实盘模拟(supervisor 轮询)</p>
</div>
</div>
<el-alert
type="info"
:closable="false"
title="创建后状态为 stopped,需到列表点『启动』才会启动(supervisor 轮询发现后起 engine)"
/>
<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="实例名" required>
<el-input v-model="form.name" placeholder="live-600000" style="width: 320px" />
<span class="muted form-hint">页面显示用</span>
</el-form-item>
<el-form-item label="交易账号" required>
<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>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="loading" @click="onSubmit">
创建实盘实例
</el-button>
<el-button size="large" @click="router.push('/live')">取消</el-button>
</div>
</div>
</template>
<style scoped>
.live-new { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0; display: flex; gap: 12px; }
</style>
+2
View File
@@ -2,3 +2,5 @@
pythonpath = .
testpaths = tests
asyncio_mode = auto
markers =
requires_bullet_trade: 需要 bullet-trade 已安装(否则 skip)
+7 -2
View File
@@ -3,7 +3,9 @@ FastAPI application factory for Sanguo Quant API
"""
from fastapi import FastAPI
from .routes import router, set_orchestrator, set_auth_config
from .routes_paper import router as paper_router, set_db_path
from .routes_paper import router as paper_router, set_db_path as set_paper_db_path
from .routes_live import router as live_router, set_db_path as set_live_db_path
from .routes_portfolio import router as portfolio_router
from .auth import set_jwt_config
from .ws import manager
from sanguo_orchestrator.runner import Orchestrator
@@ -35,7 +37,10 @@ def create_app(db_path: str, file_dir=None, auth_config=None, max_workers: int =
# Include routes
app.include_router(router, prefix="/api/v1")
app.include_router(paper_router, prefix="/api/v1")
set_db_path(db_path)
app.include_router(live_router, prefix="/api/v1")
app.include_router(portfolio_router, prefix="/api/v1")
set_paper_db_path(db_path)
set_live_db_path(db_path)
@app.on_event("startup")
def _register_live_step():
+178
View File
@@ -0,0 +1,178 @@
"""实盘模拟 API 路由(spec §live-api)。
create 建 live_account(持久化配置,status=stopped);start/stop 改 status 字段;
GET 查询持仓/成交/账户/状态。runner(supervisor) 是独立常驻进程,轮询 status 字段
决定起停 LiveTradingEngine;两者只通过 DB 通信,本模块不实例化 engine。
风格参考 ``sanguo_api/routes_paper.py``。
"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from .auth import verify_token as verify_token_impl
router = APIRouter()
_db_path = {"path": None}
# miniQMT 默认 userdata_mini 路径(国金QMT交易端模拟);
# req.mini_path 空 → env SANGUO_QMT_PATH → 此默认(双保险,避免 connect=-1)
_DEFAULT_MINI_PATH = r"C:\国金QMT交易端模拟\userdata_mini"
def set_db_path(p):
_db_path["path"] = p
if p:
from sanguo_live.persistence import init_db
init_db(p) # app 启动建表(幂等)
async def verify_token(authorization: str | None = Header(None)):
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing/invalid authorization")
return verify_token_impl(authorization.split(" ", 1)[1])
class LiveCreateRequest(BaseModel):
name: str = "live"
account: str
vt_symbol: str = "600000.SSE"
strategy_class: str = "AShareDoubleMaStrategy"
strategy_name: str
setting: dict = {}
interval: str = "15m"
initial_capital: float = 1_000_000
connect_wait_sec: int = 10
init_wait_sec: int = 60
mini_path: str = ""
@router.post("/live/create", dependencies=[Depends(verify_token)])
def create_live(req: LiveCreateRequest):
"""创建实盘实例(写 live_accounts,status=stopped)。需调 start 才会启动。"""
from sanguo_live.persistence import init_db, save_account
db = _db_path["path"] or ":memory:"
init_db(db)
payload = req.model_dump()
# mini_path 兜底:req → env SANGUO_QMT_PATH → 内置默认(空值会导致 connect=-1)
if not payload.get("mini_path"):
payload["mini_path"] = (
os.environ.get("SANGUO_QMT_PATH") or _DEFAULT_MINI_PATH
)
aid = save_account(db, {**payload, "status": "stopped"})
return {"account_id": aid, "status": "stopped"}
@router.get("/live", dependencies=[Depends(verify_token)])
def list_lives():
"""实盘实例列表。每行带最新账户快照摘要(total/收益率)。
收益率用首快照基线:(last_total - first_total) / first_total,
避免用 initial_capital 兜底导致入金/出金瞬间收益率失真。
无快照时 total_return=None(不兜底 initial_capital)。
"""
from sanguo_live.persistence import (
list_accounts, get_last_balance, get_first_balance, load_positions,
)
db = _db_path["path"]
if not db:
return {"accounts": []}
items = list_accounts(db)
for item in items:
last = get_last_balance(db, item["id"])
first = get_first_balance(db, item["id"])
if last:
item["latest_equity"] = last.get("total")
item["latest_date"] = last.get("date")
else:
item["latest_equity"] = None
item["latest_date"] = None
# 收益率:首快照 total 为 baseline;last/first 同条时为 0
baseline = (first or {}).get("total") if first else None
if last and baseline:
item["total_return"] = (last.get("total", 0) - baseline) / baseline
else:
item["total_return"] = None
item["position_count"] = len(load_positions(db, item["id"]))
return {"accounts": items}
@router.get("/live/{aid}", dependencies=[Depends(verify_token)])
def get_live(aid: int):
from sanguo_live.persistence import get_account
acc = get_account(_db_path["path"], aid)
if not acc:
raise HTTPException(404, "account not found")
return acc
@router.post("/live/{aid}/start", dependencies=[Depends(verify_token)])
def start_live(aid: int):
"""启动实例(status=running)。supervisor 轮询发现后起 engine。"""
from sanguo_live.persistence import get_account, update_account_status
acc = get_account(_db_path["path"], aid)
if not acc:
raise HTTPException(404, "account not found")
if not acc["account"]:
raise HTTPException(400, "account 字段(交易账号)不能为空")
update_account_status(_db_path["path"], aid, "running")
return {"account_id": aid, "status": "running"}
@router.post("/live/{aid}/stop", dependencies=[Depends(verify_token)])
def stop_live(aid: int):
"""停止实例(status=stopped)。supervisor 轮询发现后停 engine。"""
from sanguo_live.persistence import get_account, update_account_status
if not get_account(_db_path["path"], aid):
raise HTTPException(404, "account not found")
update_account_status(_db_path["path"], aid, "stopped")
return {"account_id": aid, "status": "stopped"}
@router.get("/live/{aid}/positions", dependencies=[Depends(verify_token)])
def get_positions(aid: int):
"""持仓快照(读 live_positions,supervisor 定时落库)。"""
from sanguo_live.persistence import load_positions
return load_positions(_db_path["path"], aid)
@router.get("/live/{aid}/trades", dependencies=[Depends(verify_token)])
def get_trades(aid: int):
"""成交明细(读 live_trades,supervisor 事件回调落库)。"""
from sanguo_live.persistence import list_trades
return list_trades(_db_path["path"], aid)
@router.get("/live/{aid}/account", dependencies=[Depends(verify_token)])
def get_account_balance(aid: int):
"""账户最新快照(读 live_balance 最新一条)。"""
from sanguo_live.persistence import get_last_balance
last = get_last_balance(_db_path["path"], aid)
return last or {}
@router.get("/live/{aid}/status", dependencies=[Depends(verify_token)])
def get_status(aid: int):
"""运行状态(读 live_accounts.status)。"""
from sanguo_live.persistence import get_account
acc = get_account(_db_path["path"], aid)
if not acc:
raise HTTPException(404, "account not found")
return {
"account_id": aid, "status": acc["status"], "name": acc["name"],
"account": acc["account"], "vt_symbol": acc["vt_symbol"],
"strategy_name": acc["strategy_name"], "updated_at": acc["updated_at"],
"error_msg": acc.get("error_msg", ""),
}
+126
View File
@@ -0,0 +1,126 @@
"""组合策略 API 路由(MVP)。
POST /portfolio/backtest: SSH 触发 VPS 跑 BulletTrade + all_weather,
捕获 stdout JSON 返回前端。同步模式(回测耗时,前端 loading,timeout 600s)。
风格参考 routes_paper.py / routes_live.py。
"""
from __future__ import annotations
import logging
import shlex
import subprocess
from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from .auth import verify_token as verify_token_impl
logger = logging.getLogger(__name__)
router = APIRouter()
# VPS SSH 连接配置(~/.ssh/config 已配 49.232.102.198 免密 key)
_VPS_HOST = "49.232.102.198"
_VPS_WORKDIR = r"C:\\sanguo_vnpy_v2"
_VPS_PYTHON = "python"
_VPS_TIMEOUT = 600 # 回测耗时,给 10 分钟
async def verify_token(authorization: str | None = Header(None)):
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing/invalid authorization")
return verify_token_impl(authorization.split(" ", 1)[1])
class PortfolioBacktestRequest(BaseModel):
pool: str = Field(default="hs300_subset", description="标的池(占位,MVP 用默认)")
start_date: str = Field(default="2024-01-01", description="YYYY-MM-DD")
end_date: str = Field(default="2024-02-29", description="YYYY-MM-DD")
initial_cash: float = Field(default=1_000_000.0, description="初始资金(元)")
benchmark: str = Field(default="000300.XSHG", description="基准代码")
@router.post("/portfolio/backtest", dependencies=[Depends(verify_token)])
def run_portfolio_backtest(req: PortfolioBacktestRequest):
"""SSH 触发 VPS 跑 BulletTrade + all_weather 回测,同步返回 JSON 结果。
Windows SSH 坑:
- GBK 编码:python -X utf8 避免中文 print 编码崩
- 引号嵌套:用 list 形式 argv,避免 shell 引号 escape 噩梦
- 没有 tail/head/grep:用 python 后处理(本函数在 Mac 端直接解析 stdout)
- 中文路径:VPS_WORKDIR / userdata_mini 走 env(DEFAULT_DATA_PROVIDER=miniqmt)
"""
# 在 VPS 上跑的命令:cd workdir && set ENV && python -m sanguo_portfolio.runner_backtest --json
# Windows cmd: set X=Y&&cmd2 (注意 & 必须紧贴前一条,不能有空格,否则 set 会把尾部空格算进 value)
cmd_parts = [
"set", "DEFAULT_DATA_PROVIDER=miniqmt", "&&",
"cd", _VPS_WORKDIR, "&&",
_VPS_PYTHON, "-X", "utf8", "-m", "sanguo_portfolio.runner_backtest",
"--json",
"--start", req.start_date,
"--end", req.end_date,
"--cash", str(req.initial_cash),
"--benchmark", req.benchmark,
]
# 用 ssh host "cmd string" 形式;argv 在 ssh 远端走 cmd /c 解析
ssh_argv = [
"ssh",
"-o", "ConnectTimeout=15",
"-o", "StrictHostKeyChecking=no",
_VPS_HOST,
" ".join(shlex.quote(p) if p != "&&" else "&&" for p in cmd_parts),
]
logger.info("[portfolio] SSH 触发: %s", ssh_argv[-1])
try:
proc = subprocess.run(
ssh_argv,
capture_output=True,
text=True,
timeout=_VPS_TIMEOUT,
check=False,
)
except subprocess.TimeoutExpired:
raise HTTPException(504, f"VPS 回测超时(>{_VPS_TIMEOUT}s)")
except FileNotFoundError:
raise HTTPException(500, "本机未找到 ssh 命令")
except Exception as exc:
logger.exception("[portfolio] SSH 调用失败")
raise HTTPException(500, f"SSH 调用失败: {exc}")
if proc.returncode != 0:
stderr_tail = (proc.stderr or "")[-2000:]
logger.error("[portfolio] VPS 回测失败 rc=%s stderr=%s", proc.returncode, stderr_tail)
raise HTTPException(
500,
f"VPS 回测失败(rc={proc.returncode}): {stderr_tail}",
)
# 从 stdout 提取最后一行 JSON(runner --json 只 print 一行)
stdout = proc.stdout or ""
import json as _json
result: Optional[dict[str, Any]] = None
parse_err: Optional[str] = None
for line in reversed(stdout.strip().splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
try:
result = _json.loads(line)
break
except _json.JSONDecodeError as exc:
parse_err = str(exc)
continue
if result is None:
logger.error(
"[portfolio] stdout 无 JSON 行。parse_err=%s stdout_tail=%s",
parse_err, stdout[-2000:],
)
raise HTTPException(
500,
f"VPS stdout 解析失败: {parse_err or 'no JSON line'}; stdout_tail={stdout[-500:]!r}",
)
return result
+14
View File
@@ -0,0 +1,14 @@
"""sanguo_live — vnpy 原生实盘交易引擎(A股 15min 策略)。
模块组成:
- base_template.AShareCtaTemplate A股 CTA 基类(定寸 size=100 + 禁做空)
- strategies.AShareDoubleMaStrategy 15min 双均线策略
- engine.LiveTradingEngine MainEngine + QmtGateway + CtaStrategyApp 封装
- runner.run 实盘入口: connect → add → subscribe → init → start → 常驻
本机 dev 视图通常未装 vnpy_ctastrategy / vnpy_qmt,模块 import 容错(运行时才校验依赖)。
"""
from __future__ import annotations
__all__ = ["__version__"]
__version__ = "0.1.0"
+17
View File
@@ -0,0 +1,17 @@
"""python -m sanguo_live [config_path | --supervisor [db_path]]
默认(无参 / yaml 路径):config 驱动单实例模式(run)。
--supervisor [db_path]:DB 驱动 supervisor,轮询 live_accounts.status 管理多实例。
"""
import sys
from sanguo_live.runner import run, run_supervisor
if __name__ == "__main__":
args = sys.argv[1:]
if args and args[0] == "--supervisor":
db_path = args[1] if len(args) > 1 else None
run_supervisor(db_path)
else:
cfg = args[0] if args else None
run(cfg)
+117
View File
@@ -0,0 +1,117 @@
"""A股 CTA 策略基类:定寸(1 手 = 100 股)+ 禁做空。
为什么需要这一层:
- vnpy_qmt 的 send_order 把 ``volume`` 当**股数**直接传给 xtquant,不会自动乘合约 size。
标准 CtaTemplate 策略写 ``buy(price, 1)`` 表示 1 手 → 实际只下 1 股,会被券商拒单
或当成废单。AShareCtaTemplate 在 buy/sell/cover 内部把 volume ×= ``self.size``
(默认 100),让策略保留""的语义。
- A 股不可做空:``short`` 直接返回 [] 并写日志(``forbid_short=True`` 生效时)。
依赖 vnpy_ctastrategy.CtaTemplate。本机未装时 CtaTemplate fallback 为 ``object``,
模块 import 不崩,只有运行时实例化或调用方法才会报错。
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
try:
from vnpy_ctastrategy import CtaTemplate # type: ignore
_HAS_CTA_BASE = True
_IMPORT_ERROR: Exception | None = None
except ImportError as _e: # 本机 dev 环境常未装 vnpy_ctastrategy
CtaTemplate = object # type: ignore[assignment,misc]
_HAS_CTA_BASE = False
_IMPORT_ERROR = _e
class AShareCtaTemplate(CtaTemplate): # type: ignore[misc]
"""A 股 CTA 策略基类。
定寸:``buy`` / ``cover`` / ``sell`` 的 ``volume`` 自动 ``×= size``(默认 100,
即 1 手 = 100 股),不足 1 手向下取整并告警。
禁做空:``short`` 直接返回 [](由 ``forbid_short`` 控制,默认 True)。
子类化注意:必须把 ``size``/``forbid_short`` 加入自己的 ``parameters`` 列表,
否则 ``update_setting`` 不会回填这两个字段。
"""
# 1 手 = 100 股(A 股最小交易单位)。ContractData.size 也是 100。
size: int = 100
# True: short() 被拦截(A 股不能开空);False: 透传到基类(仅供测试/期货场景)。
forbid_short: bool = True
parameters = ["size", "forbid_short"]
def buy(
self,
price: float,
volume: float,
stop: bool = False,
lock: bool = False,
net: bool = False,
) -> list:
"""开多 → A 股买入,定寸到整手。"""
return super().buy(price, self._to_lots(volume), stop, lock, net)
def cover(
self,
price: float,
volume: float,
stop: bool = False,
lock: bool = False,
net: bool = False,
) -> list:
"""平空 → A 股 Normally 不会触达(禁做空);保留定寸以防策略逻辑误调。"""
return super().cover(price, self._to_lots(volume), stop, lock, net)
def sell(
self,
price: float,
volume: float,
stop: bool = False,
lock: bool = False,
net: bool = False,
) -> list:
"""平多 → A 股卖出持仓,定寸到整手。"""
return super().sell(price, self._to_lots(volume), stop, lock, net)
def short(
self,
price: float,
volume: float,
stop: bool = False,
lock: bool = False,
net: bool = False,
) -> list:
"""开空 → A 股不可做空,默认拦截。"""
if self.forbid_short:
self.write_log(
f"A股禁做空: 拦截 short price={price} volume={volume}"
)
return []
return super().short(price, self._to_lots(volume), stop, lock, net)
def _to_lots(self, volume: float) -> int:
"""策略手数 → A 股股数(``volume × size``,不足 1 手向下取整)。"""
target = volume * self.size
lots = int(target)
if lots != target:
logger.warning(
"%s 定寸出现零股: volume=%s size=%s%d (向下取整)",
getattr(self, "strategy_name", "?"), volume, self.size, lots,
)
return lots
def ensure_base_available() -> None:
"""显式检查 vnpy_ctastrategy 是否就绪。模块加载时容错,真正实盘前调一次。"""
if not _HAS_CTA_BASE:
raise RuntimeError(
f"vnpy_ctastrategy 未安装,无法实例化 AShareCtaTemplate: {_IMPORT_ERROR}"
)
__all__ = ["AShareCtaTemplate", "ensure_base_available"]
+154
View File
@@ -0,0 +1,154 @@
"""LiveTradingEngine:vnpy 原生实盘链路封装。
参考 ``vnpy_v4.4.0/examples/no_ui/run.py`` 和 ``examples/no_ui/run.py``,
按 A 股 + QMT 场景精简:
- EventEngine + MainEngine
- add_gateway(QmtGateway) 连 miniQMT
- add_app(CtaStrategyApp) 挂 CTA 引擎
- 透出 connect / add_strategy / subscribe / init_all / start_all / stop_all / 查询
本机未装 vnpy_ctastrategy / vnpy_qmt 时 import 容错,实例化才报错。
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
try:
from vnpy.event import EventEngine # type: ignore
from vnpy.trader.engine import MainEngine # type: ignore
from vnpy_qmt import QmtGateway # type: ignore
from vnpy_ctastrategy import CtaStrategyApp, CtaEngine # type: ignore
_DEPS_OK = True
_IMPORT_ERROR: Exception | None = None
except ImportError as _e:
EventEngine = None # type: ignore
MainEngine = None # type: ignore
QmtGateway = None # type: ignore
CtaStrategyApp = None # type: ignore
CtaEngine = None # type: ignore
_DEPS_OK = False
_IMPORT_ERROR = _e
class LiveTradingEngine:
"""vnpy 原生实盘引擎封装。单实例持有 MainEngine 生命周期。
使用:
eng = LiveTradingEngine()
eng.connect({"交易账号": "66639661", "mini路径": "C:\\\\..."})
eng.add_strategy(AShareDoubleMaStrategy, "dm1", "600000.SSE", {...})
eng.subscribe(["600000.SSE"])
eng.init_all(); eng.start_all()
# ... 常驻 ...
eng.stop_all(); eng.close()
"""
def __init__(self) -> None:
if not _DEPS_OK:
raise RuntimeError(
f"vnpy 依赖缺失(vnpy/vnpy_qmt/vnpy_ctastrategy),"
f"无法初始化 LiveTradingEngine: {_IMPORT_ERROR}"
)
self.event_engine: EventEngine = EventEngine()
self.main_engine: MainEngine = MainEngine(self.event_engine)
self.main_engine.add_gateway(QmtGateway)
self.cta_engine: CtaEngine = self.main_engine.add_app(CtaStrategyApp)
logger.info("LiveTradingEngine 初始化完成(MainEngine + QMT + CTA)")
# ---------------------- 连接 / 策略 / 行情 ----------------------
def connect(self, setting: dict[str, str]) -> None:
"""连接 miniQMT。``setting`` = ``{"交易账号":..., "mini路径":...}``。"""
self.main_engine.connect(setting, "QMT")
logger.info("已请求连接 QMT: 账号=%s", setting.get("交易账号"))
def add_strategy(
self,
strategy_class: type,
strategy_name: str,
vt_symbol: str,
setting: dict[str, Any],
) -> None:
"""注册策略实例到 CTA 引擎。"""
self.cta_engine.add_strategy(
strategy_class, strategy_name, vt_symbol, setting
)
logger.info("已添加策略 %s @ %s (class=%s)", strategy_name, vt_symbol,
strategy_class.__name__)
def subscribe(self, vt_symbols: list[str]) -> None:
"""订阅 ``vt_symbol`` 列表(格式 ``SYMBOL.EXCHANGE``,如 ``600000.SSE``)。
订阅是行情驱动策略的前提:QmtGateway.on_tick → EVENT_TICK →
CtaEngine → strategy.on_tick → BarGenerator 合成 15min bar。
"""
from vnpy.trader.object import SubscribeRequest # type: ignore
from vnpy.trader.constant import Exchange # type: ignore
ok = 0
for vt_symbol in vt_symbols:
try:
symbol, exch_code = vt_symbol.split(".", 1)
exchange = Exchange(exch_code)
except (ValueError, KeyError):
logger.warning("vt_symbol 无法解析,跳过: %r", vt_symbol)
continue
req = SubscribeRequest(symbol=symbol, exchange=exchange)
self.main_engine.subscribe(req, "QMT")
ok += 1
logger.info("已请求订阅 %d / %d 个标的", ok, len(vt_symbols))
# ---------------------- 生命周期 ----------------------
def init_all(self) -> None:
self.cta_engine.init_all_strategies()
logger.info("所有策略初始化完成")
def start_all(self) -> None:
self.cta_engine.start_all_strategies()
logger.info("所有策略已启动")
def stop_all(self) -> None:
try:
self.cta_engine.stop_all_strategies()
logger.info("所有策略已停止")
except Exception as e: # noqa: BLE001
logger.warning("stop_all_strategies 异常: %s", e)
def close(self) -> None:
try:
self.main_engine.close()
finally:
logger.info("LiveTradingEngine 已关闭")
# ---------------------- 查询(OMS 缓存) ----------------------
def _oms(self) -> Any:
return self.main_engine.get_engine("oms")
def get_all_accounts(self) -> list:
"""返回 QMT 网关所有账户(AccountData 列表)。连接前可能为空。"""
oms = self._oms()
if oms is None:
return []
return [a for a in oms.get_all_accounts() if a.gateway_name == "QMT"]
def get_positions(self) -> list:
"""返回 QMT 持仓(PositionData 列表)。"""
oms = self._oms()
if oms is None:
return []
return [p for p in oms.get_all_positions() if p.gateway_name == "QMT"]
def get_orders(self) -> list:
"""返回 QMT 当日委托(OrderData 列表)。"""
oms = self._oms()
if oms is None:
return []
return [o for o in oms.get_all_orders() if o.gateway_name == "QMT"]
__all__ = ["LiveTradingEngine"]
+280
View File
@@ -0,0 +1,280 @@
"""实盘模拟 SQLite 持久化(4 表,spec §live-api)。
表:live_accounts / live_trades / live_positions / live_balance
WAL 模式支持 supervisor 进程写 + API 进程读(DB 解耦,spec §live-api)。
设计参考 ``sanguo_trader/persistence.py``paper_* 表),但:
- account.setting 存 JSON 字符串(策略参数透传给 CtaTemplate.update_setting
- status: stopped | runningAPI 改字段,supervisor 轮询该字段决定起停)
- positions 为覆盖式快照(supervisor 定时把 OMS PositionData 落库,不做增量)
"""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS live_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER,
strategy_name TEXT,
symbol TEXT,
direction TEXT,
offset TEXT,
price REAL,
volume REAL,
traded_at TEXT,
vt_tradeid TEXT
);
CREATE TABLE IF NOT EXISTS live_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER,
symbol TEXT,
volume REAL,
frozen REAL,
avg_price REAL,
updated_at TEXT,
UNIQUE(account_id, symbol)
);
CREATE TABLE IF NOT EXISTS live_balance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER,
date TEXT,
cash REAL,
market_value REAL,
total REAL
);
CREATE INDEX IF NOT EXISTS idx_live_trades_account ON live_trades(account_id);
CREATE INDEX IF NOT EXISTS idx_live_balance_account ON live_balance(account_id, date);
"""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
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)
conn.execute("PRAGMA journal_mode=WAL")
conn.commit()
# ----------------- live_accounts CRUD -----------------
def save_account(db_path: str, account: dict[str, Any]) -> int:
with sqlite3.connect(db_path) as conn:
cur = conn.execute(
"""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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
account.get("name", "live"),
account.get("account", ""),
account.get("vt_symbol", ""),
account.get("strategy_class", "AShareDoubleMaStrategy"),
account.get("strategy_name", ""),
json.dumps(account.get("setting", {})),
account.get("status", "stopped"),
account.get("interval", "15m"),
account.get("initial_capital", 1_000_000),
int(account.get("connect_wait_sec", 10)),
int(account.get("init_wait_sec", 60)),
account.get("mini_path", ""),
_now(), _now(),
),
)
conn.commit()
return cur.lastrowid
def list_accounts(db_path: str) -> list[dict]:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM live_accounts ORDER BY id DESC")
return [dict(r) for r in cur.fetchall()]
def get_account(db_path: str, account_id: int) -> dict | None:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT * FROM live_accounts WHERE id=?", (account_id,)
)
row = cur.fetchone()
return dict(row) if row else None
def update_account_status(
db_path: str, account_id: int, status: str, error_msg: str = ""
) -> None:
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE live_accounts SET status=?, error_msg=?, updated_at=? WHERE id=?",
(status, error_msg, _now(), account_id),
)
conn.commit()
def list_running_accounts(db_path: str) -> list[dict]:
"""supervisor 轮询:取所有 status=running 的实例。"""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT * FROM live_accounts WHERE status=? ORDER BY id", ("running",)
)
return [dict(r) for r in cur.fetchall()]
# ----------------- live_trades -----------------
def save_trade(db_path: str, account_id: int, trade: dict[str, Any]) -> int:
with sqlite3.connect(db_path) as conn:
cur = conn.execute(
"""INSERT INTO live_trades
(account_id, strategy_name, symbol, direction, offset,
price, volume, traded_at, vt_tradeid)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
account_id, trade.get("strategy_name", ""),
trade.get("symbol", ""), trade.get("direction", ""),
trade.get("offset", ""), trade.get("price", 0),
trade.get("volume", 0), trade.get("traded_at", ""),
trade.get("vt_tradeid", ""),
),
)
conn.commit()
return cur.lastrowid
def list_trades(db_path: str, account_id: int) -> list[dict]:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT * FROM live_trades WHERE account_id=? ORDER BY id",
(account_id,),
)
return [dict(r) for r in cur.fetchall()]
# ----------------- live_positions (覆盖式快照) -----------------
def save_positions(
db_path: str, account_id: int, positions: dict[str, dict]
) -> None:
"""覆盖式落库。positions = {symbol: {volume, frozen, avg_price}}。
supervisor 每 snapshot_interval_sec 调一次,把 OMS 最新 PositionData 覆盖落库。
只保留 volume>0 的持仓。
"""
now = _now()
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM live_positions WHERE account_id=?", (account_id,))
conn.executemany(
"""INSERT INTO live_positions
(account_id, symbol, volume, frozen, avg_price, updated_at)
VALUES (?,?,?,?,?,?)""",
[
(account_id, sym, p["volume"], p.get("frozen", 0),
p["avg_price"], now)
for sym, p in positions.items() if p.get("volume", 0) > 0
],
)
conn.commit()
def load_positions(db_path: str, account_id: int) -> list[dict]:
"""API 读持仓快照 → [{symbol, volume, frozen, avg_price, updated_at}]。"""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT symbol, volume, frozen, avg_price, updated_at "
"FROM live_positions WHERE account_id=?",
(account_id,),
)
return [dict(r) for r in cur.fetchall()]
# ----------------- live_balance -----------------
def save_balance(
db_path: str, account_id: int, date: str, cash: float,
market_value: float, total: float
) -> None:
with sqlite3.connect(db_path) as conn:
conn.execute(
"""INSERT INTO live_balance
(account_id, date, cash, market_value, total)
VALUES (?,?,?,?,?)""",
(account_id, date, cash, market_value, total),
)
conn.commit()
def list_balance(db_path: str, account_id: int) -> list[dict]:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT * FROM live_balance WHERE account_id=? ORDER BY date, id",
(account_id,),
)
return [dict(r) for r in cur.fetchall()]
def get_last_balance(db_path: str, account_id: int) -> dict | None:
"""最新一条账户快照(API /live/{aid}/account)。"""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT account_id, date, cash, market_value, total "
"FROM live_balance WHERE account_id=? ORDER BY id DESC LIMIT 1",
(account_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_first_balance(db_path: str, account_id: int) -> dict | None:
"""最早一条账户快照(收益率基线;首快照 total = baseline)。"""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT account_id, date, cash, market_value, total "
"FROM live_balance WHERE account_id=? ORDER BY id ASC LIMIT 1",
(account_id,),
)
row = cur.fetchone()
return dict(row) if row else None
__all__ = [
"init_db", "save_account", "list_accounts", "get_account",
"update_account_status", "list_running_accounts",
"save_trade", "list_trades",
"save_positions", "load_positions",
"save_balance", "list_balance", "get_last_balance", "get_first_balance",
]
+421
View File
@@ -0,0 +1,421 @@
"""实盘交易入口:connect → add_strategy → subscribe → init_all → start_all → 常驻。
配置来源(优先级递增):
1) ``sanguo_live/runner.py`` 内 DEFAULT_CONFIG
2) ``config/live.yaml``(可选)
3) 环境变量 ``SANGUO_QMT_ACCOUNT`` / ``SANGUO_QMT_PATH``
用法:
python -m sanguo_live # 用 config/live.yaml
python -m sanguo_live /path/to/cfg.yaml # 指定配置
python -m sanguo_live --supervisor [db] # DB 驱动 supervisor
SANGUO_QMT_ACCOUNT=66639661 python -m sanguo_live
注意:真实下单需要 miniQMT 同机运行且在交易时段。非交易时段运行只校验链路搭建。
"""
from __future__ import annotations
import logging
import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
import yaml
from sanguo_live.engine import LiveTradingEngine
from sanguo_live.strategies import AShareDoubleMaStrategy
logger = logging.getLogger(__name__)
DEFAULT_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "live.yaml"
)
DEFAULT_CONFIG: dict[str, Any] = {
"account": "",
"mini_path": "",
"strategy_name": "dm_15min_default",
"vt_symbol": "600000.SSE",
"strategy_class": "AShareDoubleMaStrategy",
# 连接后 sleep(秒):等 QmtGateway 完成 contract 拉取
"connect_wait_sec": 10,
# init_all 后 sleep(秒):等策略 load_bar 加载历史
"init_wait_sec": 60,
"setting": {
"fast_window": 10,
"slow_window": 20,
"window": 15, # BarGenerator 分钟窗口(A 股 15min)
"size": 100, # 1 手 = 100 股
"forbid_short": True,
},
}
# 策略类注册表(可扩展)
_STRATEGY_REGISTRY: dict[str, type] = {
"AShareDoubleMaStrategy": AShareDoubleMaStrategy,
}
def load_config(path: str | Path | None = None) -> dict[str, Any]:
"""加载配置:yaml 文件 + 环境变量覆盖(env 优先)。"""
cfg: dict[str, Any] = {k: (dict(v) if isinstance(v, dict) else v)
for k, v in DEFAULT_CONFIG.items()}
p = Path(path) if path else DEFAULT_CONFIG_PATH
if p.exists():
try:
with open(p, encoding="utf-8") as f:
file_cfg = yaml.safe_load(f) or {}
except OSError as e:
logger.warning("读取配置失败 %s: %s(使用默认)", p, e)
file_cfg = {}
for k, v in file_cfg.items():
if k == "setting" and isinstance(v, dict):
cfg["setting"].update(v)
else:
cfg[k] = v
# env 优先
if os.environ.get("SANGUO_QMT_ACCOUNT"):
cfg["account"] = os.environ["SANGUO_QMT_ACCOUNT"]
if os.environ.get("SANGUO_QMT_PATH"):
cfg["mini_path"] = os.environ["SANGUO_QMT_PATH"]
return cfg
def build_strategy_class(name: str) -> type:
"""策略类名 → 类。未知类抛 ValueError。"""
if name not in _STRATEGY_REGISTRY:
raise ValueError(
f"未知策略类: {name}; 可用: {list(_STRATEGY_REGISTRY)}"
)
return _STRATEGY_REGISTRY[name]
def _install_signal_handlers(engine: LiveTradingEngine) -> None:
"""SIGINT / SIGTERM → stop_all + close + exit。"""
def _shutdown(signum: int, frame: Any) -> None:
logger.info("收到信号 %s,停止所有策略并退出", signum)
try:
engine.stop_all()
engine.close()
finally:
sys.exit(0)
# SIGINT (Ctrl+C) 全平台;SIGTERM 仅 POSIX(Windows 上 Python 有定义但语义弱)
signal.signal(signal.SIGINT, _shutdown)
if hasattr(signal, "SIGTERM"):
try:
signal.signal(signal.SIGTERM, _shutdown)
except (ValueError, OSError):
pass # 非主线程或 Windows 子进程 — 忽略
def run(config_path: str | Path | None = None) -> None:
"""主流程:connect → add → subscribe → init → start → 常驻循环。"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
cfg = load_config(config_path)
if not cfg["account"]:
logger.error(
"未配置 QMT 交易账号。请设 SANGUO_QMT_ACCOUNT 或在 %s 写 account",
DEFAULT_CONFIG_PATH,
)
sys.exit(2)
engine = LiveTradingEngine()
# 优雅退出
_install_signal_handlers(engine)
# 1) 连接 miniQMT
engine.connect({"交易账号": cfg["account"], "mini路径": cfg["mini_path"]})
logger.info("等待 QMT 连接就绪 %ds...", cfg["connect_wait_sec"])
time.sleep(int(cfg["connect_wait_sec"]))
# 2) 注册策略
strategy_cls = build_strategy_class(cfg["strategy_class"])
engine.add_strategy(
strategy_cls,
cfg["strategy_name"],
cfg["vt_symbol"],
cfg["setting"],
)
# 3) 订阅行情(tick → BarGenerator → 15min bar → on_bar)
engine.subscribe([cfg["vt_symbol"]])
# 4) 初始化策略(load_bar 拉 10 天历史)
engine.init_all()
logger.info("等待策略 init 完成 %ds...", cfg["init_wait_sec"])
time.sleep(int(cfg["init_wait_sec"]))
# 5) 启动策略,进入实盘
engine.start_all()
logger.info("=== 实盘已启动 (策略=%s 标的=%s)。Ctrl+C 退出 ===",
cfg["strategy_name"], cfg["vt_symbol"])
# 6) 常驻:主线程保活,行情/下单都在 EventEngine 工作线程
while True:
time.sleep(10)
__all__ = ["run", "load_config", "build_strategy_class",
"DEFAULT_CONFIG", "DEFAULT_CONFIG_PATH", "run_supervisor",
"default_supervisor_db_path"]
# =============================================================================
# supervisor 模式:DB 驱动,独立常驻进程轮询 live_accounts.status
# (task #4 持久化扩展)。API 只改 status 字段,supervisor 据此起停 engine。
# =============================================================================
_SUPERVISOR_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "data_platform.yaml"
)
def default_supervisor_db_path() -> str:
"""从 config/data_platform.yaml 的 live_trading.db_path 读;fallback 主库。"""
try:
if _SUPERVISOR_CONFIG_PATH.exists():
with open(_SUPERVISOR_CONFIG_PATH, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
lt = (cfg.get("live_trading") or {})
if lt.get("db_path"):
return str(lt["db_path"])
dp = cfg.get("data_paths") or {}
if dp.get("vnpy_db"):
return str(dp["vnpy_db"])
except OSError as e:
logger.warning("读 supervisor 配置失败: %s", e)
return "quant_trading.db"
def _account_to_cfg(account_row: dict[str, Any]) -> dict[str, Any]:
"""live_accounts 行 → runner 内部 cfg 结构(setting JSON 解析)。"""
import json as _json
try:
setting = _json.loads(account_row.get("setting") or "{}")
except (ValueError, TypeError):
setting = {}
return {
"account": account_row.get("account", ""),
"mini_path": account_row.get("mini_path", ""),
"strategy_name": account_row.get("strategy_name", ""),
"vt_symbol": account_row.get("vt_symbol", ""),
"strategy_class": account_row.get("strategy_class",
"AShareDoubleMaStrategy"),
"connect_wait_sec": int(account_row.get("connect_wait_sec", 10)),
"init_wait_sec": int(account_row.get("init_wait_sec", 60)),
"setting": setting,
}
def _start_engine_for_account(account_row: dict[str, Any]) -> LiveTradingEngine:
"""根据 live_accounts 行起 LiveTradingEngine(connect→add→subscribe→init→start)。"""
from sanguo_live.engine import LiveTradingEngine
cfg = _account_to_cfg(account_row)
engine = LiveTradingEngine()
engine.connect({"交易账号": cfg["account"], "mini路径": cfg["mini_path"]})
logger.info("[supervisor] 等待 QMT 连接就绪 %ds (account=%s)...",
cfg["connect_wait_sec"], cfg["account"])
time.sleep(cfg["connect_wait_sec"])
strategy_cls = build_strategy_class(cfg["strategy_class"])
engine.add_strategy(strategy_cls, cfg["strategy_name"],
cfg["vt_symbol"], cfg["setting"])
engine.subscribe([cfg["vt_symbol"]])
engine.init_all()
logger.info("[supervisor] 等待策略 init %ds...", cfg["init_wait_sec"])
time.sleep(cfg["init_wait_sec"])
engine.start_all()
logger.info("[supervisor] engine 已启动 (account=%s strategy=%s)",
cfg["account"], cfg["strategy_name"])
return engine
def _register_trade_handler(
engine: LiveTradingEngine, account_id: int, db_path: str
) -> Any:
"""注册 EVENT_TRADE 回调:成交落 live_trades。返回 handler(供 unregister)。
EVENT_TRADE 定义在 ``vnpy.trader/event.py``(常量 "eTrade."),
``vnpy.event`` 只导出 Event/EventEngine/EVENT_TIMER — 从 vnpy.event
import EVENT_TRADE 会 ImportError,导致成交回调静默不注册。
"""
from sanguo_live.persistence import save_trade
try:
from vnpy.trader.event import EVENT_TRADE # type: ignore
except ImportError:
logger.warning(
"[supervisor] 无 vnpy.trader.event,EVENT_TRADE 回调未注册"
)
return None
def _on_trade(event: Any) -> None:
try:
t = event.data
save_trade(db_path, account_id, {
"strategy_name": "",
"symbol": getattr(t, "vt_symbol", "") or "",
"direction": _enum_tail(getattr(t, "direction", "")),
"offset": _enum_tail(getattr(t, "offset", "")),
"price": float(getattr(t, "price", 0)),
"volume": float(getattr(t, "volume", 0)),
"traded_at": (t.datetime.isoformat()
if getattr(t, "datetime", None) else ""),
"vt_tradeid": getattr(t, "vt_tradeid", ""),
})
except Exception as e: # noqa: BLE001
logger.warning("[supervisor] save_trade 失败: %s", e)
engine.event_engine.register(EVENT_TRADE, _on_trade)
return _on_trade
def _enum_tail(val: Any) -> str:
"""Direction.LONG → 'long';Offset.OPEN → 'open';非 enum → str(val).lower()。"""
name = getattr(val, "name", None)
if name:
return str(name).lower()
return str(val).lower() if val else ""
def _snapshot_to_db(
engine: LiveTradingEngine, account_id: int, db_path: str
) -> None:
"""定时把 OMS 持仓 + 账户快照落库(供 API 读)。"""
from sanguo_live.persistence import save_positions, save_balance
try:
positions: dict[str, dict] = {}
for p in engine.get_positions():
sym = getattr(p, "vt_symbol", "") or getattr(p, "symbol", "")
if not sym:
continue
# A 股只关心多头持仓(PositionDirection.LONG / NET)
direction = getattr(p, "direction", None)
dname = getattr(direction, "name", "")
if dname == "SHORT":
continue
vol = float(getattr(p, "volume", 0) or 0)
if vol <= 0:
continue
positions[sym] = {
"volume": vol,
"frozen": float(getattr(p, "frozen", 0) or 0),
"avg_price": float(getattr(p, "price", 0) or 0),
}
save_positions(db_path, account_id, positions)
date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for acc in engine.get_all_accounts():
total = float(getattr(acc, "balance", 0) or 0)
cash = float(getattr(acc, "available", 0) or 0)
save_balance(db_path, account_id, date_str, cash,
market_value=max(total - cash, 0.0), total=total)
except Exception as e: # noqa: BLE001
logger.warning("[supervisor] snapshot 落库失败 (account=%s): %s",
account_id, e)
def _stop_engine(engine: LiveTradingEngine) -> None:
"""stop_all + close(容错)。"""
try:
engine.stop_all()
finally:
try:
engine.close()
except Exception as e: # noqa: BLE001
logger.warning("[supervisor] engine.close 异常: %s", e)
def run_supervisor(
db_path: str | None = None,
poll_interval_sec: float = 5.0,
snapshot_interval_sec: float = 30.0,
) -> None:
"""DB 驱动的 supervisor 常驻进程。
轮询 ``live_accounts.status``:
- 新 running → 起 LiveTradingEngine + 注册 EVENT_TRADE 回调
- 变 stopped → 停 engine + close
定时(snapshot_interval_sec)把 OMS 持仓/账户落 DB 供 API 读。
信号(SIGINT/SIGTERM)→ 停所有 engine 后退出。
MVP:每实例一个 engine,表结构支持多行(多实例同时跑只是内存多 engine)。
"""
from sanguo_live.persistence import (
init_db, list_running_accounts, get_account,
update_account_status,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db = db_path or default_supervisor_db_path()
init_db(db)
logger.info("[supervisor] 启动 (db=%s poll=%.1fs snapshot=%.1fs)",
db, poll_interval_sec, snapshot_interval_sec)
engines: dict[int, LiveTradingEngine] = {}
stop_flag = {"stop": False}
def _shutdown(signum: int, frame: Any) -> None:
logger.info("[supervisor] 收到信号 %s,停止所有 engine", signum)
stop_flag["stop"] = True
signal.signal(signal.SIGINT, _shutdown)
if hasattr(signal, "SIGTERM"):
try:
signal.signal(signal.SIGTERM, _shutdown)
except (ValueError, OSError):
pass
last_snapshot: float = 0.0
while not stop_flag["stop"]:
now = time.time()
# 1) 同步 status
running_ids = {r["id"] for r in list_running_accounts(db)}
# 启动新 running
for aid in running_ids - engines.keys():
acc = get_account(db, aid)
if not acc:
continue
try:
eng = _start_engine_for_account(acc)
_register_trade_handler(eng, aid, db)
engines[aid] = eng
except Exception as e: # noqa: BLE001
logger.error("[supervisor] 起 engine 失败 (account=%s): %s",
aid, e)
update_account_status(db, aid, "stopped", str(e))
# 停止变 stopped 的
for aid in list(engines.keys() - running_ids):
logger.info("[supervisor] 停止 engine (account=%s)", aid)
_stop_engine(engines.pop(aid))
# 2) 定时 snapshot
if now - last_snapshot >= snapshot_interval_sec:
for aid, eng in engines.items():
_snapshot_to_db(eng, aid, db)
last_snapshot = now
time.sleep(poll_interval_sec)
# 退出清理
for aid, eng in engines.items():
logger.info("[supervisor] 退出清理 (account=%s)", aid)
_stop_engine(eng)
engines.clear()
logger.info("[supervisor] 已退出")
+118
View File
@@ -0,0 +1,118 @@
"""A 股 15min 双均线策略(AShareDoubleMaStrategy)。
为什么不直接用 ``vnpy_ctastrategy.strategies.DoubleMaStrategy``:
- 标准 DoubleMa 在 ``on_init`` 里 ``BarGenerator(self.on_bar)`` —— 无 ``window`` 参数,
只合成 1min bar;且其 ``parameters = ["fast_window", "slow_window"]`` 不暴露周期配置,
**无法通过 setting 切到 15min**。
- 本策略改用 ``BarGenerator(self._on_1min_bar, window=15, on_window_bar=self.on_bar)``,
让 ``on_bar`` 直接收 15min bar,通过 ``window`` 参数(默认 15)可配。
- 定寸 + 禁做空由 :class:`AShareCtaTemplate` 保证。
- A 股不能做空,死叉时只平多,不开空(原 DoubleMa 的 ``pos<0 → cover+buy`` 分支删除)。
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
try:
from vnpy.trader.utility import BarGenerator, ArrayManager # type: ignore
from vnpy.trader.constant import Interval # type: ignore
from vnpy.trader.object import BarData, TickData # type: ignore
_DEPS_OK = True
except ImportError: # 本机 dev 容错
BarGenerator = None # type: ignore
ArrayManager = None # type: ignore
Interval = None # type: ignore
BarData = None # type: ignore
TickData = None # type: ignore
_DEPS_OK = False
from sanguo_live.base_template import AShareCtaTemplate
class AShareDoubleMaStrategy(AShareCtaTemplate):
"""A 股 双均线 15min 策略。
金叉(fast 上穿 slow)且无持仓 → 买开 1 手;
死叉(fast 下穿 slow)且持多 → 卖平 1 手;
A 股禁做空 → 不开空单(基类 short 已拦截)。
"""
author = "sanguo_live"
fast_window: int = 10
slow_window: int = 20
# BarGenerator 分钟窗口。15 表示 15min。A 股支持 2/3/5/6/10/15/20/30(必须能整除 60)。
window: int = 15
# 把基类的 size/forbid_short 一起暴露,update_setting 才会回填全部字段。
parameters = ["fast_window", "slow_window", "window", "size", "forbid_short"]
variables = ["fast_ma0", "fast_ma1", "slow_ma0", "slow_ma1"]
fast_ma0: float = 0.0
fast_ma1: float = 0.0
slow_ma0: float = 0.0
slow_ma1: float = 0.0
def on_init(self) -> None:
self.write_log("AShareDoubleMa 策略初始化")
# 关键:用 BarGenerator 把 1min bar 合成 window 分钟 bar 后回调 self.on_bar。
# _on_1min_bar 故意空实现 —— 我们不处理 1min,只让它喂养 BarGenerator。
self.bg: BarGenerator = BarGenerator(
self._on_1min_bar,
window=self.window,
on_window_bar=self.on_bar,
)
self.am: ArrayManager = ArrayManager()
self.load_bar(10)
def on_start(self) -> None:
self.write_log("AShareDoubleMa 策略启动")
self.put_event()
def on_stop(self) -> None:
self.write_log("AShareDoubleMa 策略停止")
self.put_event()
def on_tick(self, tick: TickData) -> None:
"""tick 推入 BarGenerator,由其合成 1min 与 window 分钟 bar。"""
self.bg.update_tick(tick)
def _on_1min_bar(self, bar: BarData) -> None:
"""1min bar 回调:故意忽略(由 BarGenerator 内部累积合成 15min)。"""
return
def on_bar(self, bar: BarData) -> None:
"""15min(或 setting 配置的 window)bar 回调。"""
self.cancel_all()
am: ArrayManager = self.am
am.update_bar(bar)
if not am.inited:
return
fast_ma = am.sma(self.fast_window, array=True)
self.fast_ma0 = fast_ma[-1]
self.fast_ma1 = fast_ma[-2]
slow_ma = am.sma(self.slow_window, array=True)
self.slow_ma0 = slow_ma[-1]
self.slow_ma1 = slow_ma[-2]
cross_over = self.fast_ma0 > self.slow_ma0 and self.fast_ma1 < self.slow_ma1
cross_below = self.fast_ma0 < self.slow_ma0 and self.fast_ma1 > self.slow_ma1
if cross_over:
if self.pos == 0:
self.buy(bar.close_price, 1)
# A 股不可做空:原 DoubleMa 的 pos<0 分支(cover+buy)省略
elif cross_below:
if self.pos > 0:
self.sell(bar.close_price, 1)
# A 股不可做空:原 DoubleMa 的 pos==0 short 分支省略
self.put_event()
__all__ = ["AShareDoubleMaStrategy"]
+195 -2
View File
@@ -5,6 +5,10 @@
python -m sanguo_portfolio.runner_backtest \\
--start 2020-01-01 --end 2024-12-31 --cash 1000000
JSON 输出(供 SSH 捕获,前端 MVP 用):
python -m sanguo_portfolio.runner_backtest --json \\
--start 2024-01-01 --end 2024-02-29 --cash 1000000
Mac 没装 xtquant,这里仅作为入口脚本(测试用 mock,实际跑 rsync 到 VPS)。
"""
from __future__ import annotations
@@ -14,6 +18,7 @@ import os
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
import argparse
import json
import logging
from typing import Any, Dict
@@ -35,6 +40,10 @@ def parse_args() -> argparse.Namespace:
"--result-file", default="docs/portfolio_backtest_result.md",
help="结果输出文件(.md)",
)
p.add_argument(
"--json", action="store_true",
help="JSON 模式:print(json.dumps(result)) 到 stdout,供 SSH 捕获",
)
return p.parse_args()
@@ -91,7 +100,7 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
BulletTrade 的 BacktestEngine 接受 strategy_file 或 initialize 等函数。
我们把 AllWeatherStrategy 包成 initialize 函数:initialize 闭包挂 run_daily 等。
"""
from bullet_trade.core import BacktestEngine # type: ignore
from bullet_trade import BacktestEngine # type: ignore
from bullet_trade.data.api import set_data_provider # type: ignore
from .strategies import AllWeatherStrategy
@@ -145,7 +154,8 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
)
result = engine.run()
# 输出结果摘要到 markdown
# 输出结果摘要到 markdown(JSON 模式时 result_file="" 跳过)
if getattr(args, "result_file", ""):
_write_result_md(result, args.result_file, args)
return result
@@ -181,9 +191,192 @@ def _write_result_md(result: Dict[str, Any], path: str, args: argparse.Namespace
logger.warning("写结果文件失败: %s", exc)
def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
"""JSON 入口(供 SSH 触发,前端 MVP 用)。
Args:
params: {
pool: 标的池(暂未实际使用,占位),
start_date, end_date: YYYY-MM-DD,
initial_cash: 初始资金,
}
Returns:
{
"strategy": "all_weather",
"period": {"start": ..., "end": ..., "trading_days": N},
"stocks_selected": [{"code":..., "name":...}, ...], # 末日持仓
"trades": [{date, code, side, amount, price, ...}, ...],
"equity_curve": [{"date":..., "equity":...}, ...],
"metrics": {total_return, annual_return, max_drawdown, sharpe, ...},
}
"""
# 构造一个 Namespace 复用 run_backtest
args = argparse.Namespace(
start=params.get("start_date", "2024-01-01"),
end=params.get("end_date", "2024-02-29"),
cash=float(params.get("initial_cash", 1_000_000.0)),
benchmark=params.get("benchmark", "000300.XSHG"),
frequency="day",
provider_config="{}",
result_file="", # JSON 模式不写 md
)
raw = run_backtest(args)
summary = raw.get("summary", {}) if isinstance(raw, dict) else {}
metrics = _extract_metrics(summary)
# 净值曲线:daily_records 是 DataFrame,index=date,列含 total_value
equity_curve = _extract_equity_curve(raw.get("daily_records"))
# 选股(末日持仓):daily_positions 最后一日
stocks_selected = _extract_last_positions(raw.get("daily_positions"))
# 成交明细
trades = _extract_trades(raw.get("trades"))
meta = raw.get("meta", {}) if isinstance(raw, dict) else {}
return {
"strategy": "all_weather",
"period": {
"start": meta.get("start_date", args.start),
"end": meta.get("end_date", args.end),
"trading_days": len(equity_curve),
},
"stocks_selected": stocks_selected,
"trades": trades,
"equity_curve": equity_curve,
"metrics": metrics,
"raw_summary": summary,
}
def _to_float(v: Any) -> float | None:
"""从 string/number 提取 float,失败返 None。bullet-trade summary 多为 '12.34%' 字符串。"""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace("%", "").replace(",", "")
try:
return float(s)
except (TypeError, ValueError):
return None
def _extract_metrics(summary: Dict[str, Any]) -> Dict[str, float | None]:
"""bullet-trade summary 用中文 key('策略收益'/'最大回撤'/...)。
百分比按字面数值(12.34% → 12.34),前端按需 /100 显示。
"""
return {
"total_return": _to_float(summary.get("策略收益")),
"annual_return": _to_float(summary.get("策略年化收益")),
"max_drawdown": _to_float(summary.get("最大回撤")),
"sharpe": _to_float(summary.get("夏普比率")),
"win_rate_daily": _to_float(summary.get("日胜率")),
"win_rate_trade": _to_float(summary.get("交易胜率")),
"trading_days": _to_float(summary.get("交易天数")),
}
def _extract_equity_curve(daily_records: Any) -> list[Dict[str, Any]]:
"""daily_records: DataFrame,index=date,列含 total_value。"""
out: list[Dict[str, Any]] = []
if daily_records is None:
return out
try:
import pandas as pd # type: ignore
if isinstance(daily_records, pd.DataFrame):
df = daily_records.reset_index()
date_col = "date" if "date" in df.columns else df.columns[0]
val_col = "total_value" if "total_value" in df.columns else None
if val_col is None:
return out
for _, row in df.iterrows():
d = row[date_col]
out.append({
"date": getattr(d, "strftime", lambda f: str(d))("%Y-%m-%d"),
"equity": float(row[val_col]),
})
except Exception as exc:
logger.warning("解析 equity_curve 失败: %s", exc)
return out
def _extract_last_positions(daily_positions: Any) -> list[Dict[str, Any]]:
"""daily_positions: DataFrame,列含 date/code/amount/avg_cost/price/value。
取最后一日的非零持仓作为选股名单。"""
out: list[Dict[str, Any]] = []
if daily_positions is None:
return out
try:
import pandas as pd # type: ignore
if isinstance(daily_positions, pd.DataFrame) and not daily_positions.empty:
df = daily_positions
if "date" in df.columns:
last_date = df["date"].max()
df = df[df["date"] == last_date]
for _, row in df.iterrows():
amt = row.get("amount", 0)
if amt is None or float(amt) <= 0:
continue
out.append({
"code": str(row.get("code", "")),
"name": str(row.get("code", "")), # name 字段 bullet-trade 没存,前端展示 code
"amount": float(amt),
"avg_cost": float(row.get("avg_cost", 0) or 0),
"price": float(row.get("price", 0) or 0),
"value": float(row.get("value", 0) or 0),
})
except Exception as exc:
logger.warning("解析 last_positions 失败: %s", exc)
return out
def _extract_trades(trades: Any) -> list[Dict[str, Any]]:
"""trades: list[Trade],用 __dict__ 或属性兜底提取关键字段。"""
out: list[Dict[str, Any]] = []
if not trades:
return out
keys = ("datetime", "date", "code", "side", "action", "amount",
"filled_amount", "price", "filled_price", "commission", "status")
for t in trades:
item: Dict[str, Any] = {}
for k in keys:
v = None
if hasattr(t, k):
v = getattr(t, k)
elif isinstance(t, dict):
v = t.get(k)
if v is None:
continue
# datetime 类转字符串
if hasattr(v, "strftime"):
v = v.strftime("%Y-%m-%d %H:%M:%S")
try:
if isinstance(v, (int, float)):
v = float(v)
except Exception:
pass
item[k] = v
if item:
out.append(item)
return out
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
args = parse_args()
if args.json:
# JSON 模式:stderr 仍打日志,stdout 只输出 JSON(供 SSH 捕获)
result = run_backtest_json({
"start_date": args.start,
"end_date": args.end,
"initial_cash": args.cash,
"benchmark": args.benchmark,
})
print(json.dumps(result, ensure_ascii=False, default=str))
else:
run_backtest(args)
+370
View File
@@ -0,0 +1,370 @@
"""实盘模拟 API + 持久化单测(task #4)。
两层:
(1) persistence CRUD —— sqlite tmp,纯 PythonMac 跑通;
(2) routes_live API —— FastAPI TestClient,不实例化 LiveTradingEngine
supervisor 才起 engine,本模块只测 DB CRUD 路由)。
Mac 跑:``pytest tests/test_live_api.py -v``
"""
from __future__ import annotations
import os
from fastapi.testclient import TestClient
from sanguo_api.app import create_app
from sanguo_api.auth import create_token, set_jwt_config
from sanguo_api.routes_live import set_db_path
# =============================================================================
# 层 1persistence CRUD
# =============================================================================
def _db(tmp_path) -> str:
from sanguo_live.persistence import init_db
db = os.path.join(str(tmp_path), "live.db")
init_db(db)
return db
def test_save_and_get_account(tmp_path):
from sanguo_live.persistence import save_account, get_account
db = _db(tmp_path)
aid = save_account(db, {
"name": "live1", "account": "12345678",
"vt_symbol": "600000.SSE", "strategy_name": "dm1",
"setting": {"fast_window": 5}, "initial_capital": 5e5,
})
assert aid > 0
acc = get_account(db, aid)
assert acc["account"] == "12345678"
assert acc["status"] == "stopped" # 默认 stopped
assert acc["vt_symbol"] == "600000.SSE"
assert "\"fast_window\": 5" in acc["setting"] # JSON 字符串
def test_list_accounts_and_default_status(tmp_path):
from sanguo_live.persistence import save_account, list_accounts
db = _db(tmp_path)
save_account(db, {"account": "1", "strategy_name": "s1"})
save_account(db, {"account": "2", "strategy_name": "s2"})
rows = list_accounts(db)
assert len(rows) == 2
# DESC 排序:最新建的在前
assert rows[0]["account"] == "2"
assert all(r["status"] == "stopped" for r in rows)
def test_update_account_status(tmp_path):
from sanguo_live.persistence import (
save_account, update_account_status, get_account,
list_running_accounts,
)
db = _db(tmp_path)
aid = save_account(db, {"account": "999", "strategy_name": "s"})
update_account_status(db, aid, "running")
assert get_account(db, aid)["status"] == "running"
assert len(list_running_accounts(db)) == 1
update_account_status(db, aid, "stopped", "test error")
assert get_account(db, aid)["status"] == "stopped"
assert get_account(db, aid)["error_msg"] == "test error"
assert list_running_accounts(db) == []
def test_save_and_list_trades(tmp_path):
from sanguo_live.persistence import save_account, save_trade, list_trades
db = _db(tmp_path)
aid = save_account(db, {"account": "1", "strategy_name": "s"})
tid1 = save_trade(db, aid, {"symbol": "600000.SSE", "direction": "long",
"offset": "open", "price": 10.5, "volume": 100,
"traded_at": "2026-07-17T10:00:00",
"vt_tradeid": "T1"})
tid2 = save_trade(db, aid, {"symbol": "600000.SSE", "direction": "short",
"offset": "close", "price": 11.0, "volume": 100,
"traded_at": "2026-07-17T11:00:00",
"vt_tradeid": "T2"})
assert tid1 > 0 and tid2 > tid1
trades = list_trades(db, aid)
assert len(trades) == 2
assert trades[0]["vt_tradeid"] == "T1"
assert trades[1]["price"] == 11.0
def test_save_positions_overwrites_snapshot(tmp_path):
"""positions 覆盖式快照:第二次 save 完全替换第一次。"""
from sanguo_live.persistence import (
save_account, save_positions, load_positions,
)
db = _db(tmp_path)
aid = save_account(db, {"account": "1", "strategy_name": "s"})
save_positions(db, aid, {
"600000.SSE": {"volume": 100, "frozen": 0, "avg_price": 10.0},
"000001.SZSE": {"volume": 200, "frozen": 50, "avg_price": 15.0},
})
pos = load_positions(db, aid)
assert len(pos) == 2
# 覆盖(600000 减仓,000001 清仓)
save_positions(db, aid, {
"600000.SSE": {"volume": 50, "frozen": 0, "avg_price": 10.0},
})
pos2 = load_positions(db, aid)
assert len(pos2) == 1
assert pos2[0]["symbol"] == "600000.SSE"
assert pos2[0]["volume"] == 50
def test_save_positions_skips_zero_volume(tmp_path):
from sanguo_live.persistence import save_account, save_positions, load_positions
db = _db(tmp_path)
aid = save_account(db, {"account": "1", "strategy_name": "s"})
save_positions(db, aid, {
"600000.SSE": {"volume": 0, "frozen": 0, "avg_price": 0},
"000001.SZSE": {"volume": 100, "frozen": 0, "avg_price": 15.0},
})
pos = load_positions(db, aid)
assert len(pos) == 1
assert pos[0]["symbol"] == "000001.SZSE"
def test_save_and_get_last_balance(tmp_path):
from sanguo_live.persistence import (
save_account, save_balance, list_balance, get_last_balance,
)
db = _db(tmp_path)
aid = save_account(db, {"account": "1", "strategy_name": "s"})
save_balance(db, aid, "2026-07-17 10:00:00", 5e5, 1e5, 6e5)
save_balance(db, aid, "2026-07-17 11:00:00", 4e5, 2e5, 6e5)
all_bal = list_balance(db, aid)
assert len(all_bal) == 2
last = get_last_balance(db, aid)
assert last["cash"] == 4e5
assert last["total"] == 6e5
assert last["date"] == "2026-07-17 11:00:00"
def test_get_last_balance_empty(tmp_path):
from sanguo_live.persistence import save_account, get_last_balance
db = _db(tmp_path)
aid = save_account(db, {"account": "1", "strategy_name": "s"})
assert get_last_balance(db, aid) is None
# =============================================================================
# 层 2routes_live APITestClient,不依赖 vnpy
# =============================================================================
def _client(tmp_path):
set_jwt_config(secret="t", expire_minutes=60)
db = os.path.join(str(tmp_path), "live_api.db")
app = create_app(db_path=db)
set_db_path(db)
return TestClient(app), create_token("admin")
def _auth(token):
return {"Authorization": f"Bearer {token}"}
def test_create_live(tmp_path):
c, token = _client(tmp_path)
resp = c.post(
"/api/v1/live/create",
json={
"name": "live1", "account": "12345678",
"vt_symbol": "600000.SSE", "strategy_name": "dm1",
"setting": {"fast_window": 5}, "initial_capital": 5e5,
},
headers=_auth(token),
)
assert resp.status_code == 200
body = resp.json()
assert body["account_id"] > 0
assert body["status"] == "stopped" # create 后默认 stopped
def test_list_and_get_live(tmp_path):
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1"},
headers=_auth(token),
).json()["account_id"]
# 列表
lst = c.get("/api/v1/live", headers=_auth(token)).json()
assert len(lst["accounts"]) == 1
assert lst["accounts"][0]["id"] == aid
assert lst["accounts"][0]["total_return"] is None # 无 balance
assert lst["accounts"][0]["position_count"] == 0
# 详情
detail = c.get(f"/api/v1/live/{aid}", headers=_auth(token)).json()
assert detail["account"] == "123"
assert detail["status"] == "stopped"
def test_start_and_stop(tmp_path):
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1"},
headers=_auth(token),
).json()["account_id"]
# start
r = c.post(f"/api/v1/live/{aid}/start", headers=_auth(token))
assert r.status_code == 200
assert r.json()["status"] == "running"
assert c.get(f"/api/v1/live/{aid}/status",
headers=_auth(token)).json()["status"] == "running"
# stop
r = c.post(f"/api/v1/live/{aid}/stop", headers=_auth(token))
assert r.json()["status"] == "stopped"
assert c.get(f"/api/v1/live/{aid}/status",
headers=_auth(token)).json()["status"] == "stopped"
def test_start_empty_account_rejected(tmp_path):
"""account 字段空 → start 返回 400。"""
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "", "strategy_name": "dm1"},
headers=_auth(token),
).json()["account_id"]
r = c.post(f"/api/v1/live/{aid}/start", headers=_auth(token))
assert r.status_code == 400
def test_empty_trades_positions_account(tmp_path):
"""新建实例:trades / positions / account 应返回空结构。"""
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1"},
headers=_auth(token),
).json()["account_id"]
assert c.get(f"/api/v1/live/{aid}/trades",
headers=_auth(token)).json() == []
assert c.get(f"/api/v1/live/{aid}/positions",
headers=_auth(token)).json() == []
assert c.get(f"/api/v1/live/{aid}/account",
headers=_auth(token)).json() == {}
def test_get_live_404(tmp_path):
c, token = _client(tmp_path)
assert c.get("/api/v1/live/999", headers=_auth(token)).status_code == 404
assert c.get("/api/v1/live/999/status",
headers=_auth(token)).status_code == 404
assert c.post("/api/v1/live/999/start",
headers=_auth(token)).status_code == 404
def test_unauthorized_401(tmp_path):
c, _ = _client(tmp_path)
assert c.get("/api/v1/live").status_code == 401
assert c.post("/api/v1/live/create",
json={"account": "1", "strategy_name": "s"}).status_code == 401
def test_create_live_mini_path_default_when_empty(monkeypatch, tmp_path):
"""create 不传 mini_path → 后端 env/内置默认兜底,落库 mini_path 非空。
避免空 mini_path 导致 connect=-1(task #6a 冒烟发现)。
"""
monkeypatch.delenv("SANGUO_QMT_PATH", raising=False)
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1"}, # 不传 mini_path
headers=_auth(token),
).json()["account_id"]
from sanguo_live.persistence import get_account
db = os.path.join(str(tmp_path), "live_api.db")
acc = get_account(db, aid)
assert acc["mini_path"] # 非空
assert "userdata_mini" in acc["mini_path"] # 内置默认
def test_create_live_mini_path_env_fallback(monkeypatch, tmp_path):
"""req.mini_path 空 → env SANGUO_QMT_PATH 兜底(优先于内置默认)。"""
monkeypatch.setenv("SANGUO_QMT_PATH", "/from/env/mini")
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1", "mini_path": ""},
headers=_auth(token),
).json()["account_id"]
from sanguo_live.persistence import get_account
db = os.path.join(str(tmp_path), "live_api.db")
assert get_account(db, aid)["mini_path"] == "/from/env/mini"
def test_list_lives_total_return_uses_first_snapshot_baseline(tmp_path):
"""list 收益率按首快照 total 为 baseline,不是 initial_capital。
场景:initial_capital=6e5,但首快照 total=5e5(模拟入金后立刻记录)。
两条 balance:5e5 → 5.5e5,收益率应为 (5.5e5 - 5e5) / 5e5 = 0.1,
而非按 initial_capital 6e5 算的 -0.0833。
"""
from sanguo_live.persistence import save_balance
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1",
"initial_capital": 6e5},
headers=_auth(token),
).json()["account_id"]
db = os.path.join(str(tmp_path), "live_api.db")
save_balance(db, aid, "2026-07-17 09:30:00", 5e5, 0, 5e5) # baseline
save_balance(db, aid, "2026-07-17 15:00:00", 5e5, 0.5e5, 5.5e5)
item = c.get("/api/v1/live", headers=_auth(token)).json()["accounts"][0]
assert item["latest_equity"] == 5.5e5
# (5.5e5 - 5e5) / 5e5 = 0.1
assert abs(item["total_return"] - 0.1) < 1e-9
def test_routes_reflect_db_writes(tmp_path):
"""直接写 DB(模拟 supervisor 落库)→ API 路由读到。"""
from sanguo_live.persistence import (
save_trade, save_positions, save_balance,
)
c, token = _client(tmp_path)
aid = c.post(
"/api/v1/live/create",
json={"account": "123", "strategy_name": "dm1",
"initial_capital": 6e5},
headers=_auth(token),
).json()["account_id"]
db = os.path.join(str(tmp_path), "live_api.db")
save_trade(db, aid, {"symbol": "600000.SSE", "direction": "long",
"offset": "open", "price": 10.0, "volume": 100,
"traded_at": "2026-07-17T10:00:00"})
save_positions(db, aid, {"600000.SSE": {"volume": 100, "frozen": 0,
"avg_price": 10.0}})
save_balance(db, aid, "2026-07-17 10:00:00", 5e5, 1e5, 6e5)
trades = c.get(f"/api/v1/live/{aid}/trades", headers=_auth(token)).json()
assert len(trades) == 1
assert trades[0]["price"] == 10.0
pos = c.get(f"/api/v1/live/{aid}/positions", headers=_auth(token)).json()
assert len(pos) == 1 and pos[0]["symbol"] == "600000.SSE"
acc = c.get(f"/api/v1/live/{aid}/account", headers=_auth(token)).json()
assert acc["total"] == 6e5
# 列表汇总:有 balance 后 total_return 应非 None
lst = c.get("/api/v1/live", headers=_auth(token)).json()
item = lst["accounts"][0]
assert item["latest_equity"] == 6e5
assert item["total_return"] == 0.0 # 6e5 == 初始 6e5
assert item["position_count"] == 1
+252
View File
@@ -0,0 +1,252 @@
"""sanguo_live 单元测试。
分两层:
(1) 纯 Python 逻辑层 —— Mac dev 机也跑(config 解析、注册表、默认参数);
(2) 依赖 vnpy_ctastrategy 层 —— Mac 未装时单测级 skip,VPS 装齐则跑通。
Mac 跑:``pytest tests/test_live_engine.py -v``(层 1 全 pass + 层 2 skipped,exit 0)。
VPS 跑:全部 pass(含定寸/禁做空/引擎装配)。
"""
from __future__ import annotations
import importlib
import pytest
def _has_vnpy_cta() -> bool:
"""Mac dev 机没装 vnpy_ctastrategy(只装在 VPS)。"""
try:
importlib.import_module("vnpy_ctastrategy")
return True
except ImportError:
return False
# 单测级 skip marker(模块级 importorskip 会跳过整个文件,误伤层 1)
needs_vnpy_cta = pytest.mark.skipif(
not _has_vnpy_cta(),
reason="本机未装 vnpy_ctastrategy(仅 VPS 有)— 跳过依赖它的单测",
)
# =============================================================================
# 层 1:纯 Python 逻辑(Mac dev 机也跑)
# =============================================================================
def test_module_import_tolerant():
"""``sanguo_live`` 包 import 不应崩(即便本机没 vnpy_ctastrategy)。"""
importlib.import_module("sanguo_live")
importlib.import_module("sanguo_live.base_template")
importlib.import_module("sanguo_live.runner")
# engine / strategies import 了 vnpy_qmt/vnpy_ctastrategy 的类绑定,
# 但都用 try/except 容错,模块本身能 import。
importlib.import_module("sanguo_live.engine")
importlib.import_module("sanguo_live.strategies")
def test_default_config_fields():
from sanguo_live.runner import DEFAULT_CONFIG
assert DEFAULT_CONFIG["strategy_class"] == "AShareDoubleMaStrategy"
assert DEFAULT_CONFIG["vt_symbol"] == "600000.SSE"
s = DEFAULT_CONFIG["setting"]
assert s["window"] == 15
assert s["size"] == 100
assert s["forbid_short"] is True
assert s["fast_window"] == 10
assert s["slow_window"] == 20
def test_load_config_env_override(monkeypatch):
"""env SANGUO_QMT_ACCOUNT / SANGUO_QMT_PATH 优先于 yaml / 默认。"""
monkeypatch.setenv("SANGUO_QMT_ACCOUNT", "12345678")
monkeypatch.setenv("SANGUO_QMT_PATH", "/tmp/fake_mini")
from sanguo_live.runner import load_config
cfg = load_config("/nonexistent/path.yaml") # 文件不存在 → 走默认
assert cfg["account"] == "12345678"
assert cfg["mini_path"] == "/tmp/fake_mini"
def test_load_config_yaml_merge(tmp_path):
"""yaml 能覆盖默认 fast_window 等。"""
yaml_file = tmp_path / "live.yaml"
yaml_file.write_text(
"account: '99999999'\n"
"vt_symbol: '000001.SZSE'\n"
"setting:\n"
" fast_window: 5\n"
" slow_window: 30\n",
encoding="utf-8",
)
from sanguo_live.runner import load_config
cfg = load_config(str(yaml_file))
assert cfg["account"] == "99999999"
assert cfg["vt_symbol"] == "000001.SZSE"
assert cfg["setting"]["fast_window"] == 5
assert cfg["setting"]["slow_window"] == 30
# 未覆盖的字段保留默认
assert cfg["setting"]["window"] == 15
assert cfg["strategy_class"] == "AShareDoubleMaStrategy"
def test_build_strategy_class_known():
from sanguo_live.runner import build_strategy_class
cls = build_strategy_class("AShareDoubleMaStrategy")
assert cls.__name__ == "AShareDoubleMaStrategy"
def test_build_strategy_class_unknown_raises():
from sanguo_live.runner import build_strategy_class
with pytest.raises(ValueError, match="未知策略类"):
build_strategy_class("NoSuchStrategy_xyz")
def test_strategy_class_has_parameters():
"""AShareDoubleMaStrategy.parameters 必须暴露 size/forbid_short/window
+ fast/slow_window(缺一个都会让 update_setting 漏字段)。"""
from sanguo_live.strategies import AShareDoubleMaStrategy
params = AShareDoubleMaStrategy.parameters
for required in ("fast_window", "slow_window", "window",
"size", "forbid_short"):
assert required in params, f"缺少 parameter: {required}"
# =============================================================================
# 层 2:依赖 vnpy_ctastrategy(Mac skip,VPS 跑)
# =============================================================================
class _FakeCtaEngine:
"""记录 send_order 调用,模拟 CtaTemplate 依赖的 cta_engine。"""
def __init__(self) -> None:
self.calls: list[tuple] = []
def send_order(self, strategy, direction, offset, price, volume,
stop=False, lock=False, net=False):
self.calls.append((direction, offset, price, volume, stop, lock, net))
return []
def cancel_all(self, strategy):
return None
def _make_strategy(cls, setting=None):
"""构造一个策略实例(trading=True,可发单)。
``cls`` 必须是具体类(CtaTemplate 是 ABC,带抽象 on_init,不能直接实例化)。
用 ``_ConcreteAShare`` 包装 AShareCtaTemplate 来测基类定寸/禁做空逻辑。
"""
strat = cls(_FakeCtaEngine(), "test_strat", "600000.SSE", setting or {})
strat.trading = True
return strat
def _concrete_asare():
"""返回 AShareCtaTemplate 的一个具体子类(stub on_init/on_tick/on_bar)。"""
from sanguo_live.base_template import AShareCtaTemplate
class _Concrete(AShareCtaTemplate):
author = "test"
def on_init(self) -> None: # type: ignore[override]
return
def on_tick(self, tick) -> None: # type: ignore[override]
return
def on_bar(self, bar) -> None: # type: ignore[override]
return
return _Concrete
@needs_vnpy_cta
def test_buy_volume_multiplied_by_size():
"""buy(1) 实际下单 volume=100(1 手 × size)。"""
strat = _make_strategy(_concrete_asare(), {"size": 100})
strat.buy(10.0, 1)
assert len(strat.cta_engine.calls) == 1
_, _, price, volume, *_ = strat.cta_engine.calls[0]
assert price == 10.0
assert volume == 100
@needs_vnpy_cta
def test_buy_custom_size_multiplier():
"""size=200 → buy(2) 下 400。"""
strat = _make_strategy(_concrete_asare(), {"size": 200})
strat.buy(8.8, 2)
assert strat.cta_engine.calls[0][3] == 400
@needs_vnpy_cta
def test_sell_volume_multiplied_by_size():
"""sell(平多)同样定寸。"""
strat = _make_strategy(_concrete_asare(), {"size": 100})
strat.sell(11.0, 1)
assert strat.cta_engine.calls[0][3] == 100
@needs_vnpy_cta
def test_cover_volume_multiplied_by_size():
"""cover 也定寸(策略逻辑误调时不至于下零股)。"""
strat = _make_strategy(_concrete_asare(), {"size": 100})
strat.cover(11.0, 1)
assert strat.cta_engine.calls[0][3] == 100
@needs_vnpy_cta
def test_short_blocked_by_default():
"""forbid_short=True(默认) → short 返回 [],不触达 send_order。"""
class _ExplodingEngine:
def send_order(self, *a, **kw):
raise AssertionError("short 不应到达 send_order")
def write_log(self, msg, strategy=None):
return
strat = _concrete_asare()(_ExplodingEngine(), "t", "600000.SSE", {})
strat.trading = True
result = strat.short(10.0, 1)
assert result == []
@needs_vnpy_cta
def test_short_passes_when_forbid_disabled():
"""forbid_short=False → short 定寸后透传到基类(A 股不推荐,仅测试逻辑)。"""
strat = _make_strategy(_concrete_asare(),
{"size": 100, "forbid_short": False})
strat.short(10.0, 1)
assert len(strat.cta_engine.calls) == 1
assert strat.cta_engine.calls[0][3] == 100 # 1 手 × 100
@needs_vnpy_cta
def test_double_ma_strategy_uses_15min_window():
"""AShareDoubleMaStrategy 默认 window=15(不是 1min)。"""
from sanguo_live.strategies import AShareDoubleMaStrategy
strat = _make_strategy(AShareDoubleMaStrategy, {})
assert strat.window == 15
assert strat.size == 100
assert strat.forbid_short is True
@needs_vnpy_cta
def test_engine_assembly_and_close():
"""LiveTradingEngine 初始化 → MainEngine 装入 QMT gateway + CTA app,close 干净退出。
会真启动 EventEngine 线程,测试结束必须 close。
"""
from sanguo_live.engine import LiveTradingEngine
eng = LiveTradingEngine()
try:
assert eng.cta_engine is not None
assert "QMT" in eng.main_engine.gateways
# 查询方法不抛(连接前可能返回空)
assert isinstance(eng.get_all_accounts(), list)
assert isinstance(eng.get_positions(), list)
assert isinstance(eng.get_orders(), list)
finally:
eng.close()