From cdb3a24b0e3c51d2ed4c74695bef930901c24589 Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Wed, 6 May 2026 12:58:11 +0300 Subject: [PATCH 1/6] fix(ui): fail integration run when no backtests found Treat 'No backtest results to send' and 'No results to process' log markers as job failures so Progress does not show succeeded for empty integration runs. --- packages/cli/src/commands/ui.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/cli/src/commands/ui.ts b/packages/cli/src/commands/ui.ts index d456b14..d418575 100644 --- a/packages/cli/src/commands/ui.ts +++ b/packages/cli/src/commands/ui.ts @@ -1166,6 +1166,18 @@ async function runIntegrationCommand( ); return; } + const noResultsLine = detectNoBacktestResultsFromOutput(allOutputLines); + if (noResultsLine) { + setJobFailed( + job, + new Error( + "Integration produced no backtest results. Run/backfill Freqtrade backtests first so user_data/backtest_results contains files. " + + noResultsLine, + ), + "integration run failed", + ); + return; + } setJobSucceeded(job, "integration run completed"); } catch (error) { if (job.status === "cancelled") { @@ -1411,6 +1423,16 @@ function detectKiploksUploadFailureFromOutput(lines: string[]): string | null { return null; } +/** Some bridge revisions returned exit code 0 despite empty scan; treat that as failed integration run. */ +function detectNoBacktestResultsFromOutput(lines: string[]): string | null { + for (const line of lines) { + if (/No backtest results to send\./i.test(line) || /No results to process/i.test(line)) { + return line.length > 500 ? line.slice(0, 500) + "..." : line; + } + } + return null; +} + function extractAnalyzeUrlsFromOutput(lines: string[]): string[] { const out: string[] = []; const seen = new Set(); From 8e9b83604844df580265e2a4d4d51cd889280857 Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Thu, 7 May 2026 17:28:10 +0300 Subject: [PATCH 2/6] feat(ui): add custom API target with persisted selection Add a Custom URL API target with URL/token fields and validation, and persist explicit api_target in kiploks.json so target selection no longer resets after save/reload. --- packages/cli/src/commands/ui.ts | 30 +++++++++--- .../legacy/wizard/KiploksWorkspacePanel.tsx | 49 +++++++++++++++++-- .../src/legacy/wizard/useOrchestratorApp.ts | 7 ++- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/ui.ts b/packages/cli/src/commands/ui.ts index d418575..7179ff7 100644 --- a/packages/cli/src/commands/ui.ts +++ b/packages/cli/src/commands/ui.ts @@ -1530,7 +1530,11 @@ function normalizeOrigin(url: string): string { .replace(/\/$/, ""); } -function resolveApiTarget(merged: Record): "local" | "cloud" { +function resolveApiTarget(merged: Record): "local" | "cloud" | "custom" { + const explicit = String(merged.api_target ?? "").trim(); + if (explicit === "local" || explicit === "cloud" || explicit === "custom") { + return explicit; + } if (merged.local_mode === true) return "local"; const u = normalizeOrigin(String(merged.api_url || "")); const browserLocal = normalizeOrigin(state.localApiBaseUrl); @@ -1540,11 +1544,11 @@ function resolveApiTarget(merged: Record): "local" | "cloud" { if (u.startsWith("http://host.docker.internal")) return "local"; if (/^http:\/\/172\.(17|18|19)\./.test(u)) return "local"; if (u.includes("kiploks.com")) return "cloud"; - return "cloud"; + return "custom"; } function readKiploksConfigForGet(integration: IntegrationKind): { - apiTarget: "local" | "cloud"; + apiTarget: "local" | "cloud" | "custom"; config: Record; localApiBaseUrl: string; localApiDockerBaseUrl: string; @@ -1577,7 +1581,7 @@ function readKiploksConfigForGet(integration: IntegrationKind): { function saveKiploksConfigFromUi( integration: IntegrationKind, body: Record, -): { ok: boolean; configPath: string; apiTarget: "local" | "cloud" } { +): { ok: boolean; configPath: string; apiTarget: "local" | "cloud" | "custom" } { const defaults = getFullDefaultKiploksJson(integration); const configPath = join(getIntegrationBridgePath(integration), "kiploks.json"); let disk: Record = {}; @@ -1589,7 +1593,9 @@ function saveKiploksConfigFromUi( } } const base = { ...defaults, ...disk }; - const apiTarget = body.api_target === "cloud" ? "cloud" : "local"; + const requestedTarget = String(body.api_target ?? "local").trim(); + const apiTarget: "local" | "cloud" | "custom" = + requestedTarget === "cloud" ? "cloud" : requestedTarget === "custom" ? "custom" : "local"; const out: Record = { ...base }; @@ -1632,7 +1638,7 @@ function saveKiploksConfigFromUi( out.api_url = state.localApiDockerBaseUrl; out.api_token = state.localApiToken; out.local_mode = true; - } else { + } else if (apiTarget === "cloud") { out.api_url = "https://kiploks.com/"; out.local_mode = false; const submitted = String(body.api_token ?? "").trim(); @@ -1646,9 +1652,21 @@ function saveKiploksConfigFromUi( } else { out.api_token = ""; } + } else { + const submittedUrl = String(body.custom_api_url ?? body.api_url ?? "").trim(); + if (!/^https?:\/\//i.test(submittedUrl)) { + throw new Error("custom_api_url must start with http:// or https://"); + } + out.api_url = submittedUrl; + out.local_mode = false; + const submitted = String(body.api_token ?? "").trim(); + const previous = String((base as Record).api_token ?? "").trim(); + if (submitted) out.api_token = submitted; + else out.api_token = previous; } out.schema_version = base.schema_version ?? defaults.schema_version; + out.api_target = apiTarget; out.integration_type = integration; out.engine_version = base.engine_version ?? defaults.engine_version; out.managed_by = "kiploks-orchestrator"; diff --git a/packages/cli/web/src/legacy/wizard/KiploksWorkspacePanel.tsx b/packages/cli/web/src/legacy/wizard/KiploksWorkspacePanel.tsx index 5109e2f..7234ed0 100644 --- a/packages/cli/web/src/legacy/wizard/KiploksWorkspacePanel.tsx +++ b/packages/cli/web/src/legacy/wizard/KiploksWorkspacePanel.tsx @@ -157,11 +157,14 @@ export function KiploksWorkspacePanel({ ctx }: Props) { setReportTitleTouched(true); }, [ctx.integration, ctx.selectedBacktestArtifactKey, ctx.backtestArtifacts, previousSelectedBacktestArtifactKey, reportTitleDraft]); - const cloudTokenMissing = - ctx.kiploksUi?.apiTarget === "cloud" && String(ctx.kiploksUi?.config?.api_token ?? "").trim() === ""; + const cloudTokenMissing = (ctx.kiploksUi?.apiTarget === "cloud" || ctx.kiploksUi?.apiTarget === "custom") && + String(ctx.kiploksUi?.config?.api_token ?? "").trim() === ""; + const customApiUrl = String(ctx.kiploksUi?.config?.api_url ?? "").trim(); + const customApiUrlInvalid = ctx.kiploksUi?.apiTarget === "custom" && !/^https?:\/\//i.test(customApiUrl); const cfg = ctx.kiploksUi?.config ?? {}; const fieldValid = { - apiToken: ctx.kiploksUi?.apiTarget === "cloud" ? hasText(cfg.api_token) : true, + apiToken: ctx.kiploksUi?.apiTarget === "cloud" || ctx.kiploksUi?.apiTarget === "custom" ? hasText(cfg.api_token) : true, + customApiUrl: ctx.kiploksUi?.apiTarget === "custom" ? /^https?:\/\//i.test(String(cfg.api_url ?? "").trim()) : true, topN: isPositiveInt(cfg.top_n), skipAlreadyUploaded: typeof cfg.skip_already_uploaded === "boolean", wfaPeriods: isPositiveInt(cfg.wfaPeriods), @@ -336,7 +339,12 @@ export function KiploksWorkspacePanel({ ctx }: Props) { ) : null} {cloudTokenMissing ? (

- Cloud target is selected, but api_token is empty. Fill API key in kiploks.json settings before Run Integration. + Cloud or Custom target is selected, but api_token is empty. Fill API key in kiploks.json settings before Run Integration. +

+ ) : null} + {customApiUrlInvalid ? ( +

+ Custom target is selected, but api_url must start with http:// or https://.

) : null} {(() => { @@ -356,6 +364,7 @@ export function KiploksWorkspacePanel({ ctx }: Props) { !ctx.canRunIntegration || ctx.hasKiploksChanges || cloudTokenMissing || + customApiUrlInvalid || integrationSubmitting || ctx.activeIntegrationJob?.status === "queued" || ctx.activeIntegrationJob?.status === "running" @@ -370,6 +379,7 @@ export function KiploksWorkspacePanel({ ctx }: Props) { disabled={ !ctx.hasPathForIntegration || cloudTokenMissing || + customApiUrlInvalid || integrationSubmitting || ctx.activeIntegrationJob?.status === "queued" || ctx.activeIntegrationJob?.status === "running" @@ -385,6 +395,7 @@ export function KiploksWorkspacePanel({ ctx }: Props) { disabled={ !ctx.hasPathForIntegration || cloudTokenMissing || + customApiUrlInvalid || integrationSubmitting || ctx.activeIntegrationJob?.status === "queued" || ctx.activeIntegrationJob?.status === "running" @@ -416,6 +427,7 @@ export function KiploksWorkspacePanel({ ctx }: Props) { Local (UI {String(ctx.kiploksUi.localApiBaseUrl || "")} · Docker {String(ctx.kiploksUi.localApiDockerBaseUrl || "")}) + {ctx.kiploksUi.apiTarget === "local" ? (

@@ -442,6 +454,33 @@ export function KiploksWorkspacePanel({ ctx }: Props) {

) : null} + {ctx.kiploksUi.apiTarget === "custom" ? ( + <> + + ctx.setKiploksField("api_url", e.target.value)} + placeholder="http(s)://your-server.example" + /> +

+ Save writes this value to kiploks.json as api_url and integration runs will upload to this server. +

+ + ctx.setKiploksField("api_token", e.target.value)} + placeholder="Paste API key for custom Kiploks server" + /> + + ) : null} {ctx.integration === "freqtrade" ? (
@@ -645,7 +684,7 @@ export function KiploksWorkspacePanel({ ctx }: Props) { } })(); }} - disabled={!ctx.kiploksUi || !ctx.hasKiploksChanges} + disabled={!ctx.kiploksUi || !ctx.hasKiploksChanges || customApiUrlInvalid} > Save kiploks.json diff --git a/packages/cli/web/src/legacy/wizard/useOrchestratorApp.ts b/packages/cli/web/src/legacy/wizard/useOrchestratorApp.ts index fae58e6..6ffc0af 100644 --- a/packages/cli/web/src/legacy/wizard/useOrchestratorApp.ts +++ b/packages/cli/web/src/legacy/wizard/useOrchestratorApp.ts @@ -267,6 +267,7 @@ export function useOrchestratorApp() { if (!prev) return prev; const nextCfg = { ...prev.config }; if (v === "local") nextCfg.api_token = ""; + if (v === "custom") nextCfg.api_url = ""; return { ...prev, apiTarget: v, config: nextCfg }; }); }; @@ -286,7 +287,8 @@ export function useOrchestratorApp() { payload.hyperopt_loss = c.hyperopt_loss; payload.hyperopt_result_path = c.hyperopt_result_path; payload.keep_last_n_backtest_files = c.keep_last_n_backtest_files; - if (kiploksUi.apiTarget === "cloud") payload.api_token = c.api_token || ""; + if (kiploksUi.apiTarget === "cloud" || kiploksUi.apiTarget === "custom") payload.api_token = c.api_token || ""; + if (kiploksUi.apiTarget === "custom") payload.custom_api_url = c.api_url || ""; } else { payload.backtesting_path = c.backtesting_path; payload.top_n = c.top_n; @@ -295,7 +297,8 @@ export function useOrchestratorApp() { payload.wfaISSize = c.wfaISSize; payload.wfaOOSSize = c.wfaOOSSize; payload.skip_already_uploaded = c.skip_already_uploaded; - if (kiploksUi.apiTarget === "cloud") payload.api_token = c.api_token || ""; + if (kiploksUi.apiTarget === "cloud" || kiploksUi.apiTarget === "custom") payload.api_token = c.api_token || ""; + if (kiploksUi.apiTarget === "custom") payload.custom_api_url = c.api_url || ""; } await api.post("/integrations/kiploks-config", payload); await loadKiploksConfig(); From 629a2fcc50c7d1b5c88705c731fff2f83196df7f Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Thu, 7 May 2026 17:33:08 +0300 Subject: [PATCH 3/6] fix(parity): align local report math contract and add parity bench Add benchmarkComparison fallback synthesis and normalize computed riskAnalysisVersion to 0 for local/cloud contract parity. Include a bench script plus core regression test to catch payload parity drifts early. --- package.json | 1 + .../buildTestResultDataFromUnified.test.ts | 27 +++++ .../src/buildTestResultDataFromUnified.ts | 44 ++++++- scripts/bench-local-cloud-parity.mjs | 112 ++++++++++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 scripts/bench-local-cloud-parity.mjs diff --git a/package.json b/package.json index b85ec91..ffb34c9 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test:coverage": "vitest run -c vitest.config.ts --coverage", "engine:check:boundary": "node scripts/check-engine-boundary.mjs", "engine:check:bundle": "node scripts/check-open-core-bundles.mjs", + "bench:parity": "node scripts/bench-local-cloud-parity.mjs", "engine:validate": "npm run lint && npm run test && npm run engine:check:boundary && npm run engine:check:bundle", "engine:examples:generate-samples": "node docs/examples/scripts/generate-samples.mjs", "engine:examples:generate-monte-carlo-fixture": "node docs/examples/scripts/generate-monte-carlo-seed42.mjs", diff --git a/packages/core/src/buildTestResultDataFromUnified.test.ts b/packages/core/src/buildTestResultDataFromUnified.test.ts index 7f3cc9f..51d5136 100644 --- a/packages/core/src/buildTestResultDataFromUnified.test.ts +++ b/packages/core/src/buildTestResultDataFromUnified.test.ts @@ -111,6 +111,33 @@ describe("buildTestResultDataFromUnified", () => { expect(out?.verdictPayload).toBeDefined(); }); + it("creates benchmarkComparison fallback when payload has no benchmark block", () => { + const payload = makePayload({ + benchmarkComparison: undefined, + backtestResult: { + config: { + symbol: "BTCUSDT", + timeframe: "1h", + exchange: "binance", + initialBalance: 1000, + startDate: "2024-01-01", + endDate: "2024-12-31", + }, + results: { + symbol: "BTCUSDT", + totalTrades: 48, + totalReturn: 0.25, + }, + }, + }); + const out = buildTestResultDataFromUnified(payload as never, "r1"); + const bc = out?.benchmarkComparison as Record | null | undefined; + expect(bc).toBeTruthy(); + expect(typeof bc?.strategyCAGR).toBe("number"); + expect(typeof bc?.btcCAGR).toBe("number"); + expect(Array.isArray(bc?.interpretation)).toBe(true); + }); + it("Layer 2.5 uses WFA windows when periods absent (OOS rows + pro sumOos + invariants)", () => { const wfaOnlyWindows = { windows: [ diff --git a/packages/core/src/buildTestResultDataFromUnified.ts b/packages/core/src/buildTestResultDataFromUnified.ts index fe3dcec..d4aa215 100644 --- a/packages/core/src/buildTestResultDataFromUnified.ts +++ b/packages/core/src/buildTestResultDataFromUnified.ts @@ -124,6 +124,34 @@ function validBenchmarkComparison(value: unknown): boolean { ); } +function buildFallbackBenchmarkComparison( + unifiedPayload: Record, + strategy: { testPeriodStart: string; testPeriodEnd: string }, +): Record | null { + const backtest = unifiedPayload.backtestResult as Record | undefined; + const results = backtest?.results as Record | undefined; + const totalReturnRaw = results?.totalReturn; + if (typeof totalReturnRaw !== "number" || !Number.isFinite(totalReturnRaw)) return null; + const totalReturn = totalReturnRaw as number; + const days = parseDateToDays(strategy.testPeriodStart, strategy.testPeriodEnd); + const years = days != null && days > 0 ? days / 365.25 : null; + const strategyCAGR = + years != null && years > 0 + ? (Math.pow(1 + totalReturn, 1 / years) - 1) * 100 + : totalReturn * 100; + const strategyCagrRounded = Number.isFinite(strategyCAGR) + ? Number(strategyCAGR.toFixed(2)) + : 0; + return { + strategyCAGR: strategyCagrRounded, + btcCAGR: 0, + excessReturn: strategyCagrRounded, + informationRatio: 0, + correlationToBTC: null, + interpretation: ["Benchmark data unavailable in payload - showing strategy-only fallback."], + }; +} + function parseDateToDays(startStr: string, endStr: string): number | null { if (!startStr || !endStr) return null; const start = new Date(startStr.slice(0, 10)); @@ -712,9 +740,9 @@ export function buildTestResultDataFromUnified( usePayloadRisk && payloadRiskObj ? { ...payloadRiskObj, riskAnalysisVersion: (payloadRiskObj.riskAnalysisVersion as number) ?? 0 } : riskAnalysisBase && riskSource - ? { ...(riskAnalysisBase as unknown as Record), source: riskSource, riskAnalysisVersion: 1 } + ? { ...(riskAnalysisBase as unknown as Record), source: riskSource, riskAnalysisVersion: 0 } : riskAnalysisBase != null - ? { ...(riskAnalysisBase as unknown as Record), riskAnalysisVersion: 1 } + ? { ...(riskAnalysisBase as unknown as Record), riskAnalysisVersion: 0 } : null; if (typeof process !== "undefined" && process.env?.NODE_ENV !== "test") { const source = hasOosTrades ? "oos_trades" : (oosMetrics ? oosMetrics.source : "empty"); @@ -954,7 +982,10 @@ export function buildTestResultDataFromUnified( NA; const benchmarkComparison = (() => { - const bc = unifiedPayload.benchmarkComparison; + const bc = + unifiedPayload.benchmarkComparison ?? + (unifiedPayload.proBenchmarkMetrics as Record | undefined)?.benchmarkComparison ?? + ((unifiedPayload.backtestResult as Record | undefined)?.benchmarkComparison); const valid = validBenchmarkComparison(bc); if (bc != null && !valid) { engineWarn( @@ -965,7 +996,12 @@ export function buildTestResultDataFromUnified( Array.isArray((bc as Record)?.interpretation), ); } - if (!valid || bc == null) return null; + if (!valid || bc == null) { + return buildFallbackBenchmarkComparison(unifiedPayload as Record, { + testPeriodStart: strategy.testPeriodStart, + testPeriodEnd: strategy.testPeriodEnd, + }); + } const bcObj = bc as Record; const out = { ...bcObj } as TestResultData["benchmarkComparison"]; const s = (out as Record).strategyCAGR as number | undefined; diff --git a/scripts/bench-local-cloud-parity.mjs b/scripts/bench-local-cloud-parity.mjs new file mode 100644 index 0000000..588da3b --- /dev/null +++ b/scripts/bench-local-cloud-parity.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; + +function usage() { + process.stderr.write( + "Usage: node scripts/bench-local-cloud-parity.mjs [epsilon]\n", + ); +} + +function getReportRoot(raw) { + if (!raw || typeof raw !== "object") return {}; + if (raw.report && typeof raw.report === "object") return raw.report; + return raw; +} + +function flatten(value, path = "", out = {}) { + if (Array.isArray(value)) { + out[path] = value; + return out; + } + if (value && typeof value === "object") { + if (path) out[path] = value; + for (const [k, v] of Object.entries(value)) { + const next = path ? `${path}.${k}` : k; + flatten(v, next, out); + } + return out; + } + out[path] = value; + return out; +} + +function parseFile(path) { + const txt = readFileSync(path, "utf8"); + return JSON.parse(txt); +} + +const [, , localPath, cloudPath, epsRaw] = process.argv; +if (!localPath || !cloudPath) { + usage(); + process.exit(2); +} +const epsilon = Number.isFinite(Number(epsRaw)) ? Number(epsRaw) : 1e-9; + +const local = getReportRoot(parseFile(localPath)); +const cloud = getReportRoot(parseFile(cloudPath)); +const L = flatten(local); +const C = flatten(cloud); + +const requiredKeys = [ + "results.totalReturn", + "results.totalTrades", + "riskAnalysis.metrics.winRate", + "riskAnalysis.metrics.profitFactor", + "proBenchmarkMetrics.oosRetention", + "proBenchmarkMetrics.wfaPassProbability", + "decisionLogic.verdict", + "robustnessScore.overall", + "benchmarkComparison", +]; + +const failures = []; +for (const key of requiredKeys) { + if (!(key in L)) failures.push(`local missing required key: ${key}`); + if (!(key in C)) failures.push(`cloud missing required key: ${key}`); +} + +const common = Object.keys(L).filter((k) => k in C); +const numericDiffs = []; +for (const key of common) { + const lv = L[key]; + const cv = C[key]; + if (typeof lv === "number" && typeof cv === "number") { + const d = Math.abs(lv - cv); + if (Number.isFinite(d) && d > epsilon) { + numericDiffs.push({ key, local: lv, cloud: cv, absDelta: d }); + } + } +} + +const highPriorityKeys = [ + "results.totalReturn", + "results.totalTrades", + "decisionLogic.verdict", + "riskAnalysis.riskAnalysisVersion", + "benchmarkComparison", +]; +for (const k of highPriorityKeys) { + if (k in L && k in C) { + const lv = L[k]; + const cv = C[k]; + const same = JSON.stringify(lv) === JSON.stringify(cv); + if (!same) failures.push(`high-priority mismatch: ${k}`); + } +} + +process.stdout.write(`Compared local=${localPath} cloud=${cloudPath} epsilon=${epsilon}\n`); +process.stdout.write(`Common flattened keys: ${common.length}\n`); +process.stdout.write(`Numeric diffs over epsilon: ${numericDiffs.length}\n`); +if (numericDiffs.length > 0) { + for (const row of numericDiffs.slice(0, 25)) { + process.stdout.write( + ` ${row.key}: local=${row.local} cloud=${row.cloud} delta=${row.absDelta}\n`, + ); + } +} +if (failures.length > 0) { + process.stderr.write("Parity bench FAILED:\n"); + for (const f of failures) process.stderr.write(` - ${f}\n`); + process.exit(1); +} +process.stdout.write("Parity bench OK\n"); From a6fb8d866e380f91b29de423786d6c32869d5fa9 Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Fri, 8 May 2026 11:38:37 +0300 Subject: [PATCH 4/6] feat(parity): compute local benchmark from Binance and finalize cloud parity bench Build benchmarkComparison from real BTC klines in the local orchestrator path (same benchmark source model as cloud) and make parity bench compare benchmark objects order-insensitively with numeric epsilon, so true contract parity is validated. --- packages/cli/src/commands/ui.ts | 278 ++++++++++++++++++++++++++- scripts/bench-local-cloud-parity.mjs | 20 +- 2 files changed, 294 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/ui.ts b/packages/cli/src/commands/ui.ts index 7179ff7..a821e85 100644 --- a/packages/cli/src/commands/ui.ts +++ b/packages/cli/src/commands/ui.ts @@ -3,7 +3,18 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join, resolve, sep } from "node:path"; import type { ViteDevServer } from "vite"; -import { analyze, mapPayloadToUnified } from "@kiploks/engine-core"; +import { + analyze, + buildBenchmarkFallbackComparison, + buildEquityCurveFromTradesForBenchmark, + mapPayloadToUnified, + normalizeEquityCurveFromPayload, + normalizeRate, + parseBenchmarkInterval, + resolveBenchmarkBtcKlines, + tryBuildBenchmarkComparisonFromEquityPath, + yearsBetweenIsoDates, +} from "@kiploks/engine-core"; import { buildTestResultDataFromUnified } from "@kiploks/engine-core/server"; type IntegrationKind = "freqtrade" | "octobot"; @@ -71,6 +82,265 @@ type AnalyzeLinkRecord = { reportId?: string; }; +type BinanceKlineRow = [number, string, string, string, string, string, number, string, number, string, string, string]; + +function toNum(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : Number.NaN; +} + +function deriveBenchmarkWindow(payload: Record): { + startDate: string; + endDate: string; + timeframe: string; +} | null { + const backtest = + (payload.backtestResult ?? payload.backtest) as Record | undefined; + const config = (backtest?.config ?? {}) as Record; + const strategy = (payload.strategy ?? {}) as Record; + const results = (backtest?.results ?? backtest) as Record | undefined; + + let dateFrom = String( + payload.dateFrom ?? + backtest?.dateFrom ?? + config.startDate ?? + "", + ).slice(0, 10); + let dateTo = String( + payload.dateTo ?? + backtest?.dateTo ?? + config.endDate ?? + "", + ).slice(0, 10); + + if ((!dateFrom || !dateTo) && results && typeof results === "object") { + const start = String(results.backtest_start ?? results.start_date ?? results.startDate ?? "").slice(0, 10); + const end = String(results.backtest_end ?? results.end_date ?? results.endDate ?? "").slice(0, 10); + if (!dateFrom && start) dateFrom = start; + if (!dateTo && end) dateTo = end; + } + + if (!dateFrom || !dateTo) return null; + const timeframe = String(config.timeframe ?? strategy.timeframe ?? "1h"); + return { startDate: dateFrom, endDate: dateTo, timeframe }; +} + +async function fetchBinanceBtcKlines(args: { + interval: string; + startMs: number; + endMs: number; +}): Promise> { + const out: Array<{ timestamp: number; close: number }> = []; + const limit = 1000; + let since = args.startMs; + let safety = 0; + while (since < args.endMs && safety < 50) { + safety += 1; + const u = new URL("https://api.binance.com/api/v3/klines"); + u.searchParams.set("symbol", "BTCUSDT"); + u.searchParams.set("interval", args.interval); + u.searchParams.set("startTime", String(since)); + u.searchParams.set("endTime", String(args.endMs)); + u.searchParams.set("limit", String(limit)); + const response = await fetch(u.toString()); + if (!response.ok) { + throw new Error(`Binance klines request failed (${response.status})`); + } + const rows = (await response.json()) as BinanceKlineRow[]; + if (!Array.isArray(rows) || rows.length === 0) break; + for (const row of rows) { + const ts = Number(row?.[0]); + const close = Number(row?.[4]); + if (Number.isFinite(ts) && Number.isFinite(close) && ts <= args.endMs) { + out.push({ timestamp: ts, close }); + } + } + const lastTs = Number(rows[rows.length - 1]?.[0]); + if (!Number.isFinite(lastTs) || rows.length < limit) break; + // Binance klines are candle-open timestamps; increment by 1ms to avoid refetching last bar. + since = lastTs + 1; + } + return out; +} + +async function enrichPayloadWithBenchmarkComparison(raw: Record): Promise> { + const existing = raw.benchmarkComparison; + if (existing && typeof existing === "object" && !Array.isArray(existing)) { + return raw; + } + const backtest = (raw.backtestResult ?? raw.backtest) as Record | undefined; + const config = (backtest?.config ?? {}) as Record; + const results = (backtest?.results ?? backtest) as Record | undefined; + const window = deriveBenchmarkWindow(raw); + if (!window) return raw; + + const startMs = Date.parse(`${window.startDate}T00:00:00.000Z`); + const endMs = Date.parse(`${window.endDate}T23:59:59.999Z`); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return raw; + + const timeframeInterval = parseBenchmarkInterval(window.timeframe); + const initialBalance = toNum(config.initialBalance); + const equityRaw = (backtest?.equityCurve ?? backtest?.equity_curve) as unknown[] | undefined; + const equityCurve = + normalizeEquityCurveFromPayload(equityRaw) || + []; + const trades = Array.isArray(backtest?.trades) ? (backtest?.trades as unknown[]) : []; + const equity = + equityCurve.length >= 2 + ? equityCurve + : buildEquityCurveFromTradesForBenchmark(trades, Number.isFinite(initialBalance) ? initialBalance : 1000); + if (!Array.isArray(equity) || equity.length < 2) return raw; + + try { + const btcKlines = await resolveBenchmarkBtcKlines({ + exchangeType: "binance", + symbol: "BTCUSDT", + interval: timeframeInterval, + startMs, + endMs, + fetchKlines: async ({ interval, startMs, endMs }) => fetchBinanceBtcKlines({ interval, startMs, endMs }), + }); + if (!btcKlines || btcKlines.length < 2) return raw; + const totalReturn = toNum(results?.totalReturn); + const totalTrades = toNum(results?.totalTrades); + const years = yearsBetweenIsoDates(window.startDate, window.endDate); + const feeRaw = toNum(config.fee_open ?? config.commission); + const slippageRaw = toNum(config.slippage); + const commissionDecimal = normalizeRate(Number.isFinite(feeRaw) ? feeRaw : 0.001); + const slippageDecimal = normalizeRate(Number.isFinite(slippageRaw) ? slippageRaw : 0.0005); + let comparison = tryBuildBenchmarkComparisonFromEquityPath({ + equityCurve: equity, + btcKlines, + initialBalance: Number.isFinite(initialBalance) ? initialBalance : 1000, + timeframeStr: window.timeframe, + totalReturn: Number.isFinite(totalReturn) ? totalReturn : 0, + commissionDecimal, + slippageDecimal, + feesPerTradeIsDefault: config.fee_open == null && config.commission == null, + slippagePerTradeIsDefault: config.slippage == null, + totalTrades: Number.isFinite(totalTrades) ? totalTrades : 0, + }); + if (!comparison) { + comparison = buildBenchmarkFallbackComparison({ + totalReturn: Number.isFinite(totalReturn) ? totalReturn : 0, + btcKlines, + years: years ?? 1, + commissionDecimal, + slippageDecimal, + feesPerTradeIsDefault: config.fee_open == null && config.commission == null, + slippagePerTradeIsDefault: config.slippage == null, + }) as unknown as Record; + } + return { ...raw, benchmarkComparison: comparison }; + } catch { + return raw; + } +} + +function alignReportContractForCloudParity(report: unknown): unknown { + if (!report || typeof report !== "object" || Array.isArray(report)) return report; + const out = { ...(report as Record) }; + const risk = out.riskAnalysis; + if (risk && typeof risk === "object" && !Array.isArray(risk)) { + const r = { ...(risk as Record) }; + r.riskAnalysisVersion = 0; + out.riskAnalysis = r; + } + if (out.benchmarkComparison == null) { + const canonical = + out.canonicalMetrics && typeof out.canonicalMetrics === "object" && !Array.isArray(out.canonicalMetrics) + ? (out.canonicalMetrics as Record) + : null; + const fullCanonical = + canonical?.fullBacktestMetrics && typeof canonical.fullBacktestMetrics === "object" && !Array.isArray(canonical.fullBacktestMetrics) + ? (canonical.fullBacktestMetrics as Record) + : null; + const pro = out.proBenchmarkMetrics; + const buckets = + pro && typeof pro === "object" && !Array.isArray(pro) + ? (pro as Record).benchmarkMetricsBuckets + : null; + const full = + buckets && typeof buckets === "object" && !Array.isArray(buckets) + ? ((buckets as Record).fullBacktestContext as Record | undefined) + : undefined; + const strategyCalmarRatio = Number((full as Record | undefined)?.fullCalmar ?? fullCanonical?.calmarRatio); + const strategyMaxDrawdownRaw = Number((full as Record | undefined)?.fullMaxDrawdown ?? fullCanonical?.maxDrawdown); + const strategyVolatility = Number((full as Record | undefined)?.fullVolatility); + const totalReturn = Number(fullCanonical?.totalReturn); + const dateFrom = String(fullCanonical?.dateFrom ?? ""); + const dateTo = String(fullCanonical?.dateTo ?? ""); + const d0 = Date.parse(dateFrom); + const d1 = Date.parse(dateTo); + const years = Number.isFinite(d0) && Number.isFinite(d1) && d1 > d0 ? (d1 - d0) / (365.25 * 24 * 60 * 60 * 1000) : null; + const strategyCAGR = + Number.isFinite(totalReturn) && years != null && years > 0 + ? ((Math.pow(1 + totalReturn, 1 / years) - 1) * 100) + : Number.NaN; + const turnover = + out.turnoverAndCostDrag && typeof out.turnoverAndCostDrag === "object" && !Array.isArray(out.turnoverAndCostDrag) + ? (out.turnoverAndCostDrag as Record) + : null; + const costDecomp = + turnover?.costDecomposition && typeof turnover.costDecomposition === "object" && !Array.isArray(turnover.costDecomposition) + ? (turnover.costDecomposition as Record) + : null; + const netEdgeFromTriggers = (() => { + const proObj = + out.proBenchmarkMetrics && typeof out.proBenchmarkMetrics === "object" && !Array.isArray(out.proBenchmarkMetrics) + ? (out.proBenchmarkMetrics as Record) + : null; + const triggers = Array.isArray(proObj?.killSwitchTriggers) ? (proObj?.killSwitchTriggers as unknown[]) : []; + for (const t of triggers) { + const s = String(t ?? ""); + const m = s.match(/Net Edge[^:]*current:\s*([-+]?\d+(?:\.\d+)?)\s*bps/i); + if (m?.[1]) { + const n = Number(m[1]); + if (Number.isFinite(n)) return n; + } + } + return null; + })(); + if (Number.isFinite(strategyCAGR) || Number.isFinite(strategyCalmarRatio) || Number.isFinite(strategyMaxDrawdownRaw)) { + out.benchmarkComparison = { + strategyCAGR: Number.isFinite(strategyCAGR) ? Number(strategyCAGR.toFixed(2)) : 0, + btcCAGR: 0, + excessReturn: Number.isFinite(strategyCAGR) ? Number(strategyCAGR.toFixed(2)) : 0, + informationRatio: 0, + correlationToBTC: 0, + betaToBTC: 0, + trackingError: 0, + rollingCorrelationPeak: 0, + alphaTStatLags: 0, + nObservationsTStat: 0, + strategyVolatility: Number.isFinite(strategyVolatility) ? Number(strategyVolatility.toFixed(2)) : undefined, + btcVolatility: 0, + btcCalmarRatio: 0, + btcMaxDrawdown: 0, + btcSkewness: 0, + btcKurtosis: 0, + strategyCalmarRatio: Number.isFinite(strategyCalmarRatio) ? Number(strategyCalmarRatio.toFixed(2)) : undefined, + strategyMaxDrawdown: Number.isFinite(strategyMaxDrawdownRaw) + ? Number((Math.abs(strategyMaxDrawdownRaw) * 100).toFixed(2)) + : undefined, + feesPerTrade: Number.isFinite(Number(costDecomp?.exchangeFeesPct)) + ? Number((Math.abs(Number(costDecomp?.exchangeFeesPct)) / 100).toFixed(4)) + : 0.001, + slippagePerTrade: Number.isFinite(Number(costDecomp?.slippagePct)) + ? Number((Math.abs(Number(costDecomp?.slippagePct)) / 100).toFixed(4)) + : 0.0005, + slippagePerTradeIsDefault: true, + breakEvenSlippageNote: "N/A (local synthesized benchmark)", + netEdgeBps: netEdgeFromTriggers ?? 0, + interpretation: [ + "Benchmark comparison is synthesized locally when cloud benchmark inputs are unavailable.", + ], + }; + } + } + return out; +} + const state = { lastPreflight: null as PreflightResult | null, paths: new Map(), @@ -629,9 +899,11 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const reportIds: string[] = []; const batchTotal = body.results.length; for (let i = 0; i < body.results.length; i++) { - const raw = body.results[i] as Record; + const rawInput = body.results[i] as Record; + const raw = await enrichPayloadWithBenchmarkComparison(rawInput); const unified = mapPayloadToUnified(raw); - const report = buildTestResultDataFromUnified(unified, `local_${Date.now()}_${i}`); + const reportRaw = buildTestResultDataFromUnified(unified, `local_${Date.now()}_${i}`); + const report = alignReportContractForCloudParity(reportRaw); const reportId = `report_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const shellUrl = `${state.localApiBaseUrl}/ui/#report=${reportId}`; const kiploksUrl = pickKiploksAnalyzeUrlForResult(body, i, raw); diff --git a/scripts/bench-local-cloud-parity.mjs b/scripts/bench-local-cloud-parity.mjs index 588da3b..3479ae9 100644 --- a/scripts/bench-local-cloud-parity.mjs +++ b/scripts/bench-local-cloud-parity.mjs @@ -89,7 +89,25 @@ for (const k of highPriorityKeys) { if (k in L && k in C) { const lv = L[k]; const cv = C[k]; - const same = JSON.stringify(lv) === JSON.stringify(cv); + const same = (() => { + if (k !== "benchmarkComparison") return JSON.stringify(lv) === JSON.stringify(cv); + if (!lv || !cv || typeof lv !== "object" || typeof cv !== "object") { + return JSON.stringify(lv) === JSON.stringify(cv); + } + const a = lv; + const b = cv; + const keys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).sort(); + for (const key of keys) { + const av = a[key]; + const bv = b[key]; + if (typeof av === "number" && typeof bv === "number") { + if (Math.abs(av - bv) > epsilon) return false; + continue; + } + if (JSON.stringify(av) !== JSON.stringify(bv)) return false; + } + return true; + })(); if (!same) failures.push(`high-priority mismatch: ${k}`); } } From cf1174901fca158e914278b02888d527dbbaf5bc Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Fri, 8 May 2026 13:48:02 +0300 Subject: [PATCH 5/6] fix(parity): align local WFA rendering with cloud outputs Unify local WFA normalization with cloud-style sources and scaling so key walkthrough metrics match across views. Also reduce false-critical integrity noise for low-sample Luck Factor cases while keeping warnings visible. --- .../web/src/shell/report/ReportBlocksView.tsx | 61 +++- .../shell/report/reportDisplayNormalize.ts | 326 ++++++++++++++++-- packages/core/src/integrity.ts | 10 +- packages/core/src/mapPayloadToUnified.ts | 55 ++- 4 files changed, 416 insertions(+), 36 deletions(-) diff --git a/packages/cli/web/src/shell/report/ReportBlocksView.tsx b/packages/cli/web/src/shell/report/ReportBlocksView.tsx index 63345c1..6fb177c 100644 --- a/packages/cli/web/src/shell/report/ReportBlocksView.tsx +++ b/packages/cli/web/src/shell/report/ReportBlocksView.tsx @@ -233,6 +233,8 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) { const pro = asObj(lite.proBenchmarkMetrics); const wfa = asObj(lite.walkForwardAnalysis); const sens = asObj(lite.parameterSensitivity); + const deploymentStatusGlobal = str(sens?.deploymentStatus); + const deploymentRejectedGlobal = /REJECT|FAIL/i.test(deploymentStatusGlobal || ""); const turnover = asObj(lite.turnoverAndCostDrag); const risk = asObj(lite.riskAnalysis); const actionPlan = asObj(lite.strategyActionPlan); @@ -370,13 +372,16 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) { {robustRows.map((r) => { const v = typeof r.value === "number" ? Math.round(r.value) : null; const isBlocked = v != null && v <= 0; + const isStabilityRow = r.key === "stability"; const tone = scoreTone(v); + const effectiveTone = + isStabilityRow && deploymentRejectedGlobal && tone === "good" ? "warn" : tone; const weight = r.key === "validation" ? 40 : r.key === "risk" ? 30 : r.key === "stability" ? 20 : r.key === "execution" ? 10 : 0; const barClass = - tone === "good" + effectiveTone === "good" ? "text-emerald-400" - : tone === "warn" + : effectiveTone === "warn" ? "text-amber-300" : "text-rose-400"; return ( @@ -389,6 +394,14 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) { >

{r.label} ({weight}%) + {isStabilityRow ? ( + + [?] + + ) : null} {isBlocked ? (blocking) : null}

@@ -398,7 +411,9 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) {

{isBlocked ? "→ BLOCKED" - : r.key === "stability" + : isStabilityRow && deploymentRejectedGlobal + ? "→ Parameters stable in isolation, but deployment is blocked by audit gates" + : r.key === "stability" ? "→ Parameters stable across sensitivity tests" : "→ Within threshold"}

@@ -748,16 +763,28 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) { const row = asObj(p); const sensitivity = num(row?.sensitivity); const status = str(row?.status) || (sensitivity != null && sensitivity >= 0.6 ? "Fragile" : "Stable"); + const topology = + str(row?.topology) || + str(row?.displayLabel) || + (sensitivity == null + ? "n/a" + : sensitivity >= 0.6 + ? "Sharp peak" + : sensitivity >= 0.4 + ? "Moderate" + : "Flat"); const statusCls = /FRAGILE|HIGH/i.test(status) ? "text-rose-400" : /TUNING|MODERATE/i.test(status) ? "text-amber-300" : "text-emerald-400"; + const statusIcon = + /FRAGILE|HIGH/i.test(status) ? "🔴" : /TUNING|MODERATE/i.test(status) ? "🟡" : "🟢"; return (
{str(row?.name) || `param_${idx + 1}`}
{str(row?.optimal) || num(row?.optimal) || "n/a"}
-
~
+
{topology}
{asNum(sensitivity, 2)}
-
🟢 {status}
+
{statusIcon} {status}
Suggested Mitigation: {str(row?.mitigation) || "Risk Neutral"}
@@ -794,9 +821,24 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) {

AUDIT VERDICT

+ {(() => { + const deploymentStatus = str(sens.deploymentStatus) || "APPROVED (no Decay check)"; + const deploymentCls = /REJECT|FAIL/i.test(deploymentStatus) + ? "text-rose-400" + : /HOLD|WARN|CAUTION/i.test(deploymentStatus) + ? "text-amber-300" + : "text-emerald-400"; + const riskClass = str(sens.riskClass) || "LOW"; + const riskClassCls = /HIGH|REJECT|FAIL/i.test(riskClass) + ? "text-rose-400" + : /MODERATE|WARN|CAUTION/i.test(riskClass) + ? "text-amber-300" + : "text-emerald-400"; + return ( + <>

Deployment Status:{" "} - {str(sens.deploymentStatus) || "APPROVED (no Decay check)"} + {deploymentStatus}

Performance Decay: {str(sens.performanceDecayNote) || "n/a (min 3 periods required for decay check)."}

@@ -804,11 +846,14 @@ export function ReportBlocksView({ lite }: { lite: TestResultDataLite }) { {str(sens.riskScoreFormula) || `Base ${num(sens.baseScore) ?? DISPLAY_NA} - Penalty ${num(sens.penalty) ?? DISPLAY_NA} ->`} {" "} - - {str(sens.riskClass) || "LOW"} ({num(sens.riskScore) ?? DISPLAY_NA}/100) + + {riskClass} ({num(sens.riskScore) ?? DISPLAY_NA}/100)

Pro-Note: {str(sens.proNote) || "Highest sensitivity parameter shown in table."}

+ + ); + })()}
) : null} diff --git a/packages/cli/web/src/shell/report/reportDisplayNormalize.ts b/packages/cli/web/src/shell/report/reportDisplayNormalize.ts index 57e8e85..f898d5c 100644 --- a/packages/cli/web/src/shell/report/reportDisplayNormalize.ts +++ b/packages/cli/web/src/shell/report/reportDisplayNormalize.ts @@ -41,7 +41,32 @@ function stringifyEdgeHalfLifeFields(row: Record): Record | null | undefined): Record | null { if (!pro) return null; const buckets = asRecord(pro.benchmarkMetricsBuckets); - if (!buckets) return stringifyEdgeHalfLifeFields({ ...pro }); + if (!buckets) { + const base = stringifyEdgeHalfLifeFields({ ...pro }); + const triggers = Array.isArray(base.killSwitchTriggers) + ? (base.killSwitchTriggers as unknown[]).map((x) => String(x)).filter(Boolean) + : []; + const killLimit = firstFinite(base.killSwitchMaxOosDrawdownWindows); + const synthesizedKillValue = + killLimit != null ? `${Math.round(killLimit)} consecutive (all windows) (limit: ${Math.round(killLimit)})` : undefined; + const verdictReason = + typeof base.verdictReason === "string" && base.verdictReason.trim() + ? (base.verdictReason as string) + : triggers.length > 0 + ? `${base.killSwitchKilled === true ? "Immediate Kill Switch triggered. " : ""}${triggers.join("; ")}` + : undefined; + return { + ...base, + ...(typeof base.verdict !== "string" && base.killSwitchKilled === true ? { verdict: "REJECT" } : {}), + ...(verdictReason ? { verdictReason } : {}), + ...(base.killSwitchKilled === true + ? { killSwitchValue: strOr(base.killSwitchValue, synthesizedKillValue ?? "TRIGGERED") } + : {}), + ...(triggers.length > 0 + ? { killSwitchHint: strOr(base.killSwitchHint, "Next OOS window in minus → turn off bot") } + : {}), + }; + } const oos = asRecord(buckets.oosEquityBased); const wpl = asRecord(buckets.wfaPeriodLevel); const wfeStd = firstFinite(wpl?.wfeStd); @@ -56,7 +81,7 @@ function mergeProBenchmarkMetrics(pro: Record | null | undefine const count = pwc ?? Math.round(pwRatio * tw); profitableWindowsText = `${count} / ${Math.round(tw)}`; } - return stringifyEdgeHalfLifeFields({ + const merged = stringifyEdgeHalfLifeFields({ ...pro, avgOosSharpe: firstFinite(pro.avgOosSharpe, oos?.oosSharpe), avgOosCalmar: firstFinite(pro.avgOosCalmar, oos?.oosCalmar), @@ -79,6 +104,33 @@ function mergeProBenchmarkMetrics(pro: Record | null | undefine : undefined, winRateChangePp: firstFinite(pro.winRateChangePp, wpl?.winRateDegradationPp), }); + const triggers = Array.isArray(merged.killSwitchTriggers) + ? (merged.killSwitchTriggers as unknown[]).map((x) => String(x)).filter(Boolean) + : []; + const killLimit = firstFinite(merged.killSwitchMaxOosDrawdownWindows); + const synthesizedKillValue = + killLimit != null ? `${Math.round(killLimit)} consecutive (all windows) (limit: ${Math.round(killLimit)})` : undefined; + const verdictReason = + typeof merged.verdictReason === "string" && merged.verdictReason.trim() + ? (merged.verdictReason as string) + : triggers.length > 0 + ? `${merged.killSwitchKilled === true ? "Immediate Kill Switch triggered. " : ""}${triggers.join("; ")}` + : undefined; + return { + ...merged, + ...(typeof merged.verdict !== "string" && merged.killSwitchKilled === true ? { verdict: "REJECT" } : {}), + ...(verdictReason ? { verdictReason } : {}), + ...(merged.killSwitchKilled === true + ? { killSwitchValue: strOr(merged.killSwitchValue, synthesizedKillValue ?? "TRIGGERED") } + : {}), + ...(triggers.length > 0 + ? { killSwitchHint: strOr(merged.killSwitchHint, "Next OOS window in minus → turn off bot") } + : {}), + }; +} + +function strOr(current: unknown, fallback: string): string { + return typeof current === "string" && current.trim() ? current : fallback; } function mergeBenchmarkComparison( @@ -160,6 +212,16 @@ function normalizeParameterSensitivity(sens: Record | null | un const s = (x as { sensitivity?: number }).sensitivity; return typeof s === "number" && Number.isFinite(s) ? Math.max(m, s) : m; }, 0); + const highestSensitivityParam = normalizedParams.reduce((best, x) => { + const s = (x as { sensitivity?: number }).sensitivity; + if (typeof s !== "number" || !Number.isFinite(s)) return best; + if (!best || s > best.sensitivity) { + const name = typeof (x as { name?: string }).name === "string" ? (x as { name: string }).name : "param"; + const label = typeof (x as { displayLabel?: string }).displayLabel === "string" ? (x as { displayLabel: string }).displayLabel : "Stable"; + return { name, sensitivity: s, label }; + } + return best; + }, null as { name: string; sensitivity: number; label: string } | null); const diagOut = diagnostics ?? (maxSens > 0 @@ -167,10 +229,55 @@ function normalizeParameterSensitivity(sens: Record | null | un parameterStabilityIndex: Math.max(0, Math.min(1, 1 - maxSens)), } : undefined); + const signalAttenuation = firstFinite((sens as { signalAttenuation?: number }).signalAttenuation, diagOut?.signalAttenuation); + const sharpeRetention = firstFinite((sens as { sharpeRetention?: number }).sharpeRetention, diagOut?.sharpeRetention); + const sharpeDrift = firstFinite((sens as { sharpeDrift?: number }).sharpeDrift, diagOut?.sharpeDriftPct); + const maxTailRiskReduction = firstFinite((sens as { maxTailRiskReduction?: number }).maxTailRiskReduction, diagOut?.maxTailRiskReduction); + const deploymentStatus = typeof (sens as { deploymentStatus?: string }).deploymentStatus === "string" + ? String((sens as { deploymentStatus?: string }).deploymentStatus) + : typeof diagOut?.deploymentStatus === "string" + ? String(diagOut.deploymentStatus) + : undefined; + const performanceDecayPct = firstFinite(diagOut?.performanceDecayPct); + const performanceDecayNote = performanceDecayPct != null + ? `${performanceDecayPct.toFixed(1)}% (REJECTED if >= 80%).` + : undefined; + const baseScore = firstFinite((sens as { baseScore?: number }).baseScore, diagOut?.riskScoreBase); + const penalty = firstFinite((sens as { penalty?: number }).penalty, diagOut?.riskScorePenalty); + const riskScoreResolved = firstFinite((sens as { riskScore?: number }).riskScore, diagOut?.aggregateRiskScore, riskScore); + const riskClass = (() => { + if (riskScoreResolved == null) return undefined; + if (riskScoreResolved < 50) return "HIGH"; + if (riskScoreResolved < 65) return "MODERATE"; + return "LOW"; + })(); + const riskScoreFormula = + baseScore != null && penalty != null + ? `Base ${Math.round(baseScore)} − Penalty ${Math.round(penalty)} →` + : undefined; + const proNote = highestSensitivityParam + ? `Highest sensitivity: ${highestSensitivityParam.name} (${highestSensitivityParam.sensitivity.toFixed(2)}, ${highestSensitivityParam.label}).` + : undefined; + const couplingSummary = + typeof (sens as { couplingSummary?: string }).couplingSummary === "string" && String((sens as { couplingSummary?: string }).couplingSummary).trim() + ? String((sens as { couplingSummary?: string }).couplingSummary) + : "Coupling analysis: No dominant unstable interactions detected."; return { ...sens, parameters: normalizedParams, - riskScore, + ...(riskScoreResolved != null ? { riskScore: riskScoreResolved } : {}), + ...(riskClass ? { riskClass } : {}), + ...(riskScoreFormula ? { riskScoreFormula } : {}), + ...(baseScore != null ? { baseScore } : {}), + ...(penalty != null ? { penalty } : {}), + ...(signalAttenuation != null ? { signalAttenuation } : {}), + ...(sharpeRetention != null ? { sharpeRetention } : {}), + ...(sharpeDrift != null ? { sharpeDrift } : {}), + ...(maxTailRiskReduction != null ? { maxTailRiskReduction } : {}), + ...(deploymentStatus ? { deploymentStatus } : {}), + ...(performanceDecayNote ? { performanceDecayNote } : {}), + ...(proNote ? { proNote } : {}), + ...(couplingSummary ? { couplingSummary } : {}), ...(diagOut ? { diagnostics: diagOut } : {}), }; } @@ -281,6 +388,42 @@ function summarizeProfessionalSub(prof: Record): Record, + pro: Record | null | undefined, +): Record | null { + const verdict = typeof wfaOut.verdict === "string" ? String(wfaOut.verdict).toUpperCase() : ""; + const failed = asRecord(wfaOut.failedWindows); + const failedCount = firstFinite(failed?.count); + const failedTotal = firstFinite(failed?.total, wfaOut.windowsCount); + const failureRate = + failedCount != null && failedTotal != null && failedTotal > 0 + ? failedCount / failedTotal + : undefined; + const shouldCapGrade = verdict === "FAIL" && failureRate != null && failureRate > 0.3; + + const grade = shouldCapGrade ? "BBB - RESEARCH ONLY" : undefined; + const gradeOverride = shouldCapGrade + ? "(override: Verdict FAIL and failure rate > 30%; grade capped to BBB - RESEARCH ONLY.)" + : undefined; + const recommendation = shouldCapGrade + ? "Research only. Do not deploy to production without further validation." + : "Recommendation unavailable."; + + const wfeAdvancedFromPro = asRecord(pro?.wfeAdvanced); + const wfeAdvanced = + wfeAdvancedFromPro != null + ? summarizeProfessionalSub({ wfeAdvanced: wfeAdvancedFromPro }).wfeAdvanced + : undefined; + + return { + ...(grade ? { grade } : {}), + ...(gradeOverride ? { gradeOverride } : {}), + recommendation, + ...(typeof wfeAdvanced === "string" ? { wfeAdvanced } : {}), + }; +} + function patchDistributionAliases(dist: Record): Record { const worst5 = firstFinite(dist.worst5Percent, dist.worst5); const best95 = firstFinite(dist.best95Percent, dist.best95); @@ -310,12 +453,30 @@ function toWindowDecimalReturn( const m = p.metrics as Record | undefined; const o = m?.optimization as Record | undefined; const v = m?.validation as Record | undefined; - const raw = + const directRaw = kind === "optimization" - ? p.optimizationReturn ?? p.optimization_return ?? o?.totalReturn ?? o?.total - : p.validationReturn ?? p.validation_return ?? v?.totalReturn ?? v?.total; - if (typeof raw === "number" && Number.isFinite(raw)) return raw; - if (typeof raw === "string" && raw.trim() && Number.isFinite(Number(raw))) return Number(raw); + ? p.optimizationReturn ?? p.optimization_return + : p.validationReturn ?? p.validation_return; + const metricsRaw = kind === "optimization" ? o?.totalReturn ?? o?.total : v?.totalReturn ?? v?.total; + const asFinite = (x: unknown): number | null => { + if (typeof x === "number" && Number.isFinite(x)) return x; + if (typeof x === "string" && x.trim() && Number.isFinite(Number(x))) return Number(x); + return null; + }; + const toDecimal = (x: number): number => (Math.abs(x) > 1 ? x / 100 : x); + const direct = asFinite(directRaw); + const metrics = asFinite(metricsRaw); + const metricsDec = metrics != null ? toDecimal(metrics) : null; + if (direct != null && metricsDec != null) { + /** + * Cloud reports may preserve richer period metrics while local payload can carry already-normalized + * shorthand fields. If metrics-derived value is materially larger (x10+), prefer it for parity. + */ + if (Math.abs(metricsDec) >= Math.max(0.1, Math.abs(direct) * 10)) return metricsDec; + return direct; + } + if (direct != null) return direct; + if (metricsDec != null) return metricsDec; return null; } @@ -328,6 +489,8 @@ function collectWfaWindowReturnArrays( distOpt: unknown[], distVal: unknown[], ): { opt: number[]; val: number[] } { + const distOptNums = distOpt.filter((x): x is number => typeof x === "number" && Number.isFinite(x)); + const distValNums = distVal.filter((x): x is number => typeof x === "number" && Number.isFinite(x)); const opt: number[] = []; const val: number[] = []; const rows = Array.isArray(wfa.windows) @@ -344,15 +507,30 @@ function collectWfaWindowReturnArrays( if (oR != null) opt.push(oR); if (vR != null) val.push(vR); } + /** + * If distribution series exists and materially disagrees with row shorthand returns, + * trust distribution as canonical (cloud parity). This fixes x100 drift where local + * shorthand fields are reduced but distribution carries full-fidelity period returns. + */ + if ( + distOptNums.length === opt.length && + distValNums.length === val.length && + opt.length > 0 && + val.length > 0 + ) { + const optRowAbs = meanFinite(opt.map((x) => Math.abs(x))) ?? 0; + const valRowAbs = meanFinite(val.map((x) => Math.abs(x))) ?? 0; + const optDistAbs = meanFinite(distOptNums.map((x) => Math.abs(x))) ?? 0; + const valDistAbs = meanFinite(distValNums.map((x) => Math.abs(x))) ?? 0; + const optScaleMismatch = optDistAbs >= Math.max(0.1, optRowAbs * 10); + const valScaleMismatch = valDistAbs >= Math.max(0.1, valRowAbs * 10); + if (optScaleMismatch || valScaleMismatch) { + return { opt: distOptNums, val: distValNums }; + } + } return { opt, val }; } - for (const x of distOpt) { - if (typeof x === "number" && Number.isFinite(x)) opt.push(x); - } - for (const x of distVal) { - if (typeof x === "number" && Number.isFinite(x)) val.push(x); - } - return { opt, val }; + return { opt: distOptNums, val: distValNums }; } function compoundFromReturns(decimals: number[]): number | undefined { @@ -365,6 +543,64 @@ function compoundFromReturns(decimals: number[]): number | undefined { return p - 1; } +function toBalanceNum(point: unknown): number | undefined { + if (point == null) return undefined; + if (typeof point === "number" && Number.isFinite(point)) return point; + const rec = asRecord(point); + const raw = rec?.value ?? rec?.balance ?? rec?.equity; + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; +} + +function extractScaledReturnsFromPerformanceTransfer( + wfa: Record, +): { opt: number[]; val: number[]; total?: number } | null { + const pt = asRecord(wfa.performanceTransfer); + const wins = Array.isArray(pt?.windows) ? (pt?.windows as unknown[]) : []; + if (!wins.length) return null; + const opt: number[] = []; + const val: number[] = []; + let compoundedPath = 1; + for (const w of wins) { + const wr = asRecord(w); + const isCurve = Array.isArray(wr?.isEquityCurve) ? (wr?.isEquityCurve as unknown[]) : []; + const oosCurve = Array.isArray(wr?.oosEquityCurve) ? (wr?.oosEquityCurve as unknown[]) : []; + if (!isCurve.length || !oosCurve.length) continue; + const isStart = toBalanceNum(isCurve[0]); + const isEnd = toBalanceNum(isCurve[isCurve.length - 1]); + const oosStart = toBalanceNum(oosCurve[0]); + const oosEnd = toBalanceNum(oosCurve[oosCurve.length - 1]); + if ( + isStart == null || + isEnd == null || + oosStart == null || + oosEnd == null || + isStart === 0 || + oosStart === 0 + ) { + continue; + } + /** + * Cloud WFA display currently expects period returns in "percent-points" scale here. + * Example: 1000 -> 1009.49 becomes 94.9 (not 0.949). + */ + const isScaled = ((isEnd / isStart) - 1) * 100; + const oosScaled = ((oosEnd / oosStart) - 1) * 100; + opt.push(isScaled); + val.push(oosScaled); + /** + * Cloud WFA total in this mode reflects compounded IS+OOS path per window, + * not OOS-only return. + */ + compoundedPath *= (1 + isScaled / 100) * (1 + oosScaled / 100); + } + if (!opt.length || !val.length) return null; + const total = + Number.isFinite(compoundedPath) && compoundedPath > 0 + ? (compoundedPath - 1) * 100 + : undefined; + return { opt, val, total }; +} + /** * `performanceDegradation` is canonical; `degradationRatio` in some payloads is OOS retention. Prefer the former. */ @@ -393,10 +629,36 @@ function normalizeWalkForwardAnalysis( const distOpt = dist && Array.isArray(dist.optimizationReturns) ? dist.optimizationReturns : []; const distVal = dist && Array.isArray(dist.validationReturns) ? dist.validationReturns : []; const { opt, val } = collectWfaWindowReturnArrays(wfa, distOpt, distVal); - const nPair = Math.min(opt.length, val.length); + const perfScaled = extractScaledReturnsFromPerformanceTransfer(wfa); + const shouldUsePerfScaled = + perfScaled != null && + opt.length === perfScaled.opt.length && + val.length === perfScaled.val.length && + opt.length > 0 && + val.length > 0 && + (meanFinite(opt.map((x) => Math.abs(x))) ?? 0) < 0.2 && + (meanFinite(val.map((x) => Math.abs(x))) ?? 0) < 0.2; + const effOpt = shouldUsePerfScaled ? perfScaled!.opt : opt; + const effVal = shouldUsePerfScaled ? perfScaled!.val : val; + const rowKey = Array.isArray(wfa.windows) ? "windows" : Array.isArray(wfa.periods) ? "periods" : null; + if (rowKey && Array.isArray(out[rowKey]) && effOpt.length > 0 && effVal.length > 0) { + const rows = out[rowKey] as unknown[]; + if (rows.length === effOpt.length && rows.length === effVal.length) { + out[rowKey] = rows.map((row, i) => { + const rec = asRecord(row); + if (!rec) return row; + return { + ...rec, + optimizationReturn: effOpt[i], + validationReturn: effVal[i], + }; + }); + } + } + const nPair = Math.min(effOpt.length, effVal.length); - const sumVal = val.length ? sumFinite(val) : null; - const compoundOos = val.length ? compoundFromReturns(val) : undefined; + const sumVal = effVal.length ? sumFinite(effVal) : null; + const compoundOos = effVal.length ? compoundFromReturns(effVal) : undefined; if (out.totalOosReturn == null) { const sO = firstFinite(pro?.sumOos); @@ -405,16 +667,29 @@ function normalizeWalkForwardAnalysis( else if (compoundOos != null) out.totalOosReturn = compoundOos; } if (out.isAvgReturn == null) { - const m = meanFinite(opt); + const m = meanFinite(effOpt); if (m != null) out.isAvgReturn = m; } if (out.oosAvgReturn == null) { - const m = meanFinite(val); + const m = meanFinite(effVal); if (m != null) out.oosAvgReturn = m; } - if (out.oosWinRateText == null && val.length) { - const wins = val.filter((x) => x > 0).length; - out.oosWinRateText = `${wins} / ${val.length}`; + if (out.oosWinRateText == null && effVal.length) { + const wins = effVal.filter((x) => x > 0).length; + out.oosWinRateText = `${wins} / ${effVal.length}`; + } + if (shouldUsePerfScaled && perfScaled?.total != null) { + const currentTotal = firstFinite(out.totalOosReturn); + /** + * In x100 mismatch cases local `sumOos` may already set totalOosReturn to a tiny decimal-derived value + * (e.g. 0.0201 => 2.0%). Prefer performanceTransfer-derived total to match cloud display scale. + */ + if ( + currentTotal == null || + Math.abs(perfScaled.total) >= Math.max(0.1, Math.abs(currentTotal) * 10) + ) { + out.totalOosReturn = perfScaled.total; + } } if (out.overfittingScore == null) { const of = asRecord(wfa.overfittingRisk); @@ -429,7 +704,7 @@ function normalizeWalkForwardAnalysis( if (nPair > 0) { const posIs = []; for (let i = 0; i < nPair; i++) { - if (opt[i]! > 0 && val[i] != null) posIs.push({ oos: val[i]! }); + if (effOpt[i]! > 0 && effVal[i] != null) posIs.push({ oos: effVal[i]! }); } if (posIs.length) { const oosPos = posIs.filter((x) => x.oos > 0).length; @@ -459,6 +734,9 @@ function normalizeWalkForwardAnalysis( summarized = { ...summarized, equityCurve: au[0] as string }; } out.professional = summarized; + } else { + const synthesized = synthesizeProfessionalWfaFallback(out, pro); + if (synthesized) out.professional = synthesized; } return out; } diff --git a/packages/core/src/integrity.ts b/packages/core/src/integrity.ts index 3dd4740..76cf07b 100644 --- a/packages/core/src/integrity.ts +++ b/packages/core/src/integrity.ts @@ -96,7 +96,13 @@ export function runIntegrityJudge( retForRule3 != null && retForRule3 > 0 ) { - pushIssue(issues, "error", "Execution Warning: Profit with negative expected edge (Luck Factor)."); + /** + * Single-window + low-N can legitimately show positive return with negative expected edge. + * Keep this as warning for research contexts; escalate to error only with adequate sample. + */ + const luckFactorSeverity: IntegrityIssueSeverity = + totalTrades >= MIN_TRADES_FOR_SIGNIFICANCE ? "error" : "warning"; + pushIssue(issues, luckFactorSeverity, "Execution Warning: Profit with negative expected edge (Luck Factor)."); } } @@ -169,6 +175,6 @@ export function runIntegrityJudge( return { issues, - isValid: issues.length === 0, + isValid: !issues.some((i) => i.severity === "error"), }; } diff --git a/packages/core/src/mapPayloadToUnified.ts b/packages/core/src/mapPayloadToUnified.ts index 2880f4e..380603c 100644 --- a/packages/core/src/mapPayloadToUnified.ts +++ b/packages/core/src/mapPayloadToUnified.ts @@ -9,6 +9,51 @@ import type { } from "@kiploks/engine-contracts"; import { toDecimalReturn } from "./normalize"; +function toFiniteNumber(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() && Number.isFinite(Number(v))) return Number(v); + return null; +} + +function normalizePeriodReturnValue( + primaryRaw: unknown, + metricsRaw: unknown, +): number { + const primaryNum = toFiniteNumber(primaryRaw); + const metricsNum = toFiniteNumber(metricsRaw); + const primaryNorm = toDecimalReturn(primaryNum); + const metricsNorm = toDecimalReturn(metricsNum); + + if (Number.isFinite(primaryNorm) && Number.isFinite(metricsNorm)) { + /** + * Prefer metrics return when it is materially larger than shorthand return. + * This aligns local integration payloads with cloud snapshots where metrics.*.totalReturn + * can contain the canonical period return while shorthand fields are reduced. + */ + if (Math.abs(metricsNorm) >= Math.max(0.1, Math.abs(primaryNorm) * 10)) { + return metricsNorm; + } + /** + * Ambiguous zone: values in (1,5] can be either 1-5% or 100-500% decimal return. + * If shorthand value is tiny and metrics value is much larger, keep metrics as decimal. + */ + if ( + metricsNum != null && + Math.abs(metricsNum) > 1 && + Math.abs(metricsNum) <= 5 && + Math.abs(primaryNorm) < 0.05 && + Math.abs(metricsNum) >= Math.max(0.5, Math.abs(primaryNorm) * 20) + ) { + return metricsNum; + } + return primaryNorm; + } + + if (Number.isFinite(primaryNorm)) return primaryNorm; + if (Number.isFinite(metricsNorm)) return metricsNorm; + return Number.NaN; +} + /** * Normalizes a payload (e.g. Freqtrade or generic integration) so downstream logic * receives canonical keys and decimal returns. @@ -73,8 +118,14 @@ export function mapPayloadToUnified( (val && typeof val === "object" ? (val.totalReturn ?? val.total) : undefined); return { ...rec, - optimizationReturn: toDecimalReturn(rawOpt), - validationReturn: toDecimalReturn(rawVal), + optimizationReturn: normalizePeriodReturnValue( + rec.optimizationReturn ?? rec.optimization_return, + opt && typeof opt === "object" ? (opt.totalReturn ?? opt.total) : undefined, + ), + validationReturn: normalizePeriodReturnValue( + rec.validationReturn ?? rec.validation_return, + val && typeof val === "object" ? (val.totalReturn ?? val.total) : undefined, + ), }; }); const key = wfa.periods ? "periods" : "windows"; From a348a18e0c5ce9e411360e13053533e51f67a8dc Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Fri, 8 May 2026 13:51:23 +0300 Subject: [PATCH 6/6] fix(core): resolve benchmark fallback period type mismatch Use normalized payload date bounds when building fallback benchmark comparison so engine-core compiles cleanly and release validation can run end-to-end. --- packages/core/src/buildTestResultDataFromUnified.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/buildTestResultDataFromUnified.ts b/packages/core/src/buildTestResultDataFromUnified.ts index d4aa215..b4c1fac 100644 --- a/packages/core/src/buildTestResultDataFromUnified.ts +++ b/packages/core/src/buildTestResultDataFromUnified.ts @@ -998,8 +998,8 @@ export function buildTestResultDataFromUnified( } if (!valid || bc == null) { return buildFallbackBenchmarkComparison(unifiedPayload as Record, { - testPeriodStart: strategy.testPeriodStart, - testPeriodEnd: strategy.testPeriodEnd, + testPeriodStart: dateFromPayload || NA, + testPeriodEnd: dateToPayload || NA, }); } const bcObj = bc as Record;