From ec99aecbddd6657a24ba78a1109ca3ad5150e41c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:46:03 +0800 Subject: [PATCH 1/5] feat(performance): add opt-in external cash-flow facts and Dietz returns Co-Authored-By: Codex --- tests/console_external_flow_validation.mjs | 93 +++++++++++++ .../console_performance_stats_validation.mjs | 2 + tests/external_flow_receiver_validation.mjs | 84 ++++++++++++ .../account_facts/external_flow.js | 110 +++++++++++++++ .../application/account_facts/read.js | 4 + .../application/account_facts/sync.js | 15 ++ .../console_api/cashflow_performance.js | 129 ++++++++++++++++++ .../console_api/performance_routes.js | 10 +- .../contracts/console-api.schema.json | 126 +++++++++++++++++ .../frontend/src/api/contracts.ts | 19 +++ web/strategy-switch-console/worker.js | 22 ++- 11 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 tests/console_external_flow_validation.mjs create mode 100644 tests/external_flow_receiver_validation.mjs create mode 100644 web/strategy-switch-console/application/account_facts/external_flow.js create mode 100644 web/strategy-switch-console/application/console_api/cashflow_performance.js diff --git a/tests/console_external_flow_validation.mjs b/tests/console_external_flow_validation.mjs new file mode 100644 index 00000000..ade4dadb --- /dev/null +++ b/tests/console_external_flow_validation.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { normalizeExternalFlow, mergeExternalFlow, externalFlowEnabled } from "../web/strategy-switch-console/application/account_facts/external_flow.js"; +import { cashflowCurrencyResult, applyCashflowPerformance, intervalFlows } from "../web/strategy-switch-console/application/console_api/cashflow_performance.js"; +import { performanceBlocks } from "../web/strategy-switch-console/application/console_api/performance.js"; + +const env = { CONSOLE_EXTERNAL_FLOW_ENABLED: "true" }; +const start = "2026-01-01T12:00:00.000Z", end = "2026-01-02T12:00:00.000Z"; +const record = { source_ref: "a".repeat(64), type: "deposit", amount: "100", effective_at: "2026-01-02T00:00:00.000Z", settlement_date: "2026-01-02", transfer_ref: null }; +const window = { currency: "USD", date: "2026-01-02", period_start: start, period_end: end, source: "schwab_transactions", coverage: "complete", records: [record] }; +const payload = w => ({ schema_version: "external_flow.v1", windows: Array.isArray(w) ? w : [w] }); +const flow = normalizeExternalFlow(payload(window)); +assert.equal(externalFlowEnabled({}), false); +for (const value of [true, "1", "TRUE", "false"]) assert.equal(externalFlowEnabled({ CONSOLE_EXTERNAL_FLOW_ENABLED: value }), false); +assert.equal(normalizeExternalFlow(payload({ ...window, records: [record, record] })).windows[0].records.length, 1); +assert.deepEqual(mergeExternalFlow(flow, flow), flow); +const mutate = fn => { const copy = structuredClone(flow); fn(copy.windows[0]); return copy; }; +for (const invalid of [ + mutate(w => { w.records[0].amount = "NaN"; }), + mutate(w => { w.records[0].amount = "-1"; }), + mutate(w => { w.records[0].amount = true; }), + mutate(w => { w.records[0].amount = "0"; }), + mutate(w => { w.records[0].source_ref = "private-native-id"; }), + mutate(w => { w.records[0].effective_at = start; }), + mutate(w => { w.records[0].effective_at = "2026-01-02T13:00:00Z"; }), + mutate(w => { w.records[0].effective_at = null; }), + mutate(w => { w.records[0].settlement_date = "2026-02-30"; }), + mutate(w => { w.date = "2026-01-03"; }), + mutate(w => { w.extra = "forbidden"; }), + payload(Array.from({ length: 32 }, () => window)), + mutate(w => { w.records = Array.from({ length: 257 }, () => record); }), +]) assert.throws(() => normalizeExternalFlow(invalid)); +assert.throws(() => mergeExternalFlow(flow, mutate(w => { w.records[0].amount = "101"; })), /conflict/); +assert.throws(() => normalizeExternalFlow(payload([window, { ...window, period_start: "2026-01-01T13:00:00Z" }])), /overlap/); +const incomplete = normalizeExternalFlow(mutate(w => { w.coverage = "incomplete"; w.records = []; })); +assert.deepEqual(mergeExternalFlow(incomplete, flow), flow); +assert.equal(intervalFlows(incomplete, "USD", start, end), null); +assert.equal(intervalFlows(flow, "USDT", start, end), null); +assert.equal(intervalFlows(flow, "USD", start, end)[0].weight, .5); +assert.equal(intervalFlows(flow, "USD", "2026-01-01T11:00:00Z", end), null); + +const input = { + accounts: [{ account_key: "synthetic", platform: "schwab" }], + histories: [{ id: "synthetic", currency: "USD", externalFlow: flow, + points: [{ date: "2026-01-01", value: 100, observed_at: start, externalFlow: null }, + { date: "2026-01-02", value: 215, observed_at: end, externalFlow: null }] }], + range: "ALL", today: "2026-01-03", year: 2026, month: 1, benchmarks: [], failed: [], +}; +const result = cashflowCurrencyResult(input.histories, input); +assert.ok(Math.abs(result.twr_return_percent - 10) < 1e-10, "midperiod deposit: 15/(100+0.5*100)"); +assert.equal(result.approximate, true); +assert.equal(result.max_drawdown.percent, 0); +const legacy = performanceBlocks(input); +assert.equal(legacy.summary.twr_return_percent, null, "reproduces missing-flow main"); +assert.deepEqual(applyCashflowPerformance(structuredClone(legacy), input, {}), legacy, "OFF preserves complete old response"); +const none = { ...input, histories: input.histories.map(({ externalFlow, ...h }) => h) }; +assert.deepEqual(applyCashflowPerformance(performanceBlocks(none), none, env), performanceBlocks(none), "ON with no source preserves complete old response"); +const on = applyCashflowPerformance(structuredClone(legacy), input, env); +assert.ok(Math.abs(on.summary.twr_return_percent - 10) < 1e-10); +assert.deepEqual(on.assets, legacy.assets); +assert.deepEqual(on.calendar, legacy.calendar); +assert.equal(on.returns.return_kind, "twr"); +const change = fn => { const x = structuredClone(input); fn(x); return cashflowCurrencyResult(x.histories, x); }; +for (const fn of [ + x => { x.histories[0].externalFlow = null; }, + x => { x.histories[0].externalFlow.windows[0].coverage = "incomplete"; }, + x => { x.histories[0].points[1].forwardFilled = true; }, + x => { x.histories[0].points[1].forward_filled_accounts = ["synthetic"]; }, + x => { x.histories[0].points[1].observed_at = null; }, + x => { x.failed = ["missing"]; }, + x => { x.histories.push({ id: "missing", currency: "USD", points: [] }); }, +]) assert.equal(change(fn).twr_return_percent, null); +assert.equal(change(x => { x.histories[0].points[0].value = 0; }).unavailable_reason, "invalid_return_period"); +const withdrawal = change(x => { + x.histories[0].externalFlow.windows[0].records[0] = { ...record, type: "withdrawal", amount: "-50" }; + x.histories[0].points[1].value = 42.5; +}); +assert.ok(Math.abs(withdrawal.twr_return_percent + 10) < 1e-10); +assert.ok(Math.abs(withdrawal.max_drawdown.percent - 10) < 1e-10); +const zero = change(x => { + x.histories[0].externalFlow.windows[0].records = []; + x.histories[0].points[1].value = 110; +}); +assert.ok(Math.abs(zero.twr_return_percent - 10) < 1e-10, "only explicit complete zero is zero"); +const terminal = change(x => { x.histories[0].points[1].value = 0; x.histories[0].externalFlow.windows[0].records = []; }); +assert.equal(terminal.twr_return_percent, -100); +assert.equal(terminal.max_drawdown.percent, 100); +const two = structuredClone(input); +two.histories.push({ ...structuredClone(two.histories[0]), id: "second", currency: "USDT", externalFlow: payload({ ...window, currency: "USDT", records: [] }) }); +assert.equal(applyCashflowPerformance(performanceBlocks(two), two, env).summary.twr_return_percent, null, "no cross-currency total"); +two.histories[1].currency = "USD"; +two.histories[1].points[1].observed_at = "2026-01-02T12:01:00.000Z"; +assert.equal(cashflowCurrencyResult(two.histories, two).twr_return_percent, null, "same date alone is not simultaneous NAV"); +console.log("console_external_flow_validation: ok (synthetic only)"); diff --git a/tests/console_performance_stats_validation.mjs b/tests/console_performance_stats_validation.mjs index be86d1bb..bf5be421 100644 --- a/tests/console_performance_stats_validation.mjs +++ b/tests/console_performance_stats_validation.mjs @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import "./console_external_flow_validation.mjs"; +import "./external_flow_receiver_validation.mjs"; import { businessDate, calendarCells, diff --git a/tests/external_flow_receiver_validation.mjs b/tests/external_flow_receiver_validation.mjs new file mode 100644 index 00000000..8a0f186e --- /dev/null +++ b/tests/external_flow_receiver_validation.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import worker, { __test } from "../web/strategy-switch-console/worker.js"; + +const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); +const { Miniflare } = require(process.env.QRT_MINIFLARE_MODULE || "miniflare"); +const hash = "a".repeat(64); +const now = Date.now() - 120000; +const at = offset => new Date(now + offset).toISOString(); +const start = at(-86400000), end = at(0); +const base = { + schema_version: "longbridge_account_snapshot_history.v1", snapshot_schema_version: "longbridge_account_snapshot.v1", + account_scope: "PAPER", target_id: "synthetic-target", snapshot_atomic: false, + source_binding: { kind: "deployment_scope_token_version", status: "bound", id: hash }, + observed_started_at: start, observed_finished_at: start, observation_date: start.slice(0, 10), + broker_reported_balances: [{ currency: "USD", net_assets: "100", total_cash: "100" }], + cash: [{ currency: "USD", available_cash: "100", frozen_cash: "0", settling_cash: "0" }], +}; +const flow = { schema_version: "external_flow.v1", windows: [{ currency: "USD", date: end.slice(0, 10), period_start: start, period_end: end, + source: "longbridge_cashflow", coverage: "complete", records: [{ source_ref: "b".repeat(64), type: "deposit", amount: "100", + effective_at: at(-43200000), settlement_date: null, transfer_ref: null }] }] }; +const latest = { ...base, observed_started_at: end, observed_finished_at: end, observation_date: end.slice(0, 10), + broker_reported_balances: [{ currency: "USD", net_assets: "215", total_cash: "215" }], + cash: [{ currency: "USD", available_cash: "215", frozen_cash: "0", settling_cash: "0" }], externalFlow: flow }; +const settings = { ACCOUNT_FACTS_READ_MODEL_ENABLED: "true", CONSOLE_EXTERNAL_FLOW_ENABLED: "true", + ACCOUNT_FACTS_SYNC_TOKEN: "synthetic-sync-token", SESSION_SECRET: "synthetic-session", ALLOWED_GITHUB_LOGINS: "synthetic-reader" }; +let outbound = 0; +const mf = new Miniflare({ modules: true, modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], + scriptPath: fileURLToPath(new URL("../web/strategy-switch-console/worker.js", import.meta.url)), + compatibilityDate: "2026-06-08", bindings: settings, + durableObjects: { STRATEGY_SWITCH_RUNTIME_INSTANCES: { className: "RuntimeInstances", useSQLite: true } }, + kvNamespaces: ["STRATEGY_SWITCH_CONFIG"], + outboundService: () => { outbound++; throw new Error("external calls forbidden"); }, +}); +try { + const kv = await mf.getKVNamespace("STRATEGY_SWITCH_CONFIG"); + const namespace = await mf.getDurableObjectNamespace("STRATEGY_SWITCH_RUNTIME_INSTANCES"); + const option = { key: "synthetic", label: "Synthetic", target_name: "synthetic-target", service_name: "synthetic-service", + deployment_selector: "synthetic-deployment", account_scope: "paper", account_selector: "PAPER", supported_domains: ["us_equity"] }; + const binding = { ...option, platform: "longbridge", account_key: option.key, target_id: "synthetic-target", + source_binding: { kind: "deployment_scope_token_version", id: hash } }; + delete binding.key; delete binding.label; delete binding.supported_domains; + await kv.put("account_options", JSON.stringify({ longbridge: [option] })); + await kv.put("account_facts_bindings", JSON.stringify({ schema_version: "qsl_account_facts_bindings.v1", bindings: [binding] })); + const env = { ...settings, STRATEGY_SWITCH_CONFIG: kv, STRATEGY_SWITCH_RUNTIME_INSTANCES: namespace }; + const cookie = await __test.makeSession("synthetic-reader", [], settings); + const post = (body, token = settings.ACCOUNT_FACTS_SYNC_TOKEN, override = {}) => worker.fetch(new Request("https://synthetic.example/api/account-facts/sync", { + method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(body), + }), { ...env, ...override }); + const accept = async body => { const r = await post(body); assert.equal(r.status, 200, await r.clone().text()); return r.json(); }; + const get = async (path, override = {}) => { + const r = await worker.fetch(new Request(`https://synthetic.example${path}`, { headers: { Cookie: `qsl_switch_session=${cookie}` } }), { ...env, ...override }); + assert.equal(r.status, 200, await r.clone().text()); return r.json(); + }; + const historyPath = "/api/account-facts/history?platform=longbridge&account_key=synthetic¤cy=USD"; + const summaryPath = "/api/performance/summary?scope=synthetic&platform=longbridge"; + await accept(base); + const { externalFlow, ...legacyLatest } = latest; + await accept(legacyLatest); + const before = await get(summaryPath); + const offBefore = await get(summaryPath, { CONSOLE_EXTERNAL_FLOW_ENABLED: "false" }); + assert.deepEqual(before, offBefore, "ON no source equals OFF through HTTP adapter"); + assert.equal((await post(latest, "wrong-synthetic-token")).status, 401); + assert.equal((await post(latest, settings.ACCOUNT_FACTS_SYNC_TOKEN, { CONSOLE_EXTERNAL_FLOW_ENABLED: "false" })).status, 409); + await accept(latest); + assert.equal((await accept(latest)).unchanged, true); + assert.deepEqual((await get(historyPath)).externalFlow, flow); + const summary = await get(summaryPath); + assert.ok(Math.abs(summary.twr_return_percent - 10) < 1e-10, JSON.stringify(summary)); + assert.equal(summary.cashflow_per_currency[0].approximate, true); + assert.deepEqual(await get(summaryPath, { CONSOLE_EXTERNAL_FLOW_ENABLED: "false" }), offBefore, "OFF read ignores stored extension"); + const conflict = structuredClone(latest); conflict.externalFlow.windows[0].records[0].amount = "101"; + assert.equal((await post(conflict)).status, 409); + assert.deepEqual((await get(historyPath)).externalFlow, flow, "conflict cannot change SQLite history"); + const malformed = structuredClone(latest); malformed.externalFlow.windows[0].records[0].source_ref = "raw-private-reference"; + assert.equal((await post(malformed)).status, 400); + const wrong = structuredClone(latest); wrong.source_binding.id = "c".repeat(64); + assert.equal((await post(wrong)).status, 409); + const oversize = structuredClone(latest); oversize.externalFlow.windows[0].records = Array(257).fill(flow.windows[0].records[0]); + assert.equal((await post(oversize)).status, 400); + assert.equal(outbound, 0); + console.log("external_flow_receiver_validation: ok (authenticated synthetic HTTP -> SQLite -> history -> Dietz; no outbound calls)"); +} finally { await mf.dispose(); } diff --git a/web/strategy-switch-console/application/account_facts/external_flow.js b/web/strategy-switch-console/application/account_facts/external_flow.js new file mode 100644 index 00000000..1aab2f5e --- /dev/null +++ b/web/strategy-switch-console/application/account_facts/external_flow.js @@ -0,0 +1,110 @@ +// Private account-facts extension. Never infer complete coverage from an empty list. +import { dateMs } from "../../performance_math.js"; + +export const EXTERNAL_FLOW_SCHEMA = "external_flow.v1"; +const DAY = 86400000; +const SOURCES = new Set(["ibkr_flex", "schwab_transactions", "longbridge_cashflow", "binance_history"]); +const TYPES = new Set(["deposit", "withdrawal", "transfer_in", "transfer_out"]); +const HASH = /^[a-f0-9]{64}$/; +const MONEY = /^-?(?:0|[1-9]\d{0,11})(?:\.\d{1,8})?$/; +export const externalFlowEnabled = (env) => env?.CONSOLE_EXTERNAL_FLOW_ENABLED === "true"; +export function flowError(code = "invalid_external_flow", status = 400) { + const error = new Error(code); + error.code = code; + error.status = status; + return error; +} +function exact(value, keys) { + return value && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k)); +} +export function instant(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return NaN; + const ms = Date.parse(value); + return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 19) === value.slice(0, 19) ? ms : NaN; +} +const windowKey = w => `${w.currency}:${w.period_start}:${w.period_end}`; +const stable = value => JSON.stringify(value); + +export function normalizeExternalFlow(raw, { now = Date.now(), maxWindows = 31, maxRecords = 256 } = {}) { + if (!exact(raw, ["schema_version", "windows"]) || raw.schema_version !== EXTERNAL_FLOW_SCHEMA + || !Array.isArray(raw.windows) || !raw.windows.length || raw.windows.length > maxWindows + || new TextEncoder().encode(stable(raw)).length > (maxWindows === 31 ? 32768 : 524288)) throw flowError(); + let count = 0; + const seen = new Map(); + const keys = new Set(); + const windows = raw.windows.map(w => { + if (!exact(w, ["currency", "date", "period_start", "period_end", "coverage", "source", "records"]) + || !/^[A-Z][A-Z0-9]{2,9}$/.test(w.currency) || !Number.isFinite(dateMs(w.date)) + || !SOURCES.has(w.source) || !["complete", "incomplete"].includes(w.coverage) + || !Array.isArray(w.records)) throw flowError(); + const start = instant(w.period_start), end = instant(w.period_end); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 2 * DAY + || end > now || w.date !== w.period_end.slice(0, 10) || keys.has(windowKey(w))) throw flowError(); + keys.add(windowKey(w)); + const refs = new Map(); + for (const r of w.records) { + if (++count > maxRecords || !exact(r, ["source_ref", "type", "amount", "effective_at", "settlement_date", "transfer_ref"]) + || !HASH.test(r.source_ref) || !TYPES.has(r.type) || typeof r.amount !== "string" || !MONEY.test(r.amount) + || !Number.isFinite(Number(r.amount)) || Number(r.amount) === 0 + || ((r.type === "deposit" || r.type === "transfer_in") !== (Number(r.amount) > 0)) + || (r.settlement_date !== null && !Number.isFinite(dateMs(r.settlement_date))) + || (r.transfer_ref !== null && !HASH.test(r.transfer_ref))) throw flowError(); + const at = instant(r.effective_at); + if (!Number.isFinite(at) || at <= start || at > end) throw flowError(); + const record = { source_ref: r.source_ref, type: r.type, amount: r.amount, + effective_at: r.effective_at, settlement_date: r.settlement_date, transfer_ref: r.transfer_ref }; + const prior = seen.get(r.source_ref); + const identity = stable({ currency: w.currency, source: w.source, ...record }); + if (prior && prior !== identity) throw flowError("external_flow_conflict", 409); + seen.set(r.source_ref, identity); + refs.set(r.source_ref, record); + } + return { currency: w.currency, date: w.date, period_start: w.period_start, period_end: w.period_end, + coverage: w.coverage, source: w.source, + records: [...refs.values()].sort((a, b) => a.source_ref.localeCompare(b.source_ref)) }; + }).sort((a, b) => windowKey(a).localeCompare(windowKey(b))); + for (let i = 1; i < windows.length; i++) { + const a = windows[i], b = windows[i - 1]; + if (a.currency === b.currency && instant(a.period_start) < instant(b.period_end) + && instant(b.period_start) < instant(a.period_end)) throw flowError("external_flow_overlap", 409); + } + return { schema_version: EXTERNAL_FLOW_SCHEMA, windows }; +} + +export function mergeExternalFlow(prior, incoming) { + const rows = new Map((prior?.windows || []).map(w => [windowKey(w), w])); + for (const w of incoming.windows) { + const old = rows.get(windowKey(w)); + if (old && stable(old) !== stable(w)) { + // Coverage may be completed later, but facts already accepted are immutable. + if (old.coverage !== "incomplete" || w.source !== old.source + || old.records.some(r => !w.records.some(n => stable(n) === stable(r)))) throw flowError("external_flow_conflict", 409); + } + rows.set(windowKey(w), w); + } + const newest = Math.max(...[...rows.values()].map(w => dateMs(w.date))); + const windows = [...rows.values()].filter(w => dateMs(w.date) >= newest - 365 * DAY); + return normalizeExternalFlow({ schema_version: EXTERNAL_FLOW_SCHEMA, windows }, { maxWindows: 4096, maxRecords: 4096 }); +} + +export function initializeExternalFlow(sql) { + sql.exec("CREATE TABLE IF NOT EXISTS account_external_flow (platform TEXT NOT NULL, account_key TEXT NOT NULL, context_json TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY(platform, account_key))"); +} +const identity = c => stable([c.target_id, c.source_binding_id, c.account_scope, c.account_selector, + c.broker_account_hash ?? null, c.broker_account_id ?? null]); +export function readExternalFlow(sql, context) { + const row = sql.exec("SELECT context_json, payload_json FROM account_external_flow WHERE platform = ? AND account_key = ?", + context.platform, context.account_key).toArray()[0]; + if (!row || row.context_json !== identity(context)) return null; + return normalizeExternalFlow(JSON.parse(row.payload_json), { maxWindows: 4096, maxRecords: 4096 }); +} +export function putExternalFlow(sql, context, raw) { + const incoming = normalizeExternalFlow(raw); + const prior = readExternalFlow(sql, context); + const merged = mergeExternalFlow(prior, incoming); + if (stable(prior) === stable(merged)) return { unchanged: true }; + sql.exec("INSERT INTO account_external_flow (platform, account_key, context_json, payload_json) VALUES (?, ?, ?, ?) ON CONFLICT(platform, account_key) DO UPDATE SET context_json = excluded.context_json, payload_json = excluded.payload_json", + context.platform, context.account_key, identity(context), stable(merged)); + return { unchanged: false }; +} diff --git a/web/strategy-switch-console/application/account_facts/read.js b/web/strategy-switch-console/application/account_facts/read.js index 34543919..c49341e9 100644 --- a/web/strategy-switch-console/application/account_facts/read.js +++ b/web/strategy-switch-console/application/account_facts/read.js @@ -7,6 +7,7 @@ * does not import worker.js (avoids cycles) and wire stays identical. */ +import { externalFlowEnabled } from "./external_flow.js"; import { ACCOUNT_FACTS_HISTORY_MAX_DAYS, ACCOUNT_FACTS_SUPPORTED_PLATFORMS, @@ -156,6 +157,7 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { (item) => item.platform === platform && item.account_key === accountKey, ) || null; let days = []; + let externalFlow = null; if (option && binding && accountFactsOptionMatchesBinding(option, binding)) { try { const listed = await runtimeInstanceCommand(env, { @@ -174,6 +176,7 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { source_binding_id: binding.source_binding.id, }); days = Array.isArray(listed?.days) ? listed.days : []; + if (externalFlowEnabled(env)) externalFlow = listed?.externalFlow || null; } catch (error) { return accountFactsErrorResponse(error); } @@ -191,5 +194,6 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { ...model, enabled: true, retention_days: ACCOUNT_FACTS_HISTORY_MAX_DAYS, + ...(externalFlowEnabled(env) && externalFlow ? { externalFlow } : {}), }); } diff --git a/web/strategy-switch-console/application/account_facts/sync.js b/web/strategy-switch-console/application/account_facts/sync.js index 5deeeb8d..e1f3365c 100644 --- a/web/strategy-switch-console/application/account_facts/sync.js +++ b/web/strategy-switch-console/application/account_facts/sync.js @@ -7,6 +7,7 @@ * the DO account_facts_put command (unchanged). */ +import { externalFlowEnabled, normalizeExternalFlow } from "./external_flow.js"; import { ACCOUNT_FACTS_MAX_BODY_BYTES, ACCOUNT_FACTS_RETURN_UNAVAILABLE, @@ -90,6 +91,19 @@ export async function syncAccountFactsResponse(request, env, deps) { if (payloadPlatform !== authorizedPlatform) { return json({ ok: false, error: "account_facts_sync_token_platform_mismatch" }, 403); } + let externalFlow; + if (raw && Object.hasOwn(raw, "externalFlow")) { + if (!externalFlowEnabled(env)) return json({ ok: false, error: "external_flow_disabled" }, 409); + try { + externalFlow = normalizeExternalFlow(raw.externalFlow); + const source = { ibkr: "ibkr_flex", schwab: "schwab_transactions", longbridge: "longbridge_cashflow" }[authorizedPlatform]; + if (externalFlow.windows.some(w => w.source !== source)) throw new Error("external_flow_source_mismatch"); + } catch (error) { + return accountFactsErrorResponse(error); + } + raw = { ...raw }; + delete raw.externalFlow; + } let history; try { history = normalizeAccountFactsHistoryPayload(raw, { expectedPlatform: authorizedPlatform }); @@ -160,6 +174,7 @@ export async function syncAccountFactsResponse(request, env, deps) { target_id: resolved.binding.target_id, source_binding_id: resolved.binding.source_binding.id, history, + ...(externalFlow ? { externalFlow } : {}), }); } catch (error) { return accountFactsErrorResponse(error); diff --git a/web/strategy-switch-console/application/console_api/cashflow_performance.js b/web/strategy-switch-console/application/console_api/cashflow_performance.js new file mode 100644 index 00000000..62abf6fa --- /dev/null +++ b/web/strategy-switch-console/application/console_api/cashflow_performance.js @@ -0,0 +1,129 @@ +import { dateMs, sliceRange, alignReturns } from "../../performance_math.js"; +import { externalFlowEnabled, instant, normalizeExternalFlow } from "../account_facts/external_flow.js"; +import { maxDrawdown } from "./performance.js"; + +// Complete source windows cover (start, end]. Cash flows at start belong to the +// previous valuation; at end their Dietz weight is zero. +export function intervalFlows(payload, currency, start, end) { + let windows; + try { windows = normalizeExternalFlow(payload, { maxWindows: 4096, maxRecords: 4096 }).windows; } + catch { return null; } + const a = instant(start), b = instant(end); + if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return null; + const rows = windows.filter(w => w.currency === currency && w.coverage === "complete" + && instant(w.period_end) > a && instant(w.period_start) < b) + .sort((x, y) => instant(x.period_start) - instant(y.period_start)); + let cursor = a; + const records = new Map(); + for (const w of rows) { + if (instant(w.period_start) > cursor) return null; + cursor = Math.max(cursor, instant(w.period_end)); + for (const r of w.records) { + const at = instant(r.effective_at); + if (at > a && at <= b) records.set(r.source_ref, { ...r, weight: (b - at) / (b - a) }); + } + } + return cursor >= b ? [...records.values()] : null; +} + +export function cashflowCurrencyResult(histories, { range, today, failed = [] }) { + const currency = histories[0]?.currency; + const normalized = histories.map(h => ({ ...h, points: sliceRange(h.points, range.toLowerCase(), today) })); + const dates = [...new Set(normalized.flatMap(h => h.points.map(p => p.date)))].sort(); + const maps = normalized.map(h => new Map(h.points.map(p => [p.date, p]))); + const common = dates.filter(d => maps.every(m => m.has(d))); + const unavailable = reason => ({ currency, status: "partial", unavailable_reason: reason, + twr_return_percent: null, max_drawdown: { percent: null, peak_date: null, trough_date: null, unavailable_reason: reason }, + index: [], points: [], return_kind: "unadjusted_asset_ratio", cashflow_method: "modified_dietz", approximate: true }); + if (!normalized.length || failed.length || normalized.some(h => !h.points.length)) return unavailable("snapshot_history_missing"); + if (normalized.some(h => h.points.some(p => p.value < 0))) return unavailable("invalid_return_period"); + if (common.length < 2) return unavailable("insufficient_observations"); + // Omitting another member's real valuation must not manufacture a daily return. + if (common.length !== dates.length) return unavailable("snapshot_history_missing"); + const index = [{ date: common[0], value: 1 }]; + let growth = 1; + let multiDay = false; + for (let i = 1; i < common.length; i++) { + const prev = maps.map(m => m.get(common[i - 1])), next = maps.map(m => m.get(common[i])); + if ([...prev, ...next].some(p => p.forwardFilled === true || p.partial === true || p.forward_filled_accounts?.length)) { + return unavailable("snapshot_history_missing"); + } + const starts = new Set(prev.map(p => p.observed_at)), ends = new Set(next.map(p => p.observed_at)); + // Aggregation needs simultaneous real valuations, not merely matching dates. + if (starts.size !== 1 || ends.size !== 1) return unavailable("snapshot_history_missing"); + const start = prev[0].observed_at, end = next[0].observed_at; + if (!Number.isFinite(instant(start)) || !Number.isFinite(instant(end)) + || start.slice(0, 10) !== common[i - 1] || end.slice(0, 10) !== common[i]) return unavailable("snapshot_history_missing"); + multiDay ||= dateMs(common[i]) - dateMs(common[i - 1]) > 86400000; + const flows = normalized.map(h => intervalFlows(h.externalFlow, currency, start, end)); + if (flows.some(f => f === null)) return unavailable("external_cashflow_required"); + const v0 = prev.reduce((s, p) => s + p.value, 0), v1 = next.reduce((s, p) => s + p.value, 0); + const records = flows.flat(); + // A cross-account transfer needs both marked legs in this exact cohort and + // interval. Unpaired legs and unsettled in-transit capital stay unavailable. + const transfers = new Map(); + for (const r of records.filter(r => r.type.startsWith("transfer_"))) { + if (!r.transfer_ref) return unavailable("external_cashflow_required"); + const pair = transfers.get(r.transfer_ref) || []; + pair.push(r); transfers.set(r.transfer_ref, pair); + } + for (const pair of transfers.values()) { + if (normalized.length > 1 && (pair.length !== 2 || pair[0].type === pair[1].type + || Math.abs(Number(pair[0].amount) + Number(pair[1].amount)) > 1e-8 + || pair[0].effective_at !== pair[1].effective_at)) return unavailable("external_cashflow_required"); + } + const net = records.reduce((s, r) => s + Number(r.amount), 0); + const weighted = records.reduce((s, r) => s + r.weight * Number(r.amount), 0); + const denominator = v0 + weighted; + const r = (v1 - v0 - net) / denominator; + if (!(v0 > 0) || v1 < 0 || !(denominator > 0) || !Number.isFinite(r) || r < -1) return unavailable("invalid_return_period"); + growth *= 1 + r; + if (!Number.isFinite(growth)) return unavailable("invalid_return_period"); + index.push({ date: common[i], value: growth, breakBefore: dateMs(common[i]) - dateMs(common[i - 1]) > 86400000 }); + } + return { currency, status: "ok", unavailable_reason: null, twr_return_percent: (growth - 1) * 100, + max_drawdown: maxDrawdown(index), index, points: alignReturns(index, []).primary, + return_kind: "twr", cashflow_method: "modified_dietz", approximate: true, multi_day_intervals: multiDay }; +} + +// Leave the legacy response byte-for-byte equivalent when OFF or no source data +// exists. The asset union/forward-fill path (#711) is deliberately not consumed. +export function applyCashflowPerformance(blocks, input, env) { + if (!externalFlowEnabled(env) || !input.histories.some(h => h.externalFlow)) return blocks; + const currencies = [...new Set(input.histories.map(h => h.currency))]; + const results = currencies.map(c => cashflowCurrencyResult(input.histories.filter(h => h.currency === c), input)); + const publicResult = r => { const { index, ...rest } = r; return rest; }; + const perCurrency = results.map(publicResult); + blocks.summary.cashflow_per_currency = perCurrency; + blocks.returns.cashflow_per_currency = perCurrency; + if (blocks.summary.per_currency) blocks.summary.per_currency = blocks.summary.per_currency.map(old => { + const r = results.find(x => x.currency === old.currency); + return r?.status === "ok" ? { ...old, max_drawdown: r.max_drawdown, twr_return_percent: r.twr_return_percent, + cashflow_method: r.cashflow_method, approximate: true } : old; + }); + if (currencies.length > 1) { + blocks.returns.per_currency = (blocks.returns.per_currency || []).map(old => { + const r = results.find(x => x.currency === old.currency); + return r?.status === "ok" ? { ...old, ...publicResult(r), benchmarks: [], aligned_start: r.index[0].date } : old; + }); + return blocks; + } + const r = results[0]; + if (!r || r.status !== "ok") return blocks; + const benchmarks = input.benchmarks.map(b => ({ ...b, points: sliceRange(b.points, input.range.toLowerCase(), input.today) })); + const aligned = alignReturns(r.index, benchmarks); + Object.assign(blocks.summary, { twr_return_percent: r.twr_return_percent, max_drawdown: r.max_drawdown, + cashflow_method: r.cashflow_method, approximate: true }); + if (blocks.summary.metric_reasons) blocks.summary.metric_reasons.twr = null; + // Legacy daily P/L/calendar use another denominator; they remain untouched. + blocks.summary.excess_returns = []; + for (const block of [blocks.summary, blocks.returns]) { + block.unavailable_reason = null; + block.status = block.status === "stale" ? "stale" : "ok"; + block.coverage = { ...block.coverage, cashflows: "complete" }; + } + Object.assign(blocks.returns, { points: aligned.primary, benchmarks: aligned.benchmarks, + aligned_start: aligned.start, no_common_date: aligned.noCommonDate, return_kind: "twr", + cashflow_method: r.cashflow_method, approximate: true }); + return blocks; +} diff --git a/web/strategy-switch-console/application/console_api/performance_routes.js b/web/strategy-switch-console/application/console_api/performance_routes.js index 3cf0caa9..e9fce5e0 100644 --- a/web/strategy-switch-console/application/console_api/performance_routes.js +++ b/web/strategy-switch-console/application/console_api/performance_routes.js @@ -1,6 +1,8 @@ import { isAggregateAssetAccount } from "../../performance_math.js"; import { MARKET_BENCHMARK_SERIES_IDS } from "../../infrastructure/market_benchmark/market_benchmark.js"; import { businessDate, performanceBlocks } from "./performance.js"; +import { externalFlowEnabled } from "../account_facts/external_flow.js"; +import { applyCashflowPerformance } from "./cashflow_performance.js"; const ENDPOINTS = { calendar: "calendar", summary: "summary", @@ -170,6 +172,7 @@ export async function performanceResponse(request, env, deps) { accountHistories.push({ id: `${option.platform}:${option.key}:${currency}`, currency, + ...(externalFlowEnabled(env) && body.externalFlow ? { externalFlow: body.externalFlow } : {}), points: (body.series.points || []).flatMap((p) => p.currency !== currency || number(p.net_assets) === null ? [] @@ -178,6 +181,8 @@ export async function performanceResponse(request, env, deps) { date: p.observation_date, value: number(p.net_assets), externalFlow: null, + ...(externalFlowEnabled(env) ? { observed_at: Number.isFinite(Date.parse(p.observed_finished_at)) + ? new Date(p.observed_finished_at).toISOString() : null } : {}), }, ], ), @@ -214,7 +219,7 @@ export async function performanceResponse(request, env, deps) { } catch { benchmarkFailed = true; } - const blocks = performanceBlocks({ + const performanceInput = { accounts, histories, benchmarks, @@ -223,7 +228,8 @@ export async function performanceResponse(request, env, deps) { year, month, failed: [...new Set(failed)], - }); + }; + const blocks = applyCashflowPerformance(performanceBlocks(performanceInput), performanceInput, env); const block = blocks[endpoint]; if (["calendar", "summary", "returns", "assets"].includes(endpoint)) { block.coverage.truncated = truncated; diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index cf7da28b..f0b73a53 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -998,6 +998,18 @@ "unavailable_reason" ] } + }, + "cashflow_per_currency": { + "type": "array", + "items": { + "$ref": "#/$defs/cashflow_return_metrics" + } + }, + "cashflow_method": { + "const": "modified_dietz" + }, + "approximate": { + "const": true } }, "required": [ @@ -1272,6 +1284,18 @@ } } } + }, + "cashflow_per_currency": { + "type": "array", + "items": { + "$ref": "#/$defs/cashflow_return_metrics" + } + }, + "cashflow_method": { + "const": "modified_dietz" + }, + "approximate": { + "const": true } }, "required": [ @@ -2205,6 +2229,108 @@ "unavailable_reason" ], "additionalProperties": false + }, + "cashflow_return_metrics": { + "type": "object", + "required": [ + "currency", + "status", + "unavailable_reason", + "twr_return_percent", + "max_drawdown", + "points", + "return_kind", + "cashflow_method", + "approximate" + ], + "properties": { + "currency": { + "type": "string" + }, + "status": { + "type": "string" + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ] + }, + "twr_return_percent": { + "type": [ + "number", + "null" + ] + }, + "max_drawdown": { + "type": "object", + "properties": { + "percent": { + "type": [ + "number", + "null" + ] + }, + "peak_date": { + "type": [ + "string", + "null" + ] + }, + "trough_date": { + "type": [ + "string", + "null" + ] + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "percent", + "peak_date", + "trough_date", + "unavailable_reason" + ] + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "date", + "value" + ] + } + }, + "return_kind": { + "enum": [ + "twr", + "unadjusted_asset_ratio" + ] + }, + "cashflow_method": { + "const": "modified_dietz" + }, + "approximate": { + "const": true + }, + "multi_day_intervals": { + "type": "boolean" + } + } } } } diff --git a/web/strategy-switch-console/frontend/src/api/contracts.ts b/web/strategy-switch-console/frontend/src/api/contracts.ts index 9c6c478c..546d60ee 100644 --- a/web/strategy-switch-console/frontend/src/api/contracts.ts +++ b/web/strategy-switch-console/frontend/src/api/contracts.ts @@ -243,6 +243,9 @@ export interface DailyChange { unavailable_reason: Reason | null; } export interface SummaryResponse extends PerformanceMeta { + cashflow_per_currency?: CashflowReturnMetrics[]; + cashflow_method?: "modified_dietz"; + approximate?: boolean; twr_return_percent: number | null; amount: number | null; simple_weighted_return_percent: number | null; @@ -263,6 +266,9 @@ export interface SummaryResponse extends PerformanceMeta { }[]; } export interface ReturnsResponse extends PerformanceMeta { + cashflow_per_currency?: CashflowReturnMetrics[]; + cashflow_method?: "modified_dietz"; + approximate?: boolean; aligned_start: string | null; return_kind: "twr" | "unadjusted_asset_ratio"; points: Point[]; @@ -438,3 +444,16 @@ export interface M0ResearchDashboard { policy: { authority: "research_only"; no_order: true; permitted_next_step: "research_validation_only"; notice: string }; errors: string[]; } + +export interface CashflowReturnMetrics { + currency: string; + status: Status; + unavailable_reason: Reason | null; + twr_return_percent: number | null; + max_drawdown: SummaryResponse["max_drawdown"]; + points: Point[]; + return_kind: "twr" | "unadjusted_asset_ratio"; + cashflow_method: "modified_dietz"; + approximate: boolean; + multi_day_intervals?: boolean; +} diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 4170214a..9e5f9552 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -2164,11 +2164,15 @@ async function runtimeInstancesResponse(request, env) { return json(await runtimeInstanceCommand(env, command)); } +import { externalFlowEnabled, initializeExternalFlow, normalizeExternalFlow, putExternalFlow, readExternalFlow } from "./application/account_facts/external_flow.js"; + export class RuntimeInstances { constructor(ctx, env) { this.directory = new KvDirectory(ctx.storage, env?.STRATEGY_SWITCH_CONFIG, env); this.storage = ctx.storage; this.sql = ctx.storage.sql; + this.externalFlowEnabled = externalFlowEnabled(env); + if (this.externalFlowEnabled) initializeExternalFlow(this.sql); initializeCredentialMaintenance(this.sql); // Latest native interval only; existing private read-model store, no new ledger. this.sql.exec(`CREATE TABLE IF NOT EXISTS account_period_return ( @@ -3163,13 +3167,25 @@ export class RuntimeInstances { ); const decision = decideAccountFactsPut(existingRow?.payload || null, history); if (decision.action === "reject") throw new HttpError(decision.reason, decision.status || 409); + let flowChanged = false; + if (command.externalFlow !== undefined) { + if (!this.externalFlowEnabled) throw new HttpError("external_flow_disabled", 409); + const flow = normalizeExternalFlow(command.externalFlow); + const source = { ibkr: "ibkr_flex", schwab: "schwab_transactions", longbridge: "longbridge_cashflow" }[context.platform]; + const currencies = new Set(history.broker_reported_balances.map(b => b.currency)); + if (flow.windows.some(w => w.source !== source || !currencies.has(w.currency) + || Date.parse(w.period_end) > Date.parse(history.observed_finished_at))) { + throw new HttpError("external_flow_observation_mismatch", 409); + } + flowChanged = !putExternalFlow(this.sql, context, flow).unchanged; + } if (decision.action === "keep") { // Latest table unchanged; still ensure the accepted day is retained. this.upsertAccountFactsDaily(context, history); return { ok: true, stored: true, - unchanged: true, + unchanged: !flowChanged, platform: command.platform, account_key: command.account_key, observation_date: existingRow.observation_date, @@ -3330,7 +3346,9 @@ export class RuntimeInstances { account_scope: row.account_scope, }); } - return { ok: true, days }; + return { ok: true, days, + ...(this.externalFlowEnabled ? { externalFlow: readExternalFlow(this.sql, context) } : {}), + }; } readAccountFactsObservation(command) { From dfb000246fb0a311621cec257ec07442a4c331a9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:47:19 +0800 Subject: [PATCH 2/5] test(performance): cover conflicting valuations and chained flow returns Co-Authored-By: Codex --- tests/console_external_flow_validation.mjs | 20 +++++++++++++++++++ .../console_api/cashflow_performance.js | 8 ++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/console_external_flow_validation.mjs b/tests/console_external_flow_validation.mjs index ade4dadb..f8f4cc8b 100644 --- a/tests/console_external_flow_validation.mjs +++ b/tests/console_external_flow_validation.mjs @@ -59,6 +59,8 @@ assert.ok(Math.abs(on.summary.twr_return_percent - 10) < 1e-10); assert.deepEqual(on.assets, legacy.assets); assert.deepEqual(on.calendar, legacy.calendar); assert.equal(on.returns.return_kind, "twr"); +if (on.summary.metric_reasons) assert.equal(on.summary.metric_reasons.twr, null); +if (on.summary.per_currency) assert.equal(on.summary.per_currency[0].max_drawdown.percent, 0); const change = fn => { const x = structuredClone(input); fn(x); return cashflowCurrencyResult(x.histories, x); }; for (const fn of [ x => { x.histories[0].externalFlow = null; }, @@ -68,6 +70,7 @@ for (const fn of [ x => { x.histories[0].points[1].observed_at = null; }, x => { x.failed = ["missing"]; }, x => { x.histories.push({ id: "missing", currency: "USD", points: [] }); }, + x => { x.histories[0].points.push({ ...x.histories[0].points[1], value: 999 }); }, ]) assert.equal(change(fn).twr_return_percent, null); assert.equal(change(x => { x.histories[0].points[0].value = 0; }).unavailable_reason, "invalid_return_period"); const withdrawal = change(x => { @@ -84,6 +87,23 @@ assert.ok(Math.abs(zero.twr_return_percent - 10) < 1e-10, "only explicit complet const terminal = change(x => { x.histories[0].points[1].value = 0; x.histories[0].externalFlow.windows[0].records = []; }); assert.equal(terminal.twr_return_percent, -100); assert.equal(terminal.max_drawdown.percent, 100); +const endFlow = change(x => { + x.histories[0].externalFlow.windows[0].records[0].effective_at = end; + x.histories[0].points[1].value = 200; +}); +assert.equal(endFlow.twr_return_percent, 0, "end-flow weight is zero"); +assert.equal(change(x => { + x.histories[0].externalFlow.windows[0].records[0] = { ...record, type: "withdrawal", amount: "-300" }; +}).unavailable_reason, "invalid_return_period"); +const linked = change(x => { + x.histories[0].points.push({ date: "2026-01-03", value: 193.5, observed_at: "2026-01-03T12:00:00.000Z", externalFlow: null }); + x.histories[0].externalFlow.windows.push({ ...window, date: "2026-01-03", period_start: end, + period_end: "2026-01-03T12:00:00.000Z", records: [] }); +}); +assert.ok(Math.abs(linked.twr_return_percent + 1) < 1e-10, "chain 10% then -10% gives -1%"); +assert.ok(Math.abs(linked.max_drawdown.percent - 10) < 1e-10); +const singleTransfer = change(x => { x.histories[0].externalFlow.windows[0].records[0].type = "transfer_in"; }); +assert.ok(Math.abs(singleTransfer.twr_return_percent - 10) < 1e-10); const two = structuredClone(input); two.histories.push({ ...structuredClone(two.histories[0]), id: "second", currency: "USDT", externalFlow: payload({ ...window, currency: "USDT", records: [] }) }); assert.equal(applyCashflowPerformance(performanceBlocks(two), two, env).summary.twr_return_percent, null, "no cross-currency total"); diff --git a/web/strategy-switch-console/application/console_api/cashflow_performance.js b/web/strategy-switch-console/application/console_api/cashflow_performance.js index 62abf6fa..7972ed17 100644 --- a/web/strategy-switch-console/application/console_api/cashflow_performance.js +++ b/web/strategy-switch-console/application/console_api/cashflow_performance.js @@ -1,4 +1,4 @@ -import { dateMs, sliceRange, alignReturns } from "../../performance_math.js"; +import { cleanPoints, dateMs, sliceRange, alignReturns } from "../../performance_math.js"; import { externalFlowEnabled, instant, normalizeExternalFlow } from "../account_facts/external_flow.js"; import { maxDrawdown } from "./performance.js"; @@ -36,6 +36,10 @@ export function cashflowCurrencyResult(histories, { range, today, failed = [] }) twr_return_percent: null, max_drawdown: { percent: null, peak_date: null, trough_date: null, unavailable_reason: reason }, index: [], points: [], return_kind: "unadjusted_asset_ratio", cashflow_method: "modified_dietz", approximate: true }); if (!normalized.length || failed.length || normalized.some(h => !h.points.length)) return unavailable("snapshot_history_missing"); + if (histories.some(h => { + const valid = h.points.filter(p => Number.isFinite(dateMs(p.date)) && Number.isFinite(p.value)); + return cleanPoints(h.points).length !== new Set(valid.map(p => p.date)).size; + })) return unavailable("snapshot_history_missing"); if (normalized.some(h => h.points.some(p => p.value < 0))) return unavailable("invalid_return_period"); if (common.length < 2) return unavailable("insufficient_observations"); // Omitting another member's real valuation must not manufacture a daily return. @@ -63,7 +67,7 @@ export function cashflowCurrencyResult(histories, { range, today, failed = [] }) // interval. Unpaired legs and unsettled in-transit capital stay unavailable. const transfers = new Map(); for (const r of records.filter(r => r.type.startsWith("transfer_"))) { - if (!r.transfer_ref) return unavailable("external_cashflow_required"); + if (!r.transfer_ref && normalized.length > 1) return unavailable("external_cashflow_required"); const pair = transfers.get(r.transfer_ref) || []; pair.push(r); transfers.set(r.transfer_ref, pair); } From 49bec3cdf9a3858c4ecf32957cb95eb47fdf233a Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 15:03:03 +0000 Subject: [PATCH 3/5] fix(console): preserve date alignment on cashflow failure --- tests/console_external_flow_validation.mjs | 28 +++++++++++++++++++ .../console_api/cashflow_performance.js | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/console_external_flow_validation.mjs b/tests/console_external_flow_validation.mjs index b62ce0e7..7e34a2b4 100644 --- a/tests/console_external_flow_validation.mjs +++ b/tests/console_external_flow_validation.mjs @@ -159,4 +159,32 @@ assert.ok(Math.abs(validCurrency.twr_return_percent - 10) < 1e-10); assert.equal(validCurrency.max_drawdown.percent, 0); assert.deepEqual(mixedBlocks.assets, mixedLegacy.assets); assert.deepEqual(mixedBlocks.calendar, mixedLegacy.calendar); + +// Calculation failures must preserve the existing benchmark date-alignment fact. +// A shared date (or no benchmark) lets the empty curve show the real reason; +// genuinely disjoint benchmark dates must still be reported as such. +for (const [source, reason] of [ + [invalidInput, "invalid_return_period"], + [incompleteInput, "external_cashflow_required"], +]) { + for (const [dates, noCommonDate] of [ + [["2026-01-01", "2026-01-02"], false], + [[], false], + [["2026-01-03"], true], + ]) { + const alignedInput = structuredClone(source); + alignedInput.benchmarks = dates.length + ? [{ id: "synthetic-benchmark", points: dates.map(date => ({ date, value: 100 })) }] : []; + const before = performanceBlocks(alignedInput); + assert.equal(before.returns.no_common_date, noCommonDate, "fixture has the intended date alignment"); + const after = applyCashflowPerformance(structuredClone(before), alignedInput, env); + assert.equal(after.returns.no_common_date, noCommonDate, `${reason}: preserve benchmark date alignment`); + assert.equal(after.returns.unavailable_reason, reason); + assert.equal(after.summary.twr_return_percent, null); + assert.equal(after.summary.max_drawdown.percent, null); + assert.deepEqual(after.returns.points, []); + assert.deepEqual(after.assets, before.assets); + assert.deepEqual(after.calendar, before.calendar); + } +} console.log("console_external_flow_validation: ok (synthetic only)"); diff --git a/web/strategy-switch-console/application/console_api/cashflow_performance.js b/web/strategy-switch-console/application/console_api/cashflow_performance.js index 3f319fa4..ff0d9722 100644 --- a/web/strategy-switch-console/application/console_api/cashflow_performance.js +++ b/web/strategy-switch-console/application/console_api/cashflow_performance.js @@ -120,7 +120,7 @@ export function applyCashflowPerformance(blocks, input, env) { unavailable_reason: r.unavailable_reason }); if (blocks.summary.metric_reasons) blocks.summary.metric_reasons.twr = r.unavailable_reason; Object.assign(blocks.returns, { unavailable_reason: r.unavailable_reason, - points: [], benchmarks: [], aligned_start: null, no_common_date: true }); + points: [], benchmarks: [], aligned_start: null }); return blocks; } const benchmarks = input.benchmarks.map(b => ({ ...b, points: sliceRange(b.points, input.range.toLowerCase(), input.today) })); From c7d8ca08ea000551a4810bf9ae591cd40eada936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=80=9D=E6=87=BF?= Date: Sat, 17 Oct 2026 15:22:36 +0000 Subject: [PATCH 4/5] fix: derive cashflow failure date overlap from observations --- tests/console_external_flow_validation.mjs | 25 +++++++++++++++++++ .../console_api/cashflow_performance.js | 11 +++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/console_external_flow_validation.mjs b/tests/console_external_flow_validation.mjs index 7e34a2b4..6b703a13 100644 --- a/tests/console_external_flow_validation.mjs +++ b/tests/console_external_flow_validation.mjs @@ -187,4 +187,29 @@ for (const [source, reason] of [ assert.deepEqual(after.calendar, before.calendar); } } +// A zero opening NAV empties the legacy return chain even when real observation +// dates intersect. Date facts must survive both kinds of cashflow failure. +for (const reason of ["invalid_return_period", "external_cashflow_required"]) { + const zeroOpeningInput = structuredClone(input); + zeroOpeningInput.histories[0].points[0].value = 0; + zeroOpeningInput.histories[0].points[1].value = 100; + zeroOpeningInput.histories[0].externalFlow.windows[0].records[0].effective_at = end; + if (reason === "external_cashflow_required") { + zeroOpeningInput.histories[0].externalFlow.windows[0].coverage = "incomplete"; + } + zeroOpeningInput.benchmarks = [{ id: "synthetic-benchmark", points: [ + { date: "2026-01-01", value: 100 }, { date: "2026-01-02", value: 100 }, + ] }]; + const before = performanceBlocks(zeroOpeningInput); + const after = applyCashflowPerformance(structuredClone(before), zeroOpeningInput, env); + assert.equal(after.returns.no_common_date, false, `${reason}: zero opening NAV still has shared observation dates`); + assert.equal(after.returns.unavailable_reason, reason); + assert.equal(after.summary.metric_reasons.twr, reason); + assert.equal(after.summary.twr_return_percent, null); + assert.equal(after.summary.max_drawdown.percent, null); + assert.deepEqual(after.returns.points, []); + assert.deepEqual(after.returns.benchmarks, []); + assert.deepEqual(after.assets, before.assets); + assert.deepEqual(after.calendar, before.calendar); +} console.log("console_external_flow_validation: ok (synthetic only)"); diff --git a/web/strategy-switch-console/application/console_api/cashflow_performance.js b/web/strategy-switch-console/application/console_api/cashflow_performance.js index ff0d9722..7d97e34f 100644 --- a/web/strategy-switch-console/application/console_api/cashflow_performance.js +++ b/web/strategy-switch-console/application/console_api/cashflow_performance.js @@ -116,10 +116,19 @@ export function applyCashflowPerformance(blocks, input, env) { const r = results[0]; if (!r) return blocks; if (r.status !== "ok") { + // A failed return chain says nothing about observation-date overlap. Use + // real dates from every fixed member, never the asset union/forward-fill. + const memberDates = input.histories.map(h => new Set(sliceRange(h.points, input.range.toLowerCase(), input.today) + .filter(p => p.forwardFilled !== true && p.partial !== true && !p.forward_filled_accounts?.length) + .map(p => p.date))); + const benchmarkDates = input.benchmarks.map(b => new Set(sliceRange(b.points, input.range.toLowerCase(), input.today) + .map(p => p.date))).filter(dates => dates.size); + const noCommonDate = benchmarkDates.length > 0 && ![...(memberDates[0] || [])].some(date => + memberDates.every(dates => dates.has(date)) && benchmarkDates.every(dates => dates.has(date))); Object.assign(blocks.summary, { twr_return_percent: null, max_drawdown: r.max_drawdown, unavailable_reason: r.unavailable_reason }); if (blocks.summary.metric_reasons) blocks.summary.metric_reasons.twr = r.unavailable_reason; - Object.assign(blocks.returns, { unavailable_reason: r.unavailable_reason, + Object.assign(blocks.returns, { unavailable_reason: r.unavailable_reason, no_common_date: noCommonDate, points: [], benchmarks: [], aligned_start: null }); return blocks; } From 646e4bf1a7d3d360521bf1c33e4a0f852dac0b56 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 15:54:06 +0000 Subject: [PATCH 5/5] Preserve legacy EMPTY date flag compatibility --- tests/console_external_flow_validation.mjs | 107 ++++++++++++++++++ .../console_api/cashflow_performance.js | 6 +- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/tests/console_external_flow_validation.mjs b/tests/console_external_flow_validation.mjs index 6b703a13..391f355e 100644 --- a/tests/console_external_flow_validation.mjs +++ b/tests/console_external_flow_validation.mjs @@ -212,4 +212,111 @@ for (const reason of ["invalid_return_period", "external_cashflow_required"]) { assert.deepEqual(after.assets, before.assets); assert.deepEqual(after.calendar, before.calendar); } +// Root-required direct core compatibility input: the extra empty history member +// does not represent a selected production account or establish route reachability. +const emptyCompatInput = { + "accounts": [ + { + "account_key": "synthetic", + "platform": "schwab" + } + ], + "histories": [ + { + "id": "synthetic", + "currency": "USD", + "externalFlow": { + "schema_version": "external_flow.v1", + "windows": [ + { + "currency": "USD", + "date": "2026-10-17", + "period_start": "2026-10-16T14:06:32.000Z", + "period_end": "2026-10-17T14:06:32.000Z", + "source": "schwab_transactions", + "coverage": "incomplete", + "records": [ + { + "source_ref": "9174db243ce072454686a90cd10869c3e24b417f13e67b9d0b642fdc48ac701f", + "type": "deposit", + "amount": "100", + "effective_at": "2026-10-17T02:06:32.000Z", + "settlement_date": "2026-10-17", + "transfer_ref": null + } + ] + } + ] + }, + "points": [ + { + "date": "2026-10-16", + "observed_at": "2026-10-16T14:06:32.000Z", + "value": 100, + "externalFlow": null + }, + { + "date": "2026-10-17", + "observed_at": "2026-10-17T14:06:32.000Z", + "value": 215, + "externalFlow": null + } + ] + }, + { + "id": "missing", + "currency": "USD", + "points": [] + } + ], + "benchmarks": [ + { + "id": "标普500", + "points": [ + { + "date": "2026-10-16", + "value": 100 + }, + { + "date": "2026-10-17", + "value": 100 + } + ] + } + ], + "range": "ALL", + "today": "2026-10-17", + "year": 2026, + "month": 10, + "failed": [] +}; +const emptyCompatCore = cashflowCurrencyResult(emptyCompatInput.histories, emptyCompatInput); +assert.equal(emptyCompatCore.unavailable_reason, "snapshot_history_missing"); +assert.equal(emptyCompatCore.twr_return_percent, null); +assert.equal(emptyCompatCore.max_drawdown.percent, null); +assert.equal(emptyCompatCore.max_drawdown.unavailable_reason, "snapshot_history_missing"); +assert.deepEqual(emptyCompatCore.points, []); +assert.deepEqual(emptyCompatCore.index, []); +const emptyCompatLegacy = performanceBlocks(emptyCompatInput); +assert.equal(emptyCompatLegacy.returns.no_common_date, false); +const emptyCompatBlocks = applyCashflowPerformance(structuredClone(emptyCompatLegacy), emptyCompatInput, env); +assert.equal(emptyCompatBlocks.returns.no_common_date, false, "root requires historical EMPTY false compatibility"); +assert.equal(emptyCompatBlocks.returns.no_common_date, emptyCompatLegacy.returns.no_common_date); +assert.equal(emptyCompatBlocks.summary.metric_reasons.twr, "snapshot_history_missing"); +assert.equal(emptyCompatBlocks.summary.twr_return_percent, null); +assert.equal(emptyCompatBlocks.summary.max_drawdown.percent, null); +assert.equal(emptyCompatBlocks.summary.max_drawdown.unavailable_reason, "snapshot_history_missing"); +for (const block of [emptyCompatBlocks.summary, emptyCompatBlocks.returns]) { + assert.equal(block.status, "partial"); + assert.equal(block.unavailable_reason, "snapshot_history_missing"); + assert.equal(block.cashflow_per_currency[0].unavailable_reason, "snapshot_history_missing"); + assert.equal(block.cashflow_per_currency[0].twr_return_percent, null); + assert.equal(block.cashflow_per_currency[0].max_drawdown.percent, null); + assert.equal(block.cashflow_per_currency[0].max_drawdown.unavailable_reason, "snapshot_history_missing"); +} +assert.deepEqual(emptyCompatBlocks.returns.points, []); +assert.deepEqual(emptyCompatBlocks.returns.benchmarks, []); +assert.equal(emptyCompatBlocks.returns.aligned_start, null); +assert.deepEqual(emptyCompatBlocks.assets, emptyCompatLegacy.assets); +assert.deepEqual(emptyCompatBlocks.calendar, emptyCompatLegacy.calendar); console.log("console_external_flow_validation: ok (synthetic only)"); diff --git a/web/strategy-switch-console/application/console_api/cashflow_performance.js b/web/strategy-switch-console/application/console_api/cashflow_performance.js index 7d97e34f..7c946efc 100644 --- a/web/strategy-switch-console/application/console_api/cashflow_performance.js +++ b/web/strategy-switch-console/application/console_api/cashflow_performance.js @@ -123,8 +123,10 @@ export function applyCashflowPerformance(blocks, input, env) { .map(p => p.date))); const benchmarkDates = input.benchmarks.map(b => new Set(sliceRange(b.points, input.range.toLowerCase(), input.today) .map(p => p.date))).filter(dates => dates.size); - const noCommonDate = benchmarkDates.length > 0 && ![...(memberDates[0] || [])].some(date => - memberDates.every(dates => dates.has(date)) && benchmarkDates.every(dates => dates.has(date))); + // Preserve the legacy flag when a member has no real dates to compare. + const noCommonDate = memberDates.some(dates => !dates.size) ? blocks.returns.no_common_date + : benchmarkDates.length > 0 && ![...(memberDates[0] || [])].some(date => + memberDates.every(dates => dates.has(date)) && benchmarkDates.every(dates => dates.has(date))); Object.assign(blocks.summary, { twr_return_percent: null, max_drawdown: r.max_drawdown, unavailable_reason: r.unavailable_reason }); if (blocks.summary.metric_reasons) blocks.summary.metric_reasons.twr = r.unavailable_reason;