@calculator53295/backtest-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/canonical/allocation.d.ts +64 -0
  4. package/dist/canonical/allocation.d.ts.map +1 -0
  5. package/dist/canonical/allocation.js +91 -0
  6. package/dist/canonical/allocation.js.map +1 -0
  7. package/dist/canonical/dates.d.ts +45 -0
  8. package/dist/canonical/dates.d.ts.map +1 -0
  9. package/dist/canonical/dates.js +86 -0
  10. package/dist/canonical/dates.js.map +1 -0
  11. package/dist/canonical/index.d.ts +12 -0
  12. package/dist/canonical/index.d.ts.map +1 -0
  13. package/dist/canonical/index.js +12 -0
  14. package/dist/canonical/index.js.map +1 -0
  15. package/dist/canonical/rebalance.d.ts +35 -0
  16. package/dist/canonical/rebalance.d.ts.map +1 -0
  17. package/dist/canonical/rebalance.js +56 -0
  18. package/dist/canonical/rebalance.js.map +1 -0
  19. package/dist/canonical/returns.d.ts +61 -0
  20. package/dist/canonical/returns.d.ts.map +1 -0
  21. package/dist/canonical/returns.js +65 -0
  22. package/dist/canonical/returns.js.map +1 -0
  23. package/dist/canonical/strategy.d.ts +62 -0
  24. package/dist/canonical/strategy.d.ts.map +1 -0
  25. package/dist/canonical/strategy.js +103 -0
  26. package/dist/canonical/strategy.js.map +1 -0
  27. package/dist/engine/align.d.ts +34 -0
  28. package/dist/engine/align.d.ts.map +1 -0
  29. package/dist/engine/align.js +97 -0
  30. package/dist/engine/align.js.map +1 -0
  31. package/dist/engine/backtest.d.ts +7 -0
  32. package/dist/engine/backtest.d.ts.map +1 -0
  33. package/dist/engine/backtest.js +95 -0
  34. package/dist/engine/backtest.js.map +1 -0
  35. package/dist/engine/frontier.d.ts +25 -0
  36. package/dist/engine/frontier.d.ts.map +1 -0
  37. package/dist/engine/frontier.js +40 -0
  38. package/dist/engine/frontier.js.map +1 -0
  39. package/dist/engine/index.d.ts +6 -0
  40. package/dist/engine/index.d.ts.map +1 -0
  41. package/dist/engine/index.js +5 -0
  42. package/dist/engine/index.js.map +1 -0
  43. package/dist/engine/metrics.d.ts +27 -0
  44. package/dist/engine/metrics.d.ts.map +1 -0
  45. package/dist/engine/metrics.js +202 -0
  46. package/dist/engine/metrics.js.map +1 -0
  47. package/dist/engine/types.d.ts +118 -0
  48. package/dist/engine/types.d.ts.map +1 -0
  49. package/dist/engine/types.js +2 -0
  50. package/dist/engine/types.js.map +1 -0
  51. package/dist/index.d.ts +25 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +25 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/montecarlo/data.d.ts +17 -0
  56. package/dist/montecarlo/data.d.ts.map +1 -0
  57. package/dist/montecarlo/data.js +38 -0
  58. package/dist/montecarlo/data.js.map +1 -0
  59. package/dist/montecarlo/simulate.d.ts +106 -0
  60. package/dist/montecarlo/simulate.d.ts.map +1 -0
  61. package/dist/montecarlo/simulate.js +264 -0
  62. package/dist/montecarlo/simulate.js.map +1 -0
  63. package/dist/retirement/bucketRules.d.ts +3 -0
  64. package/dist/retirement/bucketRules.d.ts.map +1 -0
  65. package/dist/retirement/bucketRules.js +37 -0
  66. package/dist/retirement/bucketRules.js.map +1 -0
  67. package/dist/retirement/engine.d.ts +7 -0
  68. package/dist/retirement/engine.d.ts.map +1 -0
  69. package/dist/retirement/engine.js +297 -0
  70. package/dist/retirement/engine.js.map +1 -0
  71. package/dist/retirement/index.d.ts +6 -0
  72. package/dist/retirement/index.d.ts.map +1 -0
  73. package/dist/retirement/index.js +6 -0
  74. package/dist/retirement/index.js.map +1 -0
  75. package/dist/retirement/runner.d.ts +15 -0
  76. package/dist/retirement/runner.d.ts.map +1 -0
  77. package/dist/retirement/runner.js +178 -0
  78. package/dist/retirement/runner.js.map +1 -0
  79. package/dist/retirement/strategies/index.d.ts +30 -0
  80. package/dist/retirement/strategies/index.d.ts.map +1 -0
  81. package/dist/retirement/strategies/index.js +270 -0
  82. package/dist/retirement/strategies/index.js.map +1 -0
  83. package/dist/retirement/types.d.ts +206 -0
  84. package/dist/retirement/types.d.ts.map +1 -0
  85. package/dist/retirement/types.js +2 -0
  86. package/dist/retirement/types.js.map +1 -0
  87. package/package.json +50 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Canonical monthly real-return series + adapters into it.
