From 59a1ccb086f911e8ad3dab8cf2d2f7c0d39553fd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:57:09 +0800 Subject: [PATCH 1/2] fix: Qualify account history and execution receipts Co-Authored-By: Codex --- .../action.yml | 36 ++++-- docs/console-api.md | 37 +++++- docs/execution-evidence-runtime-projection.md | 13 ++ .../test_execution_evidence_action_receipt.py | 112 ++++++++++++++++++ ...ount_facts_history_receiver_validation.mjs | 37 ++++++ tests/broker_executions_validation.mjs | 43 ++++++- ...le_binance_current_holdings_validation.mjs | 44 ++++++- tests/console_external_flow_validation.mjs | 45 +++++++ tests/native_daily_returns_validation.mjs | 72 +++++++++-- tests/strategy_switch_worker_validation.mjs | 46 ++++++- web/strategy-switch-console/account_facts.js | 14 +++ .../application/account_facts/executions.js | 64 ++++++++-- .../application/account_facts/read.js | 23 +++- .../console_api/cashflow_performance.js | 2 +- .../application/console_api/executions.js | 5 +- .../console_api/native_daily_returns.js | 27 +++-- .../console_api/performance_routes.js | 24 +++- web/strategy-switch-console/worker.js | 7 ++ 18 files changed, 598 insertions(+), 53 deletions(-) create mode 100644 python/tests/test_execution_evidence_action_receipt.py diff --git a/actions/publish-runtime-execution-evidence/action.yml b/actions/publish-runtime-execution-evidence/action.yml index c7eddbf0..30732cc6 100644 --- a/actions/publish-runtime-execution-evidence/action.yml +++ b/actions/publish-runtime-execution-evidence/action.yml @@ -103,12 +103,34 @@ runs: --output "$snapshot_path" \ "${report_args[@]}" - if ! curl --fail --silent --show-error \ - --request POST "${INPUT_SYNC_URL}/api/internal/sync-execution-evidence-source" \ - --header "Authorization: Bearer ${EXECUTION_EVIDENCE_SYNC_TOKEN}" \ - --header "Content-Type: application/json" \ - --data-binary "@$snapshot_path" \ - >/dev/null 2>&1; then - echo "Unable to sync the read-only execution-evidence snapshot." >&2 + receipt_id="$(python3 -c 'from uuid import uuid4; print(uuid4())')" + receipt_path="$work_dir/execution-evidence-receipt.json" + if ! http_status="$(printf 'Authorization: Bearer %s\n' "$EXECUTION_EVIDENCE_SYNC_TOKEN" | + curl --fail --silent --show-error --max-time 30 --max-filesize 4096 \ + --request POST "${INPUT_SYNC_URL}/api/internal/sync-execution-evidence-source" \ + --header @- \ + --header "Content-Type: application/json" \ + --header "X-QSL-Execution-Evidence-Receipt: $receipt_id" \ + --data-binary "@$snapshot_path" \ + --output "$receipt_path" --write-out '%{http_code}' 2>/dev/null)" || + [[ ! "$http_status" =~ ^2[0-9][0-9]$ ]]; then + echo "Unconfirmed execution-evidence upload. Do not replay this upload." >&2 exit 1 fi + + if ! deployment_count="$(jq -en --slurpfile request "$snapshot_path" \ + --slurpfile response "$receipt_path" --arg receipt_id "$receipt_id" ' + $request[0] as $snapshot + | select(($response | length) == 1) + | $response[0] + | select(.ok == true and .receipt_id == $receipt_id + and .source_id == $snapshot.source_id and .schema_version == $snapshot.schema_version + and .deployment_count == ($snapshot.deployments | length) + and has("generated_at") and .generated_at == $snapshot.generated_at) + | .deployment_count + ' 2>/dev/null)"; then + echo "Unverified execution-evidence receipt. Do not replay this upload." >&2 + exit 1 + fi + printf 'Execution-evidence receipt: http=%s deployments=%s id=%s\n' \ + "$http_status" "$deployment_count" "$receipt_id" diff --git a/docs/console-api.md b/docs/console-api.md index eee6f93b..9b660d14 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -107,12 +107,10 @@ null and do not establish amount availability or cost inclusion. Dietz cells also include `cashflow_method: "modified_dietz"` and an explicit `approximate` boolean, true only when a contributing interval contains a flow before its end. Stock daily groups can include weekend intervals; the boolean -covers those contributing intervals, not the whole return chain. Native daily -replacement sets its own method and removes inherited Dietz flags. Untouched -legacy/unavailable cells have no new metadata. Monthly cells retain their existing -values and shape; no monthly Dietz calculation or metadata is added. These are -additive backend fields; frontend consumers may continue reading the existing -fields, and frontend types and rendering are unchanged by this contract addition. +covers those contributing intervals, not the whole return chain. Calendar cells +replaced by native TWR carry the same method and unit fields at both daily and +monthly granularity and omit Dietz flags. Incomplete native months retain null +values and their coverage reason. Legacy cells retain their existing fields. `limit` defaults to 25 and accepts 1–100. `next_cursor` is an opaque string or `null`. Pass it unchanged as `cursor`. Histories sort newest first. Decision @@ -612,6 +610,20 @@ keep every failure and do not retry failed acceptance into a pass. A timeout sto acceptance/promotion. This guard does not claim global propagation is atomic and does not remove transient platform responses before readiness. +### Historical positions + +Authenticated `GET /api/account-facts/history?platform=…&account_key=…&projection=positions` +returns account-bound daily observations from verified history. Optional `from` and +`to` are inclusive ISO dates. Each observation contains the actual +`observed_finished_at`, `positions`, `positions_scope` and `positions_complete`. +Position entries carry the stored symbol, quantity, market value and currency. +They include `currency_source` when the stored observation specifies one. +A null `positions` means the source did not report positions; an empty array with +`positions_complete: true` is a reported empty inventory within its stated scope. +A null completeness value does not establish a complete inventory. Observation +instants do not assert end-of-day holdings. Only existing observations within the +history retention limit are returned; missing days are not interpolated. + ### External-flow coverage inspection Authenticated `GET /api/account-facts/history?platform=…&account_key=…&projection=external-flow-coverage` @@ -832,3 +844,16 @@ UNRECONCILED dimensions. Render unknown fields without zeros or inferred dates, in zh-CN/en and light/dark. Backend/schema/tests remain owned by the backend maintainer; frontend files, actions, task-list workflows and assets are outside this patch. Runtime collection, deployed adoption and delivery remain NOT_PROVEN. + +### Broker execution report intervals + +Execution coverage remains partial and does not establish a complete trading history. +Each `reports` entry with `period_status=observed_report` identifies a received +report period. Repeated periods are deduplicated, retaining the latest observation. +At most 512 distinct periods per source binding are retained; `reports_truncated` +indicates older intervals were omitted. Existing stored aggregates predate interval +tracking: their `period_status=legacy_aggregate_bounds_unknown` marks bounds only, +not an individual report or proof of coverage within the bounds. New observations +are recorded alongside that legacy marker. If a rollback writes a changed +aggregate without recording intervals, the bounds are marked unknown again +while the previously observed intervals remain visible. diff --git a/docs/execution-evidence-runtime-projection.md b/docs/execution-evidence-runtime-projection.md index 4aafe41e..28a72efd 100644 --- a/docs/execution-evidence-runtime-projection.md +++ b/docs/execution-evidence-runtime-projection.md @@ -55,6 +55,19 @@ configured platform prefix, and POSTs only the generated snapshot to long-lived GCP key. Credentials and runtime-report object URLs must never be added to this repository or emitted to logs. +The Action requests a receipt with a fresh, opaque +`X-QSL-Execution-Evidence-Receipt` identifier. The receiver echoes it as +`receipt_id` after saving the snapshot and includes it in the existing +best-effort audit entry. The Action validates the HTTP status and matches the +identifier, source, schema, deployment count, and report timestamp against the +upload. It logs only the status, count, and opaque identifier. A zero count +means an empty projection. An unverified or missing receipt leaves the upload +outcome uncertain. The Action does not retry that upload. + +Deploy the compatible receiver before changing a caller to the new Action +version. Requests without the receipt header keep the previous response and +audit format. The audit remains bounded and can omit a successful upload. + ## 生产端接入边界 执行回执是一个通用接口,但不允许平台为了“看起来完整”而猜测成交。当前接入的 diff --git a/python/tests/test_execution_evidence_action_receipt.py b/python/tests/test_execution_evidence_action_receipt.py new file mode 100644 index 00000000..ff7200d1 --- /dev/null +++ b/python/tests/test_execution_evidence_action_receipt.py @@ -0,0 +1,112 @@ +"""Exercise projection and receipt handling in the composite sender.""" + +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +ACTION = ROOT / "actions/publish-runtime-execution-evidence" + +CURL_FIXTURE = r""" +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +mode = os.environ["TEST_RESPONSE"] +# The former action discarded its body and did not ask curl for the status. +if "--output" not in args: + sys.exit(0) +assert "synthetic-bearer" not in " ".join(args) +header_args = [args[i + 1] for i, value in enumerate(args) if value == "--header"] +assert "@-" in header_args +header_lines = [line for line in header_args if line != "@-"] + sys.stdin.read().splitlines() +headers = dict(line.split(": ", 1) for line in header_lines) +assert headers["Authorization"] == "Bearer synthetic-bearer" +snapshot = json.loads(Path(args[args.index("--data-binary") + 1][1:]).read_text()) +receipt = { + "ok": True, + "source_id": snapshot["source_id"], + "schema_version": snapshot["schema_version"], + "deployment_count": len(snapshot["deployments"]), + "generated_at": snapshot["generated_at"], + "receipt_id": headers["X-QSL-Execution-Evidence-Receipt"], + "ignored": "synthetic-untrusted-body", +} +if mode == "wrong-id": + receipt["receipt_id"] = "other" +if mode == "wrong-source": + receipt["source_id"] = "other" +if mode == "wrong-schema": + receipt["schema_version"] = "other" +if mode == "wrong-count": + receipt["deployment_count"] = 1 +if mode == "wrong-time": + receipt["generated_at"] = "2020-01-01T00:00:00Z" +if mode == "missing-time": + del receipt["generated_at"] +if mode == "wrong-ok": + receipt["ok"] = False +body = json.dumps(receipt) +if mode == "html": + body = "synthetic-untrusted-body" +if mode == "multiple": + body += "\n" + body +Path(args[args.index("--output") + 1]).write_text(body) +print("302" if mode == "redirect" else "200", end="") +if mode == "transport-failure": + sys.exit(7) +""" + + +class ExecutionEvidenceActionReceiptTest(unittest.TestCase): + def run_sender(self, response): + action = (ACTION / "action.yml").read_text() + # Skip cloud enumeration and run the shipped projection and transport. + projection = action.split(" snapshot_path=", 1)[1] + script = ( + 'set -euo pipefail\nwork_dir="$TEST_WORK_DIR"\nreport_args=(--runtime-report "$work_dir/fixture.json")\n' + 'snapshot_path=' + projection + ) + with tempfile.TemporaryDirectory() as directory: + executable_dir = Path(directory) + (executable_dir / "fixture.json").write_text("{}") + path = executable_dir / "curl" + path.write_text(f"#!{sys.executable}\n" + CURL_FIXTURE) + path.chmod(0o700) + env = dict( + os.environ, + PATH=f"{executable_dir}{os.pathsep}{os.environ['PATH']}", + GITHUB_ACTION_PATH=str(ACTION), + INPUT_SOURCE_ID="synthetic-source", + INPUT_SYNC_URL="https://synthetic.example", + INPUT_MAX_REPORT_AGE_HOURS="24", + EXECUTION_EVIDENCE_SYNC_TOKEN="synthetic-bearer", + TEST_RESPONSE=response, + TEST_WORK_DIR=directory, + ) + return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + + def test_emits_only_bound_receipt_metadata(self): + result = self.run_sender("ok") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(re.fullmatch( + r"Execution-evidence receipt: http=200 deployments=0 id=[0-9a-f-]{36}\n", + result.stdout, + )) + self.assertEqual(result.stderr, "") + + def test_rejects_redirects_and_unbound_receipts_without_exposing_response(self): + for mode in ("redirect", "html", "multiple", "wrong-id", "wrong-source", "wrong-schema", + "wrong-count", "wrong-time", "missing-time", "wrong-ok", "transport-failure"): + with self.subTest(mode=mode): + result = self.run_sender(mode) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertIn("execution-evidence", result.stderr) + self.assertNotIn("synthetic", result.stderr) diff --git a/tests/account_facts_history_receiver_validation.mjs b/tests/account_facts_history_receiver_validation.mjs index e08dc90c..a580c72d 100644 --- a/tests/account_facts_history_receiver_validation.mjs +++ b/tests/account_facts_history_receiver_validation.mjs @@ -204,6 +204,43 @@ try { hkd.broker_reported_balances[0].currency = "HKD"; hkd.cash[0].currency = "HKD"; assert.equal((await post(first, hkd)).status, 200); + // Positions are observations at the stored instant, not end-of-day snapshots. + const schwab = fixtures.find(f => f.platform === "schwab"); + const partial = { ...payload(schwab, 4, currentId), broker_reported_positions_scope: "strategy_symbols_only", + broker_reported_positions: [ + { symbol: "ABC", quantity: "2", market_value: "20", currency: "USD", avg_cost: "8" }, + { symbol: "XYZ", quantity: "3", market_value: "30", currency: "USD", currency_source: "owner_confirmed" }, + ] }; + const empty = { ...payload(schwab, 5, currentId), broker_reported_positions_scope: "stocks_only", + broker_reported_positions_complete: true, broker_reported_positions: [] }; + assert.equal((await post(schwab, partial)).status, 200); + assert.equal((await post(schwab, empty)).status, 200); + const positionsUrl = `https://synthetic.example/api/account-facts/history?platform=schwab&account_key=${schwab.option.key}&projection=positions`; + assert.equal((await worker.fetch(new Request(positionsUrl), env)).status, 401); + response = await worker.fetch(new Request(positionsUrl, { headers }), env); + const observations = (await response.json()).observations; + assert.deepEqual(observations.find(row => row.observation_date === partial.observation_date), { + observation_date: partial.observation_date, observed_finished_at: partial.observed_finished_at, + positions: [ + { symbol: "ABC", quantity: "2", market_value: "20", currency: "USD" }, + { symbol: "XYZ", quantity: "3", market_value: "30", currency: "USD", currency_source: "owner_confirmed" }, + ], + positions_scope: "strategy_symbols_only", positions_complete: null }); + assert.deepEqual(observations.find(row => row.observation_date === empty.observation_date), { + observation_date: empty.observation_date, observed_finished_at: empty.observed_finished_at, + positions: [], positions_scope: "stocks_only", positions_complete: true }); + assert.equal(observations.find(row => row.observation_date === dateAgo(10)).positions, null); + response = await worker.fetch(new Request(`${positionsUrl}&from=${partial.observation_date}&to=${partial.observation_date}`, { headers }), env); + assert.deepEqual((await response.json()).observations.map(row => row.observation_date), [partial.observation_date]); + assert.equal((await worker.fetch(new Request(`${positionsUrl}&from=2026-02-30`, { headers }), env)).status, 400); + assert.equal((await worker.fetch(new Request(`${positionsUrl}&from=${partial.observation_date}&to=${empty.observation_date}`, { headers }), env)).status, 400); + const otherUrl = `https://synthetic.example/api/account-facts/history?platform=longbridge&account_key=${first.option.key}&projection=positions`; + response = await worker.fetch(new Request(otherUrl, { headers }), env); + assert.ok((await response.json()).observations.every(row => row.positions === null)); + await kv.put("account_options", JSON.stringify({ schwab: [{ ...schwab.option, account_scope: "paper" }] })); + assert.equal((await worker.fetch(new Request(positionsUrl, { headers }), env)).status, 409); + await kv.put("account_options", JSON.stringify(Object.fromEntries(fixtures.map(f => [f.platform, [f.option]])))); + const currenciesUrl = `https://synthetic.example/api/account-facts/history?platform=longbridge&account_key=${first.option.key}&projection=currencies`; response = await worker.fetch(new Request(currenciesUrl), env); assert.equal(response.status, 401, "currency enumeration retains session authentication"); diff --git a/tests/broker_executions_validation.mjs b/tests/broker_executions_validation.mjs index 2cee399d..44584991 100644 --- a/tests/broker_executions_validation.mjs +++ b/tests/broker_executions_validation.mjs @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; +import { DatabaseSync } from "node:sqlite"; import { fileURLToPath } from "node:url"; import worker, { __test } from "../web/strategy-switch-console/worker.js"; -import { normalizeExecutions, mergeExecutions } from "../web/strategy-switch-console/application/account_facts/executions.js"; +import { initializeExecutions, normalizeExecutions, mergeExecutions, putExecutions, readExecutions } from "../web/strategy-switch-console/application/account_facts/executions.js"; import { executionStatsResponse } from "../web/strategy-switch-console/application/console_api/executions.js"; const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); const { Miniflare } = require(process.env.QRT_MINIFLARE_MODULE || "miniflare"); @@ -31,6 +32,37 @@ assert.throws(() => mergeExecutions(payload, { ...payload, records: [{ ...record const unknownFee = { ...payload, records: [{ ...record, commission: null, commission_currency: null }] }; assert.equal(mergeExecutions(unknownFee, payload).records[0].commission, record.commission, "known fee may complete an unknown field"); assert.equal(mergeExecutions(payload, unknownFee).records[0].commission_currency, "EUR", "unknown cannot erase a known fee currency"); +// Old rows only retain aggregate bounds; they are not evidence of individual reports. +const legacyContext = { ...binding, source_binding_id: binding.source_binding.id }; +const legacySql = { exec: query => ({ toArray: () => query.includes("account_execution_reports") ? [] : [{ source_binding_id: binding.source_binding.id, + context_json: JSON.stringify([binding.platform, binding.target_id, binding.account_scope, + binding.account_selector, null, null]), payload_json: JSON.stringify(payload) }] }) }; +const legacy = readExecutions(legacySql, legacyContext); +assert.equal(legacy.reports[0].period_status, "legacy_aggregate_bounds_unknown"); +assert.deepEqual(legacy.reports[0].period, payload.period); + +// A version rollback uses the original payload table without interval metadata. +const db = new DatabaseSync(":memory:"); +try { + const sql = { exec(query, ...params) { + if (query.startsWith("SELECT")) return { toArray: () => db.prepare(query).all(...params) }; + if (params.length) db.prepare(query).run(...params); + else db.exec(query); + } }; + initializeExecutions(sql); + putExecutions(sql, legacyContext, payload); + const rollbackPeriod = { from: "2026-10-25", to: "2026-10-25" }; + const rollbackPayload = mergeExecutions(payload, { ...payload, period: rollbackPeriod, records: [] }); + db.prepare("UPDATE account_executions SET payload_json = ?").run(JSON.stringify(rollbackPayload)); + let reports = readExecutions(sql, legacyContext).reports; + assert.deepEqual(reports.map(r => [r.period_status, r.period]), [ + ["legacy_aggregate_bounds_unknown", rollbackPayload.period], ["observed_report", payload.period], + ]); + const recoveredPeriod = { from: "2026-10-27", to: "2026-10-27" }; + putExecutions(sql, legacyContext, { ...payload, period: recoveredPeriod, records: [] }); + reports = readExecutions(sql, legacyContext).reports; + assert.deepEqual(reports.map(r => r.period), [rollbackPayload.period, payload.period, recoveredPeriod]); +} finally { db.close(); } const rangeSource = { coverage: "reported_executions", reports: [{ observed_at: payload.observed_at, period: payload.period }], records: [record, { ...record, execution_ref: "e".repeat(64), trade_date: "2026-08-01", local_time: null }] }; const rangeDeps = { executions: async () => rangeSource, json: body => body }; @@ -79,6 +111,15 @@ try { assert.ok(!JSON.stringify(body).includes(option.account_selector), "native account selector is not returned"); assert.equal((await post({ ...payload, records: [{ ...record, price: "101" }] })).status, 409); assert.equal((await (await get()).json()).rows[0].price, "100.25", "conflicting data cannot overwrite executions"); + const firstPeriod = { ...payload, period: { from: "2026-10-25", to: "2026-10-25" }, records: [] }; + const secondPeriod = { ...payload, period: { from: "2026-10-27", to: "2026-10-27" }, records: [] }; + assert.equal((await post(firstPeriod)).status, 200); + assert.equal((await post(secondPeriod)).status, 200); + assert.equal((await (await post(secondPeriod)).json()).unchanged, true); + const intervals = (await (await get()).json()).coverage[0].reports; + assert.deepEqual(intervals.map(report => report.period), [payload.period, firstPeriod.period, secondPeriod.period]); + assert.ok(intervals.every(report => report.period_status === "observed_report")); + assert.equal((await (await get()).json()).coverage[0].reports_truncated, false); await setBinding({ ...binding, source_binding: { ...binding.source_binding, id: "d".repeat(64) } }); assert.equal((await (await get()).json()).status, "not_connected", "rotated unknown source cannot inherit private records"); await setBinding({ ...binding, source_binding: { ...binding.source_binding, id: "d".repeat(64) }, history_source_binding_ids: [binding.source_binding.id] }); diff --git a/tests/console_binance_current_holdings_validation.mjs b/tests/console_binance_current_holdings_validation.mjs index 4f14521d..916a9c4b 100644 --- a/tests/console_binance_current_holdings_validation.mjs +++ b/tests/console_binance_current_holdings_validation.mjs @@ -71,4 +71,46 @@ assert.equal(result.body.rows.length, 1); assert.equal(result.body.rows[0].symbol, "SYNTHETIC"); assert.equal(result.body.status, "partial"); assert.equal(result.body.coverage.find(x => x.platform === "binance").unavailable_reason, "stored_records_unreadable"); -console.log("console_binance_current_holdings_validation: PASS (native quantities only; no wallet valuation or invented PnL)"); + +// A common broker-facts failure must not hide the separate Binance source. +for (const brokenFacts of [ + async () => Response.json({ error: "private broker failure" }, { status: 503 }), + async () => { throw Error("private broker failure"); }, +]) { + const mixedDeps = { + ...deps, + accountOptions: async () => ({ options: { binance: [{ key, broker_environment: "live" }], + ibkr: [{ key: ibkr.account_key, broker_environment: "live" }] } }), + facts: brokenFacts, + }; + const mixedRequest = new Request("https://synthetic.test/api/performance/holdings?scope=all"); + const response = await performanceResponse(mixedRequest, {}, mixedDeps); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.status, "partial"); + validate("holdings", body); + assert.equal(body.rows.find(row => row.symbol === "BTC")?.quantity, 0.25); + assert.equal(body.coverage.find(row => row.platform === "ibkr")?.unavailable_reason, "stored_records_unreadable"); + assert.equal(body.coverage.find(row => row.platform === "binance")?.scope, "spot+flexible_earn"); + assert.ok(!JSON.stringify(body).includes("private broker failure")); + + const bothFailed = await performanceResponse(mixedRequest, {}, { + ...mixedDeps, binanceCurrent: async () => { throw Error("private Binance failure"); }, + }); + assert.equal(bothFailed.status, 503); + assert.equal((await bothFailed.json()).status, "unavailable"); + const missingBinance = await performanceResponse(mixedRequest, {}, { + ...mixedDeps, binanceCurrent: async () => Response.json({ report_status: "missing", report: null }), + }); + assert.equal(missingBinance.status, 503, "a missing Binance report is not a known empty position"); + + const brokerOnly = await performanceResponse( + new Request("https://synthetic.test/api/performance/holdings?scope=synthetic-equity&platform=ibkr"), {}, mixedDeps, + ); + assert.equal(brokerOnly.status, 503, "a broker-only request cannot fall back to another account"); + const unauthorized = await performanceResponse(mixedRequest, {}, { + ...mixedDeps, readSession: async () => ({ allowed: false }), + }); + assert.equal(unauthorized.status, 401); +} +console.log("console_binance_current_holdings_validation: PASS (independent sources, scope and partial coverage)"); diff --git a/tests/console_external_flow_validation.mjs b/tests/console_external_flow_validation.mjs index 628a1101..291aaeab 100644 --- a/tests/console_external_flow_validation.mjs +++ b/tests/console_external_flow_validation.mjs @@ -401,3 +401,48 @@ assert.equal(offlineStored.read_status,'stored');assert.equal(offlineStored.prec assert.equal(offlineStored.record_count,1,'v1 remains inspectable while calculation is off'); assert.doesNotMatch(JSON.stringify(offlineStored),/123\.45|source_ref|transfer_ref|[abc]{64}/); assert.equal(coverage({schema_version:'external_flow.v2',windows:[dated]}).read_status,'read_error','wrong platform source is invalid'); + +// Equal UTC instants with different valid fractional spellings must pair. +const pairStart = "2026-10-26T12:00:00.000Z", pairEnd = "2026-10-27T12:00:00.000Z"; +const pairFlow = (type, amount, effective_at, ref) => normalizeExternalFlow({ schema_version: "external_flow.v1", windows: [{ + currency: "USD", date: "2026-10-27", period_start: pairStart, period_end: pairEnd, + source: "longbridge_cashflow", coverage: "complete", records: [{ source_ref: ref.repeat(64), type, amount, + effective_at, settlement_date: null, transfer_ref: "c".repeat(64) }], +}] }); +const { performanceResponse } = await import("../web/strategy-switch-console/application/console_api/performance_routes.js"); +async function transferSummary(incomingTime, other = {}) { + const members = [ + { key: "synthetic-a", begin: 100, end: 50, flow: pairFlow("transfer_out", "-50", "2026-10-27T00:00:00.000Z", "a") }, + { key: "synthetic-b", begin: 100, end: 150, flow: pairFlow("transfer_in", "50", incomingTime, "b") }, + ]; + if (other.amount) members[1].flow.windows[0].records[0].amount = other.amount; + if (other.ref) members[1].flow.windows[0].records[0].transfer_ref = other.ref.repeat(64); + if (other.type) members[1].flow.windows[0].records[0].type = other.type; + const deps = { + json: (body, status = 200) => Response.json(body, { status }), + readSession: async () => ({ allowed: true }), + accountOptions: async () => ({ options: { longbridge: members.map(m => ({ key: m.key, broker_environment: "live" })) } }), + facts: async () => Response.json({ accounts: members.map(m => ({ platform: "longbridge", account_key: m.key, + binding_status: "bound", aggregation_status: "included", broker_environment: "live", balances: [{ currency: "USD", net_assets: String(m.end) }] })) }), + benchmarks: async () => Response.json({ points: [] }), + history: async request => { + const m = members.find(member => member.key === new URL(request.url).searchParams.get("account_key")); + return Response.json({ platform: "longbridge", account_key: m.key, currencies: ["USD"], externalFlow: m.flow, + series: { currency: "USD", points: [ + { currency: "USD", observation_date: "2026-10-26", net_assets: String(m.begin), observed_finished_at: pairStart }, + { currency: "USD", observation_date: "2026-10-27", net_assets: String(m.end), observed_finished_at: pairEnd }, + ] } }); + }, + }; + const response = await performanceResponse(new Request("https://synthetic.invalid/api/performance/summary?scope=all&range=ALL"), env, deps); + assert.equal(response.status, 200); + return response.json(); +} +const equivalentPair = await transferSummary("2026-10-27T00:00:00Z"); +assert.equal(equivalentPair.twr_return_percent, 0, "same instant and equal opposite transfer leaves cohort return at zero"); +for (const body of [await transferSummary("2026-10-27T00:00:00.001Z"), + await transferSummary("2026-10-27T00:00:00Z", { amount: "49" }), + await transferSummary("2026-10-27T00:00:00Z", { ref: "d" }), + await transferSummary("2026-10-27T00:00:00Z", { type: "deposit" })]) { + assert.equal(body.twr_return_percent, null, "different instant, amount, direction, or transfer reference stays unknown"); +} diff --git a/tests/native_daily_returns_validation.mjs b/tests/native_daily_returns_validation.mjs index a04767c2..2e6a18c6 100644 --- a/tests/native_daily_returns_validation.mjs +++ b/tests/native_daily_returns_validation.mjs @@ -134,8 +134,19 @@ assert.equal(missingMoneyBlocks.calendar.cells.find(c=>c.date==='2026-10-23').am assert.equal(applyNativeDailyReturns(performanceBlocks({...moneyInput,range:'1M'}),{...moneyInput,range:'1M'}).summary.amount,null,'incomplete range does not claim a full monetary result'); const yearMoneyInput={...yearInput,accounts:[{...input.accounts[0],return:{daily:[ moneyRow('2026-10-01','0.1','10'),moneyRow('2026-10-02','-0.05','-5'),moneyRow('2026-10-05','0.02','2')]}}]}; -const yearMoney=applyNativeDailyReturns(performanceBlocks(yearMoneyInput),yearMoneyInput).calendar; -assert.equal(yearMoney.cells.find(c=>c.month==='2026-10').amount,7); +const yearMoneyBase=performanceBlocks(yearMoneyInput); +Object.assign(yearMoneyBase.calendar.cells.find(c=>c.month==='2026-10'), { + return_method:'modified_dietz',cashflow_method:'modified_dietz',approximate:true, +}); +const yearMoney=applyNativeDailyReturns(yearMoneyBase,yearMoneyInput).calendar; +const octoberMoney=yearMoney.cells.find(c=>c.month==='2026-10'); +assert.equal(octoberMoney.amount,7); +assert.ok(Math.abs(octoberMoney.return_percent-6.59)<1e-9); +assert.equal(octoberMoney.return_method,'native_ibkr_twr'); +assert.equal(octoberMoney.return_unit,'percent'); +assert.equal(octoberMoney.amount_unit,'native_currency'); +assert.equal(octoberMoney.cashflow_method,undefined); +assert.equal(octoberMoney.approximate,undefined); assert.equal(yearMoney.cells.find(c=>c.month==='2026-11').amount,null); console.log('native NAV money: exact sums, missing money, deposits, independent TWR and calendar PASS'); @@ -340,7 +351,8 @@ const originalFetch=globalThis.fetch; try { Date.now=()=>Date.parse('2026-10-02T16:00:00Z'); globalThis.fetch=async()=>{throw new Error('Unexpected network request in synthetic regression');}; - const readRoute=async(endpoint,daily=routeDaily,enabled=true)=>{ + const readRoute=async(endpoint,daily=routeDaily,enabled=true,query='range=ALL&year=2026&month=10', + {points=routePoints, flows=routeFlows}={})=>{ const account={platform:'ibkr',account_key:'synthetic-account',binding_status:'bound', observed_finished_at:routeEnd2,observation_date:'2026-10-02', balances:[{currency:'USD',net_assets:'143'}],positions_complete:true,positions:[], @@ -351,13 +363,13 @@ try { accountOptions:async()=>({options:{ibkr:[{key:'synthetic-account'}]}}), facts:async()=>Response.json({accounts:[account]}), history:async()=>Response.json({platform:'ibkr',account_key:'synthetic-account', - currencies:['USD'],externalFlow:routeFlows,series:{currency:'USD', - points:routePoints.map(point=>({currency:'USD',observation_date:point.date, + currencies:['USD'],externalFlow:flows,series:{currency:'USD', + points:points.map(point=>({currency:'USD',observation_date:point.date, net_assets:String(point.value),observed_finished_at:point.observed_at}))}}), benchmarks:async()=>Response.json({points:[]}), }; const response=await performanceResponse(new Request( - `https://fake.invalid/api/performance/${endpoint}?range=ALL&year=2026&month=10`), + `https://fake.invalid/api/performance/${endpoint}?${query}`), enabled?{CONSOLE_EXTERNAL_FLOW_ENABLED:'true'}:{},deps); assert.equal(response.status,200); return response.json(); @@ -417,8 +429,54 @@ try { assert.equal(gapCurve.points.length,2); gapCurve.points.forEach((point,i)=>assert.ok(Math.abs(point.daily_return_percent-[7,-2][i])<1e-10)); assertNoDietz(gapSummary);assertNoDietz(gapCurve); + + Date.now=()=>Date.parse('2026-11-05T12:00:00Z'); + const octoberRows=[]; + for(let day=1;day<=31;day++){ + const date=new Date(Date.UTC(2026,9,day)); + if(![0,6].includes(date.getUTCDay())){ + octoberRows.push(row(date.toISOString().slice(0,10),day===1?'0.07':'0')); + } + } + const fullYear=await readRoute('calendar',octoberRows,true,'range=ALL&year=2026'); + const shortRangeYear=await readRoute('calendar',octoberRows,true,'range=1M&year=2026'); + const octoberCell=fullYear.cells.find(cell=>cell.month==='2026-10'); + assert.ok(Math.abs(octoberCell.return_percent-7)<1e-10); + assert.equal(octoberCell.return_method,'native_ibkr_twr'); + assertNoDietz(octoberCell); + assert.equal(shortRangeYear.range,'1M','keep the requested range in the response envelope'); + assert.deepEqual(shortRangeYear.cells,fullYear.cells,'annual cells must not be clipped by the chart range'); + const shortRangeMonth=await readRoute('calendar',octoberRows,true,'range=1M&year=2026&month=10'); + assert.ok(Math.abs(shortRangeMonth.cells.find(cell=>cell.date==='2026-10-01').return_percent-7)<1e-10); + const missingSession=octoberRows.filter(item=>item.period.to!=='2026-10-05'); + const incompleteYear=await readRoute('calendar',missingSession,true,'range=1M&year=2026'); + const incompleteOctober=incompleteYear.cells.find(cell=>cell.month==='2026-10'); + assert.equal(incompleteOctober.return_percent,null); + assert.equal(incompleteOctober.unavailable_reason,'incomplete_month'); + assert.equal(incompleteOctober.return_method,'native_ibkr_twr'); + const points=[ + {date:'2026-09-01',value:100,observed_at:'2026-09-01T12:00:00.000Z'}, + {date:'2026-10-05',value:110,observed_at:'2026-10-05T12:00:00.000Z'}, + {date:'2026-10-06',value:121,observed_at:'2026-10-06T12:00:00.000Z'}, + ]; + const flows={schema_version:'external_flow.v1',windows:[{ + currency:'USD',date:points[2].date,period_start:points[1].observed_at, + period_end:points[2].observed_at,source:'ibkr_flex',coverage:'complete',records:[], + }]}; + const dietzOctober=await readRoute('calendar',null,true,'range=1M&year=2026&month=10',{points,flows}); + const dietzCell=dietzOctober.cells.find(cell=>cell.date==='2026-10-06'); + assert.equal(dietzCell.return_method,'modified_dietz','older missing flows cannot disqualify the covered interval'); + assert.ok(Math.abs(dietzCell.return_percent-10)<1e-10); + + const septemberGap=[row('2026-09-01','0'),row('2026-09-03','0')]; + const boundedMonth=await readRoute('calendar',[...septemberGap,...octoberRows],true,'range=1M&year=2026&month=10'); + assert.equal(boundedMonth.coverage.period_from,'2026-10-01'); + assert.equal(boundedMonth.coverage.range_complete,true); + assert.equal(boundedMonth.unavailable_reason,null); + const shortRangeSummary=await readRoute('summary',octoberRows,true,'range=1M&year=2026'); + assert.equal(shortRangeSummary.coverage.period_from,'2026-10-05','summary still obeys the chart range'); } finally { Date.now=originalNow; globalThis.fetch=originalFetch; } -console.log('native_daily_returns_serialized_metadata: PASS (native over Dietz, fallback, gaps, provenance, no invented P/L)'); +console.log('native_daily_returns_serialized_metadata: PASS (native over Dietz, fallback, gaps, provenance, calendar range independence)'); diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 2ceb6597..3b1cb9da 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -3236,7 +3236,51 @@ const executionEvidenceSync = await worker.fetch( executionEvidenceEnv, ); assert.equal(executionEvidenceSync.status, 200); -assert.equal((await executionEvidenceSync.json()).deployment_count, 1); +const legacyExecutionEvidenceAck = await executionEvidenceSync.json(); +assert.equal(legacyExecutionEvidenceAck.deployment_count, 1); +assert.equal(Object.hasOwn(legacyExecutionEvidenceAck, "receipt_id"), false); + +const syntheticExecutionEvidenceReceiptId = "00000000-0000-4000-8000-000000000001"; +const sendExecutionEvidenceWithReceipt = (receiptId, env = executionEvidenceEnv) => worker.fetch( + new Request("https://switch.example/api/internal/sync-execution-evidence-source", { + method: "POST", + headers: { + Authorization: `Bearer ${executionEvidenceSyncValue}`, + "Content-Type": "application/json", + "X-QSL-Execution-Evidence-Receipt": receiptId, + }, + body: JSON.stringify(executionEvidenceSourcePayload), + }), + env, +); +const beforeMalformedReceipt = controlStore.get("audit_log"); +assert.equal((await sendExecutionEvidenceWithReceipt("not-a-receipt-id")).status, 400); +assert.equal(controlStore.get("audit_log"), beforeMalformedReceipt); + +const receivedExecutionEvidence = await sendExecutionEvidenceWithReceipt(syntheticExecutionEvidenceReceiptId); +assert.equal(receivedExecutionEvidence.status, 200); +assert.deepEqual(await receivedExecutionEvidence.json(), { + ...legacyExecutionEvidenceAck, + receipt_id: syntheticExecutionEvidenceReceiptId, +}); +const latestExecutionEvidenceAudit = JSON.parse(controlStore.get("audit_log"))[0]; +assert.equal(latestExecutionEvidenceAudit.action, "sync_execution_evidence_source"); +assert.equal(latestExecutionEvidenceAudit.receipt_id, syntheticExecutionEvidenceReceiptId); + +const originalStore = executionEvidenceEnv.STRATEGY_SWITCH_CONFIG; +const failedExecutionEvidenceWrite = await sendExecutionEvidenceWithReceipt( + syntheticExecutionEvidenceReceiptId, + { + ...executionEvidenceEnv, + STRATEGY_SWITCH_CONFIG: { + ...originalStore, + async put() { throw Error("synthetic write failure"); }, + }, + }, +); +assert.equal(failedExecutionEvidenceWrite.ok, false); +assert.equal(Object.hasOwn(await failedExecutionEvidenceWrite.json(), "receipt_id"), false); + const executionEvidenceRead = await worker.fetch( new Request("https://switch.example/api/execution-evidence", { headers: executionEvidenceCookieHeaders }), executionEvidenceEnv, diff --git a/web/strategy-switch-console/account_facts.js b/web/strategy-switch-console/account_facts.js index 8193d15c..af48e1b6 100644 --- a/web/strategy-switch-console/account_facts.js +++ b/web/strategy-switch-console/account_facts.js @@ -1179,6 +1179,20 @@ function historyCurrenciesFromQualified(qualified, maxDays) { .map(balance => balance.currency)))].sort(); } +export function projectAccountFactsHistoryPositions({ days = [], binding, from = null, to = null } = {}) { + const qualified = qualifiedHistoryDays(days, binding).slice(-ACCOUNT_FACTS_HISTORY_MAX_DAYS); + return qualified.filter(({ observation_date: date }) => (!from || date >= from) && (!to || date <= to)) + .map(({ observation_date, payload }) => ({ + observation_date, + observed_finished_at: payload.observed_finished_at, + positions: payload.broker_reported_positions?.map(({ symbol, quantity, market_value, currency, currency_source }) => ({ + symbol, quantity, market_value, currency, ...(currency_source ? { currency_source } : {}), + })) ?? null, + positions_scope: payload.broker_reported_positions_scope ?? null, + positions_complete: payload.broker_reported_positions_complete === true ? true : null, + })); +} + export function projectAccountFactsHistoryCurrencies({ days = [], binding = null, maxDays = ACCOUNT_FACTS_HISTORY_MAX_DAYS } = {}) { if (!binding) return []; return historyCurrenciesFromQualified(qualifiedHistoryDays(days, binding), maxDays); diff --git a/web/strategy-switch-console/application/account_facts/executions.js b/web/strategy-switch-console/application/account_facts/executions.js index e5ff01c1..3b33b76e 100644 --- a/web/strategy-switch-console/application/account_facts/executions.js +++ b/web/strategy-switch-console/application/account_facts/executions.js @@ -97,10 +97,42 @@ export function mergeExecutions(prior, incoming) { }, records: [...rows.values()] }, { enforceFresh: false, maxRecords: 2048, maxPeriodDays: 36500 }); } +// The payload retains the existing merged record contract. Report metadata is +// stored separately so disjoint input periods do not become a single report. +const MAX_REPORT_PERIODS = 512; +function unpackStored(payload, metadata) { + const saved = metadata ? JSON.parse(metadata) : { reports: [], reports_truncated: false }; + if (payload && (!metadata || saved.untracked_payload_write)) { + saved.legacy_aggregate = { period: payload.period, observed_at: payload.observed_at, + coverage: payload.coverage, period_status: "legacy_aggregate_bounds_unknown" }; + } + return saved; +} +function addReport(stored, incoming) { + const report = { period: incoming.period, observed_at: incoming.observed_at, + coverage: incoming.coverage, period_status: "observed_report" }; + const key = value => `${value.period.from}/${value.period.to}`; + const reports = stored.reports.filter(value => key(value) !== key(report)); + const previous = stored.reports.find(value => key(value) === key(report)); + reports.push(previous && Date.parse(previous.observed_at) > Date.parse(report.observed_at) ? previous : report); + reports.sort((a, b) => a.period.from.localeCompare(b.period.from) || a.period.to.localeCompare(b.period.to)); + const truncated = stored.reports_truncated || reports.length > MAX_REPORT_PERIODS; + return { reports: reports.slice(-MAX_REPORT_PERIODS), reports_truncated: truncated }; +} + const identity = context => stable([context.platform, context.target_id, context.account_scope, context.account_selector, context.broker_account_hash ?? null, context.broker_account_id ?? null]); export function initializeExecutions(sql) { sql.exec("CREATE TABLE IF NOT EXISTS account_executions (platform TEXT NOT NULL, account_key TEXT NOT NULL, source_binding_id TEXT NOT NULL, context_json TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY(platform, account_key, source_binding_id))"); + sql.exec("CREATE TABLE IF NOT EXISTS account_execution_reports (platform TEXT NOT NULL, account_key TEXT NOT NULL, source_binding_id TEXT NOT NULL, context_json TEXT NOT NULL, metadata_json TEXT NOT NULL, PRIMARY KEY(platform, account_key, source_binding_id))"); + // Current writes replace this marker in the same account-facts transaction. + // A rollback to a payload-only writer leaves the coverage gap visible. + sql.exec(`CREATE TRIGGER IF NOT EXISTS mark_untracked_execution_payload + AFTER UPDATE OF payload_json ON account_executions WHEN NEW.payload_json <> OLD.payload_json + BEGIN + UPDATE account_execution_reports SET metadata_json = json_set(metadata_json, '$.untracked_payload_write', json('true')) + WHERE platform = NEW.platform AND account_key = NEW.account_key AND source_binding_id = NEW.source_binding_id; + END`); } export function readExecutions(sql, context) { const allowed = [context.source_binding_id, ...(context.history_source_binding_ids || [])]; @@ -109,16 +141,25 @@ export function readExecutions(sql, context) { context.platform, context.account_key, ...allowed).toArray(); const records = new Map(); const reports = []; + let reportsTruncated = false, asOf = null; for (const stored of rows) { if (stored.context_json !== identity(context)) continue; - const payload = normalizeExecutions(JSON.parse(stored.payload_json), { binding: { ...context, + const metadataRow = sql.exec("SELECT context_json, metadata_json FROM account_execution_reports WHERE platform = ? AND account_key = ? AND source_binding_id = ?", + context.platform, context.account_key, stored.source_binding_id).toArray()[0]; + const raw = JSON.parse(stored.payload_json); + const saved = unpackStored(raw, metadataRow?.context_json === identity(context) ? metadataRow.metadata_json : null); + const payload = normalizeExecutions(raw, { binding: { ...context, source_binding_id: stored.source_binding_id }, enforceFresh: false, maxRecords: 2048, maxPeriodDays: 36500 }); - reports.push({ period: payload.period, observed_at: payload.observed_at, coverage: payload.coverage }); + if (asOf === null || Date.parse(payload.observed_at) > Date.parse(asOf)) asOf = payload.observed_at; + if (saved.legacy_aggregate) reports.push(saved.legacy_aggregate); + reports.push(...saved.reports); + reportsTruncated ||= saved.reports_truncated; for (const row of payload.records) records.set(row.execution_ref, records.has(row.execution_ref) ? mergeRecord(records.get(row.execution_ref), row) : row); } - if (!reports.length) return null; - return { source: "ibkr_flex", coverage: "reported_executions", reports, + if (asOf === null) return null; + return { source: "ibkr_flex", coverage: "reported_executions", as_of: asOf, + reports, reports_truncated: reportsTruncated, records: [...records.values()].sort((a, b) => b.trade_date.localeCompare(a.trade_date) || a.execution_ref.localeCompare(b.execution_ref)) }; } export function putExecutions(sql, context, raw) { @@ -126,12 +167,19 @@ export function putExecutions(sql, context, raw) { if (incoming.records.some(row => row.report_ref !== incoming.source_ref)) throw executionError(); const stored = sql.exec("SELECT context_json, payload_json FROM account_executions WHERE platform = ? AND account_key = ? AND source_binding_id = ?", context.platform, context.account_key, context.source_binding_id).toArray()[0]; - const prior = stored?.context_json === identity(context) - ? normalizeExecutions(JSON.parse(stored.payload_json), { binding: context, enforceFresh: false, - maxRecords: 2048, maxPeriodDays: 36500 }) : null; + const priorRaw = stored?.context_json === identity(context) ? JSON.parse(stored.payload_json) : null; + const metadataRow = sql.exec("SELECT context_json, metadata_json FROM account_execution_reports WHERE platform = ? AND account_key = ? AND source_binding_id = ?", + context.platform, context.account_key, context.source_binding_id).toArray()[0]; + const saved = unpackStored(priorRaw, metadataRow?.context_json === identity(context) ? metadataRow.metadata_json : null); + const prior = priorRaw ? normalizeExecutions(priorRaw, { binding: context, enforceFresh: false, + maxRecords: 2048, maxPeriodDays: 36500 }) : null; const merged = mergeExecutions(prior, incoming); - if (stable(prior) === stable(merged)) return { unchanged: true }; + const metadata = addReport(saved, incoming); + const next = { ...metadata, legacy_aggregate: saved.legacy_aggregate ?? null }; + if (metadataRow && stable(prior) === stable(merged) && stable(saved) === stable(next)) return { unchanged: true }; sql.exec("INSERT INTO account_executions (platform, account_key, source_binding_id, context_json, payload_json) VALUES (?, ?, ?, ?, ?) ON CONFLICT(platform, account_key, source_binding_id) DO UPDATE SET context_json = excluded.context_json, payload_json = excluded.payload_json", context.platform, context.account_key, context.source_binding_id, identity(context), stable(merged)); + sql.exec("INSERT INTO account_execution_reports (platform, account_key, source_binding_id, context_json, metadata_json) VALUES (?, ?, ?, ?, ?) ON CONFLICT(platform, account_key, source_binding_id) DO UPDATE SET context_json = excluded.context_json, metadata_json = excluded.metadata_json", + context.platform, context.account_key, context.source_binding_id, identity(context), stable(next)); 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 1a73684d..7c4fac07 100644 --- a/web/strategy-switch-console/application/account_facts/read.js +++ b/web/strategy-switch-console/application/account_facts/read.js @@ -17,6 +17,7 @@ import { buildAccountFactsHistoryReadModel, buildAccountFactsReadModel, projectAccountFactsHistoryCurrencies, + projectAccountFactsHistoryPositions, } from "../../account_facts.js"; import { externalFlowEnabled, instant } from "./external_flow.js"; @@ -218,6 +219,15 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { const projection = url.searchParams.get("projection"); const currenciesProjection = projection === "currencies"; const flowCoverage = projection === "external-flow-coverage"; + const positionsProjection = projection === "positions"; + const from = url.searchParams.get("from"), to = url.searchParams.get("to"); + if (positionsProjection && ([...url.searchParams.keys()].some(key => !["platform", "account_key", "projection", "from", "to"].includes(key)) + || ["platform", "account_key", "projection"].some(key => url.searchParams.getAll(key).length !== 1) + || ["from", "to"].some(key => url.searchParams.getAll(key).length > 1) + || (from !== null && !accountFactsIdentityDates(from, from)) + || (to !== null && !accountFactsIdentityDates(to, to)) || (from && to && from > to))) { + return json({ ok: false, error: "invalid_account_facts_history_query" }, 400); + } if (flowCoverage && ([...url.searchParams.keys()].some(key => !["platform", "account_key", "projection"].includes(key)) || ["platform", "account_key", "projection"].some(key => url.searchParams.getAll(key).length !== 1))) { return json({ ok: false, error: "invalid_account_facts_history_query" }, 400); @@ -227,18 +237,18 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { || ["platform", "account_key", "currency", "include_currencies"].some(key => url.searchParams.getAll(key).length !== 1))) { return json({ ok: false, error: "invalid_account_facts_history_query" }, 400); } - const requireCurrencies = currenciesProjection || includeCurrencies || flowCoverage; + const requireVerifiedBinding = currenciesProjection || includeCurrencies || flowCoverage || positionsProjection; const dates = projection === "identity" ? accountFactsIdentityDates(url.searchParams.get("from"), url.searchParams.get("to")) : null; if (currenciesProjection && (["platform", "account_key", "projection"].some((key) => url.searchParams.getAll(key).length !== 1) || ["currency", "from", "to"].some((key) => url.searchParams.has(key)))) { return json({ ok: false, error: "invalid_account_facts_history_query" }, 400); } - if (projection !== null && !currenciesProjection && !flowCoverage && (projection !== "identity" || !ACCOUNT_FACTS_SUPPORTED_PLATFORMS.includes(platform) || !dates + if (projection !== null && !currenciesProjection && !flowCoverage && !positionsProjection && (projection !== "identity" || !ACCOUNT_FACTS_SUPPORTED_PLATFORMS.includes(platform) || !dates || ["platform", "account_key", "currency", "projection", "from", "to"].some((key) => url.searchParams.getAll(key).length !== 1))) { return json({ ok: false, error: "invalid_account_facts_identity_query" }, 400); } - if (!ACCOUNT_FACTS_SUPPORTED_PLATFORMS.includes(platform) || !accountKey || (!currenciesProjection && !flowCoverage && !/^[A-Z]{3}$/.test(currency))) { + if (!ACCOUNT_FACTS_SUPPORTED_PLATFORMS.includes(platform) || !accountKey || (!currenciesProjection && !flowCoverage && !positionsProjection && !/^[A-Z]{3}$/.test(currency))) { return json({ ok: false, error: "invalid_account_facts_history_query" }, 400); } let accountConfig; @@ -259,7 +269,7 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { const binding = bindings?.bindings?.find( (item) => item.platform === platform && item.account_key === accountKey, ) || null; - if (requireCurrencies && (!option || !binding || !accountFactsOptionMatchesBinding(option, binding) + if (requireVerifiedBinding && (!option || !binding || !accountFactsOptionMatchesBinding(option, binding) || bindings.bindings.filter((item) => item.platform === platform && item.account_key === accountKey).length !== 1)) { return json({ ok: false, error: "account_facts_identity_unavailable" }, 409); } @@ -340,6 +350,11 @@ export async function accountFactsHistoryResponse(request, env, url, deps) { return accountFactsErrorResponse(error); } } + if (positionsProjection) { + return json({ ok: true, platform, account_key: accountKey, projection: "positions", + observations: projectAccountFactsHistoryPositions({ days, binding, from, to }), + retention_days: ACCOUNT_FACTS_HISTORY_MAX_DAYS }); + } if (currenciesProjection) { return json({ ok: true, platform, account_key: accountKey, projection: "currencies", currencies: projectAccountFactsHistoryCurrencies({ days, binding }) }); 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 de56a910..6002fe87 100644 --- a/web/strategy-switch-console/application/console_api/cashflow_performance.js +++ b/web/strategy-switch-console/application/console_api/cashflow_performance.js @@ -94,7 +94,7 @@ export function cashflowCurrencyResult(histories, { range, today, failed = [] }) 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"); + || instant(pair[0].effective_at) !== instant(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); diff --git a/web/strategy-switch-console/application/console_api/executions.js b/web/strategy-switch-console/application/console_api/executions.js index efb99710..e979b501 100644 --- a/web/strategy-switch-console/application/console_api/executions.js +++ b/web/strategy-switch-console/application/console_api/executions.js @@ -21,8 +21,9 @@ export async function executionStatsResponse(options, { scope, range, today }, e } connected++; coverage.push({ platform: option.platform, account_alias: option.key, status: "partial", - unavailable_reason: "execution_coverage_unverified", coverage: source.coverage, reports: source.reports }); - for (const report of source.reports) if (!asOf || report.observed_at > asOf) asOf = report.observed_at; + unavailable_reason: "execution_coverage_unverified", coverage: source.coverage, reports: source.reports, reports_truncated: source.reports_truncated ?? false }); + const observations = source.as_of ? [source.as_of] : source.reports.map(report => report.observed_at); + for (const observedAt of observations) if (!asOf || Date.parse(observedAt) > Date.parse(asOf)) asOf = observedAt; for (const row of source.records) { if (row.trade_date > today || start && row.trade_date < start) continue; rows.push({ platform: option.platform, account_alias: option.key, ...row }); diff --git a/web/strategy-switch-console/application/console_api/native_daily_returns.js b/web/strategy-switch-console/application/console_api/native_daily_returns.js index 15d90528..80b3c3ad 100644 --- a/web/strategy-switch-console/application/console_api/native_daily_returns.js +++ b/web/strategy-switch-console/application/console_api/native_daily_returns.js @@ -1,4 +1,4 @@ -import { cleanPoints, dateMs, hasGap, rangeStart, sliceRange } from "../../performance_math.js"; +import { cleanPoints, dateMs, hasGap, rangeStart } from "../../performance_math.js"; import { performanceBlocks } from "./performance.js"; // Amounts have already passed the private period-return receiver. Sum their @@ -83,22 +83,29 @@ export function applyNativeDailyReturns(blocks, input) { const currencies = [...new Set(rows.map(row => row.currency))]; if (currencies.length !== 1) return blocks; const currency = currencies[0]; - const daily = sliceRange(rows.map(row => ({ date: row.period.to, value: Number(row.value), + const { nativeCalendar, year, month } = input; + const periodStart = nativeCalendar ? `${year}-${String(month || 1).padStart(2, "0")}-01` + : rangeStart(input.range.toLowerCase(), input.today); + const calendarEnd = nativeCalendar ? new Date(Date.UTC(year, month || 12, 0)).toISOString().slice(0, 10) : input.today; + const through = calendarEnd < input.today ? calendarEnd : input.today; + const boundedStart = nativeCalendar || input.range !== "ALL"; + const daily = cleanPoints(rows.map(row => ({ date: row.period.to, value: Number(row.value), navAmount: row.nav_change?.method === "ibkr_nav_reconciliation" && row.nav_change.source === "ChangeInNAV" - ? row.nav_change.amount : null })), input.range.toLowerCase(), input.today); + ? row.nav_change.amount : null }))).filter(row => row.date >= periodStart && row.date <= through); if (!daily.length) return blocks; - let expectedStart = rangeStart(input.range.toLowerCase(), input.today); - if (input.range !== "ALL") { + let expectedStart = periodStart; + if (boundedStart) { while ([0, 6].includes(new Date(dateMs(expectedStart)).getUTCDay())) expectedStart = new Date(dateMs(expectedStart) + 86400000).toISOString().slice(0, 10); } - const incompleteRange = input.range !== "ALL" && daily[0].date > expectedStart; + const incompleteRange = boundedStart && daily[0].date > expectedStart; const gaps = daily.slice(1).some((row, i) => hasGap(daily[i], row)); // Today may still be pending, but prior unreported weekdays are missing. - const trailingGap = hasGap(daily.at(-1), { date: input.today }); + const trailingBoundary = through === input.today ? through : new Date(dateMs(through) + 86400000).toISOString().slice(0, 10); + const trailingGap = hasGap(daily.at(-1), { date: trailingBoundary }); const incomplete = gaps || incompleteRange; let growth = 1; const index = daily.map(row => { growth *= 1 + row.value; return { date: row.date, value: growth }; }); - const stale = dateMs(input.today) - dateMs(daily.at(-1).date) > 3 * 86400000; + const stale = dateMs(through) - dateMs(daily.at(-1).date) > 3 * 86400000; const status = incomplete ? "partial" : stale ? "stale" : trailingGap ? "partial" : "ok"; const metricReason = incomplete ? "snapshot_history_missing" : null; const reason = incomplete || trailingGap ? "snapshot_history_missing" : null; @@ -106,7 +113,7 @@ export function applyNativeDailyReturns(blocks, input) { const coverage = { ...blocks.returns.coverage, account_aliases: [account.account_key], currencies: [currency], cashflows: "complete", native_return_source: "ChangeInNAV.twr", period_from: daily[0].date, period_to: daily.at(-1).date, range_complete: !incomplete && !trailingGap, - requested_period_from: input.range === "ALL" ? daily[0].date : expectedStart, trading_dates: "broker_reported_daily_returns", + requested_period_from: boundedStart ? expectedStart : daily[0].date, trading_dates: "broker_reported_daily_returns", ...(gaps ? { native_daily_only: true } : {}), native_amount_source: amount === null ? null : "ChangeInNAV", native_amount_method: amount === null ? null : "ibkr_nav_reconciliation" }; // Native daily returns already have a starting capital boundary. Rebasing the @@ -164,6 +171,8 @@ export function applyNativeDailyReturns(blocks, input) { const complete = expected.length === monthly.length && expected.every(date => byDate.has(date)); displayed++; return { ...cell, amount: complete ? navAmountSum(monthly) : null, observed_amount: null, currency, value_kind: "pnl", + return_method: "native_ibkr_twr", method_version: null, return_unit: "percent", amount_unit: "native_currency", + cashflow_method: undefined, approximate: undefined, return_percent: complete ? (monthly.reduce((growth, row) => growth * (1 + row.value), 1) - 1) * 100 : null, status: complete ? "ok" : "partial", unavailable_reason: complete ? null : "incomplete_month", coverage: { observed_days: monthly.length, expected_weekdays: expected.length } }; 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 a997bc23..76b91a99 100644 --- a/web/strategy-switch-console/application/console_api/performance_routes.js +++ b/web/strategy-switch-console/application/console_api/performance_routes.js @@ -72,17 +72,25 @@ export async function performanceResponse(request, env, deps) { }; const nativeBinanceOnly = matches.length > 0 && matches.every(option => option.platform === "binance"); - const factsResponse = nativeBinanceOnly ? Response.json({ accounts: [] }) : await deps.facts( - makeRequest("/api/account-facts"), - env, - ); + const independentBinanceHoldings = endpoint === "holdings" + && matches.some(option => option.platform === "binance"); + let factsResponse; + try { + factsResponse = nativeBinanceOnly ? Response.json({ accounts: [] }) : await deps.facts( + makeRequest("/api/account-facts"), + env, + ); + } catch (error) { + if (!independentBinanceHoldings) throw error; + factsResponse = Response.json({ error: "account_facts_unavailable" }, { status: 503 }); + } const sourceError = !factsResponse.ok ? await factsResponse.json().catch(() => null) : null; const factsDisabled = factsResponse.status === 404 && sourceError?.error === "account_facts_disabled"; const factsUnreadable = !factsResponse.ok && !factsDisabled; const independentWalletCalendar = endpoint === "calendar" && scope === "all" && url.searchParams.get("currency") === "USDT" && matches.some(option => option.platform === "binance"); - if (factsUnreadable && !independentWalletCalendar) { + if (factsUnreadable && !independentWalletCalendar && !independentBinanceHoldings) { return json({ schema_version: "console_api.v1", scope, range, status: "unavailable", unavailable_reason: "stored_records_unreadable", as_of: null, ...(endpoint === "holdings" ? { rows: [], coverage: [], includes_cost_basis: false } @@ -193,6 +201,7 @@ export async function performanceResponse(request, env, deps) { if (factsUnreadable && independentWalletCalendar) excludedCommonAccounts.push(option.key); else failed.push(option.key); accounts.push({ account_key: option.key, platform: option.platform, + ...(endpoint === "holdings" && factsUnreadable ? { positions_read_error: "stored_records_unreadable" } : {}), snapshot_unavailable_reason: !fact ? factsUnreadable ? "stored_records_unreadable" : factsDisabled ? "account_facts_disabled" : "no_snapshot" : fact.identity_mismatch ? "identity_mismatch" @@ -355,7 +364,10 @@ export async function performanceResponse(request, env, deps) { // an independently qualified historical return chain and its real cash flows. const cashflowInput = { ...performanceInput, failed: performanceInput.failed.filter(key => !historyOnlyCandidates.has(key) || readFailures.includes(key)) }; - const blocks = applyNativeDailyReturns(applyCashflowPerformance(performanceBlocks(performanceInput), cashflowInput, env), performanceInput); + const blocks = applyNativeDailyReturns( + applyCashflowPerformance(performanceBlocks(performanceInput), cashflowInput, env), + { ...performanceInput, nativeCalendar: endpoint === "calendar" }, + ); const block = blocks[endpoint]; if (endpoint === "calendar") { block.available_currencies = availableCurrencies; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index d2f55ae7..25378ed0 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -10103,6 +10103,11 @@ async function syncExecutionEvidenceSourceResponse(request, env) { return json({ ok: false, error: "execution evidence KV is not configured" }, 503); } + const receiptId = request.headers.get("X-QSL-Execution-Evidence-Receipt"); + if (receiptId !== null && !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(receiptId)) { + return json({ ok: false, error: "invalid execution evidence receipt identifier" }, 400); + } + let raw; try { raw = await readBoundedJson(request, EXECUTION_EVIDENCE_MAX_BODY_BYTES); @@ -10127,6 +10132,7 @@ async function syncExecutionEvidenceSourceResponse(request, env) { schema_version: source.schema_version, deployment_count: source.deployments.length, data_status: source.data_status, + ...(receiptId === null ? {} : { receipt_id: receiptId }), }); } catch { // The source snapshot is accepted independently of optional audit retention. @@ -10137,6 +10143,7 @@ async function syncExecutionEvidenceSourceResponse(request, env) { schema_version: source.schema_version, deployment_count: source.deployments.length, generated_at: source.generated_at, + ...(receiptId === null ? {} : { receipt_id: receiptId }), }); } From 8e692fb77132d373b5f8f6b700538bb5394608bc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:09:01 +0800 Subject: [PATCH 2/2] Keep cash flow validation isolated from the shared test clock Co-Authored-By: Codex --- tests/console_performance_stats_validation.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/console_performance_stats_validation.mjs b/tests/console_performance_stats_validation.mjs index 27bdf1e5..4fa5f60d 100644 --- a/tests/console_performance_stats_validation.mjs +++ b/tests/console_performance_stats_validation.mjs @@ -1,6 +1,5 @@ import "./console_equity_history_validation.mjs"; import assert from "node:assert/strict"; -import "./console_external_flow_validation.mjs"; import "./external_flow_receiver_validation.mjs"; import "./broker_executions_validation.mjs"; import {