From 8746ba25ac0cc248f555b85d2882a8f6fcbbda74 Mon Sep 17 00:00:00 2001 From: Radiks Alijevs Date: Tue, 14 Apr 2026 22:33:31 +0300 Subject: [PATCH] feat: add public server entry for backends --- VERSION | 2 +- docs/MONTE_CARLO_SIMULATION_IMPLEMENTATION.md | 2 + docs/WFA_PROFESSIONAL.md | 2 + package-lock.json | 80 +----- package.json | 2 +- packages/adapters/package.json | 20 +- packages/cli/package.json | 24 +- packages/contracts/package.json | 18 +- packages/contracts/src/index.ts | 14 +- packages/contracts/src/testResultData.ts | 59 +++- packages/core/README.md | 4 +- packages/core/package.json | 11 +- packages/core/src/internal.ts | 8 +- packages/core/src/server.ts | 24 ++ packages/core/src/wfaProfessional.test.ts | 194 +++++++++++++ packages/core/src/wfaProfessional.ts | 261 +++++++++++++++++- packages/test-vectors/package.json | 18 +- scripts/engine-core-prepack.mjs | 4 +- 18 files changed, 654 insertions(+), 93 deletions(-) create mode 100644 packages/core/src/server.ts diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/docs/MONTE_CARLO_SIMULATION_IMPLEMENTATION.md b/docs/MONTE_CARLO_SIMULATION_IMPLEMENTATION.md index 7c961f2..54067a2 100644 --- a/docs/MONTE_CARLO_SIMULATION_IMPLEMENTATION.md +++ b/docs/MONTE_CARLO_SIMULATION_IMPLEMENTATION.md @@ -11,4 +11,6 @@ Public Open Core documentation for Monte-related features in `@kiploks/engine-co **Window bootstrap** (`monteCarloValidation`) is computed inside the professional WFA pipeline when applicable. For precomputed WFA, `AnalyzeConfig.monteCarloBootstrapN` sets bootstrap iteration count (see contracts). +**Call-site options:** `runProfessionalWfa` / `buildProfessionalWfa` accept **`monteCarloMode`** (`legacy` \| `auto` \| `new_only`) and **`enablePathMc`** to control when path-based Monte Carlo is eligible versus per-window bootstrap (see [**WFA_PROFESSIONAL.md**](./WFA_PROFESSIONAL.md) §5 and the option types in `@kiploks/engine-contracts`). Host applications map their own configuration (env flags, feature toggles, remote config) to these fields; that wiring is not part of the published engine packages. + **Example:** [`examples/monte-carlo-example.md`](examples/monte-carlo-example.md). Golden regression fixture: [`examples/monte-carlo-seed42.json`](examples/monte-carlo-seed42.json). diff --git a/docs/WFA_PROFESSIONAL.md b/docs/WFA_PROFESSIONAL.md index 08fb3ff..99ef7bc 100644 --- a/docs/WFA_PROFESSIONAL.md +++ b/docs/WFA_PROFESSIONAL.md @@ -107,6 +107,8 @@ Key fields: **What it does not do:** No path-dependent simulation (no synthetic full equity curves drawn step-by-step, no shock model over returns beyond window resampling, no strategy-path Monte Carlo). +**`monteCarloMode` / `enablePathMc` (optional):** In addition to window bootstrap above, the professional pipeline may emit **`method: "path_mc_v1"`** (path simulation from stored equity curves) or **`method: "unavailable"`** with a **`reasonCode`**, depending on mode, `enablePathMc`, and data eligibility. See [**MONTE_CARLO_PATH.md**](./MONTE_CARLO_PATH.md) and `wfaProfessional.ts` / `wfaProfessional.test.ts` for selection rules. When path MC cannot run, institutional grade aggregation still needs a bounded MC signal: the engine applies a **neutral Monte Carlo contribution** and records **`mc_unavailable_neutral_score_50`** plus **`mc_unavailable_reason:*`** entries in **`professionalMeta.approximationsUsed`**. + Key fields: - `actualMeanReturn: number` diff --git a/package-lock.json b/package-lock.json index eed6201..3d6f1c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kiploks/engine-monorepo", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kiploks/engine-monorepo", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -24,56 +24,6 @@ "node": ">=20" } }, - "adapters": { - "name": "@kiploks/engine-adapters", - "version": "0.1.0", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "@kiploks/engine-contracts": "0.1.0" - }, - "devDependencies": { - "typescript": "^5.9.3" - } - }, - "cli": { - "name": "@kiploks/engine-cli", - "version": "0.1.0", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "@kiploks/engine-adapters": "0.1.0", - "@kiploks/engine-contracts": "0.1.0", - "@kiploks/engine-core": "0.1.0" - }, - "bin": { - "kiploks": "bin/kiploks.cjs" - }, - "devDependencies": { - "typescript": "^5.9.3" - } - }, - "contracts": { - "name": "@kiploks/engine-contracts", - "version": "0.1.0", - "extraneous": true, - "license": "Apache-2.0", - "devDependencies": { - "typescript": "^5.9.3" - } - }, - "core": { - "name": "@kiploks/engine-core", - "version": "0.1.0", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "@kiploks/engine-contracts": "0.1.0" - }, - "devDependencies": { - "typescript": "^5.9.3" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -2744,10 +2694,10 @@ }, "packages/adapters": { "name": "@kiploks/engine-adapters", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@kiploks/engine-contracts": "0.2.0" + "@kiploks/engine-contracts": "0.3.0" }, "devDependencies": { "typescript": "^5.9.3" @@ -2755,12 +2705,12 @@ }, "packages/cli": { "name": "@kiploks/engine-cli", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@kiploks/engine-adapters": "0.2.0", - "@kiploks/engine-contracts": "0.2.0", - "@kiploks/engine-core": "0.2.0" + "@kiploks/engine-adapters": "0.3.0", + "@kiploks/engine-contracts": "0.3.0", + "@kiploks/engine-core": "0.3.0" }, "bin": { "kiploks": "bin/kiploks.cjs" @@ -2771,7 +2721,7 @@ }, "packages/contracts": { "name": "@kiploks/engine-contracts", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.9.3" @@ -2779,10 +2729,10 @@ }, "packages/core": { "name": "@kiploks/engine-core", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@kiploks/engine-contracts": "0.2.0" + "@kiploks/engine-contracts": "0.3.0" }, "devDependencies": { "typescript": "^5.9.3" @@ -2790,13 +2740,7 @@ }, "packages/test-vectors": { "name": "@kiploks/engine-test-vectors", - "version": "0.2.0", - "license": "Apache-2.0" - }, - "test-vectors": { - "name": "@kiploks/engine-test-vectors", - "version": "0.1.0", - "extraneous": true, + "version": "0.3.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index abfd547..b432815 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@kiploks/engine-monorepo", "private": true, - "version": "0.2.0", + "version": "0.3.0", "description": "Kiploks Open Core engine - npm packages: contracts, core, adapters, CLI, test vectors. This folder is the git root of the published engine repository.", "keywords": [ "algorithmic-trading", diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 2333aa1..49eaa9c 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -1,6 +1,6 @@ { "name": "@kiploks/engine-adapters", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, @@ -12,6 +12,22 @@ "bugs": { "url": "https://github.com/kiploks/engine/issues" }, + "keywords": [ + "algorithmic-trading", + "walk-forward-analysis", + "wfa", + "backtest", + "backtest-validation", + "trading-strategy", + "quantitative-finance", + "typescript", + "kiploks", + "open-core", + "risk-metrics", + "reproducible-analytics", + "out-of-sample", + "trading-research" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -29,7 +45,7 @@ "build": "tsc -p tsconfig.build.json" }, "dependencies": { - "@kiploks/engine-contracts": "0.2.0" + "@kiploks/engine-contracts": "0.3.0" }, "devDependencies": { "typescript": "^5.9.3" diff --git a/packages/cli/package.json b/packages/cli/package.json index acb0755..bbf28a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kiploks/engine-cli", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, @@ -12,6 +12,22 @@ "bugs": { "url": "https://github.com/kiploks/engine/issues" }, + "keywords": [ + "algorithmic-trading", + "walk-forward-analysis", + "wfa", + "backtest", + "backtest-validation", + "trading-strategy", + "quantitative-finance", + "typescript", + "kiploks", + "open-core", + "risk-metrics", + "reproducible-analytics", + "out-of-sample", + "trading-research" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -33,9 +49,9 @@ "build": "tsc -p tsconfig.build.json" }, "dependencies": { - "@kiploks/engine-contracts": "0.2.0", - "@kiploks/engine-core": "0.2.0", - "@kiploks/engine-adapters": "0.2.0" + "@kiploks/engine-contracts": "0.3.0", + "@kiploks/engine-core": "0.3.0", + "@kiploks/engine-adapters": "0.3.0" }, "devDependencies": { "typescript": "^5.9.3" diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9ac4398..53e0c56 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@kiploks/engine-contracts", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, @@ -12,6 +12,22 @@ "bugs": { "url": "https://github.com/kiploks/engine/issues" }, + "keywords": [ + "algorithmic-trading", + "walk-forward-analysis", + "wfa", + "backtest", + "backtest-validation", + "trading-strategy", + "quantitative-finance", + "typescript", + "kiploks", + "open-core", + "risk-metrics", + "reproducible-analytics", + "out-of-sample", + "trading-research" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a097a43..b576b86 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,7 +3,7 @@ * Keep these exports stable across minor releases. */ -export const ENGINE_VERSION = "0.2.0"; +export const ENGINE_VERSION = "0.3.0"; export const ANALYSIS_ENGINE_VERSION = "3.0"; export const FORMULA_VERSION = "2.2.0"; export const RISK_ANALYSIS_VERSION = 1; @@ -162,6 +162,13 @@ export type WfaProfessionalInput = { export type WfaProfessionalOptions = { seed?: number; + permutationN?: number; + bootstrapN?: number; + monteCarloMode?: "legacy" | "auto" | "new_only"; + enablePathMc?: boolean; + pathSimulations?: number; + maxEquityPoints?: number; + cpuBudgetMs?: number; }; export type { @@ -186,6 +193,11 @@ export type { RobustnessScoreTextPayload, RobustnessScore, WalkForwardAnalysisTextPayload, + ProfessionalMonteCarloMethod, + ProfessionalMonteCarloReasonCode, + ProfessionalMonteCarloValidation, + ProfessionalWfa, + ProfessionalMeta, WalkForwardAnalysis, TestResultData, } from "./testResultData"; diff --git a/packages/contracts/src/testResultData.ts b/packages/contracts/src/testResultData.ts index 1c5f190..b5780a9 100644 --- a/packages/contracts/src/testResultData.ts +++ b/packages/contracts/src/testResultData.ts @@ -40,6 +40,61 @@ export interface WalkForwardAnalysisTextPayload { verdictExplanation?: string; } +export type ProfessionalMonteCarloMethod = + | "path_mc_v1" + | "window_iid_bootstrap" + | "unavailable" + | "window_iid_bootstrap_legacy_snapshot"; + +export type ProfessionalMonteCarloReasonCode = + | "insufficient_windows" + | "missing_equity_curve" + | "insufficient_curve_points" + | "insufficient_curve_span" + | "flat_equity" + | "cpu_budget_exceeded"; + +export interface ProfessionalMonteCarloValidation { + method?: ProfessionalMonteCarloMethod; + version?: string; + iterations?: number; + seed?: number; + reasonCode?: ProfessionalMonteCarloReasonCode; + warnings?: string[]; + actualMeanReturn?: number; + confidenceInterval95?: [number, number]; + confidenceInterval68?: [number, number]; + probabilityPositive?: number; + verdict?: "CONFIDENT" | "PROBABLE" | "UNCERTAIN" | "DOUBTFUL"; +} + +export interface ProfessionalWfa { + equityCurveAnalysis?: unknown; + wfeAdvanced?: unknown; + parameterStability?: unknown; + regimeAnalysis?: unknown; + monteCarloValidation?: ProfessionalMonteCarloValidation; + stressTest?: unknown; + institutionalGrade?: string; + institutionalGradeOverride?: unknown; + institutionalGradeOverrideReason?: string; + recommendation?: string; +} + +export interface ProfessionalMeta { + version: string; + engineFormulaVersion?: string; + inputsSummary: { + periodCount: number; + hasPerformanceTransfer: boolean; + hasValidationMaxDD: boolean; + curvePointCount?: number; + monteCarloBootstrapIterations?: number; + }; + guardsTriggered: string[]; + approximationsUsed: string[]; +} + export interface WalkForwardAnalysis { performanceTransfer: { windows: unknown[] }; wfe?: number; @@ -59,8 +114,8 @@ export interface WalkForwardAnalysis { isDisabled?: boolean; textPayload?: WalkForwardAnalysisTextPayload; /** Core can enrich these fields with professional block structures. */ - professional?: unknown; - professionalMeta?: unknown; + professional?: ProfessionalWfa; + professionalMeta?: ProfessionalMeta; } export interface TestResultData { diff --git a/packages/core/README.md b/packages/core/README.md index 0a2934f..f3184e4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -31,7 +31,9 @@ Results are **deterministic** for a given input, config, and published version. ## API policy -Use the **root** import `from "@kiploks/engine-core"` only. That is the supported, semver-stable surface on npm. A separate `./internal` path may exist in the **git** tree for in-repo tooling and tests; it is **not** published in the package tarball (`prepack` removes it). +- **Browser / integrators:** `import { … } from "@kiploks/engine-core"` — stable, documented surface. +- **Node / hosted backends (full report assembly):** `import { … } from "@kiploks/engine-core/server"` — semver-stable **subpath** on npm. Do not use this from frontend bundles. +- The legacy `./internal` entry is stripped from published `package.json` (`prepack`); prefer `./server` for server-side code. ## License diff --git a/packages/core/package.json b/packages/core/package.json index 769e2d2..23f14b9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@kiploks/engine-core", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, @@ -42,12 +42,19 @@ }, "types": "./dist/internal.d.ts", "default": "./dist/internal.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" } }, "typesVersions": { "*": { "internal": [ "dist/internal.d.ts" + ], + "server": [ + "dist/server.d.ts" ] } }, @@ -62,7 +69,7 @@ "postpack": "node ../../scripts/engine-core-prepack.mjs restore" }, "dependencies": { - "@kiploks/engine-contracts": "0.2.0" + "@kiploks/engine-contracts": "0.3.0" }, "devDependencies": { "typescript": "^5.9.3" diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index d744de0..50462c6 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,9 +1,7 @@ /** - * Secondary export surface for the hosting application that assembles full reports from - * engine primitives. Same implementation as the rest of `@kiploks/engine-core`; this entry - * is not semver-stable and is omitted from the published npm package (see `prepack` in - * package.json). Prefer the root export (`import { ... } from '@kiploks/engine-core'`) for - * integrations unless you mirror the host assembly graph. + * In-repo / conditional export for tooling. Published npm consumers should use + * `@kiploks/engine-core/server` instead; `./internal` is stripped from the public package.json + * on pack (see `prepack`). */ export * from "./decisionArtifacts"; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts new file mode 100644 index 0000000..097c4b0 --- /dev/null +++ b/packages/core/src/server.ts @@ -0,0 +1,24 @@ +/** + * Server-side assembly API for Node.js backends and integrations (not for browser bundles). + * Import from `@kiploks/engine-core/server` — published on npm and semver-stable as a subpath. + * Do not import from frontend / browser bundles (use root `@kiploks/engine-core` there). + * + * Intentionally mirrors `internal.ts` without re-exporting `./internal`, so `prepack` can omit + * `dist/internal.*` from the tarball while this entry remains self-contained. + */ +export * from "./decisionArtifacts"; +export * from "./analyzeCardSummary"; +export * from "./standalonePayloadValidation"; +export * from "./buildTestResultDataFromUnified"; +export * from "./riskAnalysis"; +export { riskBuilderFromRCore } from "./riskCore"; +export * from "./analysisReportTypes"; +export * from "./summaryBlockEngine"; +export * from "./whatIfScenarios"; +export * from "./finalVerdictEngine"; +export * from "./strategyActionPlanPrecomputed"; +export * from "./integrity"; +export * from "./validateReportInvariants"; +export * from "./parameterSensitivity"; +export * from "./proBenchmarkMetrics"; +export * from "./dataQualityGuard"; diff --git a/packages/core/src/wfaProfessional.test.ts b/packages/core/src/wfaProfessional.test.ts index 90b080b..e0e16fc 100644 --- a/packages/core/src/wfaProfessional.test.ts +++ b/packages/core/src/wfaProfessional.test.ts @@ -362,4 +362,198 @@ describe("wfaProfessional", () => { ); expect(out?.professionalMeta.inputsSummary.monteCarloBootstrapIterations).toBe(200); }); + + it("keeps auto mode on window bootstrap by default (path flag disabled)", () => { + const baseTs = Date.UTC(2024, 0, 1); + const curve = Array.from({ length: 120 }, (_, i) => ({ + date: new Date(baseTs + i * 24 * 60 * 60 * 1000).toISOString(), + value: 1 + i * 0.001, + })); + const out = runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + performanceTransfer: { windows: [{ oosEquityCurve: curve }] }, + } as never, + { seed: 42, monteCarloMode: "auto" }, + ); + expect(out?.professional.monteCarloValidation?.method).toBe("window_iid_bootstrap"); + }); + + it("selects path_mc_v1 when path flag is enabled and curve is eligible", () => { + const baseTs = Date.UTC(2024, 0, 1); + const curve = Array.from({ length: 120 }, (_, i) => ({ + date: new Date(baseTs + i * 24 * 60 * 60 * 1000).toISOString(), + value: 1 + i * 0.001 + (i % 7 === 0 ? 0.002 : 0), + })); + const out = runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + performanceTransfer: { windows: [{ oosEquityCurve: curve }] }, + } as never, + { seed: 42, monteCarloMode: "auto", enablePathMc: true, pathSimulations: 500 }, + ); + expect(out?.professional.monteCarloValidation?.method).toBe("path_mc_v1"); + }); + + it("returns unavailable with reason in new_only mode when path inputs are missing", () => { + const out = runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + } as never, + { seed: 42, monteCarloMode: "new_only", enablePathMc: true }, + ); + expect(out?.professional.monteCarloValidation?.method).toBe("unavailable"); + expect(out?.professional.monteCarloValidation?.reasonCode).toBe("missing_equity_curve"); + expect(out?.professionalMeta.approximationsUsed).toContain("mc_unavailable_neutral_score_50"); + expect(out?.professionalMeta.approximationsUsed).toContain( + "mc_unavailable_reason:missing_equity_curve", + ); + }); + + it("does not append mc unavailable approximations when window bootstrap is used", () => { + const out = runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + } as never, + { seed: 42, monteCarloMode: "auto" }, + ); + expect(out?.professional.monteCarloValidation?.method).toBe("window_iid_bootstrap"); + expect(out?.professionalMeta.approximationsUsed).not.toContain("mc_unavailable_neutral_score_50"); + }); + + it("returns unavailable cpu_budget_exceeded when estimated path cost exceeds cap", () => { + const baseTs = Date.UTC(2024, 0, 1); + const curve = Array.from({ length: 600 }, (_, i) => ({ + date: new Date(baseTs + i * 24 * 60 * 60 * 1000).toISOString(), + value: 1 + i * 0.0001 + (i % 5 === 0 ? 0.0002 : 0), + })); + const out = runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + performanceTransfer: { windows: [{ oosEquityCurve: curve }] }, + } as never, + { + seed: 42, + monteCarloMode: "auto", + enablePathMc: true, + maxEquityPoints: 501, + pathSimulations: 5000, + }, + ); + expect(out?.professional.monteCarloValidation?.method).toBe("unavailable"); + expect(out?.professional.monteCarloValidation?.reasonCode).toBe("cpu_budget_exceeded"); + }); + + it("returns unavailable cpu_budget_exceeded when runtime exceeds cpuBudgetMs", () => { + const baseTs = Date.UTC(2024, 0, 1); + const curve = Array.from({ length: 120 }, (_, i) => ({ + date: new Date(baseTs + i * 24 * 60 * 60 * 1000).toISOString(), + value: 1 + i * 0.001 + (i % 7 === 0 ? 0.002 : 0), + })); + const out = runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + performanceTransfer: { windows: [{ oosEquityCurve: curve }] }, + } as never, + { + seed: 42, + monteCarloMode: "auto", + enablePathMc: true, + pathSimulations: 5000, + cpuBudgetMs: 0, + }, + ); + expect(out?.professional.monteCarloValidation?.method).toBe("unavailable"); + expect(out?.professional.monteCarloValidation?.reasonCode).toBe("cpu_budget_exceeded"); + }); + + it.each([ + ["legacy", { monteCarloMode: "legacy" as const, enablePathMc: true }, "window_iid_bootstrap"], + ["auto path off + curve", { monteCarloMode: "auto" as const, enablePathMc: false }, "window_iid_bootstrap"], + ["auto path on + curve", { monteCarloMode: "auto" as const, enablePathMc: true }, "path_mc_v1"], + ] as const)( + "MC selector matrix row %s", + (_label, opts, expectedMethod) => { + const baseTs = Date.UTC(2024, 0, 1); + const curve = Array.from({ length: 120 }, (_, i) => ({ + date: new Date(baseTs + i * 24 * 60 * 60 * 1000).toISOString(), + value: 1 + i * 0.001 + (i % 7 === 0 ? 0.002 : 0), + })); + const wfa = { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + performanceTransfer: { windows: [{ oosEquityCurve: curve }] }, + } as never; + const out = runProfessionalWfa(wfa, { seed: 42, pathSimulations: 500, ...opts }); + expect(out?.professional.monteCarloValidation?.method).toBe(expectedMethod); + }, + ); + + it("monteCarloValidation.method is always a known contract method when present", () => { + const allowed = new Set([ + "path_mc_v1", + "window_iid_bootstrap", + "unavailable", + "window_iid_bootstrap_legacy_snapshot", + ]); + const baseTs = Date.UTC(2024, 0, 1); + const curve = Array.from({ length: 40 }, (_, i) => ({ + date: new Date(baseTs + i * 24 * 60 * 60 * 1000).toISOString(), + value: 1 + i * 0.001, + })); + const variants = [ + runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + } as never, + { seed: 1, monteCarloMode: "auto" }, + ), + runProfessionalWfa( + { + periods: [ + { optimizationReturn: 0.1, validationReturn: 0.05, parameters: { p: 1 } }, + { optimizationReturn: 0.12, validationReturn: 0.04, parameters: { p: 2 } }, + { optimizationReturn: 0.09, validationReturn: 0.06, parameters: { p: 3 } }, + ], + performanceTransfer: { windows: [{ oosEquityCurve: curve }] }, + } as never, + { seed: 1, monteCarloMode: "auto", enablePathMc: true }, + ), + ]; + for (const out of variants) { + const m = out?.professional.monteCarloValidation?.method; + if (m) expect(allowed.has(m)).toBe(true); + } + }); }); diff --git a/packages/core/src/wfaProfessional.ts b/packages/core/src/wfaProfessional.ts index 380e665..8f6c5dd 100644 --- a/packages/core/src/wfaProfessional.ts +++ b/packages/core/src/wfaProfessional.ts @@ -18,6 +18,7 @@ import { } from "@kiploks/engine-contracts"; import { percentileType7 } from "./percentile"; import { PATH_MONTE_CARLO_DEFAULT_SEED } from "./pathMonteCarloConstants"; +import { buildPathMonteCarloSimulation } from "./pathMonteCarlo"; import { createMulberry32 } from "./prng"; import { buildWfeResult } from "./wfa/wfeCalculator"; import { @@ -28,6 +29,11 @@ import { const DEFAULT_MONTE_CARLO_BOOTSTRAP_ITERATIONS = 1000; const MONTE_CARLO_BOOTSTRAP_ITERATIONS_MIN = 100; const MONTE_CARLO_BOOTSTRAP_ITERATIONS_MAX = 50_000; +const PROFESSIONAL_MC_MIN_WINDOWS = 3; +const PROFESSIONAL_MC_MIN_CURVE_POINTS = 30; +const PROFESSIONAL_MC_MIN_YEARS = 0.25; +const PROFESSIONAL_MC_MAX_PATH_SIMULATIONS = 5_000; +const PROFESSIONAL_MC_MAX_EQUITY_POINTS = 500; function resolveMonteCarloBootstrapIterations(requested?: number): number { const n = requested ?? DEFAULT_MONTE_CARLO_BOOTSTRAP_ITERATIONS; @@ -103,6 +109,22 @@ export interface RegimeAnalysis { } export interface MonteCarloValidation { + method?: + | "path_mc_v1" + | "window_iid_bootstrap" + | "unavailable" + | "window_iid_bootstrap_legacy_snapshot"; + version?: string; + iterations?: number; + seed?: number; + reasonCode?: + | "insufficient_windows" + | "missing_equity_curve" + | "insufficient_curve_points" + | "insufficient_curve_span" + | "flat_equity" + | "cpu_budget_exceeded"; + warnings?: string[]; actualMeanReturn: number; confidenceInterval95: [number, number]; confidenceInterval68: [number, number]; @@ -110,6 +132,90 @@ export interface MonteCarloValidation { verdict: "CONFIDENT" | "PROBABLE" | "UNCERTAIN" | "DOUBTFUL"; } +type MonteCarloMethodSelection = { + method: "path_mc_v1" | "window_iid_bootstrap" | "unavailable"; + reasonCode?: MonteCarloValidation["reasonCode"]; +}; + +function parseDateLikeToMs(value: string): number | null { + if (!value) return null; + const n = Number(value); + if (Number.isFinite(n) && n > 0) return Math.floor(n); + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function buildPathMcPointsFromCurves( + normalizedCurves?: Array<{ date: string; value: number }[]>, +): Array<{ timestamp: number; value: number }> { + if (!normalizedCurves || normalizedCurves.length === 0) return []; + const out: Array<{ timestamp: number; value: number }> = []; + for (const curve of normalizedCurves) { + for (const p of curve) { + const ts = parseDateLikeToMs(p.date); + if (ts == null || !Number.isFinite(p.value) || p.value <= 0) continue; + out.push({ timestamp: ts, value: p.value }); + } + } + out.sort((a, b) => a.timestamp - b.timestamp); + const dedup = new Map(); + for (const p of out) dedup.set(p.timestamp, p.value); + return [...dedup.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([timestamp, value]) => ({ timestamp, value })); +} + +function decimatePoints(points: T[], maxPoints: number): T[] { + if (points.length <= maxPoints) return points; + if (maxPoints <= 2) return [points[0], points[points.length - 1]]; + const out: T[] = []; + const step = (points.length - 1) / (maxPoints - 1); + for (let i = 0; i < maxPoints; i++) { + out.push(points[Math.round(i * step)]!); + } + return out; +} + +function selectMonteCarloMethod( + normalizedPeriods: NormalizedPeriod[], + normalizedCurves: Array<{ date: string; value: number }[]> | undefined, + options?: { + monteCarloMode?: "legacy" | "auto" | "new_only"; + enablePathMc?: boolean; + }, +): MonteCarloMethodSelection { + const mode = options?.monteCarloMode ?? "auto"; + const pathEnabled = options?.enablePathMc === true; + + const tryPath = mode === "new_only" || (mode === "auto" && pathEnabled); + if (tryPath) { + const points = buildPathMcPointsFromCurves(normalizedCurves); + if (points.length < PROFESSIONAL_MC_MIN_CURVE_POINTS) { + return { + method: mode === "new_only" ? "unavailable" : "window_iid_bootstrap", + reasonCode: points.length === 0 ? "missing_equity_curve" : "insufficient_curve_points", + }; + } + const years = + points.length >= 2 + ? (points[points.length - 1]!.timestamp - points[0]!.timestamp) / + (365.25 * 24 * 60 * 60 * 1000) + : 0; + if (!(years >= PROFESSIONAL_MC_MIN_YEARS)) { + return { + method: mode === "new_only" ? "unavailable" : "window_iid_bootstrap", + reasonCode: "insufficient_curve_span", + }; + } + return { method: "path_mc_v1" }; + } + + if (normalizedPeriods.length >= PROFESSIONAL_MC_MIN_WINDOWS) { + return { method: "window_iid_bootstrap" }; + } + return { method: "unavailable", reasonCode: "insufficient_windows" }; +} + export interface StressTest { worstCaseReturn: number; worstCaseWindow: number; @@ -515,9 +621,120 @@ function buildRegimeAnalysis(normalizedPeriods: NormalizedPeriod[]): RegimeAnaly function buildMonteCarloValidation( normalizedPeriods: NormalizedPeriod[], + normalizedCurves: Array<{ date: string; value: number }[]> | undefined, seed?: number | null, bootstrapIterations?: number, + options?: { + monteCarloMode?: "legacy" | "auto" | "new_only"; + enablePathMc?: boolean; + pathSimulations?: number; + maxEquityPoints?: number; + cpuBudgetMs?: number; + }, ): MonteCarloValidation { + const selected = selectMonteCarloMethod(normalizedPeriods, normalizedCurves, options); + if (selected.method === "path_mc_v1") { + const pointsRaw = buildPathMcPointsFromCurves(normalizedCurves); + const maxPoints = Math.max( + 2, + Math.floor(options?.maxEquityPoints ?? PROFESSIONAL_MC_MAX_EQUITY_POINTS), + ); + const points = decimatePoints(pointsRaw, maxPoints); + const sims = Math.min( + PROFESSIONAL_MC_MAX_PATH_SIMULATIONS, + Math.max(100, Math.floor(options?.pathSimulations ?? 1_000)), + ); + const budgetMs = options?.cpuBudgetMs ?? 300; + const estimatedCost = points.length * sims; + if (estimatedCost > 2_500_000) { + return { + method: "unavailable", + version: "mc-v1", + iterations: sims, + seed: seed ?? PATH_MONTE_CARLO_DEFAULT_SEED, + reasonCode: "cpu_budget_exceeded", + actualMeanReturn: 0, + confidenceInterval95: [0, 0], + confidenceInterval68: [0, 0], + probabilityPositive: 0, + verdict: "DOUBTFUL", + }; + } + const started = Date.now(); + const path = buildPathMonteCarloSimulation( + points.map((p) => ({ timestamp: p.timestamp, value: p.value })), + { + seed: seed ?? PATH_MONTE_CARLO_DEFAULT_SEED, + simulations: sims, + minPeriods: PROFESSIONAL_MC_MIN_CURVE_POINTS, + }, + ); + if (Date.now() - started > budgetMs) { + return { + method: "unavailable", + version: "mc-v1", + iterations: sims, + seed: seed ?? PATH_MONTE_CARLO_DEFAULT_SEED, + reasonCode: "cpu_budget_exceeded", + actualMeanReturn: 0, + confidenceInterval95: [0, 0], + confidenceInterval68: [0, 0], + probabilityPositive: 0, + verdict: "DOUBTFUL", + }; + } + if (!path) { + return { + method: "unavailable", + version: "mc-v1", + iterations: sims, + seed: seed ?? PATH_MONTE_CARLO_DEFAULT_SEED, + reasonCode: "flat_equity", + actualMeanReturn: 0, + confidenceInterval95: [0, 0], + confidenceInterval68: [0, 0], + probabilityPositive: 0, + verdict: "DOUBTFUL", + }; + } + const probabilityPositive = path.probabilityPositive; + const ci95: [number, number] = [path.cagrDistribution.p5, path.cagrDistribution.p95]; + const ci68: [number, number] = [path.cagrDistribution.p25, path.cagrDistribution.p75]; + const ci95ContainsZero = ci95[0] <= 0 && ci95[1] >= 0; + let verdict: MonteCarloValidation["verdict"] = "DOUBTFUL"; + if (probabilityPositive >= 0.75 && !ci95ContainsZero) verdict = "CONFIDENT"; + else if (probabilityPositive >= 0.6) verdict = "PROBABLE"; + else if (probabilityPositive >= 0.5) verdict = "UNCERTAIN"; + return { + method: "path_mc_v1", + version: `mc-v1/path-${path.meta.methodVersion}`, + iterations: path.meta.simulationsRun, + seed: path.meta.seedUsed, + warnings: path.meta.approximationsUsed ?? [], + actualMeanReturn: path.cagrStats.mean, + confidenceInterval95: ci95, + confidenceInterval68: ci68, + probabilityPositive: Math.round(probabilityPositive * 1000) / 1000, + verdict, + }; + } + if (selected.method === "unavailable") { + const seedUsed = seed != null && Number.isFinite(seed) ? seed : PATH_MONTE_CARLO_DEFAULT_SEED; + const iterations = resolveMonteCarloBootstrapIterations(bootstrapIterations); + return { + method: "unavailable", + version: "mc-v1", + iterations, + seed: seedUsed, + reasonCode: selected.reasonCode ?? "insufficient_windows", + actualMeanReturn: 0, + confidenceInterval95: [0, 0], + confidenceInterval68: [0, 0], + probabilityPositive: 0, + verdict: "DOUBTFUL", + }; + } + const oosReturns = normalizedPeriods.map((p) => p.validationReturn).filter(Number.isFinite); const actualMeanReturn = oosReturns.length > 0 ? oosReturns.reduce((a, b) => a + b, 0) / oosReturns.length : 0; const seedUsed = @@ -526,8 +743,13 @@ function buildMonteCarloValidation( const iterations = resolveMonteCarloBootstrapIterations(bootstrapIterations); const bootstrapMeans: number[] = []; const n = oosReturns.length; - if (n === 0) { + if (n < PROFESSIONAL_MC_MIN_WINDOWS) { return { + method: "unavailable", + version: "mc-v1", + iterations, + seed: seedUsed, + reasonCode: selected.reasonCode ?? "insufficient_windows", actualMeanReturn: 0, confidenceInterval95: [0, 0], confidenceInterval68: [0, 0], @@ -559,6 +781,10 @@ function buildMonteCarloValidation( else if (probabilityPositive >= 0.5) verdict = "UNCERTAIN"; return { + method: "window_iid_bootstrap", + version: "mc-v1", + iterations, + seed: seedUsed, actualMeanReturn, confidenceInterval95: [ci95Low, ci95High], confidenceInterval68: [ci68Low, ci68High], @@ -697,6 +923,9 @@ function buildInstitutionalGrade( monteCarloValidation.verdict, stressTest.verdict, ); + if (monteCarloValidation.method === "unavailable") { + scores.monteCarlo = 50; + } const composite = scores.wfe * 0.35 + scores.monteCarlo * 0.25 + scores.equity * 0.15 + scores.param * 0.1 + scores.regime * 0.1 + scores.stress * 0.05; let grade: InstitutionalGrade = "BBB - RESEARCH ONLY"; @@ -744,7 +973,16 @@ function buildInstitutionalGrade( */ export function buildProfessionalWfa( validation: ValidationResult, - options?: { seed?: number; permutationN?: number; bootstrapN?: number }, + options?: { + seed?: number; + permutationN?: number; + bootstrapN?: number; + monteCarloMode?: "legacy" | "auto" | "new_only"; + enablePathMc?: boolean; + pathSimulations?: number; + maxEquityPoints?: number; + cpuBudgetMs?: number; + }, ): { professional: ProfessionalWfa; professionalMeta: ProfessionalMeta } | null { if (!validation.ok) return null; const { normalizedPeriods, normalizedCurves } = validation; @@ -775,9 +1013,17 @@ export function buildProfessionalWfa( const regimeAnalysis = buildRegimeAnalysis(normalizedPeriods); const monteCarloValidation = buildMonteCarloValidation( normalizedPeriods, + normalizedCurves, seedEff, bootstrapIterations, + options, ); + if (monteCarloValidation.method === "unavailable") { + meta.approximationsUsed.push("mc_unavailable_neutral_score_50"); + meta.approximationsUsed.push( + `mc_unavailable_reason:${monteCarloValidation.reasonCode ?? "unknown"}`, + ); + } const stressTest = buildStressTest(normalizedPeriods, normalizedCurves, meta); const { grade, recommendation, institutionalGradeOverride } = buildInstitutionalGrade( equityCurveAnalysis, @@ -906,7 +1152,16 @@ function applyFailureRateInstitutionalGuard( */ export function runProfessionalWfa( wfa: WfaProfessionalInput | null | undefined, - options?: { seed?: number; permutationN?: number; bootstrapN?: number }, + options?: { + seed?: number; + permutationN?: number; + bootstrapN?: number; + monteCarloMode?: "legacy" | "auto" | "new_only"; + enablePathMc?: boolean; + pathSimulations?: number; + maxEquityPoints?: number; + cpuBudgetMs?: number; + }, ): { professional: ProfessionalWfa; professionalMeta: ProfessionalMeta } | null { const validation = validateAndNormalizeWfaInput(wfa); if (!validation.ok) return null; diff --git a/packages/test-vectors/package.json b/packages/test-vectors/package.json index 80b835a..174adb8 100644 --- a/packages/test-vectors/package.json +++ b/packages/test-vectors/package.json @@ -1,6 +1,6 @@ { "name": "@kiploks/engine-test-vectors", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, @@ -12,6 +12,22 @@ "bugs": { "url": "https://github.com/kiploks/engine/issues" }, + "keywords": [ + "algorithmic-trading", + "walk-forward-analysis", + "wfa", + "backtest", + "backtest-validation", + "trading-strategy", + "quantitative-finance", + "typescript", + "kiploks", + "open-core", + "risk-metrics", + "reproducible-analytics", + "out-of-sample", + "trading-research" + ], "type": "module", "files": [ "v1", diff --git a/scripts/engine-core-prepack.mjs b/scripts/engine-core-prepack.mjs index b69026b..bbb6a7f 100644 --- a/scripts/engine-core-prepack.mjs +++ b/scripts/engine-core-prepack.mjs @@ -1,6 +1,8 @@ /** * npm lifecycle for @kiploks/engine-core: - * - prepack "strip": remove ./internal from exports (and typesVersions.internal) so npm tarballs cannot resolve internal. + * - prepack "strip": remove ./internal from exports (and typesVersions.internal) and drop dist/internal.* + * so npm consumers cannot import @kiploks/engine-core/internal. The published ./server entry stays + * (self-contained dist/server.js that does not depend on dist/internal.js). * - postpack "restore": restore package.json from backup. * * Lives under the engine repo root so a public Open Core checkout is self-contained.