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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions tests/console_desk_mock_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,16 +185,19 @@ assert.equal(resolveMockScenario("?mock=0", "ok"), null);
assert.equal(resolveMockScenario("?page=overview", "fail"), "fail");
assert.equal(resolveMockScenario("?mock=<script>", null), null);

// 5. Endpoint sources: session and credentials are live; the rest stay mock until their backend lands.
// 5. Endpoint sources: session, credentials and the switch target list are live;
// the rest stay mock until their backend lands.
assert.deepEqual(DESK_SOURCES, {
session: "live", ribbon: "mock", overview: "mock", accounts: "mock", credentials: "live",
switchTargets: "mock", switchStrategies: "mock", performance: "mock",
switchTargets: "live", switchStrategies: "mock", performance: "mock",
alerts: "mock", alertDelivery: "mock", logs: "mock",
});
const calls = [];
const fetchJson = async path => {
calls.push(path);
return structuredClone(path === "/api/desk/credentials" ? credentials.ok : session.ok);
if (path === "/api/desk/credentials") return structuredClone(credentials.ok);
if (path === "/api/desk/switch/targets") return structuredClone(switchTargets.ok);
return structuredClone(session.ok);
};
const api = createDeskApi({ forcedScenario: null, delayMs: 0, fetchJson });
const s = await api.session();
Expand All @@ -208,19 +211,21 @@ const cred = await api.credentials();
assert.deepEqual([cred.source, cred.scenario, calls], ["live", null, ["/api/session", "/api/desk/credentials"]]);
assert.ok(cred.data.items.some(item => item.phase === "7"), "fixture covers the 7-day phase");
const st = await api.switchTargets();
assert.deepEqual([st.source, st.scenario], ["mock", "ok"]);
assert.deepEqual([st.source, st.scenario], ["live", null]);
assert.equal(st.data.targets[0].target_id, "ibkr/tqqq");
const sc = await api.switchStrategies("schwab/core");
assert.deepEqual([sc.source, sc.scenario], ["mock", "ok"]);
assert.equal(sc.data.target_id, "schwab/core");
assert.equal(sc.data.domain, "us_equity");
assert.equal(calls.length, 2, "mock endpoints never hit the network; only live sources do");
assert.equal(calls.length, 3, "mock endpoints never hit the network; only live sources do");
r.data.tape.length = 0;
assert.ok((await api.ribbon()).data.tape.length > 0, "mock responses are copies");
const forced = createDeskApi({ forcedScenario: "degraded", delayMs: 0, fetchJson });
assert.equal((await forced.session()).source, "mock");
assert.equal((await forced.overview()).data.totals.status, "partial");
calls.length = 0;
assert.equal((await forced.credentials()).source, "mock", "a forced scenario overrides the live sources");
assert.equal((await forced.switchTargets()).source, "mock", "a forced scenario overrides the target list too");
assert.equal(calls.length, 0, "a forced scenario never hits the network");
const failing = createDeskApi({ forcedScenario: "fail", delayMs: 0, fetchJson });
await assert.rejects(failing.overview(), error => error instanceof HttpError && error.status === 503 && error.payload.error === "store_unavailable");
Expand Down
93 changes: 93 additions & 0 deletions tests/desk_switch_targets_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import {
switchTargetsFromConfig, deskSwitchTargetsResponse,
} from "../web/strategy-switch-console/application/desk/switch_targets.js";
import { DeskError } from "../web/strategy-switch-console/application/desk/http.js";

const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url));
const ajv = new (require("ajv/dist/2020").default)({ strict: false });
require("ajv-formats")(ajv);
for (const name of ["common", "switch-targets", "error"]) {
ajv.addSchema(JSON.parse(readFileSync(new URL(`../schemas/desk/${name}.json`, import.meta.url))));
}
const conforms = (data) => {
const check = ajv.getSchema("https://schemas.example.invalid/qsl/desk/switch-targets.json");
assert.ok(check(data), ajv.errorsText(check.errors));
};

const config = {
revision: 7,
options: {
ibkr: [
{ key: "ibkr-main", label: "IBKR Main", target_name: "tqqq",
supported_domains: ["us_equity"], default_execution_mode: "paper",
default_strategy_profile: "tqqq_growth_income", account_selector: "DU1234567" },
{ key: "ibkr-no-selector", label: "IBKR Unbound", target_name: "soxl", supported_domains: [] },
],
schwab: [
{ key: "schwab-core", label: "Schwab Core", target_name: "core",
supported_domains: ["us_equity", "hk_equity"], default_execution_mode: "not_a_mode",
account_selector: "1234" },
],
},
};

