canli-validation-mcp 0.5.0 → 0.7.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.
- package/README.md +91 -12
- package/package.json +2 -2
- package/src/local/api/_lib/limits.js +1 -1
- package/src/local/js/dsr-core.js +52 -4
- package/src/local/js/haircut-core.js +96 -0
- package/src/local/js/luck-core.js +97 -0
- package/src/local/js/receipt-statement.js +54 -0
- package/src/local/js/student-t.js +139 -0
- package/src/local/js/validate/backtest-length.js +61 -0
- package/src/local/js/validate/haircut-sharpe.js +54 -0
- package/src/local/js/validate/luck-trials.js +63 -0
- package/src/local/js/validate/track-record.js +3 -1
- package/src/local/scripts/canonical-json.mjs +176 -0
- package/src/local.mjs +6 -0
- package/src/receipt-keys.json +13 -0
- package/src/schemas.mjs +184 -53
- package/src/series-file.mjs +124 -0
- package/src/server.mjs +228 -20
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// js/validate/backtest-length.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/backtest-length, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { expectedMaxStandardNormal, maximumIndependentTrials, minimumBacktestLength } from "../dsr-core.js";
|
|
5
|
+
|
|
6
|
+
// Minimum Backtest Length: Bailey, Borwein, López de Prado and Zhu, "Pseudo-Mathematics and
|
|
7
|
+
// Financial Charlatanism" (Notices of the AMS, 2014), Theorem 3.1, checked against the paper's own
|
|
8
|
+
// statements in js/minbtl-paper-vectors.test.js. With N independent trials and a target in-sample
|
|
9
|
+
// Sharpe, how many years a backtest needs before the best trial is not expected to reach the target
|
|
10
|
+
// by luck; with the backtest's years, how many independent trials those years allow.
|
|
11
|
+
|
|
12
|
+
export function compute(body) {
|
|
13
|
+
const hasTrials = body.effective_independent_trials !== undefined;
|
|
14
|
+
const hasYears = body.backtest_years !== undefined;
|
|
15
|
+
if (!hasTrials && !hasYears) throw new RangeError("Send effective_independent_trials, backtest_years, or both");
|
|
16
|
+
const inputs = { target_sharpe_annualized: body.target_sharpe_annualized === undefined ? 1 : Number(body.target_sharpe_annualized) };
|
|
17
|
+
if (!(inputs.target_sharpe_annualized > 0 && inputs.target_sharpe_annualized <= 10)) throw new RangeError("target_sharpe_annualized must be greater than 0 and at most 10");
|
|
18
|
+
if (hasTrials) {
|
|
19
|
+
inputs.effective_independent_trials = Number(body.effective_independent_trials);
|
|
20
|
+
if (!Number.isInteger(inputs.effective_independent_trials) || inputs.effective_independent_trials < 2 || inputs.effective_independent_trials > 1e9) {
|
|
21
|
+
throw new RangeError("effective_independent_trials must be an integer from 2 to 1000000000");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (hasYears) {
|
|
25
|
+
inputs.backtest_years = Number(body.backtest_years);
|
|
26
|
+
if (!(inputs.backtest_years > 0 && inputs.backtest_years <= 1000)) throw new RangeError("backtest_years must be greater than 0 and at most 1000");
|
|
27
|
+
}
|
|
28
|
+
const result = {};
|
|
29
|
+
if (hasTrials) {
|
|
30
|
+
const minimum = minimumBacktestLength({ trials: inputs.effective_independent_trials, targetSharpe: inputs.target_sharpe_annualized });
|
|
31
|
+
result.minimum_backtest_years = minimum.years;
|
|
32
|
+
result.upper_bound_years = minimum.upper_bound_years;
|
|
33
|
+
}
|
|
34
|
+
if (hasYears) {
|
|
35
|
+
result.maximum_independent_trials = maximumIndependentTrials({ years: inputs.backtest_years, targetSharpe: inputs.target_sharpe_annualized });
|
|
36
|
+
}
|
|
37
|
+
if (hasTrials && hasYears) {
|
|
38
|
+
result.expected_max_sharpe_annualized = expectedMaxStandardNormal(inputs.effective_independent_trials) / Math.sqrt(inputs.backtest_years);
|
|
39
|
+
result.long_enough = inputs.backtest_years >= result.minimum_backtest_years;
|
|
40
|
+
}
|
|
41
|
+
return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reading(inputs, r) {
|
|
45
|
+
const sharpe = (x) => String(Number(Number(x).toFixed(3)));
|
|
46
|
+
const target = sharpe(inputs.target_sharpe_annualized);
|
|
47
|
+
const parts = [];
|
|
48
|
+
if (r.minimum_backtest_years !== undefined) {
|
|
49
|
+
parts.push(`With ${inputs.effective_independent_trials} independent trials, the best one is expected to show an in-sample Sharpe of ${target} by luck alone unless the backtest covers at least ${r.minimum_backtest_years.toFixed(2)} years.`);
|
|
50
|
+
}
|
|
51
|
+
if (r.maximum_independent_trials !== undefined) {
|
|
52
|
+
parts.push(r.maximum_independent_trials === 1
|
|
53
|
+
? `${sharpe(inputs.backtest_years)} years of backtest is too short for even two independent trials: their best is expected to reach a Sharpe of ${target} by luck.`
|
|
54
|
+
: `${sharpe(inputs.backtest_years)} years of backtest allows at most ${r.maximum_independent_trials} independent trials before the best is expected to reach a Sharpe of ${target} by luck.`);
|
|
55
|
+
}
|
|
56
|
+
if (r.expected_max_sharpe_annualized !== undefined) {
|
|
57
|
+
parts.push(`Over ${sharpe(inputs.backtest_years)} years, the best of ${inputs.effective_independent_trials} skill-less trials is expected to show a Sharpe of ${sharpe(r.expected_max_sharpe_annualized)}.`);
|
|
58
|
+
}
|
|
59
|
+
parts.push("Meeting this length is necessary, not sufficient: a backtest can still be overfit.");
|
|
60
|
+
return parts.join(" ");
|
|
61
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// js/validate/haircut-sharpe.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/haircut-sharpe, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { haircutSharpe } from "../haircut-core.js";
|
|
5
|
+
|
|
6
|
+
// The haircut Sharpe ratio of Harvey and Liu, "Backtesting" (2015), checked against the authors'
|
|
7
|
+
// Haircut_SR.m and R in js/haircut-paper-vectors.test.js. Bonferroni and independent-test adjustments
|
|
8
|
+
// need only the number of tests; Holm and BHY need the other tests' Sharpe ratios, which the caller
|
|
9
|
+
// knows (the authors simulate them from a published factor model when they are unknown).
|
|
10
|
+
|
|
11
|
+
const MAX_OTHERS = 10000;
|
|
12
|
+
|
|
13
|
+
export function compute(body) {
|
|
14
|
+
const missing = ["observed_sharpe_annualized", "periods_per_year", "observations"].filter((k) => body[k] === undefined);
|
|
15
|
+
if (missing.length) throw new RangeError(`Missing required fields: ${missing.join(", ")}`);
|
|
16
|
+
const others = body.other_sharpe_ratios_annualized;
|
|
17
|
+
if (others !== undefined && (!Array.isArray(others) || others.length < 1 || others.length > MAX_OTHERS)) {
|
|
18
|
+
throw new RangeError(`other_sharpe_ratios_annualized must be an array of 1 to ${MAX_OTHERS} Sharpe ratios`);
|
|
19
|
+
}
|
|
20
|
+
if (others === undefined && body.tests === undefined) throw new RangeError("Send tests, other_sharpe_ratios_annualized, or both");
|
|
21
|
+
const inputs = {
|
|
22
|
+
observed_sharpe_annualized: Number(body.observed_sharpe_annualized),
|
|
23
|
+
periods_per_year: Number(body.periods_per_year),
|
|
24
|
+
observations: Number(body.observations),
|
|
25
|
+
autocorrelation: body.autocorrelation === undefined ? 0 : Number(body.autocorrelation),
|
|
26
|
+
...(body.tests !== undefined ? { tests: Number(body.tests) } : {}),
|
|
27
|
+
...(others !== undefined ? { other_tests: others.length } : {}),
|
|
28
|
+
};
|
|
29
|
+
if (!(inputs.observations <= 1000000)) throw new RangeError("observations must be at most 1000000");
|
|
30
|
+
const result = haircutSharpe({
|
|
31
|
+
sharpeAnnualized: inputs.observed_sharpe_annualized,
|
|
32
|
+
periodsPerYear: inputs.periods_per_year,
|
|
33
|
+
observations: inputs.observations,
|
|
34
|
+
tests: inputs.tests,
|
|
35
|
+
autocorrelation: inputs.autocorrelation,
|
|
36
|
+
otherSharpesAnnualized: others,
|
|
37
|
+
});
|
|
38
|
+
return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reading(inputs, r) {
|
|
42
|
+
const num = (x, d = 3) => String(Number(Number(x).toFixed(d)));
|
|
43
|
+
const pct = (x) => `${(x * 100).toFixed(1)} percent`;
|
|
44
|
+
const p = (x) => (x < 0.0001 ? Number(x).toExponential(2) : num(x, 4));
|
|
45
|
+
const line = (name, a) => `${name} gives an adjusted p-value of ${p(a.adjusted_p)} and a haircut Sharpe of ${num(a.haircut_sharpe_annualized)}, a ${pct(a.haircut)} haircut`;
|
|
46
|
+
const corrected = inputs.autocorrelation !== 0 ? ` (${num(r.sharpe_annualized_corrected)} after the autocorrelation correction)` : "";
|
|
47
|
+
const parts = [
|
|
48
|
+
`Tested alone, an annualized Sharpe of ${num(inputs.observed_sharpe_annualized)}${corrected} over ${inputs.observations} observations has a p-value of ${p(r.p_value_single)}.`,
|
|
49
|
+
`Counting ${r.tests} tests, ${line("Bonferroni", r.bonferroni)}; treating the tests as independent ${line("", r.independent).trim()}.`,
|
|
50
|
+
];
|
|
51
|
+
if (r.holm) parts.push(`With the other tests' own Sharpe ratios, ${line("Holm", r.holm)}, and ${line("BHY", r.bhy)}.`);
|
|
52
|
+
parts.push("The haircut counts only the tests declared; tests run and not counted are invisible to it.");
|
|
53
|
+
return parts.join(" ");
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// js/validate/luck-trials.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/luck-trials, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { luckEquivalentTrials, TRIAL_CAP } from "../luck-core.js";
|
|
5
|
+
|
|
6
|
+
// Luck-equivalent trials (js/luck-core.js): how many skill-less strategies a search would have had
|
|
7
|
+
// to try for its best to reach the observed Sharpe by luck. Calibrated by Monte Carlo in
|
|
8
|
+
// js/luck-core.test.js; too kind to negatively skewed strategies, which the reading says when the
|
|
9
|
+
// caller sends the skew.
|
|
10
|
+
|
|
11
|
+
const NEGATIVE_SKEW = -0.5;
|
|
12
|
+
|
|
13
|
+
export function compute(body) {
|
|
14
|
+
const missing = ["observed_sharpe_annualized", "periods_per_year", "observations"].filter((k) => body[k] === undefined);
|
|
15
|
+
if (missing.length) throw new RangeError(`Missing required fields: ${missing.join(", ")}`);
|
|
16
|
+
const inputs = {
|
|
17
|
+
observed_sharpe_annualized: Number(body.observed_sharpe_annualized),
|
|
18
|
+
periods_per_year: Number(body.periods_per_year),
|
|
19
|
+
observations: Number(body.observations),
|
|
20
|
+
...(body.effective_independent_trials !== undefined ? { effective_independent_trials: Number(body.effective_independent_trials) } : {}),
|
|
21
|
+
...(body.skew !== undefined ? { skew: Number(body.skew) } : {}),
|
|
22
|
+
...(body.autocorrelation !== undefined ? { autocorrelation: Number(body.autocorrelation) } : {}),
|
|
23
|
+
};
|
|
24
|
+
if (!(Math.abs(inputs.observed_sharpe_annualized) <= 20)) throw new RangeError("observed_sharpe_annualized must be a number from -20 to 20");
|
|
25
|
+
if (!(inputs.periods_per_year > 0 && inputs.periods_per_year <= 100000)) throw new RangeError("periods_per_year must be greater than 0 and at most 100000");
|
|
26
|
+
if (!Number.isInteger(inputs.observations) || inputs.observations < 3 || inputs.observations > 1000000) throw new RangeError("observations must be an integer from 3 to 1000000");
|
|
27
|
+
const trials = inputs.effective_independent_trials;
|
|
28
|
+
if (trials !== undefined && (!Number.isInteger(trials) || trials < 1 || trials > 1e9)) throw new RangeError("effective_independent_trials must be an integer from 1 to 1000000000");
|
|
29
|
+
if (inputs.skew !== undefined && !Number.isFinite(inputs.skew)) throw new RangeError("skew must be a finite number");
|
|
30
|
+
if (inputs.autocorrelation !== undefined && !(inputs.autocorrelation > -1 && inputs.autocorrelation < 1)) throw new RangeError("autocorrelation must be strictly between -1 and 1");
|
|
31
|
+
const result = luckEquivalentTrials({ sharpe: inputs.observed_sharpe_annualized, observations: inputs.observations, periodsPerYear: inputs.periods_per_year, trials, autocorrelation: inputs.autocorrelation });
|
|
32
|
+
return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const whole = (n) => Math.floor(n).toLocaleString("en-US");
|
|
36
|
+
|
|
37
|
+
function reading(inputs, r) {
|
|
38
|
+
const sharpe = String(Number(inputs.observed_sharpe_annualized.toFixed(3)));
|
|
39
|
+
const over = `over these ${inputs.observations} observations`;
|
|
40
|
+
const parts = [];
|
|
41
|
+
if (r.trials_for_five_percent >= TRIAL_CAP) {
|
|
42
|
+
parts.push(`Luck alone would need more than ${TRIAL_CAP.toExponential(0).replace("e+", "e")} skill-less strategies to reach a Sharpe of ${sharpe} ${over}.`);
|
|
43
|
+
} else if (r.trials_for_even_odds < 2) {
|
|
44
|
+
parts.push(`A single skill-less strategy shows a Sharpe of ${sharpe} or more ${over} with probability ${r.single_trial_probability.toFixed(3)}: luck alone readily explains it.`);
|
|
45
|
+
} else if (r.trials_for_five_percent < 1) {
|
|
46
|
+
parts.push(`The best of ${whole(r.trials_for_even_odds)} skill-less strategies reaches a Sharpe of ${sharpe} ${over} about half the time, and even one reaches it more than 5 percent of the time.`);
|
|
47
|
+
} else {
|
|
48
|
+
parts.push(`The best of ${whole(r.trials_for_even_odds)} skill-less strategies reaches a Sharpe of ${sharpe} ${over} about half the time; with at most ${whole(r.trials_for_five_percent)} tried, luck reaches it less than 5 percent of the time.`);
|
|
49
|
+
}
|
|
50
|
+
if (r.best_of_trials_probability !== undefined) {
|
|
51
|
+
parts.push(`With ${inputs.effective_independent_trials} independent trials, the chance that the best reaches it by luck is ${(r.best_of_trials_probability * 100).toPrecision(3)} percent.`);
|
|
52
|
+
}
|
|
53
|
+
if (inputs.skew !== undefined && inputs.skew < NEGATIVE_SKEW) {
|
|
54
|
+
parts.push(`With a return skew of ${inputs.skew}, these counts are too generous: negatively skewed returns reach high Sharpe ratios by luck more often than this assumes.`);
|
|
55
|
+
}
|
|
56
|
+
if (inputs.autocorrelation === undefined) {
|
|
57
|
+
parts.push("Without the returns' autocorrelation this assumes none; positively autocorrelated returns reach high Sharpe ratios by luck more often.");
|
|
58
|
+
} else if (inputs.autocorrelation !== 0) {
|
|
59
|
+
parts.push(`The Sharpe was first corrected for an autocorrelation of ${inputs.autocorrelation} (Lo, 2002), to ${String(Number((inputs.observed_sharpe_annualized * r.autocorrelation_factor).toFixed(3)))}.`);
|
|
60
|
+
}
|
|
61
|
+
parts.push("It counts independent trials; correlated trials count as fewer.");
|
|
62
|
+
return parts.join(" ");
|
|
63
|
+
}
|
|
@@ -41,7 +41,9 @@ export function compute(body) {
|
|
|
41
41
|
|
|
42
42
|
function reading(inputs, r) {
|
|
43
43
|
const pct = (x) => `${(x * 100).toFixed(1)} percent`;
|
|
44
|
-
|
|
44
|
+
// A Sharpe derived from a return series carries every digit of the float; three decimals read.
|
|
45
|
+
const sharpe = (x) => String(Number(Number(x).toFixed(3)));
|
|
46
|
+
const need = `To be ${pct(r.confidence)} confident that a Sharpe of ${sharpe(inputs.observed_sharpe_annualized)} is above ${sharpe(inputs.benchmark_sharpe_annualized)}, the track record needs at least ${r.minimum_observations} observations, about ${r.minimum_years.toFixed(2)} years.`;
|
|
45
47
|
if (!r.record) return `${need} This counts sample uncertainty and the shape of the returns only; it is not a forecast.`;
|
|
46
48
|
const verdict = r.record.long_enough ? "is long enough" : "is not long enough yet";
|
|
47
49
|
return `${need} The record sent, ${r.record.observations} observations, ${verdict}: the probability its Sharpe is above the benchmark is ${pct(r.record.psr_against_benchmark)}. Neither number is a forecast.`;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// canonical-json.mjs
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// ONE canonicalisation for every published `content_hash` on this site.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS FILE EXISTS. `reproduce.py` -- the kit a reader runs to check that the
|
|
7
|
+
// published record reproduces -- recomputes every artifact's hash with Python's
|
|
8
|
+
//
|
|
9
|
+
// json.dumps(payload_without_content_hash, sort_keys=True, separators=(",", ":"))
|
|
10
|
+
//
|
|
11
|
+
// Seven generators carried their own copy-pasted `canonical()` that happened to
|
|
12
|
+
// agree with it. `build-paper-evidence-vectors.mjs` carried NO copy and hashed
|
|
13
|
+
// with a bare `JSON.stringify`, which preserves insertion order instead of
|
|
14
|
+
// sorting. Its artifact therefore never reproduced, L1 read 158/1, and because
|
|
15
|
+
// the nightly ceremony gates on L1 the whole publish halted -- OTS anchoring,
|
|
16
|
+
// capacity and founder commitments included. One generator missing a helper the
|
|
17
|
+
// other seven had duplicated took the ceremony down.
|
|
18
|
+
//
|
|
19
|
+
// That is the same shape as `normalizeEditableCopy`, which was pasted into three
|
|
20
|
+
// generators while the verifier had no copy at all. A helper that must agree
|
|
21
|
+
// across N call sites cannot live as N copies. It lives here.
|
|
22
|
+
//
|
|
23
|
+
// WHAT "CANONICAL" MEANS HERE, exactly, because two of these are traps:
|
|
24
|
+
//
|
|
25
|
+
// * KEY ORDER -- recursively sorted, matching `sort_keys=True`. This is the one
|
|
26
|
+
// the missing copy got wrong.
|
|
27
|
+
// * SEPARATORS -- "," and ":" with no spaces, matching `separators=(",", ":")`.
|
|
28
|
+
// * NON-ASCII -- escaped to \uXXXX, matching Python's default `ensure_ascii=True`.
|
|
29
|
+
// JS `JSON.stringify` emits raw UTF-8 instead. For a pure-ASCII document the
|
|
30
|
+
// two settings emit IDENTICAL bytes, so this divergence stays invisible until
|
|
31
|
+
// the first accented character -- which is exactly how "Lopez de Prado" in one
|
|
32
|
+
// contract halted the nightly for three nights.
|
|
33
|
+
// * NUMBERS -- Python and JS disagree on when to switch to exponent form
|
|
34
|
+
// (Python at >=1e16 and <1e-4, JS at >=1e21 and <1e-6) and on the exponent's
|
|
35
|
+
// own spelling (Python "1e-05", JS "1e-5"). `pythonNumber` reproduces
|
|
36
|
+
// Python's rule. A number this cannot render faithfully THROWS rather than
|
|
37
|
+
// emitting a hash that will not reproduce: a wrong hash fails at 22:12 in the
|
|
38
|
+
// nightly, an exception fails in the generator that caused it.
|
|
39
|
+
//
|
|
40
|
+
// The differential test in `canonical-json.test.mjs` is what makes any of this
|
|
41
|
+
// believable: it hands the same values to Python and compares the bytes.
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render a number the way Python re-emits it after parsing the literal JS wrote.
|
|
46
|
+
*
|
|
47
|
+
* THE CHAIN THIS HAS TO PREDICT, because getting it wrong is invisible until the
|
|
48
|
+
* nightly ceremony halts: a JS generator builds an object, writes it with
|
|
49
|
+
* `JSON.stringify(payload, null, 2)`, and `reproduce.py` later PARSES that file and
|
|
50
|
+
* re-emits it with `json.dumps`. So the target is not "what Python's repr does to
|
|
51
|
+
* this JS number" -- it is "what Python does to the LITERAL JSON.stringify writes".
|
|
52
|
+
*
|
|
53
|
+
* That distinction is the whole rule. `1e16` reaches the file as
|
|
54
|
+
* `10000000000000000`, which Python parses as an INT and echoes digit for digit;
|
|
55
|
+
* predicting Python's float repr (`1e+16`) there would be wrong. `1e-7` reaches the
|
|
56
|
+
* file as `1e-7`, which Python parses as a float and re-spells `1e-07`.
|
|
57
|
+
*
|
|
58
|
+
* KNOWN LIMIT, stated because it bounds where this module may be used: a float
|
|
59
|
+
* Python wrote as `1.0` parses to the JS number `1`, and no JS function can tell it
|
|
60
|
+
* from an integer. So this canonicalisation is faithful for payloads BUILT in JS and
|
|
61
|
+
* written by `JSON.stringify` -- the artifact and its hash then agree -- and is NOT a
|
|
62
|
+
* general re-canonicaliser for files a Python exporter produced.
|
|
63
|
+
*/
|
|
64
|
+
export function pythonNumber(value) {
|
|
65
|
+
if (!Number.isFinite(value)) {
|
|
66
|
+
// Python writes Infinity/NaN; JSON has no such literals and JS writes null.
|
|
67
|
+
// Neither is reproducible, so refuse rather than publish an unreproducible hash.
|
|
68
|
+
throw new Error(`canonical-json: ${value} has no reproducible JSON form`);
|
|
69
|
+
}
|
|
70
|
+
// Exactly the bytes JSON.stringify will put in the artifact file.
|
|
71
|
+
const literal = JSON.stringify(value);
|
|
72
|
+
if (!/[.eE]/.test(literal)) {
|
|
73
|
+
// No decimal point and no exponent: Python parses an int and echoes the digits.
|
|
74
|
+
return literal;
|
|
75
|
+
}
|
|
76
|
+
// Python parses a float, so the output is `repr(float)`: shortest round-trip
|
|
77
|
+
// digits -- identical in both languages -- laid out by Python's rule, which is
|
|
78
|
+
// fixed notation for a decimal exponent in [-4, 16) and exponent form otherwise.
|
|
79
|
+
const [mantissa, exponent] = value.toExponential().split("e");
|
|
80
|
+
const e = Number(exponent);
|
|
81
|
+
const negative = mantissa.startsWith("-");
|
|
82
|
+
const digits = mantissa.replace("-", "").replace(".", "");
|
|
83
|
+
if (e >= -4 && e < 16) {
|
|
84
|
+
let whole;
|
|
85
|
+
let fraction;
|
|
86
|
+
if (e >= 0) {
|
|
87
|
+
whole = digits.slice(0, e + 1).padEnd(e + 1, "0");
|
|
88
|
+
fraction = digits.slice(e + 1);
|
|
89
|
+
} else {
|
|
90
|
+
whole = "0";
|
|
91
|
+
fraction = "0".repeat(-e - 1) + digits;
|
|
92
|
+
}
|
|
93
|
+
// Python always keeps at least one fractional digit on a float.
|
|
94
|
+
return `${negative ? "-" : ""}${whole}.${fraction === "" ? "0" : fraction}`;
|
|
95
|
+
}
|
|
96
|
+
const sign = e < 0 ? "-" : "+";
|
|
97
|
+
const magnitude = String(Math.abs(e)).padStart(2, "0");
|
|
98
|
+
return `${mantissa}e${sign}${magnitude}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Every code unit above ASCII. Written as an escape range rather than as literal
|
|
102
|
+
// characters so the source file itself stays ASCII -- a file about non-ASCII
|
|
103
|
+
// escaping is the worst possible place to smuggle in a stray accented byte.
|
|
104
|
+
const NON_ASCII = /[\u0080-\uffff]/g;
|
|
105
|
+
|
|
106
|
+
/** Escape a string the way Python's `json.dumps(ensure_ascii=True)` does. */
|
|
107
|
+
export function pythonString(value) {
|
|
108
|
+
// JSON.stringify already handles quotes, backslashes and control characters
|
|
109
|
+
// identically to Python. Only non-ASCII is spelled differently, so escape that
|
|
110
|
+
// and leave the rest of JS's output alone. Astral characters are already
|
|
111
|
+
// surrogate pairs in JS, and Python escapes them as a surrogate pair too.
|
|
112
|
+
return JSON.stringify(value).replace(
|
|
113
|
+
NON_ASCII,
|
|
114
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The canonical byte string an artifact's `content_hash` is taken over. */
|
|
119
|
+
export function canonicalJson(value) {
|
|
120
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
121
|
+
if (value && typeof value === "object") {
|
|
122
|
+
return `{${Object.keys(value)
|
|
123
|
+
.sort()
|
|
124
|
+
.map((k) => `${pythonString(k)}:${canonicalJson(value[k])}`)
|
|
125
|
+
.join(",")}}`;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === "number") return pythonNumber(value);
|
|
128
|
+
if (typeof value === "string") return pythonString(value);
|
|
129
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
130
|
+
// undefined, function, symbol, bigint: JSON.stringify would silently drop or
|
|
131
|
+
// throw. Neither belongs in a published artifact, so say so by name.
|
|
132
|
+
throw new Error(`canonical-json: ${typeof value} has no canonical JSON form`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `sha256:...` over the payload with its own `content_hash` field removed. */
|
|
136
|
+
export function contentHash(payload, createHash) {
|
|
137
|
+
const body = Object.fromEntries(
|
|
138
|
+
Object.entries(payload).filter(([k]) => k !== "content_hash"),
|
|
139
|
+
);
|
|
140
|
+
return `sha256:${createHash("sha256").update(canonicalJson(body)).digest("hex")}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Write a glass-box artifact, stamp its `content_hash`, and PROVE the file on disk
|
|
145
|
+
* reproduces that hash before returning.
|
|
146
|
+
*
|
|
147
|
+
* WHY THE READ-BACK. Computing a hash and writing a file are two steps, and every
|
|
148
|
+
* incident in this pipeline's history lives in the gap between them: a generator
|
|
149
|
+
* that hashed insertion-ordered bytes while the reader sorted them, an exporter that
|
|
150
|
+
* emitted raw UTF-8 while the reader escaped it. In both cases the generator
|
|
151
|
+
* succeeded, the artifact looked fine, and the failure surfaced days later as a
|
|
152
|
+
* halted nightly ceremony pointing at the reader rather than the writer.
|
|
153
|
+
*
|
|
154
|
+
* So this re-reads what it just wrote, re-canonicalises it the way `reproduce.py`
|
|
155
|
+
* will, and throws if the two disagree. The generator that caused the problem is the
|
|
156
|
+
* thing that fails, at the moment it causes it. A list of "artifacts to check" would
|
|
157
|
+
* have to be maintained and would silently omit the next new one; a self-check
|
|
158
|
+
* cannot be omitted by anyone who uses the helper.
|
|
159
|
+
*/
|
|
160
|
+
export function writeArtifact(path, payload, { createHash, readFileSync, writeFileSync }) {
|
|
161
|
+
const stamped = { ...payload };
|
|
162
|
+
delete stamped.content_hash;
|
|
163
|
+
stamped.content_hash = contentHash(stamped, createHash);
|
|
164
|
+
writeFileSync(path, `${JSON.stringify(stamped, null, 2)}\n`);
|
|
165
|
+
|
|
166
|
+
const reread = JSON.parse(readFileSync(path, "utf8"));
|
|
167
|
+
const observed = contentHash(reread, createHash);
|
|
168
|
+
if (observed !== reread.content_hash) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`canonical-json: ${path} does not reproduce its own content_hash ` +
|
|
171
|
+
`(wrote ${reread.content_hash}, re-read as ${observed}). ` +
|
|
172
|
+
"The published record would not reproduce and the nightly ceremony would halt.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return stamped.content_hash;
|
|
176
|
+
}
|
package/src/local.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { compute as deflatedSharpe } from "./local/js/validate/deflated-sharpe.j
|
|
|
7
7
|
import { compute as overfitting } from "./local/js/validate/overfitting.js";
|
|
8
8
|
import { compute as paperEvidence } from "./local/js/validate/paper-evidence.js";
|
|
9
9
|
import { compute as trackRecord } from "./local/js/validate/track-record.js";
|
|
10
|
+
import { compute as backtestLength } from "./local/js/validate/backtest-length.js";
|
|
11
|
+
import { compute as haircutSharpe } from "./local/js/validate/haircut-sharpe.js";
|
|
12
|
+
import { compute as luckTrials } from "./local/js/validate/luck-trials.js";
|
|
10
13
|
|
|
11
14
|
export const LOCAL_VALIDATORS = Object.freeze({
|
|
12
15
|
validate_deflated_sharpe: { endpoint: "validate/deflated-sharpe", compute: deflatedSharpe },
|
|
@@ -14,6 +17,9 @@ export const LOCAL_VALIDATORS = Object.freeze({
|
|
|
14
17
|
validate_paper_evidence: { endpoint: "validate/paper-evidence", compute: paperEvidence },
|
|
15
18
|
validate_breadth: { endpoint: "validate/breadth", compute: breadth },
|
|
16
19
|
validate_track_record: { endpoint: "validate/track-record", compute: trackRecord },
|
|
20
|
+
validate_backtest_length: { endpoint: "validate/backtest-length", compute: backtestLength },
|
|
21
|
+
validate_haircut_sharpe: { endpoint: "validate/haircut-sharpe", compute: haircutSharpe },
|
|
22
|
+
validate_luck_trials: { endpoint: "validate/luck-trials", compute: luckTrials },
|
|
17
23
|
});
|
|
18
24
|
|
|
19
25
|
const NOTE = "Computed on this machine in local mode. Nothing was sent to canlicapital.com and no receipt was stored.";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "canli.receipt-keys.v1",
|
|
3
|
+
"description": "Public keys that sign canlicapital.com validation receipts. A receipt's signature is Ed25519 over the canonical JSON of {schema, id, endpoint, input_sha256, output_sha256, bindings}; its key_id is the first 16 hex characters of sha256 over the key's 32 raw bytes.",
|
|
4
|
+
"keys": [
|
|
5
|
+
{
|
|
6
|
+
"key_id": "269d381734732ea6",
|
|
7
|
+
"alg": "Ed25519",
|
|
8
|
+
"x": "k1ojmfzJlaiXwaEU5aql0CvNyHMS_vXTj9Q9-zTeYAw",
|
|
9
|
+
"status": "active",
|
|
10
|
+
"valid_from": "2026-09-26"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|