canli-validation-mcp 0.5.0 → 0.6.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 +57 -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 +194 -11
package/src/server.mjs
CHANGED
|
@@ -11,10 +11,18 @@ import { pathToFileURL } from "node:url";
|
|
|
11
11
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { computeLocally } from "./local.mjs";
|
|
14
|
+
import { readMatrixFile, readSeriesFile } from "./series-file.mjs";
|
|
15
|
+
import { verifyReceipt } from "./local/js/receipt-statement.js";
|
|
14
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
17
|
import {
|
|
16
18
|
breadthInput,
|
|
17
19
|
trackRecordInput,
|
|
20
|
+
auditBacktestInput,
|
|
21
|
+
verifyReceiptToolShape,
|
|
22
|
+
backtestLengthInput,
|
|
23
|
+
haircutSharpeInput,
|
|
24
|
+
luckTrialsInput,
|
|
25
|
+
auditBacktestToolShape,
|
|
18
26
|
companyHistoryInput,
|
|
19
27
|
companyHistoryToolShape,
|
|
20
28
|
deflatedSharpeInput,
|
|
@@ -58,7 +66,7 @@ export function configuredLocal(value) {
|
|
|
58
66
|
return v === "1" || v?.toLowerCase() === "true";
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local } = {}) {
|
|
69
|
+
export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local, fullEnvelope, receiptKeys } = {}) {
|
|
62
70
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
|
|
63
71
|
return {
|
|
64
72
|
base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
|
|
@@ -70,6 +78,9 @@ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIM
|
|
|
70
78
|
// so instead of issuing a key the next stateless request would never see.
|
|
71
79
|
hosted: hosted ?? undefined,
|
|
72
80
|
local: local ?? configuredLocal(process.env.CANLI_LOCAL),
|
|
81
|
+
fullEnvelope: fullEnvelope ?? configuredFullEnvelope(process.env.CANLI_FULL_ENVELOPE),
|
|
82
|
+
// Tests pass their own keys; everyone else verifies against the bundled published keys.
|
|
83
|
+
receiptKeys: receiptKeys ?? undefined,
|
|
73
84
|
};
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -110,6 +121,30 @@ const asText = (envelope, failed = false) => ({
|
|
|
110
121
|
...(failed ? { isError: true } : {}),
|
|
111
122
|
});
|
|
112
123
|
|
|
124
|
+
// A validation result as the model reads it: the answer, the sentences saying what it does not
|
|
125
|
+
// establish, and the receipt that holds the rest. Metadata (schema, endpoint, timestamps, claim and
|
|
126
|
+
// capital class, human page), the source hashes and the quota sentence are about the service, not
|
|
127
|
+
// the answer; they stay in the stored receipt (get_receipt) and service_status, and
|
|
128
|
+
// CANLI_FULL_ENVELOPE=1 returns every field. On a result this cuts the text roughly in half.
|
|
129
|
+
const QUOTA_SENTENCE = /^Quotas:/;
|
|
130
|
+
|
|
131
|
+
export function compactEnvelope(envelope) {
|
|
132
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return envelope;
|
|
133
|
+
const out = {};
|
|
134
|
+
if (envelope.computed) { out.computed = envelope.computed; out.note = envelope.note; }
|
|
135
|
+
if ("data" in envelope) out.data = envelope.data;
|
|
136
|
+
if (envelope.error) out.error = envelope.error;
|
|
137
|
+
if (Array.isArray(envelope.limits)) out.limits = envelope.limits.filter((s) => !QUOTA_SENTENCE.test(s));
|
|
138
|
+
if ("receipt" in envelope) out.receipt = envelope.receipt ? { id: envelope.receipt.id, url: envelope.receipt.url } : null;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function configuredFullEnvelope(value) {
|
|
143
|
+
return value === "1" || value === "true";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const validationText = (session, { envelope, failed }) => asText(session.fullEnvelope ? envelope : compactEnvelope(envelope), failed);
|
|
147
|
+
|
|
113
148
|
// A history's observations as one header and one row each, instead of every field name repeated
|
|
114
149
|
// on every observation. The column order is fixed so a row can be read without its keys; a unit
|
|
115
150
|
// shared by every row is stated once.
|
|
@@ -171,37 +206,157 @@ export async function toolValidateDeflatedSharpe(session, args) {
|
|
|
171
206
|
"or a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized), never a mix of both and never neither.",
|
|
172
207
|
);
|
|
173
208
|
}
|
|
174
|
-
if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return
|
|
209
|
+
if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return validationText(session, local); }
|
|
175
210
|
const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
|
|
176
|
-
return
|
|
211
|
+
return validationText(session, response);
|
|
177
212
|
}
|
|
178
213
|
|
|
179
214
|
export async function toolValidateOverfitting(session, args) {
|
|
180
215
|
const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
|
|
181
|
-
if (session.local) { const local = computeLocally("validate_overfitting", body); return
|
|
216
|
+
if (session.local) { const local = computeLocally("validate_overfitting", body); return validationText(session, local); }
|
|
182
217
|
const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
|
|
183
|
-
return
|
|
218
|
+
return validationText(session, response);
|
|
184
219
|
}
|
|
185
220
|
|
|
186
221
|
export async function toolValidatePaperEvidence(session, args) {
|
|
187
222
|
const body = parseOrThrow(paperEvidenceInput, args, "validate_paper_evidence");
|
|
188
|
-
if (session.local) { const local = computeLocally("validate_paper_evidence", body); return
|
|
223
|
+
if (session.local) { const local = computeLocally("validate_paper_evidence", body); return validationText(session, local); }
|
|
189
224
|
const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
|
|
190
|
-
return
|
|
225
|
+
return validationText(session, response);
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
export async function toolValidateBreadth(session, args) {
|
|
194
229
|
const body = parseOrThrow(breadthInput, args, "validate_breadth");
|
|
195
|
-
if (session.local) { const local = computeLocally("validate_breadth", body); return
|
|
230
|
+
if (session.local) { const local = computeLocally("validate_breadth", body); return validationText(session, local); }
|
|
196
231
|
const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
|
|
197
|
-
return
|
|
232
|
+
return validationText(session, response);
|
|
198
233
|
}
|
|
199
234
|
|
|
200
235
|
export async function toolValidateTrackRecord(session, args) {
|
|
201
236
|
const body = parseOrThrow(trackRecordInput, args, "validate_track_record");
|
|
202
|
-
if (session.local) { const local = computeLocally("validate_track_record", body); return
|
|
237
|
+
if (session.local) { const local = computeLocally("validate_track_record", body); return validationText(session, local); }
|
|
203
238
|
const response = await callApi(session, { path: "/api/v1/validate/track-record", method: "POST", body });
|
|
204
|
-
return
|
|
239
|
+
return validationText(session, response);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function toolValidateBacktestLength(session, args) {
|
|
243
|
+
const body = parseOrThrow(backtestLengthInput, args, "validate_backtest_length");
|
|
244
|
+
if (session.local) { const local = computeLocally("validate_backtest_length", body); return validationText(session, local); }
|
|
245
|
+
const response = await callApi(session, { path: "/api/v1/validate/backtest-length", method: "POST", body });
|
|
246
|
+
return validationText(session, response);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function toolValidateHaircutSharpe(session, args) {
|
|
250
|
+
const body = parseOrThrow(haircutSharpeInput, args, "validate_haircut_sharpe");
|
|
251
|
+
if (session.local) { const local = computeLocally("validate_haircut_sharpe", body); return validationText(session, local); }
|
|
252
|
+
const response = await callApi(session, { path: "/api/v1/validate/haircut-sharpe", method: "POST", body });
|
|
253
|
+
return validationText(session, response);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function toolValidateLuckTrials(session, args) {
|
|
257
|
+
const body = parseOrThrow(luckTrialsInput, args, "validate_luck_trials");
|
|
258
|
+
if (session.local) { const local = computeLocally("validate_luck_trials", body); return validationText(session, local); }
|
|
259
|
+
const response = await callApi(session, { path: "/api/v1/validate/luck-trials", method: "POST", body });
|
|
260
|
+
return validationText(session, response);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// One validator, run the same way its own tool runs it: on this machine in local mode, otherwise
|
|
264
|
+
// through the API (one validation of quota, one receipt).
|
|
265
|
+
async function runValidator(session, tool, path, body) {
|
|
266
|
+
if (session.local) return computeLocally(tool, body);
|
|
267
|
+
return callApi(session, { path, method: "POST", body });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
271
|
+
|
|
272
|
+
// audit_backtest: deflated Sharpe from the series, then the minimum track record length from the
|
|
273
|
+
// Sharpe, skew and kurtosis that check derived, then (with variants) CSCV overfitting. Each check
|
|
274
|
+
// keeps its envelope and receipt; the limits every envelope repeats are stated once at the top.
|
|
275
|
+
// A refused later check (for example a Sharpe that does not exceed the benchmark) is reported as
|
|
276
|
+
// that check's error, not as a failed audit; the audit fails only when the first check does.
|
|
277
|
+
export async function toolAuditBacktest(session, args) {
|
|
278
|
+
const input = parseOrThrow(auditBacktestInput, args, "audit_backtest");
|
|
279
|
+
if ((input.returns_file || input.variants_file) && session.hosted) {
|
|
280
|
+
throw new Error("audit_backtest: the hosted endpoint cannot read files on your machine; send returns (and variants) as numbers, or run the server locally with npx -y canli-validation-mcp.");
|
|
281
|
+
}
|
|
282
|
+
const returnsRead = input.returns_file ? readSeriesFile(input.returns_file, input.returns_column) : null;
|
|
283
|
+
const variantsRead = input.variants_file ? readMatrixFile(input.variants_file) : null;
|
|
284
|
+
const returns = input.returns ?? returnsRead.values;
|
|
285
|
+
const variants = input.variants ?? variantsRead?.matrix;
|
|
286
|
+
const { periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized } = input;
|
|
287
|
+
const dsr = await runValidator(session, "validate_deflated_sharpe", "/api/v1/validate/deflated-sharpe", {
|
|
288
|
+
returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized,
|
|
289
|
+
});
|
|
290
|
+
if (dsr.failed) return validationText(session, { envelope: dsr.envelope, failed: true });
|
|
291
|
+
const derived = dsr.envelope?.data?.derived_inputs ?? {};
|
|
292
|
+
const trackBody = {
|
|
293
|
+
observed_sharpe_annualized: derived.observed_sharpe_annualized,
|
|
294
|
+
periods_per_year,
|
|
295
|
+
skew: derived.skew,
|
|
296
|
+
non_excess_kurtosis: derived.non_excess_kurtosis,
|
|
297
|
+
observations: derived.observations,
|
|
298
|
+
...(input.benchmark_sharpe_annualized !== undefined ? { benchmark_sharpe_annualized: input.benchmark_sharpe_annualized } : {}),
|
|
299
|
+
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
|
300
|
+
};
|
|
301
|
+
const track = await runValidator(session, "validate_track_record", "/api/v1/validate/track-record", trackBody);
|
|
302
|
+
const overfit = variants
|
|
303
|
+
? await runValidator(session, "validate_overfitting", "/api/v1/validate/overfitting", {
|
|
304
|
+
matrix: variants,
|
|
305
|
+
...(input.n_splits !== undefined ? { n_splits: input.n_splits } : {}),
|
|
306
|
+
})
|
|
307
|
+
: null;
|
|
308
|
+
const shape = (e) => (session.fullEnvelope ? e : compactEnvelope(e));
|
|
309
|
+
const envelopes = { deflated_sharpe: shape(dsr.envelope), track_record: shape(track.envelope), ...(overfit ? { overfitting: shape(overfit.envelope) } : {}) };
|
|
310
|
+
const limits = envelopes.deflated_sharpe?.limits;
|
|
311
|
+
const shared = Object.values(envelopes).every((e) => sameJson(e?.limits, limits));
|
|
312
|
+
const checks = Object.fromEntries(
|
|
313
|
+
Object.entries(envelopes).map(([name, e]) => [name, shared ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== "limits")) : e]),
|
|
314
|
+
);
|
|
315
|
+
const readings = Object.fromEntries(
|
|
316
|
+
Object.entries(envelopes).map(([name, e]) => [name, e?.data?.plain_reading ?? e?.error?.message ?? null]),
|
|
317
|
+
);
|
|
318
|
+
return asText({
|
|
319
|
+
schema: "canli.audit.v1",
|
|
320
|
+
note: "Each check is its validator's own result and receipt, side by side. The audit does not grade the strategy.",
|
|
321
|
+
...(shared ? { limits } : {}),
|
|
322
|
+
readings,
|
|
323
|
+
checks,
|
|
324
|
+
...(input.returns_file || input.variants_file
|
|
325
|
+
? { source: {
|
|
326
|
+
...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
|
|
327
|
+
...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
|
|
328
|
+
} }
|
|
329
|
+
: {}),
|
|
330
|
+
...(variants ? {} : { not_run: { overfitting: "Send variants or variants_file (every variant's returns) to add the overfitting check." } }),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The public keys canlicapital.com signs receipts with, as published at
|
|
335
|
+
// /.well-known/canli-receipt-keys.json when this package was built; a test keeps the two identical.
|
|
336
|
+
const RECEIPT_KEYS = JSON.parse(readFileSync(new URL("./receipt-keys.json", import.meta.url), "utf8"));
|
|
337
|
+
|
|
338
|
+
// verify_receipt: every check is local; only fetching a receipt by id touches the network.
|
|
339
|
+
export async function toolVerifyReceipt(session, args) {
|
|
340
|
+
const input = parseOrThrow(verifyReceiptToolShape, args, "verify_receipt");
|
|
341
|
+
if ((input.id === undefined) === (input.receipt === undefined)) throw new Error("verify_receipt: send exactly one of id or receipt");
|
|
342
|
+
let data = input.receipt;
|
|
343
|
+
if (input.id !== undefined) {
|
|
344
|
+
const response = await callApi(session, { path: `/api/v1/receipts/${input.id}` });
|
|
345
|
+
if (response.failed) return asText(response.envelope, true);
|
|
346
|
+
data = response.envelope?.data;
|
|
347
|
+
}
|
|
348
|
+
const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
|
|
349
|
+
const result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
|
|
350
|
+
return asText({
|
|
351
|
+
receipt_id: data?.id ?? null,
|
|
352
|
+
valid: result.valid,
|
|
353
|
+
checks: result.checks,
|
|
354
|
+
key_id: result.key_id,
|
|
355
|
+
keys: "bundled with this package; published at https://canlicapital.com/.well-known/canli-receipt-keys.json",
|
|
356
|
+
meaning: result.valid
|
|
357
|
+
? "canlicapital.com signed this exact output for this exact input, computed by the source files whose hashes the receipt lists. It says nothing about how the input series was built."
|
|
358
|
+
: "At least one check failed: do not treat this receipt as issued by canlicapital.com for this content.",
|
|
359
|
+
});
|
|
205
360
|
}
|
|
206
361
|
|
|
207
362
|
export async function toolGetReceipt(session, args) {
|
|
@@ -351,11 +506,36 @@ export function registerTools(server, session) {
|
|
|
351
506
|
{ title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
|
|
352
507
|
(args) => toolValidateTrackRecord(session, args),
|
|
353
508
|
);
|
|
509
|
+
server.registerTool(
|
|
510
|
+
"validate_backtest_length",
|
|
511
|
+
{ title: "Minimum backtest length", annotations: { title: "Minimum backtest length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_backtest_length, inputSchema: backtestLengthInput },
|
|
512
|
+
(args) => toolValidateBacktestLength(session, args),
|
|
513
|
+
);
|
|
514
|
+
server.registerTool(
|
|
515
|
+
"validate_haircut_sharpe",
|
|
516
|
+
{ title: "Haircut Sharpe ratio", annotations: { title: "Haircut Sharpe ratio", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_haircut_sharpe, inputSchema: haircutSharpeInput },
|
|
517
|
+
(args) => toolValidateHaircutSharpe(session, args),
|
|
518
|
+
);
|
|
519
|
+
server.registerTool(
|
|
520
|
+
"validate_luck_trials",
|
|
521
|
+
{ title: "Luck-equivalent trials", annotations: { title: "Luck-equivalent trials", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_luck_trials, inputSchema: luckTrialsInput },
|
|
522
|
+
(args) => toolValidateLuckTrials(session, args),
|
|
523
|
+
);
|
|
524
|
+
server.registerTool(
|
|
525
|
+
"audit_backtest",
|
|
526
|
+
{ title: "Audit a backtest", annotations: { title: "Audit a backtest", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.audit_backtest, inputSchema: auditBacktestToolShape },
|
|
527
|
+
(args) => toolAuditBacktest(session, args),
|
|
528
|
+
);
|
|
354
529
|
server.registerTool(
|
|
355
530
|
"get_receipt",
|
|
356
531
|
{ title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
|
|
357
532
|
(args) => toolGetReceipt(session, args),
|
|
358
533
|
);
|
|
534
|
+
server.registerTool(
|
|
535
|
+
"verify_receipt",
|
|
536
|
+
{ title: "Verify a receipt", annotations: { title: "Verify a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.verify_receipt, inputSchema: verifyReceiptToolShape },
|
|
537
|
+
(args) => toolVerifyReceipt(session, args),
|
|
538
|
+
);
|
|
359
539
|
server.registerTool(
|
|
360
540
|
"service_status",
|
|
361
541
|
{ title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
|
|
@@ -440,6 +620,9 @@ export const RESOURCE_TEXT = Object.freeze({
|
|
|
440
620
|
"",
|
|
441
621
|
"- Deflated Sharpe ratio: Bailey and López de Prado, \"The Deflated Sharpe Ratio\", Journal of Portfolio Management, 2014. Reproduces the paper's worked example (pages 9 and 10) to its four printed decimals, checked in CI.",
|
|
442
622
|
"- Minimum track record length and probabilistic Sharpe against a benchmark: Bailey and López de Prado, \"The Sharpe Ratio Efficient Frontier\", Journal of Risk, 2012. Reproduces the paper's worked examples (page 11), checked in CI.",
|
|
623
|
+
"- Minimum backtest length: Bailey, Borwein, López de Prado and Zhu, \"Pseudo-Mathematics and Financial Charlatanism\", Notices of the American Mathematical Society, 2014. Reproduces the paper's statements exactly (the best of 10 trials at 1.57; 5 years allow at most 45 trials, 2 years at most 7), checked in CI.",
|
|
624
|
+
"- Haircut Sharpe ratio: Harvey and Liu, \"Backtesting\", Journal of Portfolio Management, 2015. Agrees with the authors' own Haircut_SR.m on every deterministic (Bonferroni) output to 1e-9; Holm and BHY agree with R's p.adjust; the Student t with R's pt and qt. Checked in CI.",
|
|
625
|
+
"- Luck-equivalent trials: Canli Capital's statistic, built from the Student t null of the Sharpe's t-statistic, the Sidak best-of-N probability and the expected maximum of Bailey, Borwein, López de Prado and Zhu (2014). Calibrated by Monte Carlo in CI: correct size for normal and Student t4 returns; too generous for negatively skewed returns (with 252 observations, a nominal 5 percent test rejected 10.8 percent of skill-less searches at skew -1.3).",
|
|
443
626
|
"- Probability of backtest overfitting by CSCV: Bailey, Borwein, López de Prado and Zhu, \"The Probability of Backtest Overfitting\", Journal of Computational Finance, 2017. Agrees with the CRAN package pbo on PBO and on every logit, after its documented rank convention, checked in CI.",
|
|
444
627
|
"- Source code: https://github.com/arhancanli/canli-validation-mcp and https://github.com/arhancanli/canlicapital",
|
|
445
628
|
].join("\n"),
|