feat(paper/live): 生命周期管理补全: paper停止/恢复(实走20:30 step跳过stopped)/删除(连带净值成交持仓挂单)/编辑(名称标的资金); live删除(运行中拒绝)/编辑(stopped才可改); 前端列表+结果页按钮/编辑弹窗/删除确认 [vps]
CI/CD / test (push) Successful in 10s
CI/CD / nas-deploy (push) Failing after 11s
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-08-13 18:27:32 +08:00
parent d8c156e6de
commit ccf958c5d6
8 changed files with 488 additions and 8 deletions
+18
View File
@@ -124,3 +124,21 @@ export async function getLiveStatus(aid: number): Promise<LiveStatus> {
const { data } = await apiClient.get<LiveStatus>(`/live/${aid}/status`)
return data
}
export async function deleteLive(aid: number): Promise<{ account_id: number; deleted: boolean }> {
const { data } = await apiClient.delete(`/live/${aid}`)
return data
}
export interface LiveUpdateReq {
name?: string
account?: string
vt_symbol?: string
strategy_name?: string
interval?: string
}
export async function updateLive(aid: number, req: LiveUpdateReq): Promise<{ updated: boolean }> {
const { data } = await apiClient.put(`/live/${aid}`, req)
return data
}
+26
View File
@@ -124,3 +124,29 @@ export async function getPending(aid: number): Promise<PendingOrder[]> {
const { data } = await apiClient.get<PendingOrder[]>(`/paper/${aid}/pending`)
return data
}
export async function stopPaper(aid: number): Promise<{ account_id: number; status: string }> {
const { data } = await apiClient.post(`/paper/${aid}/stop`)
return data
}
export async function resumePaper(aid: number): Promise<{ account_id: number; status: string }> {
const { data } = await apiClient.post(`/paper/${aid}/resume`)
return data
}
export async function deletePaper(aid: number): Promise<{ account_id: number; deleted: boolean }> {
const { data } = await apiClient.delete(`/paper/${aid}`)
return data
}
export interface PaperUpdateReq {
name?: string
symbols?: string[]
initial_capital?: number
}
export async function updatePaper(aid: number, req: PaperUpdateReq): Promise<{ updated: boolean }> {
const { data } = await apiClient.put(`/paper/${aid}`, req)
return data
}
+66 -2
View File
@@ -2,8 +2,11 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listLives, startLive, stopLive,
listLives, startLive, stopLive, deleteLive, updateLive,
type LiveAccount,
} from '@/api/live'
@@ -73,6 +76,51 @@ function goNew(): void {
router.push('/live/new')
}
async function onDelete(a: LiveAccount): Promise<void> {
try {
await ElMessageBox.confirm(
`删除实盘实例 #${a.id}${a.name})?成交/持仓/余额数据将一并删除,不可恢复。`,
'删除确认',
{ type: 'warning' },
)
} catch {
return
}
actionLoading.value = a.id
try {
await deleteLive(a.id)
ElMessage.success(`#${a.id} 已删除`)
await refreshSilent()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
} finally {
actionLoading.value = null
}
}
// 编辑(仅 stopped 可改;后端运行中会 400)
const editVisible = ref(false)
const editForm = reactive({ id: 0, name: '', account: '', vt_symbol: '', strategy_name: '', interval: '' })
function openEdit(a: LiveAccount): void {
editForm.id = a.id
editForm.name = a.name
editForm.account = a.account
editForm.vt_symbol = a.vt_symbol
editForm.strategy_name = a.strategy_name
editForm.interval = a.interval
editVisible.value = true
}
async function saveEdit(): Promise<void> {
try {
await updateLive(editForm.id, { ...editForm })
ElMessage.success(`#${editForm.id} 已更新`)
editVisible.value = false
await refreshSilent()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '更新失败')
}
}
async function onStart(a: LiveAccount): Promise<void> {
actionLoading.value = a.id
try {
@@ -178,7 +226,7 @@ async function onStop(a: LiveAccount): Promise<void> {
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<el-table-column label="操作" width="240">
<template #default="{ row }">
<el-button
v-if="row.status !== 'running'"
@@ -193,10 +241,26 @@ async function onStop(a: LiveAccount): Promise<void> {
@click="onStop(row)"
>停止</el-button>
<el-button link type="primary" size="small" @click="goMonitor(row)">监控</el-button>
<el-button v-if="row.status !== 'running'" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status !== 'running'" link type="danger" size="small" :loading="actionLoading === row.id" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="editVisible" title="编辑实盘实例" width="480px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="名称"><el-input v-model="editForm.name" /></el-form-item>
<el-form-item label="交易账号"><el-input v-model="editForm.account" /></el-form-item>
<el-form-item label="标的"><el-input v-model="editForm.vt_symbol" /></el-form-item>
<el-form-item label="策略实例名"><el-input v-model="editForm.strategy_name" /></el-form-item>
<el-form-item label="频率"><el-input v-model="editForm.interval" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
+119 -5
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { listPapers, type PaperAccount } from '@/api/paper'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPapers, stopPaper, resumePaper, deletePaper, updatePaper,
type PaperAccount,
} from '@/api/paper'
const router = useRouter()
const accounts = ref<PaperAccount[]>([])
@@ -10,6 +13,7 @@ const loading = ref(false)
const kw = ref('')
const modeFilter = ref('')
const statusFilter = ref('')
const actionLoading = ref(0)
onMounted(refresh)
@@ -62,6 +66,84 @@ function open(a: PaperAccount): void {
function goNew(): void {
router.push('/paper/new')
}
async function onStop(a: PaperAccount): Promise<void> {
actionLoading.value = a.id
try {
await stopPaper(a.id)
ElMessage.success(`#${a.id} 已停止,今晚起不再结算`)
await refresh()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '停止失败')
} finally {
actionLoading.value = 0
}
}
async function onResume(a: PaperAccount): Promise<void> {
actionLoading.value = a.id
try {
await resumePaper(a.id)
ElMessage.success(`#${a.id} 已恢复,今晚 20:30 起继续结算`)
await refresh()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '恢复失败')
} finally {
actionLoading.value = 0
}
}
async function onDelete(a: PaperAccount): Promise<void> {
try {
await ElMessageBox.confirm(
`删除模拟盘 #${a.id}${a.name})?净值/成交/持仓数据将一并删除,不可恢复。`,
'删除确认',
{ type: 'warning' },
)
} catch {
return
}
actionLoading.value = a.id
try {
await deletePaper(a.id)
ElMessage.success(`#${a.id} 已删除`)
await refresh()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
} finally {
actionLoading.value = 0
}
}
// 编辑(MVP:名称/标的/初始资金)
const editVisible = ref(false)
const editForm = reactive({ id: 0, name: '', symbols: '', initial_capital: 0 })
function openEdit(a: PaperAccount): void {
editForm.id = a.id
editForm.name = a.name
const syms: string = typeof a.symbols === 'string' ? a.symbols : JSON.stringify(a.symbols ?? [])
try {
editForm.symbols = (JSON.parse(syms) as string[]).join(',')
} catch {
editForm.symbols = syms
}
editForm.initial_capital = a.initial_capital
editVisible.value = true
}
async function saveEdit(): Promise<void> {
try {
await updatePaper(editForm.id, {
name: editForm.name,
symbols: editForm.symbols.split(',').map((s) => s.trim()).filter(Boolean),
initial_capital: Number(editForm.initial_capital),
})
ElMessage.success(`#${editForm.id} 已更新(策略参数自下次结算生效)`)
editVisible.value = false
await refresh()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '更新失败')
}
}
</script>
<template>
@@ -131,11 +213,43 @@ function goNew(): void {
<el-table-column label="状态" width="84">
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel[row.status] || row.status }}</span></template>
</el-table-column>
<el-table-column label="操作" width="76">
<template #default="{ row }"><el-button link type="primary" @click="open(row)">查看</el-button></template>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="open(row)">查看</el-button>
<el-button
v-if="row.mode === 'live' && row.status === 'running'"
link type="warning" :loading="actionLoading === row.id"
@click="onStop(row)"
>停止</el-button>
<el-button
v-if="row.mode === 'live' && row.status === 'stopped'"
link type="success" :loading="actionLoading === row.id"
@click="onResume(row)"
>恢复</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" :loading="actionLoading === row.id" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="editVisible" title="编辑模拟盘" width="480px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="名称">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="标的">
<el-input v-model="editForm.symbols" placeholder="逗号分隔" />
</el-form-item>
<el-form-item label="初始资金">
<el-input-number v-model="editForm.initial_capital" :min="10000" :step="100000" :controls="false" style="width: 200px" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
+36 -1
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getPaper, getEquity, getTrades, getStrategies,
getPaper, getEquity, getTrades, getStrategies, stopPaper, resumePaper,
type BalancePoint, type PaperTrade, type StrategySummary, type PaperAccount,
} from '@/api/paper'
import type { EquityPoint } from '@/api/backtest'
@@ -105,6 +106,32 @@ function rowClass({ row }: { row: PaperTrade }): string {
function toLive(): void {
router.push(`/paper/live/${aid}`)
}
const lifecycleBusy = ref(false)
async function onStop(): Promise<void> {
lifecycleBusy.value = true
try {
await stopPaper(aid)
ElMessage.success('已停止,今晚起不再结算')
await load()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '停止失败')
} finally {
lifecycleBusy.value = false
}
}
async function onResume(): Promise<void> {
lifecycleBusy.value = true
try {
await resumePaper(aid)
ElMessage.success('已恢复,今晚 20:30 起继续结算')
await load()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '恢复失败')
} finally {
lifecycleBusy.value = false
}
}
</script>
<template>
@@ -124,6 +151,14 @@ function toLive(): void {
? '实走中 · 每日 20:30 结算'
: statusLabel(account.status)
}}</span>
<el-button
v-if="account?.mode === 'live' && account.status === 'running'"
type="warning" :loading="lifecycleBusy" @click="onStop"
>停止</el-button>
<el-button
v-if="account?.mode === 'live' && account.status === 'stopped'"
type="success" :loading="lifecycleBusy" @click="onResume"
>恢复</el-button>
<el-button v-if="account?.mode === 'live'" type="primary" @click="toLive">实走监控 </el-button>
</div>
</div>
+66
View File
@@ -176,3 +176,69 @@ def get_status(aid: int):
"strategy_name": acc["strategy_name"], "updated_at": acc["updated_at"],
"error_msg": acc.get("error_msg", ""),
}
# ===== 生命周期管理补全:删除/编辑(停止/启动已有)=====
class LiveUpdateRequest(BaseModel):
"""可编辑字段。仅 stopped 状态可改(运行中改配置会与 engine 失配)。"""
name: str | None = None
account: str | None = None
vt_symbol: str | None = None
strategy_class: str | None = None
strategy_name: str | None = None
setting: dict | None = None
interval: str | None = None
@router.put("/live/{aid}", dependencies=[Depends(verify_token)])
def update_live(aid: int, req: LiveUpdateRequest):
import json
import sqlite3
from sanguo_live.persistence import get_account
db = _db_path["path"]
acc = get_account(db, aid)
if not acc:
raise HTTPException(404, "account not found")
if acc["status"] == "running":
raise HTTPException(400, "运行中不可编辑,请先停止实例")
sets, args = [], []
field_map = {
"name": req.name, "account": req.account, "vt_symbol": req.vt_symbol,
"strategy_class": req.strategy_class, "strategy_name": req.strategy_name,
"interval": req.interval,
}
for col, v in field_map.items():
if v is not None:
sets.append(f"{col}=?"); args.append(v)
if req.setting is not None:
sets.append("setting=?"); args.append(json.dumps(req.setting))
if not sets:
return {"account_id": aid, "updated": False}
with sqlite3.connect(db) as conn:
conn.execute(f"UPDATE live_accounts SET {', '.join(sets)} WHERE id=?", (*args, aid))
conn.commit()
return {"account_id": aid, "updated": True}
@router.delete("/live/{aid}", dependencies=[Depends(verify_token)])
def delete_live(aid: int):
"""删除实盘实例及其数据。运行中拒绝删除(先 stop)。"""
import sqlite3
from sanguo_live.persistence import get_account
db = _db_path["path"]
acc = get_account(db, aid)
if not acc:
raise HTTPException(404, "account not found")
if acc["status"] == "running":
raise HTTPException(400, "运行中不可删除,请先停止实例")
with sqlite3.connect(db) as conn:
conn.execute("DELETE FROM live_accounts WHERE id=?", (aid,))
for t in ("live_trades", "live_positions", "live_balance"):
conn.execute(f"DELETE FROM {t} WHERE account_id=?", (aid,))
conn.commit()
return {"account_id": aid, "deleted": True}
+85
View File
@@ -4,6 +4,7 @@ create 建 paper_account(持久化配置);GET 查询净值/成交/状态
回放执行engine.run orchestrator 异步触发或容器内同步跑端到端冒烟在容器
本机无 NAS parquet + vnpy 完整依赖本模块只做 account 管理 + 查询
"""
import json
import sqlite3
from fastapi import APIRouter, Depends, Header, HTTPException
@@ -164,6 +165,90 @@ def get_pending(aid: int):
return load_pending_orders(_db_path["path"], aid)
# ===== 生命周期管理(spec §10 补全:停止/恢复/删除/编辑)=====
def _get_account_row(db, aid: int) -> dict:
with sqlite3.connect(db) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM paper_accounts WHERE id=?", (aid,)).fetchone()
if not row:
raise HTTPException(404, "account not found")
return dict(row)
@router.post("/paper/{aid}/stop", dependencies=[Depends(verify_token)])
def stop_paper(aid: int):
"""停止实走:置 status=stopped,每日 20:30 全局 step 只选 running → 自动跳过。"""
from sanguo_trader.persistence import update_account_status
db = _db_path["path"]
acc = _get_account_row(db, aid)
if acc.get("mode") != "live":
raise HTTPException(400, "仅实走(live)账户支持停止;回放账户为一次性任务")
update_account_status(db, aid, "stopped")
return {"account_id": aid, "status": "stopped"}
@router.post("/paper/{aid}/resume", dependencies=[Depends(verify_token)])
def resume_paper(aid: int):
"""恢复实走:次日 20:30 起继续 step。"""
from sanguo_trader.persistence import update_account_status
db = _db_path["path"]
acc = _get_account_row(db, aid)
if acc.get("mode") != "live":
raise HTTPException(400, "仅实走(live)账户支持恢复")
update_account_status(db, aid, "running")
return {"account_id": aid, "status": "running"}
@router.delete("/paper/{aid}", dependencies=[Depends(verify_token)])
def delete_paper(aid: int):
"""删除模拟盘账户及其全部数据(净值/成交/持仓/挂单,不可恢复)。"""
db = _db_path["path"]
_get_account_row(db, aid)
tables = ("paper_accounts", "paper_daily_balance", "paper_trades",
"paper_positions", "paper_pending_orders", "paper_shadow_orders")
with sqlite3.connect(db) as conn:
for t in tables:
if t == "paper_accounts":
conn.execute("DELETE FROM paper_accounts WHERE id=?", (aid,))
else:
conn.execute(f"DELETE FROM {t} WHERE account_id=?", (aid,))
conn.commit()
return {"account_id": aid, "deleted": True}
class PaperUpdateRequest(BaseModel):
"""可编辑字段(其余字段沿用原值;策略参数/标的改动自下次 step 生效)。"""
name: str | None = None
symbols: list[str] | None = None
strategies: list[StrategyCfg] | None = None
initial_capital: float | None = None
@router.put("/paper/{aid}", dependencies=[Depends(verify_token)])
def update_paper(aid: int, req: PaperUpdateRequest):
db = _db_path["path"]
_get_account_row(db, aid)
sets, args = [], []
if req.name is not None:
sets.append("name=?"); args.append(req.name)
if req.symbols is not None:
sets.append("symbols=?"); args.append(json.dumps(req.symbols))
if req.strategies is not None:
sets.append("strategies=?")
args.append(json.dumps([s.model_dump() for s in req.strategies]))
if req.initial_capital is not None:
sets.append("initial_capital=?"); args.append(req.initial_capital)
if not sets:
return {"account_id": aid, "updated": False}
with sqlite3.connect(db) as conn:
conn.execute(f"UPDATE paper_accounts SET {', '.join(sets)} WHERE id=?", (*args, aid))
conn.commit()
return {"account_id": aid, "updated": True}
class _DataSourceWrapper:
"""包装 iter_bars/fetch_day 给 PaperEngine/live_orchestrator。"""
+72
View File
@@ -0,0 +1,72 @@
"""paper/live 生命周期 API 单测(D1):stop/resume/delete/update 直调路由函数。"""
import pytest
from sanguo_api import routes_paper as rp
@pytest.fixture()
def paper_db(tmp_path):
db = str(tmp_path / "paper.db")
rp.set_db_path(db)
return db
def _create(paper_db, mode="live", name="p1"):
req = rp.PaperCreateRequest(
mode=mode, symbols=["600000"],
strategies=[rp.StrategyCfg(name="DoubleMaStrategy", symbol="600000")],
start="2026-01-01", end="2026-12-31", name=name,
)
# 直接走 save_account(绕过 create 的后台线程/引擎依赖)
from sanguo_trader.persistence import save_account, update_account_status
aid = save_account(paper_db, req.model_dump())
if mode == "live":
update_account_status(paper_db, aid, "running")
return aid
def test_stop_resume_live(paper_db):
aid = _create(paper_db, mode="live")
assert rp.stop_paper(aid)["status"] == "stopped"
assert rp.resume_paper(aid)["status"] == "running"
def test_stop_replay_rejected(paper_db):
aid = _create(paper_db, mode="replay")
with pytest.raises(Exception):
rp.stop_paper(aid)
def test_stop_unknown_404(paper_db):
with pytest.raises(Exception):
rp.stop_paper(999)
def test_delete_removes_all_tables(paper_db):
aid = _create(paper_db, mode="live")
import sqlite3
from sanguo_trader.persistence import save_daily_balance, save_trade
save_daily_balance(paper_db, aid, "2026-08-13", 100.0, 0.0, 100.0)
save_trade(paper_db, aid, {"strategy_id": "s", "datetime": "2026-08-13",
"symbol": "600000", "direction": "long",
"offset": "open", "match_session": "next_open",
"price": 10.0, "volume": 100})
assert rp.delete_paper(aid)["deleted"] is True
with sqlite3.connect(paper_db) as conn:
for t in ("paper_accounts", "paper_daily_balance", "paper_trades"):
n = conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
assert n == 0, t
def test_update_fields(paper_db):
aid = _create(paper_db, mode="live")
req = rp.PaperUpdateRequest(name="renamed", initial_capital=2_000_000)
assert rp.update_paper(aid, req)["updated"] is True
acc = rp.get_paper(aid)
assert acc["name"] == "renamed"
assert acc["initial_capital"] == 2_000_000
def test_update_noop(paper_db):
aid = _create(paper_db, mode="live")
assert rp.update_paper(aid, rp.PaperUpdateRequest())["updated"] is False