const targets = switchTargetsFromConfig(config);
assert.equal(targets.length, 3);
conforms({ ok: true, schema_version: "qsl.desk.switch_targets.v1", generated_at: new Date().toISOString(), targets });

const [ibkr, unbound, schwab] = targets;
assert.equal(ibkr.target_id, "ibkr/tqqq");
assert.equal(ibkr.platform, "ibkr");
assert.equal(ibkr.platform_label, "IBKR");
assert.equal(ibkr.account_suffix, "4567");
assert.match(ibkr.account_ref, /••••4567/);
assert.deepEqual(ibkr.domains, ["us_equity"]);
assert.deepEqual(ibkr.execution_modes, ["paper"]);
assert.deepEqual(ibkr.switchable, { allowed: false, reason_code: "switch_not_connected" });
assert.equal(ibkr.pending_request_id, null);
assert.deepEqual(ibkr.stop, { supported: false, resume_supported: false });

// A configured default is not an observation of the running broker.
assert.deepEqual(ibkr.mode, { status: "unknown", reason: "never_read" });
assert.deepEqual(ibkr.current_strategy, { status: "unknown", reason: "never_read" });
assert.ok(!JSON.stringify(targets).includes("tqqq_growth_income"), "no invented strategy label");
assert.ok(!JSON.stringify(targets).includes("DU1234567"), "no unmasked account selector");
assert.ok(!JSON.stringify(targets).includes("revision"), "no configuration bookkeeping in the payload");

// Missing or invalid configuration details stay blank/unknown, never guessed.
assert.equal(unbound.account_suffix, "");
assert.deepEqual(unbound.domains, []);
assert.deepEqual(unbound.execution_modes, []);
assert.deepEqual(schwab.execution_modes, [], "an out-of-vocabulary mode is not emitted");
assert.deepEqual(schwab.domains, ["us_equity", "hk_equity"]);

// A broken configuration is a failure, not an empty healthy list.
for (const bad of [null, undefined, [], {}, { options: null }, { options: [] }, { options: "x" }]) {
assert.throws(() => switchTargetsFromConfig(bad), error => error instanceof DeskError && error.status === 502);
}
conforms({ ok: true, schema_version: "qsl.desk.switch_targets.v1", generated_at: new Date().toISOString(), targets: [] });

const env = {};
const url = "https://console.example/api/desk/switch/targets";
const get = (deps, method = "GET") => deskSwitchTargetsResponse(new Request(url, { method }), env, deps);
const body = async (deps, method = "GET") => { const response = await get(deps, method); return [response.status, await response.json()]; };
const okDeps = { auth: async () => ({ allowed: true, login: "synthetic" }), options: async () => config };

// Auth runs before the configuration is read; a failed read is never an empty list.
assert.deepEqual(await body({ auth: async () => { throw new DeskError("login_required", 401); }, options: async () => { throw new Error("must not run"); } }),
[401, { ok: false, error: "login required", reason_code: "login_required" }]);
const [failedStatus, failed] = await body({ auth: async () => ({ allowed: true }), options: async () => { throw new Error("kv exploded at /secret/path"); } });
assert.equal(failedStatus, 502);
assert.deepEqual(failed, { ok: false, error: "read_failed", reason_code: "read_failed" });
assert.equal((await get(okDeps, "POST")).status, 404);

const [status, payload] = await body(okDeps);
assert.equal(status, 200);
conforms(payload);
assert.equal(payload.schema_version, "qsl.desk.switch_targets.v1");
assert.equal(payload.targets.length, 3);

console.log("desk_switch_targets_validation: PASS");
77 changes: 77 additions & 0 deletions web/strategy-switch-console/application/desk/switch_targets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// §3.6 step 1: the switch target list.
//
// This is a projection of the *configured* account options, not an observation
// of the running brokers. The console has no desk read that reports a target's
// live mode or its currently deployed strategy, so both are reported as
// `unknown / never_read`. Presenting `default_execution_mode` or
// `default_strategy_profile` as a current value would be a guess.
//
// Everything that is known here is configuration: the platform, the target id,
// the declared domains, the declared default execution mode and a masked
// account suffix. Switching is not connected in this console build, so every
// row carries `switchable: { allowed: false, reason_code: switch_not_connected }`.
import { DeskError, deskResponse, deskErrorResponse } from "./http.js";
import { unknown } from "./read.js";
import { readbackTargets } from "./readback.js";

const PLATFORM_LABELS = {
ibkr: "IBKR",
schwab: "Schwab",
longbridge: "LongBridge",
binance: "Binance",
firstrade: "Firstrade",
};

const MODES = new Set(["dry_run", "paper", "live"]);

