From 0453de9321c4cadddc6466755f3fe7e093d83384 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 16 Aug 2026 10:06:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(portfolio):=20=E6=94=B6=E7=9B=8A=E5=8D=95?= =?UTF-8?q?=E4=BD=8D=E5=8F=A3=E5=BE=84=E7=BB=9F=E4=B8=80=E5=B0=8F=E6=95=B0?= =?UTF-8?q?+=E7=94=A8=E6=88=B7=E9=AA=8C=E6=94=B6=E5=9B=9B=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E2=80=94=E2=80=94=E2=91=A01044%=E7=9C=9F=E5=9B=A0:bul?= =?UTF-8?q?let=5Ftrade=E8=BF=94=E5=9B=9E=E7=99=BE=E5=88=86=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E5=80=BC(10.44=3D10.44%),=E7=BB=84=E5=90=88?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E9=A1=B5=E7=9B=B4=E6=98=BE(=E5=AF=B9)?= =?UTF-8?q?=E4=BD=86=E6=A1=A3=E6=A1=88=E5=9B=9E=E5=86=99/=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=BA=93=E6=8C=89=E5=B0=8F=E6=95=B0=C3=97100(?= =?UTF-8?q?=E9=94=991044%);=5Fextract=5Fmetrics=E7=BB=9F=E4=B8=80=C3=B7100?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=B0=8F=E6=95=B0(=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E5=A5=91=E7=BA=A6),=E7=BB=84=E5=90=88?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E9=A1=B5fmtPct=E6=94=B9=C3=97100(=E4=B8=8ECT?= =?UTF-8?q?A/empyrical=E5=85=A8=E5=B9=B3=E5=8F=B0=E4=B8=80=E8=87=B4;?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=BB=BB=E5=8A=A1NAS=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E8=BF=81=E7=A7=BB)=E2=91=A1=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E7=81=AF/=E5=9B=9E=E6=B5=8B=E5=8E=86=E5=8F=B2'=E6=89=BE?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=E7=BB=93=E6=9E=9C':done=E6=97=A0task=5Fid(?= =?UTF-8?q?=E8=80=81=E5=9B=9E=E6=B5=8B)=E5=85=9C=E5=BA=95=E8=B7=B3?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=B8=AD=E5=BF=83=E4=B8=8D=E8=BF=87=E6=BB=A4?= =?UTF-8?q?(instance=E8=BF=87=E6=BB=A4=E5=AF=B9=E8=80=81=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA)=E2=91=A2=E5=AE=9E=E8=B5=B0/=E5=AE=9E?= =?UTF-8?q?=E7=9B=98=E7=81=AF'=E8=BF=98=E6=98=AF=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=A1=B5':=E4=B8=89=E7=BA=A7fallback=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=90=8C=E7=B1=BB=E5=9E=8B=E5=9C=A8=E8=B7=91=E7=9B=B4=E8=BE=BE?= =?UTF-8?q?=E7=9B=91=E6=8E=A7,=E5=AE=9E=E4=BE=8B=E4=BB=BB=E4=BD=95?= =?UTF-8?q?=E5=9C=A8=E8=B7=91(=E5=A6=82=E5=8F=AA=E6=9C=89=E5=BD=B1?= =?UTF-8?q?=E5=AD=90)=E8=B7=B3=E5=88=97=E8=A1=A8=E8=BF=87=E6=BB=A4,?= =?UTF-8?q?=E6=97=A0=E5=9C=A8=E8=B7=91=E6=89=8D=E5=8F=91=E8=B5=B7=E2=91=A3?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=88=97=E6=8A=98=E8=A1=8C:=E5=AE=BD118+?= =?UTF-8?q?=E6=97=A5=E7=BB=88=E5=B9=B6=E5=85=A5=E5=8D=95chip(=E5=AE=9E?= =?UTF-8?q?=E8=B5=B0=C2=B7=E6=97=A5=E7=BB=88,title=E6=82=AC=E6=B5=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E)=E2=91=A4=E6=A8=A1=E6=8B=9F=E7=9B=98?= =?UTF-8?q?=E5=90=8D=E5=8A=A0=E7=89=88=E6=9C=AC=E5=8F=B7=3D{=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E5=90=8D}=C2=B7{=E6=A8=A1=E5=BC=8F}v{N}(=E5=90=8C?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=90=8C=E6=A8=A1=E5=BC=8F=E9=80=92=E5=A2=9E?= =?UTF-8?q?,=E5=AF=B9=E9=BD=90=E5=AE=9E=E7=9B=98);927=E7=BB=BF+build?= =?UTF-8?q?=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/backtest/PortfolioBacktest.vue | 3 ++- frontend/src/views/paper/List.vue | 8 ++++--- frontend/src/views/paper/New.vue | 19 +++++++++++++-- frontend/src/views/strategy/List.vue | 24 ++++++++++++++----- sanguo_portfolio/runner_backtest.py | 20 +++++++++++----- 5 files changed, 56 insertions(+), 18 deletions(-) diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index b029228..4c5d6c2 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -467,7 +467,8 @@ watch(drawdownCurve, renderDrawdown, { deep: true, flush: 'post' }) function fmtPct(v: number | null | undefined): string { if (v === null || v === undefined || !Number.isFinite(v)) return '-' - return `${v.toFixed(2)}%` + // metrics 已统一小数口径(2026-08-16;历史字面值已在 NAS 一次性迁移) + return `${(v * 100).toFixed(2)}%` } function fmtNum(v: number | null | undefined, digits = 2): string { if (v === null || v === undefined || !Number.isFinite(v)) return '-' diff --git a/frontend/src/views/paper/List.vue b/frontend/src/views/paper/List.vue index 31fe7a7..ba1878e 100644 --- a/frontend/src/views/paper/List.vue +++ b/frontend/src/views/paper/List.vue @@ -254,10 +254,12 @@ async function saveEdit(): Promise {
{{ instanceNameOf(row.instance_id) || '未绑实例' }}
- + diff --git a/frontend/src/views/paper/New.vue b/frontend/src/views/paper/New.vue index 29bdbe3..3abfbe3 100644 --- a/frontend/src/views/paper/New.vue +++ b/frontend/src/views/paper/New.vue @@ -112,8 +112,8 @@ async function applyInstance(instId: number | string): Promise { ]) const inst = ir.instance fromInstance.value = inst.name || String(instId) - // #72 名称自动跟随实例(模拟盘口径:实例名·模式) - form.value.name = `${inst.name || String(instId)}·${form.value.mode}` + // #72 名称自动跟随实例 + 版本号(同实例同模式递增,对齐实盘命名) + form.value.name = await autoName(inst.name || String(instId), Number(instId), form.value.mode) const file = fr.files.find((f) => f.name === inst.code_file) const cls = file?.class_name || inst.code_file const sym = inst.symbol_or_pool || '600000' @@ -139,6 +139,21 @@ function onPickInstance(id: number | null): void { if (id != null) applyInstance(id) } +// 模拟盘名版本号:{实例名}·{模式}v{N}(N=同实例同模式已有账户数) +async function autoName(instName: string, instId: number, mode: string): Promise { + const prefix = `${instName}·${mode}v` + let n = 0 + try { + const { listPapers } = await import('@/api/paper') + const accs = await listPapers() + n = accs.filter((a: { instance_id?: number | null; name?: string }) => + a.instance_id === instId && (a.name || '').startsWith(prefix)).length + } catch { + /* 列表拉不到 → v0 */ + } + return `${prefix}${n}` +} + const isPortfolio = computed(() => strategyType.value === 'portfolio') const portfolioStrategy = ref('all_weather') const instancesOfPortfolio = computed(() => diff --git a/frontend/src/views/strategy/List.vue b/frontend/src/views/strategy/List.vue index 6ddee21..ff94f8f 100644 --- a/frontend/src/views/strategy/List.vue +++ b/frontend/src/views/strategy/List.vue @@ -155,7 +155,8 @@ function fileTypeOf(i: Instance): 'portfolio' | 'cta' { // #68/#75 灯语义统一:事件型(回测/回放)done→直达最新结果;持续型(实走/影子/实盘) // 在跑→直达该账户监控页;否则→发起(创建页带实例)。「×历史」按钮才进过滤列表。 function runBacktest(i: Instance): void { - // 有回测结果 → 直达结果页(task_id 前缀判类型);失败/进行中 → 任务中心;没跑过 → 发起 + // 有回测结果 → 直达结果页(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) { @@ -163,7 +164,7 @@ function runBacktest(i: Instance): void { return } if (st !== '-') { - router.push(`/backtest/history?instance=${i.id}`) + router.push(tid ? `/backtest/history?instance=${i.id}` : '/backtest/history') return } const isPf = fileTypeOf(i) === 'portfolio' @@ -191,18 +192,29 @@ function runReplay(i: Instance): void { } router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } }) } +// 三级:同类型在跑→直达监控;实例有任何在跑(如只有影子)→ 列表过滤;否则→发起 function runPaper(i: Instance, mode: 'live' | 'shadow'): void { - const run = (i.running_accounts || []).find((a) => a.kind === mode || (mode === 'live' && a.kind === 'paper')) + 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}`) // 在跑 → 直达该账户监控页 + router.push(`/paper/live/${run.aid}`) + return + } + if (runs.length) { + router.push(`/paper?instance=${i.id}`) return } router.push({ path: '/paper/new', query: { mode, instance: String(i.id) } }) } function runLive(i: Instance): void { - const run = (i.running_accounts || []).find((a) => a.kind === 'live') + const runs = i.running_accounts || [] + const run = runs.find((a) => a.kind === 'live') if (run) { - router.push(`/live/monitor/${run.aid}`) // 在跑 → 直达监控页 + router.push(`/live/monitor/${run.aid}`) + return + } + if (runs.length) { + router.push(`/live?instance=${i.id}`) return } router.push({ path: '/live/new', query: { instance: String(i.id) } }) diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py index 1363c90..94e30bc 100644 --- a/sanguo_portfolio/runner_backtest.py +++ b/sanguo_portfolio/runner_backtest.py @@ -513,15 +513,23 @@ def _to_float(v: Any) -> float | None: def _extract_metrics(summary: Dict[str, Any]) -> Dict[str, float | None]: """bullet-trade summary 用中文 key('策略收益'/'最大回撤'/...)。 - 百分比按字面数值(12.34% → 12.34),前端按需 /100 显示。 + + 单位契约(2026-08-16 统一):metrics 百分比类一律**小数**(10.44% → 0.1044), + 与 CTA(empyrical)/模拟盘/对账全平台一致;前端统一 ×100 显示。 + bullet_trade 返回字面百分数值 → 此处 ÷100 归一。 + (历史任务库存的是字面值——NAS 已做一次性迁移,勿重复除。) """ + def _pct(key: str) -> float | None: + v = _to_float(summary.get(key)) + return v / 100 if v is not None else None + return { - "total_return": _to_float(summary.get("策略收益")), - "annual_return": _to_float(summary.get("策略年化收益")), - "max_drawdown": _to_float(summary.get("最大回撤")), + "total_return": _pct("策略收益"), + "annual_return": _pct("策略年化收益"), + "max_drawdown": _pct("最大回撤"), "sharpe": _to_float(summary.get("夏普比率")), - "win_rate_daily": _to_float(summary.get("日胜率")), - "win_rate_trade": _to_float(summary.get("交易胜率")), + "win_rate_daily": _pct("日胜率"), + "win_rate_trade": _pct("交易胜率"), "trading_days": _to_float(summary.get("交易天数")), }