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
108 changes: 108 additions & 0 deletions tests/console_calendar_fallback_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { performanceBlocks } from "../web/strategy-switch-console/application/console_api/performance.js";
import { applyCashflowPerformance } from "../web/strategy-switch-console/application/console_api/cashflow_performance.js";

// All identifiers, dates and amounts in this file are synthetic.
const days = (dates, values) => dates.map((date, i) => ({ date, value: values[i], externalFlow: null }));
const a = { id: "synthetic-a", currency: "USD", points: days(
["2026-10-20", "2026-10-22", "2026-10-24", "2026-10-26", "2026-10-28", "2026-10-30"],
[100, 101, 102, 103, 104, 105]) };
const b = { id: "synthetic-b", currency: "USD", points: days(
["2026-10-21", "2026-10-23", "2026-10-25", "2026-10-27", "2026-10-29"],
[200, 201, 202, 203, 204]) };
const disjoint = {
accounts: [{ platform: "ibkr", account_key: "synthetic-a" }, { platform: "ibkr", account_key: "synthetic-b" }],
histories: [a, b],
range: "ALL", today: "2026-10-30", year: 2026, month: 10, benchmarks: [], failed: [],
};

// A strict fixed cohort omits every date the members do not share, so the
// calendar has no value. The bounded per-currency asset series still describes
// money movement; it may fill the calendar as asset change, never as a return.
const blocks = performanceBlocks(disjoint);
assert.equal(blocks.summary.twr_return_percent, null, "a split cohort still has no qualified TWR");
assert.deepEqual(blocks.returns.points, [], "asset change never becomes a return point");
assert.equal(blocks.calendar.status, "partial");
assert.equal(blocks.calendar.value_kind, "asset_change");
assert.equal(blocks.calendar.unavailable_reason, "snapshot_history_missing");
assert.equal(blocks.calendar.coverage.asset_change_fallback, true);
const valued = blocks.calendar.cells.filter(cell => cell.amount !== null);
assert.ok(valued.length > 0, "the fallback calendar shows bounded asset deltas");
assert.ok(valued.every(cell => cell.return_percent === null
&& cell.value_kind === "asset_change"
&& cell.status === "partial"
&& cell.unavailable_reason === "snapshot_history_missing"));
assert.equal(blocks.calendar.as_of, "2026-10-30");
assert.ok((blocks.returns.asset_change?.points.length || 0) > 1, "the returns payload keeps the asset series separately");
assert.equal(blocks.returns.asset_change.return_kind, "unadjusted_asset_ratio");

// Year view: a partial month exposes the observed asset subtotal instead of a
// blank cell, and still refuses to report a return percentage.
const yearView = performanceBlocks({ ...disjoint, month: null });
const october = yearView.calendar.cells.find(cell => cell.month === "2026-10");
assert.equal(october.value_kind, "asset_change");
assert.equal(october.return_percent, null);
assert.equal(october.status, "partial");
assert.ok(october.amount !== null, "observed monthly asset change stays visible");
assert.equal(yearView.calendar.coverage.asset_change_fallback, true);

// A missing member is not a sparse date. The fallback must stay off so a
// subset of accounts never stands in for the requested cohort.
const missingMember = performanceBlocks({
...disjoint,
histories: [a, { id: "synthetic-c", currency: "USD", points: [] }],
});
assert.notEqual(missingMember.calendar.coverage.asset_change_fallback, true);
assert.equal(missingMember.calendar.cells.find(cell => cell.date === "2026-10-30").amount, null);

// A failed read is also a cohort gap, not a forward-fill opportunity.
const failedRead = performanceBlocks({ ...disjoint, failed: ["synthetic-b"] });
assert.notEqual(failedRead.calendar.coverage.asset_change_fallback, true);
assert.equal(failedRead.calendar.cells.find(cell => cell.date === "2026-10-30").amount, null);

// Mixed currencies keep the existing per-currency contract: the combined
// calendar stays unavailable, while each currency retains its own asset series.
const mixed = performanceBlocks({
...disjoint,
histories: [...disjoint.histories, {
id: "synthetic-wallet", currency: "USDT", crypto: true,
points: days(["2026-10-20", "2026-10-24", "2026-10-28"], [500, 505, 510]),
}],
});
assert.equal(mixed.calendar.status, "unavailable");
assert.equal(mixed.calendar.unavailable_reason, "mixed_currency");
assert.notEqual(mixed.calendar.coverage.asset_change_fallback, true);
for (const entry of mixed.returns.per_currency) {
assert.deepEqual(entry.points, [], `${entry.currency}: no TWR without a shared cohort`);
assert.ok((entry.asset_change?.points.length || 0) > 1, `${entry.currency}: asset series stays available`);
}