function platformLabel(platform) {
return PLATFORM_LABELS[platform] || platform.charAt(0).toUpperCase() + platform.slice(1);
}

/** Only a configured account selector yields a suffix; a config key is not an
account number, and an absent selector is not evidence of an account. */
function maskedAccount(option, label) {
const raw = typeof option.account_selector === "string" ? option.account_selector.trim() : "";
const suffix = /^[A-Za-z0-9]{4,}$/.test(raw) ? raw.slice(-4) : "";
return { account_ref: `${label} ••••${suffix}`, account_suffix: suffix };
}

export function switchTargetsFromConfig(config) {
if (!config || typeof config !== "object" || Array.isArray(config)
|| !config.options || typeof config.options !== "object" || Array.isArray(config.options)) {
throw new DeskError("read_failed", 502);
}
return readbackTargets(config).map((item) => {
const option = item.config || {};
const label = platformLabel(item.platform);
const defaultMode = typeof option.default_execution_mode === "string" ? option.default_execution_mode : "";
return {
target_id: item.id,
platform: item.platform,
platform_label: label,
...maskedAccount(option, label),
// No runtime read has landed for these two; absence is not a value.
mode: unknown("never_read"),
current_strategy: unknown("never_read"),
domains: Array.isArray(option.supported_domains)
? option.supported_domains.filter((domain) => typeof domain === "string") : [],
execution_modes: MODES.has(defaultMode) ? [defaultMode] : [],
switchable: { allowed: false, reason_code: "switch_not_connected" },
pending_request_id: null,
// Stop/resume are write capabilities. Declaring them from configuration
// would be an unproven claim; the desk read model has no evidence yet.
stop: { supported: false, resume_supported: false },
};
});
}

export async function deskSwitchTargetsResponse(request, env, deps) {
try {
if (request.method !== "GET") throw new DeskError("not_found", 404);
await deps.auth(request, env);
let config;
try { config = await deps.options(env); }
catch { throw new DeskError("read_failed", 502); }
return deskResponse("switch_targets", { targets: switchTargetsFromConfig(config) });
} catch (error) { return deskErrorResponse(error); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const DESK_SOURCES: Record<MockEndpoint, DeskSource> = {
overview: "mock",
accounts: "mock",
credentials: "live",
switchTargets: "mock",
switchTargets: "live",
switchStrategies: "mock",
// /api/desk/performance has no backend yet; the benchmark curves follow this source (see benchmarks()).
performance: "mock",
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"test:contracts": "node ../../tests/desk_contract_validation.mjs",
"test:operations": "node ../../tests/desk_helpers_validation.mjs && node ../../tests/desk_readback_validation.mjs && node ../../tests/desk_credentials_validation.mjs && npm --prefix frontend run test:operations"
"test:operations": "node ../../tests/desk_helpers_validation.mjs && node ../../tests/desk_readback_validation.mjs && node ../../tests/desk_credentials_validation.mjs && node ../../tests/desk_switch_targets_validation.mjs && npm --prefix frontend run test:operations"
},
"devDependencies": {
"ajv": "8.17.1",
Expand Down
42 changes: 21 additions & 21 deletions web/strategy-switch-console/v2_asset_map.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { binanceDetailCommand } from "./binance_detail_archive.js";
import { createDeskAuth, DeskError, deskErrorResponse } from "./application/desk/http.js";
import { DeskReadbackStore, deskReadbackResponse, buildReadback, readbackTargets } from "./application/desk/readback.js";
import { deskCredentialsResponse } from "./application/desk/credentials.js";
import { deskSwitchTargetsResponse } from "./application/desk/switch_targets.js";
import { auditPageQuery, auditEventInPage, auditEventOrder, auditNextCursor } from "./application/console_api/audit_pagination.js";
import { runtimeReadbackProjection } from "./application/console_api/runtime_readback.js";
import { initialEnrollmentBindingsMatch, initialEnrollmentDigest } from "./application/console_api/initial_enrollment.js";
Expand Down Expand Up @@ -872,6 +873,9 @@ export default {
if (url.pathname === "/api/desk/credentials") {
return await deskCredentialsResponse(request, env, { auth: deskAuth, command: runtimeInstanceCommand });
}
if (url.pathname === "/api/desk/switch/targets") {
return await deskSwitchTargetsResponse(request, env, { auth: deskAuth, options: loadAccountOptionsConfig });
}
if (url.pathname.startsWith("/api/desk/targets/") || url.pathname === "/api/desk/readbacks" || url.pathname.startsWith("/api/desk/readbacks/")) {
return await deskReadbackResponse(request, env, {
auth: deskAuth, command: runtimeInstanceCommand, options: loadAccountOptionsConfig,
Expand Down
Loading