feat(strategy): #77灯=列表入口×N徽标+#78实盘服务端兜底名——#77:策略库四状态灯点击一律进该类型列表(按实例过滤;空列表→新建页带实例),灯带×N徽标=该类型运行总数(enriched新增counts:backtest任务表/replay回放账户/paper_live实走+影子/live实盘,查询失败退化无徽标),删「回测/模拟/实盘历史」三按钮(灯即入口);#78:live/create名称空或默认值(live-600000等)→服务端兜底{实例名}_v{YYYYMMDD}{minor}同实例同日递增,前端autoName失败被吞也拿到正确名;+2回归测试(counts四桶/兜底名递增);docs补北京迁移注记;929绿+build绿 [vps]
CI/CD / test (push) Successful in 15s
CI/CD / nas-deploy (push) Successful in 33s
CI/CD / nas-verify (push) Successful in 16s

This commit is contained in:
2026-08-16 13:33:49 +08:00
parent 214608506a
commit 28374c341b
6 changed files with 176 additions and 80 deletions
+2
View File
@@ -2,6 +2,8 @@
> **状态:2026-07-17,端到端验证通过。** 本文档覆盖当前生产真实状态,**取代** `vps-native-brain.md`07-15,架构已变)。
> 所有服务统一到 VPS;NAS 降为备份;Mac mini 仅开发。
>
> ⚠️ **2026-08-13 更新**`mysanguo.online` ICP 备案完成 + DNS 指北京。vnpy web 已迁**北京直 serve** `https://vnpy.mysanguo.online`(北京 Caddy :443 自动签 LE 证书,不经首尔)。**本文的「首尔 HTTPS 入口 + header_up Host 绕 webblock」workaround(§2/§6)已废弃**,仅作历史记录;老 `vnpy.mysanguo.top` 首尔入口 502。详见 memory `mysanguo-online-icp-beian` + vps-access skill。
## 1. 三机角色(定稿)
+2
View File
@@ -81,6 +81,8 @@ export interface Instance {
running_accounts?: RunningAccount[]
drift?: boolean
code_changed?: boolean | null
/** #77 各类型运行总数(灯 ×N 徽标/空则新建):backtest/replay/paper_live/live */
counts?: Record<string, number>
}
export async function getInstances(): Promise<Instance[]> {
+40 -79
View File
@@ -96,19 +96,7 @@ function selectFile(name: string): void {
inspection.value = false
}
// ===== 行内:运行灯(含在跑账户数标签,如实走×2 / 影子=====
function runLabel(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): string {
const base: Record<string, string> = { backtest: '回测', replay: '回放', paper_live: '实走', live: '实盘' }
if (kind === 'paper_live') {
const runs = (i.running_accounts || []).filter((a) => a.kind === 'paper' || a.kind === 'shadow')
if (runs.length > 1) return `${runs.some((r) => r.kind === 'shadow') ? '影子' : base[kind]}×${runs.length}`
}
return base[kind]
}
function stTxt(s: string): string {
if (s === '-') return '—'
return ({ running: '运行', done: '已测', stopped: '已停', failed: '失败' } as Record<string, string>)[s] || s
}
// ===== 行内:运行灯(#77 ×N 徽标语义,旧 runLabel/stTxt 已并入 badge=====
const lampOf = (s: string): string => {
if (s === 'running') return 'lamp-ok lamp-pulse'
if (s === 'done') return 'lamp-ok'
@@ -152,71 +140,47 @@ function editInstance(i: Instance): void {
function fileTypeOf(i: Instance): 'portfolio' | 'cta' {
return files.value.find((f) => f.name === i.code_file)?.type || i.type
}
// #68/#75 灯语义统一:事件型(回测/回放)done→直达最新结果;持续型(实走/影子/实盘)
// 在跑→直达该账户监控页;否则→发起(创建页带实例)。「×历史按钮才进过滤列表
function runBacktest(i: Instance): void {
// 有回测结果 → 直达结果页(task_id 前缀判类型);跑过但无 task_id(老回测)→
// 任务中心不过滤(instance 过滤对老任务为空=「找不到结果」);没跑过 → 发起
const st = i.status?.backtest || '-'
const tid = (i as Instance & { run_meta?: Record<string, { task_id?: string }> }).run_meta?.backtest?.task_id
if (st === 'done' && tid) {
router.push(tid.startsWith('portfolio') ? `/backtest/portfolio?task_id=${tid}` : `/backtest/result/${tid}`)
return
}
if (st !== '-') {
router.push(tid ? `/backtest/history?instance=${i.id}` : '/backtest/history')
return
}
const isPf = fileTypeOf(i) === 'portfolio'
router.push({
path: isPf ? '/backtest/portfolio' : '/backtest/new',
// 组合页吃 strategy(文件名去.py)CTA 页吃 class(类名)
query: isPf
? { strategy: i.code_file.replace(/\.py$/, ''), instance: String(i.id) }
: {
class: files.value.find((f) => f.name === i.code_file)?.class_name || i.code_file,
instance: String(i.id),
},
})
// #77 灯语义(用户定稿):点击一律进该类型列表(按实例过滤)看全部运行;
// 空列表 → 新建页。灯上带 ×N 徽标(该类型运行总数)。历史按钮已删(灯即入口)
function countOf(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): number {
if (i.counts && i.counts[kind] != null) return i.counts[kind]
// enriched counts 缺失时的退化口径
if (kind === 'backtest' || kind === 'replay') return (i.status?.[kind] || '-') !== '-' ? 1 : 0
return (i.running_accounts || []).filter((a) => (kind === 'live' ? a.kind === 'live' : a.kind === 'paper' || a.kind === 'shadow')).length
}
function runReplay(i: Instance): void {
const st = i.status?.replay || '-'
const aid = (i as Instance & { run_meta?: Record<string, { account_id?: number }> }).run_meta?.replay?.account_id
if (st === 'done' && aid) {
router.push(`/paper/result/${aid}`)
return
}
if (st !== '-') {
router.push(`/paper?instance=${i.id}`)
return
}
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
// 徽标文案:×N(N>0);0 不显示(点击即新建)
function badge(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): string {
const n = countOf(i, kind)
return n > 0 ? `×${n}` : ''
}
// 三级:同类型在跑→直达监控;实例有任何在跑(如只有影子)→ 列表过滤;否则→发起
function runPaper(i: Instance, mode: 'live' | 'shadow'): void {
const runs = i.running_accounts || []
const run = runs.find((a) => a.kind === mode || (mode === 'live' && a.kind === 'paper'))
if (run) {
router.push(`/paper/live/${run.aid}`)
function runGo(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): void {
const n = countOf(i, kind)
if (kind === 'backtest') {
if (n > 0) { router.push(`/backtest/history?instance=${i.id}`); return }
const isPf = fileTypeOf(i) === 'portfolio'
router.push({
path: isPf ? '/backtest/portfolio' : '/backtest/new',
query: isPf
? { strategy: i.code_file.replace(/\.py$/, ''), instance: String(i.id) }
: {
class: files.value.find((f) => f.name === i.code_file)?.class_name || i.code_file,
instance: String(i.id),
},
})
return
}
if (runs.length) {
router.push(`/paper?instance=${i.id}`)
if (kind === 'replay') {
if (n > 0) { router.push(`/paper?instance=${i.id}`); return }
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
return
}
router.push({ path: '/paper/new', query: { mode, instance: String(i.id) } })
}
function runLive(i: Instance): void {
const runs = i.running_accounts || []
const run = runs.find((a) => a.kind === 'live')
if (run) {
router.push(`/live/monitor/${run.aid}`)
return
}
if (runs.length) {
router.push(`/live?instance=${i.id}`)
if (kind === 'paper_live') {
if (n > 0) { router.push(`/paper?instance=${i.id}`); return }
router.push({ path: '/paper/new', query: { mode: 'live', instance: String(i.id) } })
return
}
// live
if (n > 0) { router.push(`/live?instance=${i.id}`); return }
router.push({ path: '/live/new', query: { instance: String(i.id) } })
}
async function onSync(i: Instance): Promise<void> {
@@ -357,20 +321,17 @@ const runningRows = computed(() =>
<div class="sym mono">{{ i.symbol_or_pool }}</div>
<div class="ret mono" :class="retClass(latestRet(i))">{{ pct(latestRet(i)) }}<span class="sub">{{ retInfoOf(i).label }}</span></div>
<div class="runs">
<button class="run-btn" @click="runBacktest(i)"><span class="lamp" :class="lampOf(i.status.backtest)"></span>回测</button>
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runReplay(i)"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放</button>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'paper' || a.kind === 'shadow') }" @click="runPaper(i, 'live')">
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>{{ runLabel(i, 'paper_live') }}
<button class="run-btn" @click="runGo(i, 'backtest')"><span class="lamp" :class="lampOf(i.status.backtest)"></span>回测{{ badge(i, 'backtest') }}</button>
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runGo(i, 'replay')"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放{{ badge(i, 'replay') }}</button>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'paper' || a.kind === 'shadow') }" @click="runGo(i, 'paper_live')">
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>实走{{ badge(i, 'paper_live') }}
</button>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'live') }" @click="runLive(i)">
<span class="lamp" :class="lampOf(i.status.live)"></span>{{ stTxt(i.status.live) === '运行' ? '实盘·跑' : '实盘' }}
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'live') }" @click="runGo(i, 'live')">
<span class="lamp" :class="lampOf(i.status.live)"></span>实盘{{ badge(i, 'live') }}
</button>
</div>
<div class="ops">
<button v-if="i.drift" class="term-btn sm warn" :disabled="syncing" @click="onSync(i)">同步</button>
<button class="term-btn sm" @click="router.push(`/backtest/history?instance=${i.id}`)">回测历史</button>
<button class="term-btn sm" @click="router.push(`/paper?instance=${i.id}`)">模拟历史</button>
<button class="term-btn sm" @click="router.push(`/live?instance=${i.id}`)">实盘历史</button>
<button class="term-btn sm" @click="overviewId = i.id">全景</button>
<button class="term-btn sm" @click="editInstance(i)">编辑</button>
<button class="term-btn sm danger" @click="onDelInst(i)">删除</button>
+20
View File
@@ -114,6 +114,26 @@ def create_live(req: LiveCreateRequest):
code_snap = snapshot_code(inst_snap.get("code_file") or "")
payload = req.model_dump()
payload["code_hash"] = code_snap["code_hash"] if code_snap else None
# #78 名称服务端兜底:空/默认值 → {实例名}_v{YYYYMMDD}{minor}(同实例同日递增)。
# 前端 autoName 失败被吞时也能拿到正确名(服务端权威)。
if not payload.get("name") or payload["name"].strip() in ("", "live-600000", "live"):
inst_name = inst_snap.get("name") or "inst"
today = time.strftime("%Y%m%d")
prefix = f"{inst_name}_v{today}"
minor = 0
try:
from sanguo_live.persistence import list_accounts
ldb = _db_path["path"]
if ldb:
minor = sum(
1 for a in list_accounts(ldb)
if a.get("instance_id") == req.instance_id and (a.get("name") or "").startswith(prefix)
)
except Exception:
minor = 0
payload["name"] = f"{prefix}{minor}"
if payload.get("strategy_type") == "portfolio":
# 组合实盘:vt_symbol 占位为池名;setting 存组合参数(supervisor 转发 env)
if not payload.get("strategy_class"):
+58 -1
View File
@@ -83,11 +83,13 @@ def post_instance(req: InstancePayload):
@router.get("/strategy/instances/enriched")
def get_instances_enriched():
"""实例列表 + 读时聚合:持续运行覆盖四格的实走/实盘格 + 在跑账户 + 漂移标记
"""实例列表 + 读时聚合:持续运行覆盖四格的实走/实盘格 + 在跑账户 + 漂移标记 +
各类型运行数 counts#77 灯 ×N 徽标/空则新建)。
注意必须注册在 /strategy/instances/{inst_id} 之前否则 enriched 被当 inst_id 解析
"""
rt = _instance_runtime()
counts = _instance_run_counts()
out = []
for inst in instance_store.list_instances()["instances"]:
ent = rt.get(inst["id"], {"running_accounts": [], "drift": False, "code_changed": False})
@@ -104,10 +106,65 @@ def get_instances_enriched():
"running_accounts": ent["running_accounts"],
"drift": ent["drift"],
"code_changed": ent["code_changed"],
"counts": counts.get(inst["id"], {"backtest": 0, "replay": 0, "paper_live": 0, "live": 0}),
})
return {"instances": out}
def _instance_run_counts() -> dict[int, dict]:
"""每实例各类型运行总数(#77 灯徽标;查询失败返回 {} → 前端退化为无徽标)。
口径backtest=任务表行数老任务无 instance_id 不计replay=回放账户数
paper_live=实走+影子账户数不分状态live=实盘账户数
"""
import sqlite3
out: dict[int, dict] = {}
_zero = lambda: {"backtest": 0, "replay": 0, "paper_live": 0, "live": 0} # noqa: E731
try:
from .routes import get_orchestrator
orch = get_orchestrator()
if orch and orch.db_path:
with sqlite3.connect(orch.db_path) as _c:
for iid, n in _c.execute(
"SELECT instance_id, COUNT(*) FROM backtest_stats "
"WHERE instance_id IS NOT NULL GROUP BY instance_id"
).fetchall():
out.setdefault(int(iid), _zero())["backtest"] = n
except Exception:
pass
try:
from .routes_paper import _db_path as paper_db
pdb = paper_db["path"]
if pdb:
with sqlite3.connect(pdb) as conn:
for iid, mode, n in conn.execute(
"SELECT instance_id, mode, COUNT(*) FROM paper_accounts "
"WHERE instance_id IS NOT NULL GROUP BY instance_id, mode"
).fetchall():
key = "replay" if mode == "replay" else "paper_live"
out.setdefault(int(iid), _zero())[key] += n
except Exception:
pass
try:
from .routes_live import _db_path as live_db
ldb = live_db["path"]
if ldb:
with sqlite3.connect(ldb) as conn:
for iid, n in conn.execute(
"SELECT instance_id, COUNT(*) FROM live_accounts "
"WHERE instance_id IS NOT NULL GROUP BY instance_id"
).fetchall():
out.setdefault(int(iid), _zero())["live"] = n
except Exception:
pass
return out
@router.get("/strategy/instances/{inst_id}")
def get_instance(inst_id: int):
return instance_store.get_instance(inst_id)
+54
View File
@@ -169,3 +169,57 @@ def test_update_instance_run_event_kinds(tmp_path, monkeypatch):
assert inst["last_return"] == 0.15
# 不存在的档案静默丢弃
assert not instance_store.update_instance_run(999, "backtest", "done")
def test_enriched_counts_per_kind(tmp_path, monkeypatch):
"""#77 灯徽标:enriched 每实例带 counts{backtest,replay,paper_live,live}。"""
c, token = _setup(tmp_path, monkeypatch)
iid = instance_store.create_instance({
"code_file": "double_ma.py", "name": "双均线·浦发", "type": "cta",
"params": {"fast_window": 10}, "symbol_or_pool": "600000", "interval": "d",
})
# 实走 ×1paper_accounts 按 mode 分桶;paper_live=实走+影子不分状态)
r = c.post("/api/v1/paper/create",
json={**_LIVE_BODY, "mode": "live", "instance_id": iid},
headers=_auth(token))
assert r.status_code == 200
# 回放 ×1:直插行(create 走后台回放线程+区间校验,测试不必绕)
from sanguo_api.routes_paper import PaperCreateRequest, StrategyCfg, _db_path as _pdb
from sanguo_trader.persistence import save_account
rep = PaperCreateRequest(
mode="replay", symbols=["600000"],
strategies=[StrategyCfg(name="DoubleMaStrategy", symbol="600000")],
start="2026-01-01", end="2026-12-31", name="回放",
)
save_account(_pdb["path"], {**rep.model_dump(), "instance_id": iid})
# 实盘 ×1
r = c.post("/api/v1/live/create", json={
"name": "L1", "account": "123", "strategy_name": "dm1", "instance_id": iid,
}, headers=_auth(token))
assert r.status_code == 200
ent = [i for i in c.get("/api/v1/strategy/instances/enriched",
headers=_auth(token)).json()["instances"]
if i["id"] == iid][0]
assert ent["counts"] == {"backtest": 0, "replay": 1, "paper_live": 1, "live": 1}
def test_live_create_default_name_fallback(tmp_path, monkeypatch):
"""#78live/create 名称空/默认值 → 服务端兜底 {实例名}_v{YYYYMMDD}{minor}"""
import time as _time
c, token = _setup(tmp_path, monkeypatch)
iid = instance_store.create_instance({
"code_file": "double_ma.py", "name": "全天候·测试", "type": "cta",
"params": {"fast_window": 10}, "symbol_or_pool": "600000", "interval": "d",
})
body = {"account": "123", "strategy_name": "dm1", "instance_id": iid}
a1 = c.post("/api/v1/live/create", json=body, headers=_auth(token)).json()["account_id"]
# 前端默认占位名(live-600000)同样触发兜底
a2 = c.post("/api/v1/live/create", json={**body, "name": "live-600000"},
headers=_auth(token)).json()["account_id"]
accs = {a["id"]: a["name"]
for a in c.get("/api/v1/live", headers=_auth(token)).json()["accounts"]}
today = _time.strftime("%Y%m%d")
assert accs[a1] == f"全天候·测试_v{today}0"
assert accs[a2] == f"全天候·测试_v{today}1"