// Cash-flow calculation failures replace only the return points. The separate
// asset change series survives so a currency tab never falls back to blank.
const cashflowInput = {
...disjoint,
histories: disjoint.histories.map(history => ({ ...history, externalFlow: { windows: [] } })),
};
const cashflowBlocks = applyCashflowPerformance(
performanceBlocks(cashflowInput), cashflowInput, { CONSOLE_EXTERNAL_FLOW_ENABLED: "true" });
assert.equal(cashflowBlocks.returns.status, "partial");
assert.ok(cashflowBlocks.returns.per_currency.length === 0, "single-currency failure keeps the top-level contract");
assert.ok((cashflowBlocks.returns.asset_change?.points.length || 0) > 1,
"a failed return calculation does not discard the asset series");

// A strict calendar with real daily values is left untouched: the fallback is
// only a last resort, never a relabeling of a qualified return.
const overlap = performanceBlocks({
...disjoint,
histories: [
{ id: "synthetic-a", currency: "USD", points: days(["2026-10-20", "2026-10-21"], [100, 101]) },
{ id: "synthetic-b", currency: "USD", points: days(["2026-10-20", "2026-10-21"], [200, 202]) },
],
today: "2026-10-21",
});
assert.notEqual(overlap.calendar.coverage.asset_change_fallback, true);
assert.equal(overlap.calendar.cells.find(cell => cell.date === "2026-10-21").value_kind, "asset_change",
"unknown flows keep the existing asset-change label instead of becoming P/L");
assert.ok(overlap.returns.asset_change, "the separate series is still exposed for consumers that need it");

