From 28374c341b250394991b298d6f63fdf29a0dd294 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 16 Aug 2026 13:33:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(strategy):=20#77=E7=81=AF=3D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=85=A5=E5=8F=A3=C3=97N=E5=BE=BD=E6=A0=87+#78?= =?UTF-8?q?=E5=AE=9E=E7=9B=98=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=90=8D=E2=80=94=E2=80=94#77:=E7=AD=96=E7=95=A5=E5=BA=93?= =?UTF-8?q?=E5=9B=9B=E7=8A=B6=E6=80=81=E7=81=AF=E7=82=B9=E5=87=BB=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E8=BF=9B=E8=AF=A5=E7=B1=BB=E5=9E=8B=E5=88=97=E8=A1=A8?= =?UTF-8?q?(=E6=8C=89=E5=AE=9E=E4=BE=8B=E8=BF=87=E6=BB=A4;=E7=A9=BA?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E2=86=92=E6=96=B0=E5=BB=BA=E9=A1=B5=E5=B8=A6?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B),=E7=81=AF=E5=B8=A6=C3=97N=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=3D=E8=AF=A5=E7=B1=BB=E5=9E=8B=E8=BF=90=E8=A1=8C=E6=80=BB?= =?UTF-8?q?=E6=95=B0(enriched=E6=96=B0=E5=A2=9Ecounts:backtest=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=A1=A8/replay=E5=9B=9E=E6=94=BE=E8=B4=A6=E6=88=B7/p?= =?UTF-8?q?aper=5Flive=E5=AE=9E=E8=B5=B0+=E5=BD=B1=E5=AD=90/live=E5=AE=9E?= =?UTF-8?q?=E7=9B=98,=E6=9F=A5=E8=AF=A2=E5=A4=B1=E8=B4=A5=E9=80=80?= =?UTF-8?q?=E5=8C=96=E6=97=A0=E5=BE=BD=E6=A0=87),=E5=88=A0=E3=80=8C?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B/=E6=A8=A1=E6=8B=9F/=E5=AE=9E=E7=9B=98?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E3=80=8D=E4=B8=89=E6=8C=89=E9=92=AE(?= =?UTF-8?q?=E7=81=AF=E5=8D=B3=E5=85=A5=E5=8F=A3);#78:live/create=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E7=A9=BA=E6=88=96=E9=BB=98=E8=AE=A4=E5=80=BC(live-600?= =?UTF-8?q?000=E7=AD=89)=E2=86=92=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=85=9C?= =?UTF-8?q?=E5=BA=95{=E5=AE=9E=E4=BE=8B=E5=90=8D}=5Fv{YYYYMMDD}{minor}?= =?UTF-8?q?=E5=90=8C=E5=AE=9E=E4=BE=8B=E5=90=8C=E6=97=A5=E9=80=92=E5=A2=9E?= =?UTF-8?q?,=E5=89=8D=E7=AB=AFautoName=E5=A4=B1=E8=B4=A5=E8=A2=AB=E5=90=9E?= =?UTF-8?q?=E4=B9=9F=E6=8B=BF=E5=88=B0=E6=AD=A3=E7=A1=AE=E5=90=8D;+2?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95(counts=E5=9B=9B=E6=A1=B6/?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=90=8D=E9=80=92=E5=A2=9E);docs=E8=A1=A5?= =?UTF-8?q?=E5=8C=97=E4=BA=AC=E8=BF=81=E7=A7=BB=E6=B3=A8=E8=AE=B0;929?= =?UTF-8?q?=E7=BB=BF+build=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deployment/vps-unified-deploy.md | 2 + frontend/src/api/strategy.ts | 2 + frontend/src/views/strategy/List.vue | 119 +++++++++----------------- sanguo_api/routes_live.py | 20 +++++ sanguo_api/routes_strategy.py | 59 ++++++++++++- tests/api/test_instance_binding.py | 54 ++++++++++++ 6 files changed, 176 insertions(+), 80 deletions(-) diff --git a/docs/deployment/vps-unified-deploy.md b/docs/deployment/vps-unified-deploy.md index 03c925d..44e2e40 100644 --- a/docs/deployment/vps-unified-deploy.md +++ b/docs/deployment/vps-unified-deploy.md @@ -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. 三机角色(定稿) diff --git a/frontend/src/api/strategy.ts b/frontend/src/api/strategy.ts index 630fcda..3423851 100644 --- a/frontend/src/api/strategy.ts +++ b/frontend/src/api/strategy.ts @@ -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 } export async function getInstances(): Promise { diff --git a/frontend/src/views/strategy/List.vue b/frontend/src/views/strategy/List.vue index ff94f8f..a7193fd 100644 --- a/frontend/src/views/strategy/List.vue +++ b/frontend/src/views/strategy/List.vue @@ -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 = { 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)[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 }).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 }).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 { @@ -357,20 +321,17 @@ const runningRows = computed(() =>
{{ i.symbol_or_pool }}
{{ pct(latestRet(i)) }}{{ retInfoOf(i).label }}
- - - + + -
- - - diff --git a/sanguo_api/routes_live.py b/sanguo_api/routes_live.py index faadb25..1567d58 100644 --- a/sanguo_api/routes_live.py +++ b/sanguo_api/routes_live.py @@ -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"): diff --git a/sanguo_api/routes_strategy.py b/sanguo_api/routes_strategy.py index d502a34..272d1a2 100644 --- a/sanguo_api/routes_strategy.py +++ b/sanguo_api/routes_strategy.py @@ -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) diff --git a/tests/api/test_instance_binding.py b/tests/api/test_instance_binding.py index f5dd5eb..358de2f 100644 --- a/tests/api/test_instance_binding.py +++ b/tests/api/test_instance_binding.py @@ -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"