feat(paper/live): 生命周期管理补全: paper停止/恢复(实走20:30 step跳过stopped)/删除(连带净值成交持仓挂单)/编辑(名称标的资金); live删除(运行中拒绝)/编辑(stopped才可改); 前端列表+结果页按钮/编辑弹窗/删除确认 [vps]
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user