console.log("console_calendar_fallback_validation: PASS (gated forward-filled asset calendar, missing/failed/mixed guards, strict calendar untouched)");
125 changes: 102 additions & 23 deletions web/strategy-switch-console/application/console_api/performance.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,55 @@ export function calendarCells(points, year, month, options = {}) {
};
});
}
/**
* A bounded forward-filled asset series describes money movement, never a
* return. Reuse the calendar grid, then strip every return claim from the cells
* that only carry a carried balance.
*/
function assetChangeCalendarCells(points, year, month, options) {
const cells = calendarCells(
points.map(({ partial, forward_filled_accounts, missing_accounts, membership_changed, ...point }) => point),
year,
month,
options,
);
return cells.map((cell) => {
if (month) {
if (cell.amount === null) return cell;
return {
...cell,
return_percent: null,
value_kind: "asset_change",
status: "partial",
unavailable_reason: "snapshot_history_missing",
};
}
if (cell.observed_amount === null || cell.observed_amount === undefined) return cell;
return {
...cell,
amount: cell.amount ?? cell.observed_amount,
return_percent: null,
value_kind: "asset_change",
status: cell.status === "unavailable" ? cell.status : "partial",
unavailable_reason: cell.status === "unavailable"
? cell.unavailable_reason
: cell.amount === null ? "incomplete_month" : "snapshot_history_missing",
};
});
}
/** An unqualified curve still describes a cumulative asset ratio; keep it separate from TWR. */
function assetChangeSeries(curve) {
return curve.return_kind === "twr" ? null : {
return_kind: "unadjusted_asset_ratio",
status: !curve.points.length ? "no_data" : curve.status === "stale" ? "stale" : "partial",
unavailable_reason: !curve.points.length ? "snapshot_history_missing" : curve.unavailable_reason,
as_of: curve.points.at(-1)?.date || null,
aligned_start: curve.points[0]?.date || null,
points: curve.points,
benchmarks: [],
coverage: curve.coverage,
};
}
export function holdingsRows(accounts) {
return accounts
.flatMap((a) =>
Expand Down Expand Up @@ -282,6 +331,7 @@ export function performanceBlocks({
);
const incompleteCohort = failed.length > 0 || combined.missingAccounts.length > 0;
const allPoints = mixed ? [] : combined.points;
const cryptoDays = histories.length > 0 && histories.every((history) => history.crypto === true);
const points = sliceRange(allPoints, range.toLowerCase(), today);
const asOf = points.at(-1)?.date || null;
const chained = chainReturns(points);
Expand Down Expand Up @@ -353,7 +403,7 @@ export function performanceBlocks({
unavailable_reason: mixed ? "mixed_currency" : incompleteCohort && points.length ? "snapshot_history_missing" : returnUnavailableReason(points),
};
const daily = dailyChanges(incompleteCohort ? [] : allPoints, {
crypto: histories.length > 0 && histories.every((history) => history.crypto === true),
crypto: cryptoDays,
}).filter((d) => d.date >= (points[0]?.date || today) && d.date <= today);
const validDays = daily.filter((d) => d.amount !== null);
const ranked = validDays
Expand All @@ -374,7 +424,7 @@ export function performanceBlocks({
const primaryByDate = new Map(aligned.primary.map((p) => [p.date, p.value]));
const cells = calendarCells(incompleteCohort ? [] : allPoints, year, month, {
today,
crypto: histories.length > 0 && histories.every((history) => history.crypto === true),
crypto: cryptoDays,
});
const calendarHasValues = cells.some(
(c) =>
Expand All @@ -398,7 +448,24 @@ export function performanceBlocks({
return curve;
});
const singleHistory = mixed ? null : historyCurves[0];
const byCurrency = mixed ? historyCurves.map(({ asset_points, ...curve }) => curve) : [];
// A strict cohort with omitted dates can still expose a bounded, carried
// native-currency asset series. Missing members and failed reads stay gated
// off so this fallback never substitutes for a qualified cohort.
const fallbackCells = !mixed && !calendarHasValues && failed.length === 0
&& combined.missingAccounts.length === 0 && combined.omittedDates > 0
&& historyCurves[0]?.asset_points.length > 1
? assetChangeCalendarCells(historyCurves[0].asset_points, year, month, {
today, crypto: cryptoDays,
})
: null;
const fallbackHasValues = Boolean(fallbackCells?.some((cell) =>
cell.amount !== null
|| (cell.observed_amount !== undefined && cell.observed_amount !== null)));
const byCurrency = mixed ? historyCurves.map(({ asset_points, ...curve }) => ({
...curve,
points: curve.return_kind === "twr" ? curve.points : [],
asset_change: assetChangeSeries(curve),
})) : [];
const historyMeta = singleHistory ? {
status: singleHistory.status,
unavailable_reason: singleHistory.unavailable_reason,
Expand Down Expand Up @@ -427,26 +494,37 @@ export function performanceBlocks({
&& accounts.every(a => a.positions_complete === true && Array.isArray(a.positions))
&& positions.every(row => row.status === "ok");
const knownInventory = accounts.some(a => a.positions?.length || a.positions_complete === true);
const calendar = {
...meta,
status: mixed
? "unavailable"
: !calendarHasValues
? "no_data"
: meta.status === "no_data"
? "partial"
: meta.status,
unavailable_reason: mixed
? "mixed_currency"
: !calendarHasValues
? "snapshot_history_missing"
: meta.unavailable_reason,
year,
month: month || null,
cells: cells.map((c) => ({ ...c, currency: meta.currency })),
as_of: allPoints.at(-1)?.date || null,
};
if (fallbackCells && fallbackHasValues) {
Object.assign(calendar, {
status: "partial",
unavailable_reason: "snapshot_history_missing",
value_kind: "asset_change",
as_of: historyCurves[0].asset_points.at(-1)?.date || null,
coverage: { ...coverage, ...historyCurves[0].coverage, asset_change_fallback: true },
cells: fallbackCells.map((c) => ({ ...c, currency: meta.currency })),
});
}
return {
calendar: {
...meta,
status: mixed
? "unavailable"
: !calendarHasValues
? "no_data"
: meta.status === "no_data"
? "partial"
: meta.status,
unavailable_reason: mixed
? "mixed_currency"
: !calendarHasValues
? "snapshot_history_missing"
: meta.unavailable_reason,
year,
month: month || null,
cells: cells.map((c) => ({ ...c, currency: meta.currency })),
as_of: allPoints.at(-1)?.date || null,
},
calendar,
summary: {
...meta,
twr_return_percent: qualified
Expand Down Expand Up @@ -495,7 +573,8 @@ export function performanceBlocks({
return_kind: singleHistory ? singleHistory.return_kind : qualified ? "twr" : "unadjusted_asset_ratio",
points: singleHistory ? (singleHistory.return_kind === "twr" ? singleHistory.points : []) : qualified ? aligned.primary : [],
benchmarks: singleHistory ? singleHistory.benchmarks : qualified ? aligned.benchmarks : [],
per_currency: byCurrency.map(curve => ({ ...curve, points: curve.return_kind === "twr" ? curve.points : [] })),
per_currency: byCurrency,
asset_change: singleHistory ? assetChangeSeries(singleHistory) : null,
no_common_date: singleHistory ? singleHistory.no_common_date : aligned.noCommonDate,
benchmark_availability: availableBenchmarks.map((b) => ({
id: b.id,
Expand Down
16 changes: 16 additions & 0 deletions web/strategy-switch-console/frontend/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ export interface PerformanceCoverage {
read_failures?: string[];
/** Single-account native daily returns: observed days only, gaps are never chained. */
native_daily_only?: boolean;
/** Calendar cells come from a bounded, forward-filled asset series, not a return chain. */
asset_change_fallback?: boolean;
}
export interface AccountFreshness {
platform: string | null;
Expand Down Expand Up @@ -420,6 +422,17 @@ export interface SummaryResponse extends PerformanceMeta, CashflowMethodMeta {
unavailable_reason: Reason | null;
}[];
}
export interface AssetChangeSeries {
return_kind: "unadjusted_asset_ratio";
status: Status;
unavailable_reason: Reason | null;
as_of: string | null;
aligned_start: string | null;
/** Cumulative asset change in percent, including cash-flow effects. Never a TWR. */
points: Point[];
benchmarks: { id: string; points: Point[] }[];
coverage?: HistoryCoverage;
}
export interface ReturnsResponse extends PerformanceMeta, CashflowMethodMeta {
per_account?: (ReturnsResponse & { platform: string; account_alias: string })[];
cashflow_per_currency?: CashflowReturnMetrics[];
Expand All @@ -428,6 +441,8 @@ export interface ReturnsResponse extends PerformanceMeta, CashflowMethodMeta {
points: Point[];
benchmarks: { id: string; points: Point[] }[];
no_common_date: boolean;
/** Present only when the qualified return is unavailable; labeled as asset change. */
asset_change?: AssetChangeSeries | null;
per_currency: ({
currency: string;
as_of?: string | null;
Expand All @@ -438,6 +453,7 @@ export interface ReturnsResponse extends PerformanceMeta, CashflowMethodMeta {
aligned_start: string | null;
points: Point[];
benchmarks: { id: string; points: Point[] }[];
asset_change?: AssetChangeSeries | null;
} & CashflowMethodMeta)[];
benchmark_availability: {
id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const zh: Record<string, string> = {
"perf.combined_metrics_unavailable": "组合指标暂不可计算,以下展示各账户日收益。",
"perf.mixed_currency_unavailable": "当前范围含多种币种,未进行汇率转换,组合指标不可计算;下方按账户和币种展示原生收益。",
"perf.mixed_currency_native": "当前范围含多种币种,组合指标不可计算;下方按账户和币种展示原生收益。",
"perf.mixed_currency_asset_change": "当前范围含多种币种,组合收益不可计算;下方按币种展示资产变化(含出入金影响),不代表时间加权收益。",
"reason.invalid_valuation_date": "估值日期无效,未计入合计。",
"reason.invalid_balance": "资产金额未确认,未计入合计。",
"reason.duplicate_currency": "同币种有重复余额,未计入合计。",
Expand Down Expand Up @@ -86,6 +87,7 @@ export const zh: Record<string, string> = {
"perf.asset_change_tag": "资产变化 · 含出入金影响",
"perf.asset_change_calendar_tag": "含出入金影响",
"perf.asset_change_note": "暂时无法计算收益,以下展示资产变化(%)。该变化包含出入金影响,不代表时间加权收益,也不与基准比较。",
"perf.calendar_fallback_note": "部分账户缺少当日快照,本月历按可用账户的资产变化展示(含出入金影响),不代表收益。",
"perf.portfolio_series": "组合",
"perf.currency_switch": "币种",
"perf.crypto_benchmark_note": "加密账户按 7×24 交易,暂不与美股指数比较;BTC 基准数据尚未接入。",
Expand Down Expand Up @@ -137,6 +139,7 @@ export const en: Record<string, string> = {
"perf.combined_metrics_unavailable": "Combined metrics are unavailable; daily returns are shown by account below.",
"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.",
"reason.invalid_valuation_date": "The valuation date is invalid; excluded from the total.",
"reason.invalid_balance": "The balance is unconfirmed; excluded from the total.",
"reason.duplicate_currency": "Duplicate balances for this currency; excluded from the total.",
Expand Down Expand Up @@ -196,6 +199,7 @@ export const en: Record<string, string> = {
"perf.asset_change_tag": "Asset change · includes cash flows",
"perf.asset_change_calendar_tag": "Includes cash flows",
"perf.asset_change_note": "Returns are unavailable; the chart shows asset change (%), including the effect of cash flows. It is not a time-weighted return and is not compared with benchmarks.",
"perf.calendar_fallback_note": "Some accounts are missing snapshots on these dates. This calendar shows asset change from the available accounts, including cash-flow effects; it is not a return.",
"perf.portfolio_series": "Portfolio",
"perf.currency_switch": "Currency",
"perf.crypto_benchmark_note": "Crypto accounts trade 24/7 and are not compared with US equity indices; BTC benchmark data is not available yet.",
Expand Down
Loading
Loading