3
+ *
4
+ * The canonical series is the Monte Carlo `RealReturnSeries` (monthly, real,
5
+ * fractional) — it is what `runHistoricalSequence`/`runBootstrap` consume. The
6
+ * two upstream shapes are:
7
+ * - Fathom `TickerSeries`: daily nominal price records (handled elsewhere by
8
+ * the engine's alignment layer, not here).
9
+ * - retirement-sim `HistoricalRow`: monthly nominal returns per asset class
10
+ * plus a CPI level.
11
+ *
12
+ * `historicalRowsToRealReturns` converts the monthly rows using the same
13
+ * real-return math as `montecarlo/data.ts`: blend the nominal per-asset returns
14
+ * by allocation, then deflate by month-over-month CPI growth. The bond/cash
15
+ * composition is unambiguous here (each row carries a separate bond and cash
16
+ * return), so the default blend is the allocation-weighted average — but the
17
+ * blend is exposed as an explicit parameter for callers whose asset set or
18
+ * weighting differs.
19
+ */
20
+ import type { RealReturnSeries } from '../montecarlo/simulate.js';
21
+ import type { Buckets } from './allocation.js';
22
+ /**
23
+ * retirement-sim's monthly historical row (mirrored structurally; staging is
24
+ * read-only). Returns are NOMINAL monthly fractions; `cpi` is an index level.
25
+ */
26
+ export interface HistoricalRow {
27
+ /** 'YYYY-MM' (or 'YYYY-MM-DD', normalized to month on output). */
28
+ date: string;
29
+ /** Nominal monthly stock (S&P) return. */
30
+ spReturn: number;
31
+ /** Nominal monthly bond return. */
32
+ bondReturn: number;
33
+ /** Nominal monthly cash return. */
34
+ cashReturn: number;
35
+ /** CPI index level for the month. */
36
+ cpi: number;
37
+ /** Optional Shiller CAPE for the month. */
38
+ cape?: number;
39
+ }
40
+ /** Blends a row's per-asset nominal returns into one portfolio nominal return. */
41
+ export type ReturnBlend = (row: HistoricalRow, allocation: Buckets) => number;
42
+ /**
43
+ * Default blend: allocation-weighted average of the three bucket returns,
44
+ * normalized by the total weight so unnormalized buckets still behave. This
45
+ * matches `buildRealReturns`'s weighting convention.
46
+ */
47
+ export declare const weightedBucketBlend: ReturnBlend;
48
+ export interface HistoricalRowsOptions {
49
+ /** Override the per-row nominal blend (default: `weightedBucketBlend`). */
50
+ blend?: ReturnBlend;
51
+ }
52
+ /**
53
+ * Convert monthly `HistoricalRow`s into a canonical `RealReturnSeries`.
54
+ *
55
+ * For each month i ≥ 1: inflation = cpi[i] / cpi[i-1]; nominal = blend(row[i]);
56
+ * real = (1 + nominal) / inflation − 1. The label emitted is month i (the
57
+ * month the return occurs in), matching `buildRealReturns`. The first row seeds
58
+ * the CPI base and produces no return, so a series of N rows yields N−1 points.
59
+ */
60
+ export declare function historicalRowsToRealReturns(rows: HistoricalRow[], allocation: Buckets, options?: HistoricalRowsOptions): RealReturnSeries;
61
+ //# sourceMappingURL=returns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"returns.d.ts","sourceRoot":"","sources":["../../src/canonical/returns.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAA;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAG9C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAA;IACZ,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAA;IAChB,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAA;IACX,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,kFAAkF;AAClF,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,aAAa,EAAE,UAAU,EAAE,OAAO,KAAK,MAAM,CAAA;AAE7E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAQjC,CAAA;AAED,MAAM,WAAW,qBAAqB;IACpC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,WAAW,CAAA;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,aAAa,EAAE,EACrB,UAAU,EAAE,OAAO,EACnB,OAAO,GAAE,qBAA0B,GAClC,gBAAgB,CAoBlB"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Canonical monthly real-return series + adapters into it.
3
+ *
4
+ * The canonical series is the Monte Carlo `RealReturnSeries` (monthly, real,
5
+ * fractional) — it is what `runHistoricalSequence`/`runBootstrap` consume. The
6
+ * two upstream shapes are:
7
+ * - Fathom `TickerSeries`: daily nominal price records (handled elsewhere by
8
+ * the engine's alignment layer, not here).
9
+ * - retirement-sim `HistoricalRow`: monthly nominal returns per asset class
10
+ * plus a CPI level.
11
+ *
12
+ * `historicalRowsToRealReturns` converts the monthly rows using the same
13
+ * real-return math as `montecarlo/data.ts`: blend the nominal per-asset returns
14
+ * by allocation, then deflate by month-over-month CPI growth. The bond/cash
15
+ * composition is unambiguous here (each row carries a separate bond and cash
16
+ * return), so the default blend is the allocation-weighted average — but the
17
+ * blend is exposed as an explicit parameter for callers whose asset set or
18
+ * weighting differs.
19
+ */
20
+ import { toMonthKey } from './dates.js';
21
+ /**
22
+ * Default blend: allocation-weighted average of the three bucket returns,
23
+ * normalized by the total weight so unnormalized buckets still behave. This
24
+ * matches `buildRealReturns`'s weighting convention.
25
+ */
26
+ export const weightedBucketBlend = (row, allocation) => {
27
+ const total = allocation.stocks + allocation.bonds + allocation.cash;
28
+ if (total <= 0)
29
+ return 0;
30
+ const weighted = allocation.stocks * row.spReturn +
31
+ allocation.bonds * row.bondReturn +
32
+ allocation.cash * row.cashReturn;
33
+ return weighted / total;
34
+ };
35
+ /**
36
+ * Convert monthly `HistoricalRow`s into a canonical `RealReturnSeries`.
37
+ *
38
+ * For each month i ≥ 1: inflation = cpi[i] / cpi[i-1]; nominal = blend(row[i]);
39
+ * real = (1 + nominal) / inflation − 1. The label emitted is month i (the
40
+ * month the return occurs in), matching `buildRealReturns`. The first row seeds
41
+ * the CPI base and produces no return, so a series of N rows yields N−1 points.
42
+ */
43
+ export function historicalRowsToRealReturns(rows, allocation, options = {}) {
44
+ const blend = options.blend ?? weightedBucketBlend;
45
+ const dates = [];
46
+ const returns = [];
47
+ for (let i = 1; i < rows.length; i++) {
48
+ const prev = rows[i - 1];
49
+ const cur = rows[i];
50
+ if (!prev || !cur)
51
+ continue;
52
+ if (!Number.isFinite(prev.cpi) || prev.cpi <= 0) {
53
+ throw new Error(`Invalid CPI at '${prev.date}': expected a positive finite value`);
54
+ }
55
+ if (!Number.isFinite(cur.cpi) || cur.cpi <= 0) {
56
+ throw new Error(`Invalid CPI at '${cur.date}': expected a positive finite value`);
57
+ }
58
+ const inflation = cur.cpi / prev.cpi;
59
+ const nominal = blend(cur, allocation);
60
+ dates.push(toMonthKey(cur.date));
61
+ returns.push((1 + nominal) / inflation - 1);
62
+ }
63
+ return { dates, returns };
64
+ }
65
+ //# sourceMappingURL=returns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"returns.js","sourceRoot":"","sources":["../../src/canonical/returns.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAwBvC;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,EAAE;IAClE,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,GAAG,UAAU,CAAC,KAAK,GAAG,UAAU,CAAC,IAAI,CAAA;IACpE,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,CAAC,CAAA;IACxB,MAAM,QAAQ,GACZ,UAAU,CAAC,MAAM,GAAG,GAAG,CAAC,QAAQ;QAChC,UAAU,CAAC,KAAK,GAAG,GAAG,CAAC,UAAU;QACjC,UAAU,CAAC,IAAI,GAAG,GAAG,CAAC,UAAU,CAAA;IAClC,OAAO,QAAQ,GAAG,KAAK,CAAA;AACzB,CAAC,CAAA;AAOD;;;;;;;GAOG;AACH,MAAM,UAAU,2BAA2B,CACzC,IAAqB,EACrB,UAAmB,EACnB,UAAiC,EAAE;IAEnC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,mBAAmB,CAAA;IAClD,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;QACxB,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QACnB,IAAI,CAAC,IAAI,IAAI,CAAC,GAAG;YAAE,SAAQ;QAC3B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;YAChD,MAAM,IAAI,KAAK,CAAC,mBAAmB,IAAI,CAAC,IAAI,qCAAqC,CAAC,CAAA;QACpF,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;YAC9C,MAAM,IAAI,KAAK,CAAC,mBAAmB,GAAG,CAAC,IAAI,qCAAqC,CAAC,CAAA;QACnF,CAAC;QACD,MAAM,SAAS,GAAG,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,CAAA;QACpC,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,EAAE,UAAU,CAAC,CAAA;QACtC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;QAChC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,GAAG,SAAS,GAAG,CAAC,CAAC,CAAA;IAC7C,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,CAAA;AAC3B,CAAC"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Canonical withdrawal-strategy ids.
3
+ *
4
+ * Three vocabularies name the same family of rules differently:
5
+ * - Monte Carlo (`src/montecarlo/simulate.ts`): 'fixedReal' | 'fixedPercent' |
6
+ * 'vpw' | 'guardrails' — a camelCase four.
7
+ * - retirement-sim registry (`staging/retirement-sim/strategies/index.ts`):
8
+ * a kebab-case superset ('constant-dollar', 'percent-of-portfolio',
9
+ * 'one-over-n', 'vpw', 'guyton-klinger', 'cape-based', '95-percent',
10
+ * 'endowment', 'vanguard-dynamic', plus legacy aliases).
11
+ *
12
+ * Canonical ids are the kebab-case superset (they already read as the shared
13
+ * vocabulary). Mappings carry an `approximate` flag and a `note` whenever two
14
+ * ids name rules that are close but NOT identical — so the difference is
15
+ * documented at the boundary instead of being silently equated. The Monte
16
+ * Carlo 'guardrails' rule is a simplified 20%/10% band; it is mapped to
17
+ * 'guyton-klinger' as an APPROXIMATION, never as an equality.
18
+ */
19
+ import type { WithdrawalStrategy as MontecarloStrategy } from '../montecarlo/simulate.js';
20
+ /** Monte Carlo strategy id (re-export of the camelCase union). */
21
+ export type MontecarloStrategyId = MontecarloStrategy;
22
+ /** Canonical, kebab-case strategy ids (the retirement-sim "new kinds" superset). */
23
+ export type CanonicalStrategyId = 'constant-dollar' | 'percent-of-portfolio' | 'one-over-n' | 'vpw' | 'guyton-klinger' | 'cape-based' | '95-percent' | 'endowment' | 'vanguard-dynamic';
24
+ export declare const CANONICAL_STRATEGY_IDS: readonly CanonicalStrategyId[];
25
+ /**
26
+ * retirement-sim `StrategyKind`: the canonical superset plus legacy aliases
27
+ * and the non-rule 'interactive' mode (mirrored structurally; staging is
28
+ * read-only).
29
+ */
30
+ export type RetirementStrategyKind = CanonicalStrategyId | 'fixed-real' | 'fixed-pct' | 'guardrails' | 'interactive';
31
+ /** Result of a strategy-id mapping: the canonical id + whether it's exact. */
32
+ export interface StrategyMapping<Id extends string = CanonicalStrategyId> {
33
+ id: Id;
34
+ /** True when the source rule is only approximately the mapped rule. */
35
+ approximate: boolean;
36
+ /** Present only for approximate mappings: what the approximation glosses over. */
37
+ note?: string;
38
+ }
39
+ /** Monte Carlo id → canonical id. Exact except 'guardrails' (≈ Guyton-Klinger). */
40
+ export declare const MONTECARLO_TO_CANONICAL: Record<MontecarloStrategyId, StrategyMapping>;
41
+ /** Map a Monte Carlo strategy id to its canonical id. */
42
+ export declare function montecarloToCanonicalStrategy(id: MontecarloStrategyId): StrategyMapping;
43
+ /**
44
+ * Map a canonical id back to a Monte Carlo id. The Monte Carlo engine only
45
+ * implements four rules, so canonical ids outside that set (one-over-n,
46
+ * cape-based, 95-percent, endowment, vanguard-dynamic) have no equivalent and
47
+ * throw rather than being coerced to a nearest neighbour.
48
+ */
49
+ export declare function canonicalToMontecarloStrategy(id: CanonicalStrategyId): StrategyMapping<MontecarloStrategyId>;
50
+ /**
51
+ * Map a retirement-sim `StrategyKind` to its canonical id. The "new kinds" are
52
+ * already canonical (identity); legacy aliases fold onto their modern id, and
53
+ * the non-rule 'interactive' mode throws (it has no withdrawal-rule id).
54
+ */
55
+ export declare function retirementToCanonicalStrategy(kind: RetirementStrategyKind): StrategyMapping;
56
+ /**
57
+ * Map a canonical id to retirement-sim's modern strategy id. Canonical ids
58
+ * were deliberately chosen from the modern retirement-sim vocabulary, so
59
+ * this direction is exact and total.
60
+ */
61
+ export declare function canonicalToRetirementStrategy(id: CanonicalStrategyId): StrategyMapping<CanonicalStrategyId>;
62
+ //# sourceMappingURL=strategy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strategy.d.ts","sourceRoot":"","sources":["../../src/canonical/strategy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,IAAI,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAEzF,kEAAkE;AAClE,MAAM,MAAM,oBAAoB,GAAG,kBAAkB,CAAA;AAErD,oFAAoF;AACpF,MAAM,MAAM,mBAAmB,GAC3B,iBAAiB,GACjB,sBAAsB,GACtB,YAAY,GACZ,KAAK,GACL,gBAAgB,GAChB,YAAY,GACZ,YAAY,GACZ,WAAW,GACX,kBAAkB,CAAA;AAEtB,eAAO,MAAM,sBAAsB,EAAE,SAAS,mBAAmB,EAUhE,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAC9B,mBAAmB,GACnB,YAAY,GACZ,WAAW,GACX,YAAY,GACZ,aAAa,CAAA;AAEjB,8EAA8E;AAC9E,MAAM,WAAW,eAAe,CAAC,EAAE,SAAS,MAAM,GAAG,mBAAmB;IACtE,EAAE,EAAE,EAAE,CAAA;IACN,uEAAuE;IACvE,WAAW,EAAE,OAAO,CAAA;IACpB,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AASD,mFAAmF;AACnF,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAAC,oBAAoB,EAAE,eAAe,CAKjF,CAAA;AAED,yDAAyD;AACzD,wBAAgB,6BAA6B,CAAC,EAAE,EAAE,oBAAoB,GAAG,eAAe,CAIvF;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,EAAE,EAAE,mBAAmB,GACtB,eAAe,CAAC,oBAAoB,CAAC,CAavC;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,sBAAsB,GAAG,eAAe,CAoB3F;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,EAAE,EAAE,mBAAmB,GACtB,eAAe,CAAC,mBAAmB,CAAC,CAEtC"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Canonical withdrawal-strategy ids.
3
+ *
4
+ * Three vocabularies name the same family of rules differently:
5
+ * - Monte Carlo (`src/montecarlo/simulate.ts`): 'fixedReal' | 'fixedPercent' |
6
+ * 'vpw' | 'guardrails' — a camelCase four.
7
+ * - retirement-sim registry (`staging/retirement-sim/strategies/index.ts`):
8
+ * a kebab-case superset ('constant-dollar', 'percent-of-portfolio',
9
+ * 'one-over-n', 'vpw', 'guyton-klinger', 'cape-based', '95-percent',
10
+ * 'endowment', 'vanguard-dynamic', plus legacy aliases).
11
+ *
12
+ * Canonical ids are the kebab-case superset (they already read as the shared
13
+ * vocabulary). Mappings carry an `approximate` flag and a `note` whenever two
14
+ * ids name rules that are close but NOT identical — so the difference is
15
+ * documented at the boundary instead of being silently equated. The Monte
16
+ * Carlo 'guardrails' rule is a simplified 20%/10% band; it is mapped to
17
+ * 'guyton-klinger' as an APPROXIMATION, never as an equality.
18
+ */
19
+ export const CANONICAL_STRATEGY_IDS = [
20
+ 'constant-dollar',
21
+ 'percent-of-portfolio',
22
+ 'one-over-n',
23
+ 'vpw',
24
+ 'guyton-klinger',
25
+ 'cape-based',
26
+ '95-percent',
27
+ 'endowment',
28
+ 'vanguard-dynamic',
29
+ ];
30
+ const GUARDRAILS_NOTE = "Monte Carlo 'guardrails' is a simplified Guyton-Klinger: it cuts the real " +
31
+ 'withdrawal 10% when the current rate drifts 20% above the initial rate and ' +
32
+ 'raises it 10% when 20% below. Full Guyton-Klinger additionally applies an ' +
33
+ 'inflation cap, a post-loss inflation skip, and a preservation/prosperity ' +
34
+ 'timing window. Treat as an approximation, not an equivalence.';
35
+ /** Monte Carlo id → canonical id. Exact except 'guardrails' (≈ Guyton-Klinger). */
36
+ export const MONTECARLO_TO_CANONICAL = {
37
+ fixedReal: { id: 'constant-dollar', approximate: false },
38
+ fixedPercent: { id: 'percent-of-portfolio', approximate: false },
39
+ vpw: { id: 'vpw', approximate: false },
40
+ guardrails: { id: 'guyton-klinger', approximate: true, note: GUARDRAILS_NOTE },
41
+ };
42
+ /** Map a Monte Carlo strategy id to its canonical id. */
43
+ export function montecarloToCanonicalStrategy(id) {
44
+ const mapping = MONTECARLO_TO_CANONICAL[id];
45
+ if (!mapping)
46
+ throw new Error(`Unknown Monte Carlo strategy '${id}'`);
47
+ return mapping;
48
+ }
49
+ /**
50
+ * Map a canonical id back to a Monte Carlo id. The Monte Carlo engine only
51
+ * implements four rules, so canonical ids outside that set (one-over-n,
52
+ * cape-based, 95-percent, endowment, vanguard-dynamic) have no equivalent and
53
+ * throw rather than being coerced to a nearest neighbour.
54
+ */
55
+ export function canonicalToMontecarloStrategy(id) {
56
+ switch (id) {
57
+ case 'constant-dollar':
58
+ return { id: 'fixedReal', approximate: false };
59
+ case 'percent-of-portfolio':
60
+ return { id: 'fixedPercent', approximate: false };
61
+ case 'vpw':
62
+ return { id: 'vpw', approximate: false };
63
+ case 'guyton-klinger':
64
+ return { id: 'guardrails', approximate: true, note: GUARDRAILS_NOTE };
65
+ default:
66
+ throw new Error(`Canonical strategy '${id}' has no Monte Carlo equivalent`);
67
+ }
68
+ }
69
+ /**
70
+ * Map a retirement-sim `StrategyKind` to its canonical id. The "new kinds" are
71
+ * already canonical (identity); legacy aliases fold onto their modern id, and
72
+ * the non-rule 'interactive' mode throws (it has no withdrawal-rule id).
73
+ */
74
+ export function retirementToCanonicalStrategy(kind) {
75
+ if (CANONICAL_STRATEGY_IDS.includes(kind)) {
76
+ return { id: kind, approximate: false };
77
+ }
78
+ switch (kind) {
79
+ case 'fixed-real':
80
+ return { id: 'constant-dollar', approximate: false };
81
+ case 'fixed-pct':
82
+ return { id: 'percent-of-portfolio', approximate: false };
83
+ case 'guardrails':
84
+ return {
85
+ id: 'guyton-klinger',
86
+ approximate: true,
87
+ note: "Legacy 'guardrails' is a simplified guardrail rule; Guyton-Klinger is the full ruleset.",
88
+ };
89
+ case 'interactive':
90
+ throw new Error("retirement-sim 'interactive' has no canonical withdrawal-rule id");
91
+ default:
92
+ throw new Error(`Unknown retirement-sim strategy kind '${kind}'`);
93
+ }
94
+ }
95
+ /**
96
+ * Map a canonical id to retirement-sim's modern strategy id. Canonical ids
97
+ * were deliberately chosen from the modern retirement-sim vocabulary, so
98
+ * this direction is exact and total.
99
+ */
100
+ export function canonicalToRetirementStrategy(id) {
101
+ return { id, approximate: false };
102
+ }
103
+ //# sourceMappingURL=strategy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strategy.js","sourceRoot":"","sources":["../../src/canonical/strategy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAmBH,MAAM,CAAC,MAAM,sBAAsB,GAAmC;IACpE,iBAAiB;IACjB,sBAAsB;IACtB,YAAY;IACZ,KAAK;IACL,gBAAgB;IAChB,YAAY;IACZ,YAAY;IACZ,WAAW;IACX,kBAAkB;CACnB,CAAA;AAuBD,MAAM,eAAe,GACnB,4EAA4E;IAC5E,6EAA6E;IAC7E,4EAA4E;IAC5E,2EAA2E;IAC3E,+DAA+D,CAAA;AAEjE,mFAAmF;AACnF,MAAM,CAAC,MAAM,uBAAuB,GAAkD;IACpF,SAAS,EAAE,EAAE,EAAE,EAAE,iBAAiB,EAAE,WAAW,EAAE,KAAK,EAAE;IACxD,YAAY,EAAE,EAAE,EAAE,EAAE,sBAAsB,EAAE,WAAW,EAAE,KAAK,EAAE;IAChE,GAAG,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE;IACtC,UAAU,EAAE,EAAE,EAAE,EAAE,gBAAgB,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE;CAC/E,CAAA;AAED,yDAAyD;AACzD,MAAM,UAAU,6BAA6B,CAAC,EAAwB;IACpE,MAAM,OAAO,GAAG,uBAAuB,CAAC,EAAE,CAAC,CAAA;IAC3C,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,EAAE,GAAG,CAAC,CAAA;IACrE,OAAO,OAAO,CAAA;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,6BAA6B,CAC3C,EAAuB;IAEvB,QAAQ,EAAE,EAAE,CAAC;QACX,KAAK,iBAAiB;YACpB,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;QAChD,KAAK,sBAAsB;YACzB,OAAO,EAAE,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;QACnD,KAAK,KAAK;YACR,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;QAC1C,KAAK,gBAAgB;YACnB,OAAO,EAAE,EAAE,EAAE,YAAY,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE,CAAA;QACvE;YACE,MAAM,IAAI,KAAK,CAAC,uBAAuB,EAAE,iCAAiC,CAAC,CAAA;IAC/E,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAAC,IAA4B;IACxE,IAAK,sBAA4C,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACjE,OAAO,EAAE,EAAE,EAAE,IAA2B,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;IAChE,CAAC;IACD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,YAAY;YACf,OAAO,EAAE,EAAE,EAAE,iBAAiB,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;QACtD,KAAK,WAAW;YACd,OAAO,EAAE,EAAE,EAAE,sBAAsB,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;QAC3D,KAAK,YAAY;YACf,OAAO;gBACL,EAAE,EAAE,gBAAgB;gBACpB,WAAW,EAAE,IAAI;gBACjB,IAAI,EAAE,yFAAyF;aAChG,CAAA;QACH,KAAK,aAAa;YAChB,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAA;QACrF;YACE,MAAM,IAAI,KAAK,CAAC,yCAAyC,IAAI,GAAG,CAAC,CAAA;IACrE,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAC3C,EAAuB;IAEvB,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,CAAA;AACnC,CAAC"}
@@ -0,0 +1,34 @@
1
+ import type { DailyRecord, TickerSeries } from './types.js';
2
+ /**
3
+ * Per-ticker daily return components on a shared calendar.
4
+ * All arrays have length = dates.length; index 0 is the buy-in day
5
+ * (returns there are identity: 1, 1, 0).
6
+ */
7
+ export interface PreparedAsset {
8
+ ticker: string;
9
+ /** totalReturn[t] = adjClose[t] / adjClose[t-1] — dividends reinvested. */
10
+ totalReturn: number[];
11
+ /** priceReturn[t] = close[t] * splitFactor[t] / close[t-1] — splits neutralized, dividends dropped. */
12
+ priceReturn: number[];
13
+ /** divYield[t] = divCash[t] * (shares growth to t-1) / close[t-1] — cash dividend as fraction of prior value. */
14
+ divYield: number[];
15
+ }
16
+ export interface AlignedData {
17
+ dates: string[];
18
+ assets: Map<string, PreparedAsset>;
19
+ }
20
+ /**
21
+ * Build the shared calendar (intersection of trading days across all series,
22
+ * clamped to [start, end]) and per-ticker return components on it.
23
+ *
24
+ * Intersection can skip days a ticker traded (e.g. one fund had a holiday).
25
+ * Returns are computed from each ticker's own consecutive records BETWEEN
26
+ * calendar days, so a skipped day's move is never lost — it compounds into
27
+ * the next shared day.
28
+ */
29
+ export declare function alignSeries(seriesList: TickerSeries[], start?: string, end?: string): AlignedData;
30
+ /** True when `date` starts a new period vs `prevDate` for the given frequency. */
31
+ export declare function isPeriodStart(prevDate: string, date: string, freq: 'annual' | 'quarterly' | 'monthly'): boolean;
32
+ export declare function isNewMonth(prevDate: string, date: string): boolean;
33
+ export type { DailyRecord };
34
+ //# sourceMappingURL=align.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"align.d.ts","sourceRoot":"","sources":["../../src/engine/align.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE3D;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,uGAAuG;IACvG,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,iHAAiH;IACjH,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;CACnC;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,YAAY,EAAE,EAC1B,KAAK,CAAC,EAAE,MAAM,EACd,GAAG,CAAC,EAAE,MAAM,GACX,WAAW,CAmCb;AAuCD,kFAAkF;AAClF,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,GACvC,OAAO,CAQT;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAElE;AAED,YAAY,EAAE,WAAW,EAAE,CAAA"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Build the shared calendar (intersection of trading days across all series,
3
+ * clamped to [start, end]) and per-ticker return components on it.
4
+ *
5
+ * Intersection can skip days a ticker traded (e.g. one fund had a holiday).
6
+ * Returns are computed from each ticker's own consecutive records BETWEEN
7
+ * calendar days, so a skipped day's move is never lost — it compounds into
8
+ * the next shared day.
9
+ */
10
+ export function alignSeries(seriesList, start, end) {
11
+ if (seriesList.length === 0)
12
+ throw new Error('No ticker series provided');
13
+ // Effective range: caller's range clamped to common history.
14
+ let lo = start ?? '0000-00-00';
15
+ let hi = end ?? '9999-99-99';
16
+ for (const s of seriesList) {
17
+ if (s.records.length === 0)
18
+ throw new Error(`Empty series for ${s.ticker}`);
19
+ const first = s.records[0].date;
20
+ const last = s.records[s.records.length - 1].date;
21
+ if (first > lo)
22
+ lo = first;
23
+ if (last < hi)
24
+ hi = last;
25
+ }
26
+ if (lo > hi)
27
+ throw new Error(`No overlapping history in range ${lo}..${hi}`);
28
+ // Intersection of trading days within [lo, hi].
29
+ const counts = new Map();
30
+ for (const s of seriesList) {
31
+ for (const r of s.records) {
32
+ if (r.date >= lo && r.date <= hi) {
33
+ counts.set(r.date, (counts.get(r.date) ?? 0) + 1);
34
+ }
35
+ }
36
+ }
37
+ const dates = [...counts.entries()]
38
+ .filter(([, n]) => n === seriesList.length)
39
+ .map(([d]) => d)
40
+ .sort();
41
+ if (dates.length < 2)
42
+ throw new Error(`Fewer than 2 shared trading days in ${lo}..${hi}`);
43
+ const assets = new Map();
44
+ for (const s of seriesList) {
45
+ assets.set(s.ticker, prepareAsset(s, dates));
46
+ }
47
+ return { dates, assets };
48
+ }
49
+ function prepareAsset(series, dates) {
50
+ const byDate = new Map();
51
+ series.records.forEach((r, i) => byDate.set(r.date, i));
52
+ const totalReturn = [1];
53
+ const priceReturn = [1];
54
+ const divYield = [0];
55
+ for (let t = 1; t < dates.length; t++) {
56
+ const from = byDate.get(dates[t - 1]);
57
+ const to = byDate.get(dates[t]);
58
+ if (from === undefined || to === undefined) {
59
+ throw new Error(`${series.ticker} missing shared date ${dates[t - 1]} or ${dates[t]}`);
60
+ }
61
+ // Compound this ticker's own daily records across (from, to].
62
+ let tr = 1;
63
+ let pr = 1;
64
+ let dy = 0;
65
+ for (let i = from + 1; i <= to; i++) {
66
+ const prev = series.records[i - 1];
67
+ const cur = series.records[i];
68
+ tr *= cur.adjClose / prev.adjClose;
69
+ // A splitFactor of k means 1 share became k shares; neutralize the price drop.
70
+ const dayPr = (cur.close * cur.splitFactor) / prev.close;
71
+ // Dividend cash per unit of value held at `prev` close. Scale by shares
72
+ // accumulated through intermediate splits within this window (pr so far).
73
+ dy += (pr * (cur.divCash * cur.splitFactor)) / prev.close;
74
+ pr *= dayPr;
75
+ }
76
+ totalReturn.push(tr);
77
+ priceReturn.push(pr);
78
+ divYield.push(dy);
79
+ }
80
+ return { ticker: series.ticker, totalReturn, priceReturn, divYield };
81
+ }
82
+ /** True when `date` starts a new period vs `prevDate` for the given frequency. */
83
+ export function isPeriodStart(prevDate, date, freq) {
84
+ const py = Number(prevDate.slice(0, 4));
85
+ const pm = Number(prevDate.slice(5, 7));
86
+ const cy = Number(date.slice(0, 4));
87
+ const cm = Number(date.slice(5, 7));
88
+ if (freq === 'annual')
89
+ return cy !== py;
90
+ if (freq === 'monthly')
91
+ return cy !== py || cm !== pm;
92
+ return cy !== py || Math.floor((cm - 1) / 3) !== Math.floor((pm - 1) / 3);
93
+ }
94
+ export function isNewMonth(prevDate, date) {
95
+ return prevDate.slice(0, 7) !== date.slice(0, 7);
96
+ }
97
+ //# sourceMappingURL=align.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"align.js","sourceRoot":"","sources":["../../src/engine/align.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import type { BacktestConfig, BacktestResult, PortfolioSpec, TickerSeries } from './types.js';
2
+ /**
3
+ * Run a portfolio backtest. See BacktestResult in types.ts for the exact
4
+ * simulation semantics (flow timing, rebalancing, dividend handling).
5
+ */
6
+ export declare function runBacktest(seriesList: TickerSeries[], portfolio: PortfolioSpec, config: BacktestConfig): BacktestResult;
7
+ //# sourceMappingURL=backtest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backtest.d.ts","sourceRoot":"","sources":["../../src/engine/backtest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EACd,cAAc,EACd,aAAa,EACb,YAAY,EACb,MAAM,YAAY,CAAA;AAEnB;;;GAGG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,YAAY,EAAE,EAC1B,SAAS,EAAE,aAAa,EACxB,MAAM,EAAE,cAAc,GACrB,cAAc,CAuGhB"}
@@ -0,0 +1,95 @@
1
+ import { alignSeries, isNewMonth, isPeriodStart } from './align.js';
2
+ import { computeMetrics } from './metrics.js';
3
+ /**
4
+ * Run a portfolio backtest. See BacktestResult in types.ts for the exact
5
+ * simulation semantics (flow timing, rebalancing, dividend handling).
6
+ */
7
+ export function runBacktest(seriesList, portfolio, config) {
8
+ const weightSum = portfolio.allocations.reduce((s, a) => s + a.weight, 0);
9
+ if (Math.abs(weightSum - 100) > 1e-6) {
10
+ throw new Error(`Weights must sum to 100, got ${weightSum}`);
11
+ }
12
+ const bySymbol = new Map(seriesList.map((s) => [s.ticker, s]));
13
+ const used = portfolio.allocations.map((a) => {
14
+ const s = bySymbol.get(a.ticker);
15
+ if (!s)
16
+ throw new Error(`Missing series for ${a.ticker}`);
17
+ return s;
18
+ });
19
+ const { dates, assets } = alignSeries(used, config.start, config.end);
20
+ const n = dates.length;
21
+ const weights = portfolio.allocations.map((a) => a.weight / 100);
22
+ const prepared = portfolio.allocations.map((a) => assets.get(a.ticker));
23
+ // State: market value per holding, plus uninvested dividend cash.
24
+ const holdings = weights.map((w) => config.initialAmount * w);
25
+ let cash = 0;
26
+ const values = new Array(n);
27
+ const twrIndex = new Array(n);
28
+ const flows = new Array(n).fill(0);
29
+ const dividendIncome = new Array(n).fill(0);
30
+ const incomeByHolding = new Array(holdings.length).fill(0);
31
+ values[0] = config.initialAmount;
32
+ twrIndex[0] = 1;
33
+ let totalContributions = 0;
34
+ for (let t = 1; t < n; t++) {
35
+ const prevValue = holdings.reduce((s, h) => s + h, cash);
36
+ // 1. Start-of-day external flow (contribution/withdrawal at prior close values).
37
+ let flow = 0;
38
+ if (config.monthlyContribution !== 0 && isNewMonth(dates[t - 1], dates[t])) {
39
+ flow = config.monthlyContribution;
40
+ for (let i = 0; i < holdings.length; i++)
41
+ holdings[i] += flow * weights[i];
42
+ totalContributions += flow;
43
+ flows[t] = flow;
44
+ }
45
+ // 2. Start-of-day rebalance to target weights (holdings only; dividend cash stays cash).
46
+ if (config.rebalance !== 'none' &&
47
+ isPeriodStart(dates[t - 1], dates[t], config.rebalance)) {
48
+ const invested = holdings.reduce((s, h) => s + h, 0);
49
+ for (let i = 0; i < holdings.length; i++)
50
+ holdings[i] = invested * weights[i];
51
+ }
52
+ // 3. Apply the day's returns.
53
+ for (let i = 0; i < holdings.length; i++) {
54
+ const asset = prepared[i];
55
+ const income = holdings[i] * asset.divYield[t];
56
+ dividendIncome[t] += income;
57
+ incomeByHolding[i] += income;
58
+ if (config.reinvestDividends) {
59
+ holdings[i] *= asset.totalReturn[t];
60
+ }
61
+ else {
62
+ cash += income;
63
+ holdings[i] *= asset.priceReturn[t];
64
+ }
65
+ }
66
+ const value = holdings.reduce((s, h) => s + h, cash);
67
+ values[t] = value;
68
+ // Time-weighted return: the flow entered at prior-close values, so the
69
+ // day's growth acted on (prevValue + flow) — divide it out.
70
+ twrIndex[t] = twrIndex[t - 1] * (value / (prevValue + flow));
71
+ }
72
+ const metrics = computeMetrics(dates, twrIndex, values, flows, config.riskFreeRate ?? 0, config.rfByMonth);
73
+ const finalValue = values[n - 1];
74
+ const holdingBreakdowns = portfolio.allocations.map((a, i) => ({
75
+ ticker: a.ticker,
76
+ targetWeight: a.weight,
77
+ endValue: holdings[i],
78
+ endWeight: finalValue > 0 ? (holdings[i] / finalValue) * 100 : 0,
79
+ assetTotalReturn: prepared[i].totalReturn.reduce((p, r) => p * r, 1) - 1,
80
+ income: incomeByHolding[i],
81
+ }));
82
+ return {
83
+ portfolio,
84
+ dates,
85
+ values,
86
+ twrIndex,
87
+ flows,
88
+ dividendIncome,
89
+ endingCash: cash,
90
+ totalContributions,
91
+ holdings: holdingBreakdowns,
92
+ metrics,
93
+ };
94
+ }
95
+ //# sourceMappingURL=backtest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backtest.js","sourceRoot":"","sources":["../../src/engine/backtest.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ import type { BacktestConfig, TickerSeries } from './types.js';
2
+ /**
3
+ * A point on the two-asset efficient frontier: the risk (annualized
4
+ * volatility) and reward (CAGR) of a specific A/B weight mix, plus its
5
+ * Sharpe ratio, all computed by the real backtest engine over the shared
6
+ * window — no mean/covariance shortcuts, so it matches every other number
7
+ * the tool reports.
8
+ */
9
+ export interface FrontierPoint {
10
+ weightA: number;
11
+ volatility: number;
12
+ cagr: number;
13
+ sharpe: number;
14
+ }
15
+ /**
16
+ * Sweep the A/B mix from 0→100% A in `steps` increments. Two assets is the
17
+ * classic, honest frontier: the whole curve is enumerable, so there's no
18
+ * optimizer to trust — every point is a portfolio you could actually hold.
19
+ */
20
+ export declare function twoAssetFrontier(a: TickerSeries, b: TickerSeries, config: BacktestConfig, steps?: number): FrontierPoint[];
21
+ /** Index of the minimum-variance point. */
22
+ export declare function minVarianceIndex(points: FrontierPoint[]): number;
23
+ /** Index of the maximum-Sharpe (tangency) point. */
24
+ export declare function maxSharpeIndex(points: FrontierPoint[]): number;
25
+ //# sourceMappingURL=frontier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontier.d.ts","sourceRoot":"","sources":["../../src/engine/frontier.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAG9D;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,CAAC,EAAE,YAAY,EACf,CAAC,EAAE,YAAY,EACf,MAAM,EAAE,cAAc,EACtB,KAAK,SAAK,GACT,aAAa,EAAE,CAgBjB;AAED,2CAA2C;AAC3C,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,MAAM,CAIhE;AAED,oDAAoD;AACpD,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,MAAM,CAI9D"}