Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion tests/console_display_gaps_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "../web/strategy-switch-console/frontend/node_modules/esbuild/lib/main.js";
import { m0Subjects, healthStates, controlReasons, runtimeRecords } from "./fixtures/console-gaps.ts";
import { formatDateTime } from "../web/strategy-switch-console/frontend/src/lib/format.ts";
import { formatDateTime, formatDate } from "../web/strategy-switch-console/frontend/src/lib/format.ts";

// Render production components and dictionaries. Only network/hooks are replaced.
const root = new URL("../web/strategy-switch-console/frontend/", import.meta.url).pathname;
Expand Down Expand Up @@ -77,6 +77,36 @@ try {
for (const [, value] of mainValues) assert.doesNotMatch(value.replace(/<[^>]*>/g, ""), /多币种|currenc/i, "reason must not become a main metric");
assert.equal((metrics.match(/class="metric-note(?:\s[^"]*)?"/g) || []).length, 1, "one shared metric explanation");
assert.doesNotMatch(metrics, /USD [0-9]|USDT [0-9]/, "mixed currencies never produce a combined amount");
// A withheld total must name the date the store actually starts on, because
// "Unavailable" alone reads like a broken pipeline instead of a short history.
const shortHistory = coverageOverrides => ({
status: "partial", unavailable_reason: "snapshot_history_missing", as_of: "2026-10-29",
currency: "USD", value_kind: "pnl",
coverage: {
account_aliases: ["tqqq"], currencies: ["USD"], cashflows: "complete",
period_from: "2026-09-25", period_to: "2026-10-29", requested_period_from: "2026-01-01",
range_complete: false, ...coverageOverrides,
},
twr_return_percent: null, amount: null, simple_weighted_return_percent: null,
max_drawdown: { percent: null, peak_date: null, trough_date: null, unavailable_reason: "snapshot_history_missing" },
excess_returns: [{ benchmark: "SPX", start: "2026-09-25", end: "2026-10-29", percentage_points: null, unavailable_reason: "snapshot_history_missing" }],
best_day: null, worst_day: null,
});
globalThis.__displayState = { phase: "ready", data: shortHistory(), reload() {} };
const short = ui.summary(language);
const shortNote = short.match(/<div class="metric-note[^"]*"[^>]*>([\s\S]*?)<\/div>/)?.[1]?.replace(/<[^>]*>/g, " ") ?? "";
assert.equal((short.match(/class="metric-note(?:\s[^"]*)?"/g) || []).length, 1, "the window sentence stays inside the single shared note");
assert.ok(shortNote.includes(language === "zh" ? "已存记录自" : "stored records begin"), shortNote);
for (const date of ["2026-01-01", "2026-09-25"]) assert.ok(shortNote.includes(formatDate(date, language)), `${date} must be rendered in ${language}`);
for (const [, value] of [...short.matchAll(/<div class="kpi-value num">([\s\S]*?)<\/div>/g)]) {
assert.doesNotMatch(value.replace(/<[^>]*>/g, ""), /\d/, "an explained blank is still a blank: no invented total");
}
// A complete range, an equal start, or a missing window must not claim a shortfall.
for (const overrides of [{ range_complete: true }, { requested_period_from: "2026-09-25" }, { period_from: undefined }]) {
globalThis.__displayState = { phase: "ready", data: shortHistory(overrides), reload() {} };
const settled = ui.summary(language).match(/<div class="metric-note[^"]*"[^>]*>([\s\S]*?)<\/div>/)?.[1]?.replace(/<[^>]*>/g, " ") ?? "";
assert.doesNotMatch(settled, /已存记录自|stored records begin/, `${JSON.stringify(overrides)} is not a short history`);
}
const tabs = ui.tabs(language);
assert.match(tabs, /id="test-tabs-tab-first"[^>]*aria-controls="test-tabs-panel"[^>]*aria-selected="false"[^>]*tabindex="-1"/);
assert.match(tabs, /id="test-tabs-tab-second"[^>]*aria-controls="test-tabs-panel"[^>]*aria-selected="true"[^>]*tabindex="0"/);
Expand Down
5 changes: 5 additions & 0 deletions web/strategy-switch-console/frontend/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ export interface PerformanceCoverage {
native_daily_only?: boolean;
/** Calendar cells come from a bounded, forward-filled asset series, not a return chain. */
asset_change_fallback?: boolean;
/** Stored history may start after the requested period; totals then cover no requested range at all. */
period_from?: string | null;
period_to?: string | null;
requested_period_from?: string | null;
range_complete?: boolean;
}
export interface AccountFreshness {
platform: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const zh: Record<string, string> = {
"perf.per_account_daily_title": "各账户日收益率",
"perf.per_account_daily_note": "各账户分别展示,缺失日期留空。",
"perf.combined_metrics_unavailable": "组合指标暂不可计算,以下展示各账户日收益。",
"perf.history_window_short": "所选区间自 {requested} 起,已存记录自 {available} 起,因此不给出该区间合计。",
"perf.mixed_currency_unavailable": "当前范围含多种币种,未进行汇率转换,组合指标不可计算;下方按账户和币种展示原生收益。",
"perf.mixed_currency_native": "当前范围含多种币种,组合指标不可计算;下方按账户和币种展示原生收益。",
"perf.mixed_currency_asset_change": "当前范围含多种币种,组合收益不可计算;下方按币种展示资产变化(含出入金影响),不代表时间加权收益。",
Expand Down Expand Up @@ -137,6 +138,7 @@ export const en: Record<string, string> = {
"perf.per_account_daily_title": "Daily returns by account",
"perf.per_account_daily_note": "Accounts are shown separately; missing dates are left blank.",
"perf.combined_metrics_unavailable": "Combined metrics are unavailable; daily returns are shown by account below.",
"perf.history_window_short": "This range starts {requested}, but stored records begin {available}, so no total is shown for it.",
"perf.mixed_currency_unavailable": "This scope contains multiple currencies. No FX conversion was applied, so combined metrics are unavailable. Native returns are shown by account and currency below.",
"perf.mixed_currency_native": "This scope contains multiple currencies, so combined metrics are unavailable. Native returns are shown by account and currency below.",
"perf.mixed_currency_asset_change": "This scope contains multiple currencies, so a combined return is unavailable. Asset change (including cash-flow effects) is shown by currency; it is not a time-weighted return.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ function DayCard({ data, field, titleKey }: { data: SummaryResponse; field: "bes

export function SummaryCards({ scope, platform, state, chartAvailable }: SummaryCardsProps) {
const t = useT();
const language = useLanguage();
const { options } = useScope();
const policy = benchmarkPolicy(scope, platform, options);

Expand All @@ -207,11 +208,24 @@ export function SummaryCards({ scope, platform, state, chartAvailable }: Summary
// Other reasons stay diagnostic (data-reason) so the short state is not
// repeated as body copy; no FX rate is ever inferred here.
const coverage = data.coverage;
const fxNotApplied = !!coverage && !Array.isArray(coverage) && coverage !== "fills_not_connected"
&& coverage.fx === "not_applied";
const coverageDetail = !!coverage && !Array.isArray(coverage) && coverage !== "fills_not_connected" ? coverage : null;
const fxNotApplied = coverageDetail?.fx === "not_applied";
// A bare "Unavailable" hides the actionable fact: the range asks for dates the
// store never received. The server states both ends, so name the shortfall and
// keep the totals withheld exactly as before.
const lateHistoryStart = blank && coverageDetail
&& coverageDetail.range_complete === false
&& typeof coverageDetail.period_from === "string"
&& typeof coverageDetail.requested_period_from === "string"
&& coverageDetail.period_from > coverageDetail.requested_period_from
? coverageDetail : null;
const inPlaceReason = data.unavailable_reason === "mixed_currency"
? t(fxNotApplied ? "perf.mixed_currency_unavailable" : "perf.mixed_currency_native")
: blank && chartAvailable ? t("perf.combined_metrics_unavailable") : null;
: lateHistoryStart ? t("perf.history_window_short", {
requested: formatDate(lateHistoryStart.requested_period_from!, language),
available: formatDate(lateHistoryStart.period_from!, language),
})
: blank && chartAvailable ? t("perf.combined_metrics_unavailable") : null;
return (
<div className="section-stack">
{(data.status !== "ok" || data.unavailable_reason || data.as_of || inPlaceReason) && (
Expand Down
Loading
Loading