Skip to content

Commit 4e779e5

Browse files
authored
Merge pull request #346 from QuantStrategyLab/fix/console-health-truth
fix(console): align health status and research freshness
2 parents 303c2f5 + a665d5c commit 4e779e5

4 files changed

Lines changed: 126 additions & 34 deletions

File tree

tests/strategy_switch_worker_validation.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2082,6 +2082,9 @@ assert.match(healthReadPayload.strategies[0].source_revision, /^https:\/\//);
20822082
assert.deepEqual(healthReadPayload.errors, ["safe_notice"]);
20832083
assert.ok(indexHtml.includes('id="health-count-critical"'));
20842084
assert.ok(indexHtml.includes('data-i18n="healthCritical"'));
2085+
assert.ok(indexHtml.includes('healthAttentionNotice'));
2086+
assert.ok(indexHtml.includes('formatAsOfDate('));
2087+
assert.ok(indexHtml.includes('m0ResearchBoard: "外部研究记录"'));
20852088

20862089
healthPayload.generated_at = new Date().toISOString();
20872090
healthPayload.computed_at = "2020-01-01T00:00:00.000Z";
@@ -2291,6 +2294,32 @@ const staleControlRead = await worker.fetch(
22912294
);
22922295
assert.equal((await staleControlRead.json()).data_status, "stale");
22932296

2297+
const weekendResearchSnapshot = {
2298+
schema_version: "qsl_control_plane_source_snapshot.v1",
2299+
source_id: "uesp.weekend_research",
2300+
generated_at: "2026-08-29T09:21:00.000Z",
2301+
computed_at: "2026-08-29T09:21:00.000Z",
2302+
data_status: "ready",
2303+
candidates: [],
2304+
errors: [],
2305+
};
2306+
assert.equal(
2307+
__test.controlPlaneResearchSnapshotFreshness(
2308+
weekendResearchSnapshot,
2309+
300,
2310+
Date.parse("2026-08-31T16:40:00.000Z"),
2311+
).data_status,
2312+
"ready",
2313+
);
2314+
assert.equal(
2315+
__test.controlPlaneResearchSnapshotFreshness(
2316+
weekendResearchSnapshot,
2317+
300,
2318+
Date.parse("2026-09-01T08:00:00.000Z"),
2319+
).data_status,
2320+
"stale",
2321+
);
2322+
22942323
const controlSourcePayload = {
22952324
schema_version: "qsl_control_plane_source_snapshot.v1",
22962325
source_id: "uesp.tqqq_daily_research",

web/strategy-switch-console/app.js

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -940,19 +940,19 @@
940940
runtimeTargetLifecycleReasonRuntimeGuard: "运行监测需要复核",
941941
runtimeTargetLifecycleReasonHeartbeat: "执行心跳需要复核",
942942
runtimeTargetLifecycleReasonUnavailable: "监测数据不可用",
943-
m0ResearchBoard: "市场研究",
944-
m0ResearchLoginNotice: "登录后查看市场研究和观察记录。",
945-
m0ResearchStaleNotice: "研究内容更新延迟,仅供历史参考。",
946-
m0ResearchUnavailableNotice: "暂时没有可用的市场研究。",
947-
m0ResearchUpstreamNotice: "部分研究内容暂不可用,请稍后重试。",
948-
m0ResearchEmpty: "当前没有可展示的市场研究。",
943+
m0ResearchBoard: "外部研究记录",
944+
m0ResearchLoginNotice: "登录后查看外部研究记录。",
945+
m0ResearchStaleNotice: "外部研究更新延迟,仅供历史参考。",
946+
m0ResearchUnavailableNotice: "暂时没有可用的外部研究记录。",
947+
m0ResearchUpstreamNotice: "部分外部研究暂不可用,请稍后重试。",
948+
m0ResearchEmpty: "当前没有可展示的外部研究记录。",
949949
m0ResearchMore: "显示前 {count} 条研究观察。",
950950
m0ResearchMeta: "{kind} · 查看于:{viewed}",
951951
m0ResearchStateFreshness: "状态:{state} · 新鲜度:{freshness}",
952952
m0ResearchHorizons: "主要观察期:{primary} · 适用观察期:{suitable}",
953953
m0ResearchEvidence: "置信:{confidence} · 风格:{style} · 资料摘要:{digest}",
954954
m0ResearchConsistency: "当前周期分歧:{conflict} · 历史失效漂移:{drift}",
955-
m0ResearchNoOrder: "查看当前研究摘要。",
955+
m0ResearchNoOrder: "外部研究不影响当前策略运行。",
956956
adaptiveSelectionBoard: "系统建议",
957957
adaptiveSelectionLoginNotice: "登录后查看系统建议。",
958958
adaptiveSelectionStaleNotice: "建议更新延迟,仅供历史参考。",
@@ -994,6 +994,7 @@
994994
healthUnavailableNotice: "暂时无法读取策略状态,请稍后刷新。",
995995
healthUpstreamNotice: "部分策略状态暂不可用,请稍后重试。",
996996
healthNormalNotice: "当前没有需要关注的运行问题。",
997+
healthAttentionNotice: "有 {critical} 项严重、{review} 项需要复核、{watch} 项观察;系统正在持续监测。",
997998
healthEmpty: "当前分类下没有策略。",
998999
healthStatusHealthy: "健康",
9991000
healthStatusWatch: "观察",
@@ -1007,7 +1008,7 @@
10071008
healthRecommendationWatch: "系统正在继续观察。",
10081009
healthRecommendationReview: "请查看诊断详情,等待系统复核完成。",
10091010
healthRecommendationCritical: "请查看诊断详情,并暂缓相关变更。",
1010-
healthDetail: "最近检查:{date}",
1011+
healthDetail: "数据截至:{date}",
10111012
healthScoreLabel: "健康度",
10121013
diagnosticDetails: "查看诊断详情",
10131014
diagnosticDetailsHint: "仅在排查问题或查看研究记录时使用。",
@@ -1331,19 +1332,19 @@
13311332
runtimeTargetLifecycleReasonRuntimeGuard: "runtime guard needs review",
13321333
runtimeTargetLifecycleReasonHeartbeat: "execution heartbeat needs review",
13331334
runtimeTargetLifecycleReasonUnavailable: "monitoring data unavailable",
1334-
m0ResearchBoard: "Market research",
1335-
m0ResearchLoginNotice: "Sign in to see market research and observations.",
1336-
m0ResearchStaleNotice: "Research is delayed and is provided for historical context only.",
1337-
m0ResearchUnavailableNotice: "Market research is temporarily unavailable.",
1338-
m0ResearchUpstreamNotice: "Some research content is unavailable. Please retry later.",
1339-
m0ResearchEmpty: "There is no market research to display.",
1335+
m0ResearchBoard: "External research records",
1336+
m0ResearchLoginNotice: "Sign in to see external research records.",
1337+
m0ResearchStaleNotice: "External research is delayed and is provided for historical context only.",
1338+
m0ResearchUnavailableNotice: "External research records are temporarily unavailable.",
1339+
m0ResearchUpstreamNotice: "Some external research records are unavailable. Please retry later.",
1340+
m0ResearchEmpty: "There are no external research records to display.",
13401341
m0ResearchMore: "Showing the first {count} research observations.",
13411342
m0ResearchMeta: "{kind} · viewed {viewed}",
13421343
m0ResearchStateFreshness: "State: {state} · freshness: {freshness}",
13431344
m0ResearchHorizons: "Primary horizon: {primary} · suitable horizons: {suitable}",
13441345
m0ResearchEvidence: "Confidence: {confidence} · style: {style} · source digest: {digest}",
13451346
m0ResearchConsistency: "Current horizon conflict: {conflict} · historical stale drift: {drift}",
1346-
m0ResearchNoOrder: "View the current research summary.",
1347+
m0ResearchNoOrder: "External research does not affect current strategy operation.",
13471348
adaptiveSelectionBoard: "System suggestions",
13481349
adaptiveSelectionLoginNotice: "Sign in to see system suggestions.",
13491350
adaptiveSelectionStaleNotice: "Suggestions are delayed and are provided for historical context only.",
@@ -1385,6 +1386,7 @@
13851386
healthUnavailableNotice: "Strategy status is temporarily unavailable. Refresh later.",
13861387
healthUpstreamNotice: "Some strategy status is unavailable. Retry later.",
13871388
healthNormalNotice: "There are no runtime issues that need attention.",
1389+
healthAttentionNotice: "{critical} critical, {review} to review, and {watch} under observation. Monitoring continues.",
13881390
healthEmpty: "There are no strategies in this category.",
13891391
healthStatusHealthy: "Healthy",
13901392
healthStatusWatch: "Watch",
@@ -1398,7 +1400,7 @@
13981400
healthRecommendationWatch: "The system is continuing to monitor.",
13991401
healthRecommendationReview: "View diagnostic details while the system completes its review.",
14001402
healthRecommendationCritical: "View diagnostic details and hold related changes for now.",
1401-
healthDetail: "Last checked: {date}",
1403+
healthDetail: "Data as of: {date}",
14021404
healthScoreLabel: "Health",
14031405
diagnosticDetails: "View diagnostic details",
14041406
diagnosticDetailsHint: "Use this only to investigate an issue or review research records.",
@@ -1747,6 +1749,17 @@
17471749
}).format(parsed);
17481750
}
17491751

