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
34 changes: 3 additions & 31 deletions python/scripts/daily_digest_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,26 +267,13 @@ def default_workflow_allowlist(config: Mapping[str, Any]) -> list[dict[str, str]
"""Allowlisted workflows that may contribute *run existence* evidence only.

Counts remain unknown unless a candidates file supplies them. Heartbeat-only
monitor workflows are intentionally omitted.
monitor workflows are intentionally omitted. The list is explicit platform
configuration; an absent list stays empty rather than falling back to a
second copy that can drift from calibrated paths.
"""

platforms = config.get("platforms") or {}
rows: list[dict[str, str]] = []
# Fallback only when platform-config allowlist is absent.
# Keep in sync with calibrated present paths (P0-04); never list 404 stems.
defaults: dict[str, tuple[str, str, str]] = {
# workflow → strategy_profile → evidence_role
"longbridge": (
"publish-runtime-daily-once.yml",
"russell_top50_leader_rotation",
"paper_projection_once",
),
"schwab": (
"runtime-daily-sync.yml",
"soxl_soxx_trend_income",
"manual_daily_projection",
),
}
configured = (
(config.get("notifications") or {})
.get("quant_sentinel", {})
Expand Down Expand Up @@ -323,21 +310,6 @@ def default_workflow_allowlist(config: Mapping[str, Any]) -> list[dict[str, str]
rows.append(row)
return rows

for platform_id, (workflow, strategy, evidence_role) in defaults.items():
meta = platforms.get(platform_id) or {}
repo = str(meta.get("repository") or "").strip()
if not repo:
continue
rows.append(
{
"platform_id": platform_id,
"repository": repo,
"workflow": workflow,
"strategy_profile": strategy,
"evidence_role": evidence_role,
"path_status": "present",
}
)
return rows


Expand Down
9 changes: 2 additions & 7 deletions python/tests/test_daily_digest_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,20 +215,15 @@ def test_allowlist_calibrated_paths_and_roles(self):
omitted_ids = {str(item.get("platform_id")) for item in omitted}
self.assertTrue({"ibkr", "binance", "firstrade"}.issubset(omitted_ids))

def test_allowlist_fallback_defaults_match_calibrated_paths(self):
def test_allowlist_without_explicit_config_stays_empty(self):
config = {
"platforms": {
"longbridge": {"repository": "QuantStrategyLab/LongBridgePlatform"},
"schwab": {"repository": "QuantStrategyLab/CharlesSchwabPlatform"},
"ibkr": {"repository": "QuantStrategyLab/InteractiveBrokersPlatform"},
},
"notifications": {"quant_sentinel": {"daily_digest": {"aggregator": {}}}},
}
rows = aggregator.default_workflow_allowlist(config)
by_platform = {row["platform_id"]: row for row in rows}
self.assertEqual(set(by_platform), {"longbridge", "schwab"})
self.assertEqual(by_platform["schwab"]["workflow"], "runtime-daily-sync.yml")
self.assertNotIn("ibkr", by_platform)
self.assertEqual(aggregator.default_workflow_allowlist(config), [])

def test_sender_dry_run_does_not_require_secrets(self):
code = sender.main(
Expand Down
27 changes: 27 additions & 0 deletions tests/console_strategy_execution_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ assert.equal((await summarize({ execution_mode: "live", dry_run_only: false })).
assert.equal((await summarize(live, { ...profile, live_continuity: { eligible: false } })).continuation_reason, null);
assert.equal((await summarize(live, { ...profile, live_continuity: { eligible: true, allowed_platforms: ["other"] } })).continuation_reason, null);

// A configured alias belongs to its canonical catalog entry.
const dcaCatalog = [{ profile: "ibit_smart_dca", domain: "crypto", allowed_execution_modes: ["paper", "dry_run"] }];
const aliasAccount = { platform: "binance", key: "synthetic-alias" };
const aliasBody = await strategySummary(request, {}, { ...deps, profiles: async () => dcaCatalog },
[aliasAccount], { binance: { [aliasAccount.key]: { strategy_profile: "bitcoin_etf_smart_dca" } } });
assert.equal(aliasBody.strategies[0].configured_accounts[0]?.account_alias, aliasAccount.key,
"legacy alias still resolves to the catalog profile");
assert.deepEqual(aliasBody.unmatched_strategy_assignments, []);

// A binding the catalog no longer declares is reported, never silently dropped.
const retiredAccount = { platform: "binance", key: "synthetic-retired" };
const retiredBody = await strategySummary(request, {}, { ...deps, profiles: async () => dcaCatalog },
[retiredAccount], { binance: { [retiredAccount.key]: { strategy_profile: "retired_synthetic_profile" } } });
assert.equal(retiredBody.strategies[0].configured_accounts.length, 0);
assert.equal(retiredBody.unmatched_strategy_assignments.length, 1);
assert.equal(retiredBody.unmatched_strategy_assignments[0].account_alias, retiredAccount.key);
assert.equal(retiredBody.unmatched_strategy_assignments[0].strategy_profile, "retired_synthetic_profile");

// Render the actual card and dictionaries, including compatibility with old responses.
const directory = await mkdtemp(join(tmpdir(), "strategy-execution-"));
try {
Expand Down Expand Up @@ -135,6 +153,15 @@ try {
assert.ok(page.includes(labels.source), "page-level execution source note");
assert.ok(page.includes(labels.live));
assert.doesNotMatch(page, /synthetic-rotation|strategies\.execution\./, "page copy is fully translated");
const pageWithRetiredBinding = ui.renderCatalog(language, {
status: "ok", as_of: null, strategies: [card(actual)],
unmatched_strategy_assignments: [{ account_alias: account.key, platform: account.platform,
strategy_profile: "retired_synthetic_profile" }],
});
assert.ok(pageWithRetiredBinding.includes(language === "zh" ? "目录外策略绑定" : "Bindings outside the catalog"),
"a binding outside the catalog gets its own visible section");
assert.ok(pageWithRetiredBinding.includes("retired_synthetic_profile"),
"the raw profile identifier stays visible");
}
} finally { await rm(directory, { recursive: true, force: true }); }
console.log("console_strategy_execution_validation: PASS");
13 changes: 13 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,19 @@ assert.throws(
/not live-enabled/,
);

// A configured alias resolves to the canonical catalog profile before any
// DCA or domain rule runs; the alias is not a separate strategy.
assert.equal(
__test.dcaDraftSupported("longbridge", [{ profile: "ibit_smart_dca" }], "bitcoin_etf_smart_dca"),
true,
"a legacy DCA alias keeps the canonical DCA capability",
);
assert.equal(
__test.dcaDraftSupported("binance", [{ profile: "ibit_smart_dca" }], "bitcoin_etf_smart_dca"),
false,
"alias resolution must not bypass the DCA platform constraint",
);

const kvUnboundProfileSyncResponse = await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-profiles", {
method: "POST",
Expand Down
19 changes: 17 additions & 2 deletions web/strategy-switch-console/application/console_api/operations.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** Read-only page projections. Missing observations never imply a successful run. */
import { RUNTIME_DAILY_TIMEZONE, runtimeDailyBusinessDate } from "../../runtime_daily_contract.js";
import { canonicalStrategyProfile, sameStrategyProfile } from "../../strategy_identity.js";
import { configuredStrategyExecution } from "./strategy_execution.js";
import { runStatus, runStateCounts } from "./run_status.js";
export const READ_ONLY = { no_order: true, execution_authority_granted: false };
Expand Down Expand Up @@ -97,13 +98,16 @@ export async function strategySummary(request, env, deps, accounts, currentStrat
const invalid = (health.errors || []).some(e => /invalid|unreadable|failed/.test(e));
const status = health.data_status === "partial" ? "partial" : invalid ? "read_failed" : health.data_status === "ready" ? "ok"
: health.data_status === "stale" ? "stale" : "not_connected";
const boundProfile = (account) => currentStrategies[account.platform]?.[account.key]?.strategy_profile;
const catalogProfiles = new Set(profiles.map(profile => canonicalStrategyProfile(profile.profile)));
const strategies = profiles.map(profile => {
const observation = (health.strategies || []).find(s => s.profile === profile.profile);
const profileKey = canonicalStrategyProfile(profile.profile);
const observation = (health.strategies || []).find(s => canonicalStrategyProfile(s.profile) === profileKey);
const rowStatus = observation && ["ok", "partial"].includes(status) && observation.freshness?.status === "stale"
? "stale" : observation ? status === "partial" ? "ok" : status : status === "read_failed" ? status : "not_connected";
return { profile: profile.profile, domain: profile.domain,
allowed_execution_modes: profile.allowed_execution_modes || [],
configured_accounts: accounts.filter(a => currentStrategies[a.platform]?.[a.key]?.strategy_profile === profile.profile)
configured_accounts: accounts.filter(a => sameStrategyProfile(boundProfile(a), profileKey))
.map(a => ({ account_alias: a.key, platform: a.platform, account_ref: `${a.platform}:${a.key}`,
...configuredStrategyExecution(currentStrategies[a.platform][a.key], profile, a.platform),
execution_evidence: currentStrategies[a.platform][a.key].execution_evidence })),
Expand All @@ -116,6 +120,16 @@ export async function strategySummary(request, env, deps, accounts, currentStrat
: observation && status !== "read_failed" ? null : status === "read_failed"
? "stored_records_unreadable" : "strategy_health_not_connected" };
});
// A bound profile the catalog no longer declares stays visible; silently
// dropping it would make a stale binding look like an unbound account.
const unmatched_strategy_assignments = accounts.flatMap(account => {
const bound = boundProfile(account);
if (typeof bound !== "string" || !bound.trim() || catalogProfiles.has(canonicalStrategyProfile(bound))) return [];
const current = currentStrategies[account.platform][account.key];
return [{ account_alias: account.key, platform: account.platform,
account_ref: `${account.platform}:${account.key}`, strategy_profile: bound,
execution_evidence: current.execution_evidence }];
});
const assignmentFailures = accounts.flatMap(account => {
const readStatus = currentStrategies[account.platform]?.[account.key]?.execution_evidence?.status;
return ["read_failed", "permission_denied"].includes(readStatus)
Expand All @@ -124,6 +138,7 @@ export async function strategySummary(request, env, deps, accounts, currentStrat
return { status: status === "read_failed" ? "read_failed"
: assignmentFailures.length || status === "ok" && strategies.some(row => row.status !== "ok") ? "partial" : status,
account_assignment_read_failures: assignmentFailures,
unmatched_strategy_assignments,
configured_accounts_source: "currentStrategies",
account_assignment_status: assignmentFailures.length ? "read_failed" : accounts.every(a => currentStrategies[a.platform]?.[a.key]?.strategy_profile) ? "complete"
: accounts.some(a => currentStrategies[a.platform]?.[a.key]?.strategy_profile) ? "partial" : "unavailable",
Expand Down
7 changes: 7 additions & 0 deletions web/strategy-switch-console/frontend/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,13 @@ export interface StrategySummary extends ReadOnlyProjection {
configured_accounts_source?: "currentStrategies";
account_assignment_status?: "complete" | "partial" | "unavailable" | "read_failed";
account_assignment_read_failures?: { platform: string; account_alias: string; status: "read_failed" | "permission_denied" }[];
unmatched_strategy_assignments?: {
account_alias: string;
platform: string;
account_ref?: string;
strategy_profile: string;
execution_evidence?: AccountExecutionEvidence;
}[];
strategies: {
profile: string;
domain: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export const zh: Record<string, string> = {
"strategies.binding": "账户绑定",
"strategies.unbound": "未绑定账户",
"strategies.unbound_note": "目录项尚未绑定账户;这不表示策略已停用或正在运行。",
"strategies.unmatched_title": "目录外策略绑定",
"strategies.unmatched_note": "以下账户绑定的策略不在当前策略目录中(可能已停用或改名)。此处只读展示绑定事实,不推断该策略仍可运行。",
"strategies.unmatched_profile": "目录中不存在的策略标识",
};
export const en: Record<string, string> = {
"strategies.assignment_unknown": "Account binding unconfirmed",
Expand Down Expand Up @@ -64,4 +67,7 @@ export const en: Record<string, string> = {
"strategies.binding": "Account binding",
"strategies.unbound": "No account linked",
"strategies.unbound_note": "This catalog entry has no linked account; it does not mean the strategy is stopped or running.",
"strategies.unmatched_title": "Bindings outside the catalog",
"strategies.unmatched_note": "These accounts are bound to a strategy the current catalog no longer declares (retired or renamed). The row reports the binding as configured; it does not claim the strategy can still run.",
"strategies.unmatched_profile": "Strategy identifier missing from the catalog",
};
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ function StrategyCard({ strategy, assignmentKnown = true }: { strategy: Strategy
function CatalogTab({ refreshEpoch }: { refreshEpoch: number }) {
const t = useT();
const names = useDisplayNames();
const navigate = useNavigate();
const state = useApi(loadStrategySummary, [refreshEpoch]);
if (state.phase === "loading") {
return (
Expand Down Expand Up @@ -159,6 +160,7 @@ function CatalogTab({ refreshEpoch }: { refreshEpoch: number }) {
strategy.configured_accounts.some(account => accountExecutionMode(account, names.config) === "live"));
const configured = summary.strategies.filter(strategy => strategy.configured_accounts.length > 0);
const catalogOnly = summary.strategies.filter(strategy => strategy.configured_accounts.length === 0);
const unmatched = summary.unmatched_strategy_assignments || [];
return (
<section className="section-stack">
<div className="catalog-meta">
Expand All @@ -167,6 +169,29 @@ function CatalogTab({ refreshEpoch }: { refreshEpoch: number }) {
</div>
{/* Said once, in the catalogue: two different sources, neither is a run result. */}
<p className="card-note strategy-domain-note">{t("strategies.domain_note")}</p>
{unmatched.length > 0 && (
<section className="strategy-section" data-binding="unmatched">
<div className="strategy-section-head">
<h2 className="strategy-section-title">{t("strategies.unmatched_title")}</h2>
<span className="card-note">{t("strategies.section_count", { count: unmatched.length })}</span>
</div>
<p className="card-note" role="status">{t("strategies.unmatched_note")}</p>
<ul className="strategy-unmatched">
{unmatched.map(item => (
<li key={accountKey(item)}>
<button
type="button"
className="strategy-account-link"
onClick={() => navigate({ page: "accounts", account: accountKey(item) })}
>
{names.platform(item.platform)} · {names.account(item.account_alias, item.platform)}
</button>
<code title={t("strategies.unmatched_profile")}>{item.strategy_profile}</code>
</li>
))}
</ul>
</section>
)}
{hasLive && (
<div className="card-note strategy-live-note">
<Badge tone="warn">{t("strategies.execution.live")}</Badge>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
.strategy-section-head .card-note { margin: 0; }
.strategy-section-empty { margin: 0; padding: 10px 0; }

/* A binding outside the catalog stays listed with its raw profile identifier. */
.strategy-unmatched { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.strategy-unmatched li { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; }
.strategy-unmatched code { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }

.strategy-card { display: flex; flex-direction: column; gap: 12px; min-width: 0; padding: 16px 18px; }
.strategy-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.strategy-card-title { margin: 0; font-size: 16px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; }
Expand Down
11 changes: 11 additions & 0 deletions web/strategy-switch-console/strategy_identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Generated aliases are configuration, not permission: resolve identity only.
import { DISPLAY_NAMES } from "./display_names_data.js";

export function canonicalStrategyProfile(value) {
const lookup = String(value || "").trim().toLowerCase().replaceAll("-", "_");
return (DISPLAY_NAMES.strategy_aliases || {})[lookup] || lookup;
}

export function sameStrategyProfile(left, right) {
return canonicalStrategyProfile(left) === canonicalStrategyProfile(right);
}
36 changes: 18 additions & 18 deletions web/strategy-switch-console/v2_asset_map.js

Large diffs are not rendered by default.

Loading
Loading