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]
This commit is contained in:
@@ -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. 三机角色(定稿)
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
# 实走 ×1(paper_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):
|
||||
"""#78:live/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"
|
||||
|
||||
Reference in New Issue
Block a user