1752+
function formatAsOfDate(value) {
1753+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
1754+
const parsed = new Date(`${value}T00:00:00.000Z`);
1755+
return new Intl.DateTimeFormat(locale(), {
1756+
dateStyle: "medium",
1757+
timeZone: "UTC",
1758+
}).format(parsed);
1759+
}
1760+
return formatDateTime(value);
1761+
}
1762+
17501763
function localizedExternalText(value, fallback) {
17511764
const text = typeof value === "string" ? value.trim() : "";
17521765
if (!text) return fallback;
@@ -4832,6 +4845,14 @@
48324845
function renderHealth() {
48334846
const payload = state.health.payload;
48344847
const summary = payload.summary || {};
4848+
const healthCount = (status) => Math.max(
4849+
Number(summary[status]) || 0,
4850+
payload.strategies.filter((item) => item.status === status).length,
4851+
);
4852+
const criticalCount = healthCount("critical");
4853+
const reviewCount = healthCount("review");
4854+
const watchCount = healthCount("watch");
4855+
const hasAttention = criticalCount + reviewCount + watchCount > 0;
48354856
const summaryAvailable = state.auth.allowed && payload.data_status !== "unavailable";
48364857
const summaryCount = (value) => (summaryAvailable ? String(Number(value) || 0) : "—");
48374858
const statusText = payload.data_status === "ready"
@@ -4854,6 +4875,11 @@
48544875
notice.textContent = t("healthStaleNotice");
48554876
} else if (payload.data_status !== "ready") {
48564877
notice.textContent = t("healthUnavailableNotice");
4878+
} else if (hasAttention) {
4879+
notice.textContent = t("healthAttentionNotice")
4880+
.replace("{critical}", String(criticalCount))
4881+
.replace("{review}", String(reviewCount))
4882+
.replace("{watch}", String(watchCount));
48574883
} else if (payload.errors?.length) {
48584884
notice.textContent = t("healthUpstreamNotice");
48594885
} else {
@@ -4889,7 +4915,7 @@
48894915
const detail = document.createElement("div");
48904916
detail.className = "health-card__meta";
48914917
detail.textContent = t("healthDetail")
4892-
.replace("{date}", formatDateTime(item.as_of));
4918+
.replace("{date}", formatAsOfDate(item.as_of));
48934919
main.append(meta, title, reason, detail);
48944920
const scoreBlock = document.createElement("div");
48954921
scoreBlock.className = "health-card__score";

web/strategy-switch-console/app_js.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/strategy-switch-console/worker.js

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ const CONTROL_PLANE_ATTENTION_STATUSES = ["research_only", "attention_required",
6060
const CONTROL_PLANE_MAX_SOURCES = 100;
6161
const CONTROL_PLANE_MAX_BODY_BYTES = 256 * 1024;
6262
// The current source is a once-per-trading-day P1/P3 lane, rather than a live
63-
// execution heartbeat. A 36-hour boundary tolerates weekends and a delayed
64-
// scheduled run without presenting a daily snapshot as permanently stale.
63+
// execution heartbeat. Freshness therefore preserves the normal TTL, but also
64+
// waits for the next scheduled research window across the weekend before it
65+
// calls an otherwise usable Friday/Saturday snapshot stale.
6566
const CONTROL_PLANE_DEFAULT_STALE_TTL_SECONDS = 36 * 60 * 60;
67+
const CONTROL_PLANE_RESEARCH_SCHEDULE_UTC_DAYS = new Set([2, 3, 4, 5, 6]);
68+
const CONTROL_PLANE_RESEARCH_SCHEDULE_UTC_HOUR = 3;
69+
const CONTROL_PLANE_RESEARCH_DELIVERY_GRACE_MS = 4 * 60 * 60 * 1000;
6670
const CONTROL_PLANE_CANDIDATE_KINDS = ["individual", "portfolio", "plugin"];
6771
const CONTROL_PLANE_STAGES = ["P1", "P2", "P3", "P4", "P5", "P6"];
6872
const CONTROL_PLANE_LIFECYCLE_STATUSES = [
@@ -1722,19 +1726,12 @@ async function currentControlPlanePayload(env) {
17221726
return emptyControlPlanePayload("snapshot_invalid");
17231727
}
17241728

1725-
const ttlSeconds = controlPlaneStaleTtlSeconds(env);
1726-
const freshnessTimestamps = [snapshot.generated_at, snapshot.computed_at]
1727-
.filter(Boolean)
1728-
.map((value) => Date.parse(value));
1729-
const freshnessAt = freshnessTimestamps.length ? Math.min(...freshnessTimestamps) : Number.NaN;
1730-
const now = Date.now();
1731-
const futureBeyondClockSkew = Number.isFinite(freshnessAt) && freshnessAt > now + 5 * 60 * 1000;
1732-
const ageSeconds = futureBeyondClockSkew
1733-
? Number.POSITIVE_INFINITY
1734-
: Number.isFinite(freshnessAt)
1735-
? Math.max(0, (now - freshnessAt) / 1000)
1736-
: Number.POSITIVE_INFINITY;
1737-
if (snapshot.data_status === "ready" && ageSeconds > ttlSeconds) snapshot.data_status = "stale";
1729+
const freshness = controlPlaneResearchSnapshotFreshness(
1730+
snapshot,
1731+
controlPlaneStaleTtlSeconds(env),
1732+
Date.now(),
1733+
);
1734+
snapshot.data_status = freshness.data_status;
17381735
return snapshot;
17391736
}
17401737

@@ -2839,7 +2836,7 @@ async function aggregateControlPlaneSources(env) {
28392836
let hasStaleSource = false;
28402837

28412838
for (const source of sources) {
2842-
const sourceFreshness = controlPlaneSnapshotFreshness(source, ttlSeconds, now);
2839+
const sourceFreshness = controlPlaneResearchSnapshotFreshness(source, ttlSeconds, now);
28432840
if (source.generated_at) timestamps.push(source.generated_at);
28442841
if (source.computed_at) timestamps.push(source.computed_at);
28452842
if (sourceFreshness.data_status === "ready") hasReadySource = true;
@@ -3209,6 +3206,45 @@ function controlPlaneSnapshotFreshness(snapshot, ttlSeconds, now) {
32093206
return { data_status: dataStatus, age_seconds: Number.isFinite(ageSeconds) ? ageSeconds : null };
32103207
}
32113208

3209+
function controlPlaneResearchSnapshotFreshness(snapshot, ttlSeconds, now) {
3210+
if (snapshot.data_status !== "ready") {
3211+
return controlPlaneSnapshotFreshness(snapshot, ttlSeconds, now);
3212+
}
3213+
const timestamps = [snapshot.generated_at, snapshot.computed_at]
3214+
.filter(Boolean)
3215+
.map((value) => Date.parse(value));
3216+
const freshnessAt = timestamps.length ? Math.min(...timestamps) : Number.NaN;
3217+
const futureBeyondClockSkew = Number.isFinite(freshnessAt) && freshnessAt > now + 5 * 60 * 1000;
3218+
const ageSeconds = futureBeyondClockSkew
3219+
? Number.POSITIVE_INFINITY
3220+
: Number.isFinite(freshnessAt)
3221+
? Math.max(0, Math.round((now - freshnessAt) / 1000))
3222+
: Number.POSITIVE_INFINITY;
3223+
if (!Number.isFinite(freshnessAt) || futureBeyondClockSkew) {
3224+
return { data_status: "stale", age_seconds: null };
3225+
}
3226+
3227+
const ttlDeadline = freshnessAt + ttlSeconds * 1000;
3228+
const nextScheduledRefresh = nextControlPlaneResearchScheduleAfter(freshnessAt);
3229+
const scheduledDeadline = nextScheduledRefresh + CONTROL_PLANE_RESEARCH_DELIVERY_GRACE_MS;
3230+
const staleDeadline = Math.max(ttlDeadline, scheduledDeadline);
3231+
return {
3232+
data_status: now > staleDeadline ? "stale" : "ready",
3233+
age_seconds: ageSeconds,
3234+
};
3235+
}
3236+
3237+
function nextControlPlaneResearchScheduleAfter(freshnessAt) {
3238+
const scheduled = new Date(freshnessAt);
3239+
scheduled.setUTCMinutes(0, 0, 0);
3240+
scheduled.setUTCHours(CONTROL_PLANE_RESEARCH_SCHEDULE_UTC_HOUR);
3241+
if (scheduled.getTime() <= freshnessAt) scheduled.setUTCDate(scheduled.getUTCDate() + 1);
3242+
while (!CONTROL_PLANE_RESEARCH_SCHEDULE_UTC_DAYS.has(scheduled.getUTCDay())) {
3243+
scheduled.setUTCDate(scheduled.getUTCDate() + 1);
3244+
}
3245+
return scheduled.getTime();
3246+
}
3247+
32123248
// A recently published source must not be able to re-advertise an old
32133249
// reconciliation candidate as current. The private controller independently
32143250
// verifies raw receipts, but the human console also fails closed here.
@@ -7197,6 +7233,7 @@ export const __test = {
71977233
emptyStrategyHealthPayload,
71987234
normalizeControlPlaneSnapshot,
71997235
normalizeControlPlaneSourceSnapshot,
7236+
controlPlaneResearchSnapshotFreshness,
72007237
emptyControlPlanePayload,
72017238
normalizeAdaptiveSelectionSourceSnapshot,
72027239
calculateAdaptiveSelectionDecisionDigest,

0 commit comments

Comments
 (0)