tradeblocks-mcp 4.3.0 → 4.4.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 +32 -0
- package/dist/{chunk-TLX7IBCM.js → chunk-CZNPQ574.js} +5 -5
- package/dist/{chunk-TLX7IBCM.js.map → chunk-CZNPQ574.js.map} +1 -1
- package/dist/{sync-EW33QZPD.js → sync-P6QUZFSF.js} +2 -2
- package/dist/test-exports.js +1 -1
- package/package.json +1 -1
- package/server/{chunk-YIWLVX7Z.js → chunk-5JTQEV7P.js} +307 -252
- package/server/chunk-5JTQEV7P.js.map +1 -0
- package/server/index.js +374 -168
- package/server/index.js.map +1 -1
- package/server/{sync-2POG6KA6.js → sync-336543PJ.js} +2 -2
- package/src/tools/reports/predictive.ts +170 -195
- package/src/utils/filter-curve.ts +257 -0
- package/server/chunk-YIWLVX7Z.js.map +0 -1
- /package/dist/{sync-EW33QZPD.js.map → sync-P6QUZFSF.js.map} +0 -0
- /package/server/{sync-2POG6KA6.js.map → sync-336543PJ.js.map} +0 -0
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
syncAllBlocks,
|
|
10
10
|
syncBlock,
|
|
11
11
|
upsertSyncMetadata
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-5JTQEV7P.js";
|
|
13
13
|
import "./chunk-76OEJGQX.js";
|
|
14
14
|
import "./chunk-NLYLE5C6.js";
|
|
15
15
|
import "./chunk-24O66TM5.js";
|
|
@@ -27,4 +27,4 @@ export {
|
|
|
27
27
|
syncBlock,
|
|
28
28
|
upsertSyncMetadata
|
|
29
29
|
};
|
|
30
|
-
//# sourceMappingURL=sync-
|
|
30
|
+
//# sourceMappingURL=sync-336543PJ.js.map
|
|
@@ -8,14 +8,14 @@ import { z } from "zod";
|
|
|
8
8
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import { loadBlock } from "../../utils/block-loader.ts";
|
|
10
10
|
import { createToolOutput } from "../../utils/output-formatter.ts";
|
|
11
|
-
import { rankPredictiveTradeFields } from "@tradeblocks/lib";
|
|
11
|
+
import { holmAdjust, KendallPairWorkLimitError, rankPredictiveTradeFields } from "@tradeblocks/lib";
|
|
12
12
|
import { filterByStrategy, filterByDateRange } from "../shared/filters.ts";
|
|
13
|
-
import { enrichAnalysisTrades, getTradeFieldValue
|
|
13
|
+
import { enrichAnalysisTrades, getTradeFieldValue } from "./helpers.ts";
|
|
14
14
|
import { withSyncedBlock } from "../middleware/sync-middleware.ts";
|
|
15
15
|
import { getConnection } from "../../db/connection.ts";
|
|
16
16
|
import { getProfile } from "../../db/profile-schemas.ts";
|
|
17
17
|
import { requireCoveredField } from "../../utils/trade-analysis-fields.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { analyzeFilterCurve } from "../../utils/filter-curve.ts";
|
|
19
19
|
import { datasetAgeOutput, maxMissedSessionsSchema } from "../../db/dataset-schemas.ts";
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -27,10 +27,16 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
27
27
|
"find_predictive_fields",
|
|
28
28
|
{
|
|
29
29
|
description:
|
|
30
|
-
"Identify which trade entry conditions predict profitability
|
|
30
|
+
"Identify which trade entry conditions predict profitability using Pearson (default), Spearman or Kendall correlation against a target field. Returns fields ranked by the chosen coefficient's strength.",
|
|
31
31
|
inputSchema: z.object({
|
|
32
32
|
blockId: z.string().describe("Block folder name"),
|
|
33
33
|
maxMissedSessions: maxMissedSessionsSchema,
|
|
34
|
+
method: z
|
|
35
|
+
.enum(["pearson", "spearman", "kendall"])
|
|
36
|
+
.optional()
|
|
37
|
+
.describe(
|
|
38
|
+
"Correlation method; defaults to Pearson. Rank methods measure monotone relationships.",
|
|
39
|
+
),
|
|
34
40
|
strategy: z.string().optional().describe("Pre-filter by strategy name (case-insensitive)"),
|
|
35
41
|
strategyName: z
|
|
36
42
|
.string()
|
|
@@ -69,6 +75,7 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
69
75
|
minSamples,
|
|
70
76
|
includeCustomFields,
|
|
71
77
|
maxMissedSessions,
|
|
78
|
+
method,
|
|
72
79
|
}) => {
|
|
73
80
|
try {
|
|
74
81
|
const block = await loadBlock(baseDir, blockId);
|
|
@@ -112,12 +119,14 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
112
119
|
minSamples,
|
|
113
120
|
includeCustomFields,
|
|
114
121
|
resolved.catalog.map(({ field }) => ({ field, label: field })),
|
|
122
|
+
method,
|
|
115
123
|
);
|
|
116
124
|
const summary = `Found ${fieldsWithData} predictive fields out of ${totalAnalyzed} analyzed`;
|
|
117
125
|
|
|
118
126
|
const structuredData: Record<string, unknown> = {
|
|
119
127
|
blockId,
|
|
120
128
|
targetField,
|
|
129
|
+
...(method ? { method } : {}),
|
|
121
130
|
filters: {
|
|
122
131
|
strategy: effectiveStrategy ?? null,
|
|
123
132
|
strategyName: strategyName ?? null,
|
|
@@ -167,6 +176,17 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
167
176
|
|
|
168
177
|
return createToolOutput(summary, structuredData);
|
|
169
178
|
} catch (error) {
|
|
179
|
+
if (error instanceof KendallPairWorkLimitError)
|
|
180
|
+
return createToolOutput("Kendall analysis refused: kendall_pair_work_limit", {
|
|
181
|
+
blockId,
|
|
182
|
+
method,
|
|
183
|
+
status: "refused",
|
|
184
|
+
reason: "kendall_pair_work_limit",
|
|
185
|
+
estimatedPairs: error.estimatedPairs,
|
|
186
|
+
pairLimit: error.pairLimit,
|
|
187
|
+
fieldCount: error.fieldCount,
|
|
188
|
+
tradeCount: error.tradeCount,
|
|
189
|
+
});
|
|
170
190
|
return {
|
|
171
191
|
content: [
|
|
172
192
|
{
|
|
@@ -192,7 +212,28 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
192
212
|
maxMissedSessions: maxMissedSessionsSchema,
|
|
193
213
|
field: z
|
|
194
214
|
.string()
|
|
195
|
-
.
|
|
215
|
+
.optional()
|
|
216
|
+
.describe("Single field to sweep; use field or fields, not both."),
|
|
217
|
+
fields: z
|
|
218
|
+
.array(z.string())
|
|
219
|
+
.min(1)
|
|
220
|
+
.max(10)
|
|
221
|
+
.optional()
|
|
222
|
+
.describe("Fields to compare with Holm-adjusted permutation p-values."),
|
|
223
|
+
objective: z
|
|
224
|
+
.enum(["totalPl", "winRate"])
|
|
225
|
+
.optional()
|
|
226
|
+
.describe(
|
|
227
|
+
"Opt into best-threshold and per-year analysis; defaults to totalPl with fields or permutation.",
|
|
228
|
+
),
|
|
229
|
+
permutation: z
|
|
230
|
+
.object({
|
|
231
|
+
iterations: z.number().int().min(1).max(499),
|
|
232
|
+
block: z.literal("month").default("month"),
|
|
233
|
+
seed: z.number().int().min(0).max(4294967295).default(42),
|
|
234
|
+
})
|
|
235
|
+
.optional()
|
|
236
|
+
.describe("Selection-adjusted calendar-month block permutation; at most 499 iterations."),
|
|
196
237
|
mode: z
|
|
197
238
|
.enum(["lt", "gt", "both"])
|
|
198
239
|
.default("both")
|
|
@@ -221,6 +262,9 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
221
262
|
async ({
|
|
222
263
|
blockId,
|
|
223
264
|
field,
|
|
265
|
+
fields,
|
|
266
|
+
objective,
|
|
267
|
+
permutation,
|
|
224
268
|
mode,
|
|
225
269
|
thresholds,
|
|
226
270
|
percentileSteps,
|
|
@@ -251,206 +295,137 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
|
|
|
251
295
|
// Enrich trades
|
|
252
296
|
const resolved = await enrichAnalysisTrades(baseDir, trades, maxMissedSessions);
|
|
253
297
|
const enrichedTrades = resolved.trades;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
(
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
for (const trade of enrichedTrades) {
|
|
265
|
-
const value = getTradeFieldValue(trade, field);
|
|
266
|
-
if (value !== null) {
|
|
267
|
-
fieldValues.push(value);
|
|
268
|
-
tradesWithField.push(trade);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (fieldValues.length === 0) {
|
|
273
|
-
return {
|
|
274
|
-
content: [
|
|
298
|
+
const names = fields ?? (field ? [field] : []);
|
|
299
|
+
if (!names.length || (fields && field))
|
|
300
|
+
throw new Error("Provide exactly one of field or fields.");
|
|
301
|
+
if (new Set(names).size !== names.length)
|
|
302
|
+
throw new Error("fields must not contain duplicates.");
|
|
303
|
+
if (permutation) {
|
|
304
|
+
if ((thresholds?.length ?? 0) > 100 || (percentileSteps?.length ?? 0) > 100)
|
|
305
|
+
return createToolOutput(
|
|
306
|
+
"Filter curve permutation refused: permutation_candidate_limit",
|
|
275
307
|
{
|
|
276
|
-
|
|
277
|
-
|
|
308
|
+
blockId,
|
|
309
|
+
status: "refused",
|
|
310
|
+
reason: "permutation_candidate_limit",
|
|
311
|
+
candidateLimit: 100,
|
|
312
|
+
thresholdCount: thresholds?.length ?? 0,
|
|
313
|
+
percentileCount: percentileSteps?.length ?? 7,
|
|
314
|
+
requestedFields: names,
|
|
315
|
+
permutation: { ...permutation, status: "refused" },
|
|
278
316
|
},
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
count: tradesWithField.length,
|
|
292
|
-
winRate: Math.round(baselineWinRate * 10000) / 10000,
|
|
293
|
-
avgPl: Math.round(baselineAvgPl * 100) / 100,
|
|
294
|
-
totalPl: Math.round(baselineTotalPl * 100) / 100,
|
|
295
|
-
uncoveredCount: trades.length - tradesWithField.length,
|
|
296
|
-
uncoveredTotalPl:
|
|
297
|
-
Math.round(
|
|
298
|
-
enrichedTrades
|
|
299
|
-
.filter((trade) => getTradeFieldValue(trade, field) === null)
|
|
300
|
-
.reduce((sum, trade) => sum + trade.pl, 0) * 100,
|
|
301
|
-
) / 100,
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
const sweeps = sweepThresholds(
|
|
305
|
-
tradesWithField,
|
|
306
|
-
(trade) => getTradeFieldValue(trade, field),
|
|
307
|
-
thresholds,
|
|
308
|
-
percentileSteps,
|
|
309
|
-
);
|
|
310
|
-
const thresholdsToTest = sweeps.map(({ threshold }) => threshold);
|
|
311
|
-
|
|
312
|
-
// Minimum sample size for reliable statistics
|
|
313
|
-
const MIN_SAMPLE_SIZE = 30;
|
|
314
|
-
|
|
315
|
-
// Helper to calculate metrics for a filtered set of trades
|
|
316
|
-
function calculateMetrics(filteredTrades: EnrichedTrade[]): {
|
|
317
|
-
count: number;
|
|
318
|
-
percentOfTrades: number;
|
|
319
|
-
winRate: number;
|
|
320
|
-
avgPl: number;
|
|
321
|
-
totalPl: number;
|
|
322
|
-
winRateDelta: number;
|
|
323
|
-
avgPlDelta: number;
|
|
324
|
-
lowSampleWarning?: string;
|
|
325
|
-
} {
|
|
326
|
-
if (filteredTrades.length === 0) {
|
|
317
|
+
);
|
|
318
|
+
const candidates = thresholds?.length || percentileSteps?.length || 7;
|
|
319
|
+
const estimates = names.map((name) => {
|
|
320
|
+
const coveredCount = enrichedTrades.reduce(
|
|
321
|
+
(count, trade) => count + (getTradeFieldValue(trade, name) !== null ? 1 : 0),
|
|
322
|
+
0,
|
|
323
|
+
);
|
|
324
|
+
// Include sorting values/candidates and filtering/scoring both directions on every sweep.
|
|
325
|
+
const sweepWork =
|
|
326
|
+
coveredCount * Math.ceil(Math.log2(Math.max(2, coveredCount))) +
|
|
327
|
+
6 * coveredCount * candidates +
|
|
328
|
+
3 * candidates * Math.ceil(Math.log2(Math.max(2, 2 * candidates)));
|
|
327
329
|
return {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
avgPl: 0,
|
|
332
|
-
totalPl: 0,
|
|
333
|
-
winRateDelta: -baseline.winRate,
|
|
334
|
-
avgPlDelta: -baseline.avgPl,
|
|
330
|
+
field: name,
|
|
331
|
+
coveredCount,
|
|
332
|
+
estimatedWork: (permutation.iterations + 2) * sweepWork,
|
|
335
333
|
};
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
avgPl: Math.round(avgPl * 100) / 100,
|
|
350
|
-
totalPl: Math.round(totalPl * 100) / 100,
|
|
351
|
-
winRateDelta: Math.round((winRate - baseline.winRate) * 10000) / 10000,
|
|
352
|
-
avgPlDelta: Math.round((avgPl - baseline.avgPl) * 100) / 100,
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
// Add warning for small sample sizes
|
|
356
|
-
if (filteredTrades.length < MIN_SAMPLE_SIZE && filteredTrades.length > 0) {
|
|
357
|
-
result.lowSampleWarning = `${filteredTrades.length} trades may be insufficient for reliable statistics (recommend >= ${MIN_SAMPLE_SIZE})`;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
return result;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Analyze each threshold
|
|
364
|
-
interface ThresholdResult {
|
|
365
|
-
threshold: number;
|
|
366
|
-
lt?: ReturnType<typeof calculateMetrics>;
|
|
367
|
-
gt?: ReturnType<typeof calculateMetrics>;
|
|
334
|
+
});
|
|
335
|
+
const estimatedWork = estimates.reduce((sum, field) => sum + field.estimatedWork, 0);
|
|
336
|
+
const overField = estimates.find((field) => field.estimatedWork > 30_000_000);
|
|
337
|
+
if (overField || estimatedWork > 90_000_000)
|
|
338
|
+
return createToolOutput("Filter curve permutation refused by work admission", {
|
|
339
|
+
blockId,
|
|
340
|
+
status: "refused",
|
|
341
|
+
reason: overField ? "permutation_work_limit" : "permutation_request_work_limit",
|
|
342
|
+
estimatedWork: overField ? overField.estimatedWork : estimatedWork,
|
|
343
|
+
workLimit: overField ? 30_000_000 : 90_000_000,
|
|
344
|
+
permutation: { ...permutation, status: "refused" },
|
|
345
|
+
fields: estimates,
|
|
346
|
+
});
|
|
368
347
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
percentOfTrades,
|
|
403
|
-
score: Math.round(score * 10000) / 10000,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
348
|
+
const results = names.map((name) => {
|
|
349
|
+
try {
|
|
350
|
+
if (
|
|
351
|
+
resolved.catalog.some((entry) => entry.field === name) ||
|
|
352
|
+
(name.includes(".") && !name.startsWith("custom.") && !name.startsWith("daily."))
|
|
353
|
+
)
|
|
354
|
+
requireCoveredField(name, resolved.catalog, resolved.values, resolved.datasetAge);
|
|
355
|
+
if (!enrichedTrades.some((trade) => getTradeFieldValue(trade, name) !== null))
|
|
356
|
+
return {
|
|
357
|
+
field: name,
|
|
358
|
+
reason: "no_covered_trades",
|
|
359
|
+
error: `Field "${name}" has no valid numeric values in the filtered trades.`,
|
|
360
|
+
};
|
|
361
|
+
const { analysis, ...curve } = analyzeFilterCurve(
|
|
362
|
+
enrichedTrades,
|
|
363
|
+
(trade) => getTradeFieldValue(trade, name),
|
|
364
|
+
{
|
|
365
|
+
mode,
|
|
366
|
+
thresholds,
|
|
367
|
+
percentileSteps,
|
|
368
|
+
objective: objective ?? (fields || permutation ? "totalPl" : undefined),
|
|
369
|
+
permutation,
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
return {
|
|
373
|
+
field: name,
|
|
374
|
+
...datasetAgeOutput(resolved.datasetAge, [name]),
|
|
375
|
+
...curve,
|
|
376
|
+
...analysis,
|
|
377
|
+
};
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (!fields) throw error;
|
|
380
|
+
return { field: name, reason: "field_unavailable", error: (error as Error).message };
|
|
406
381
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
382
|
+
});
|
|
383
|
+
if (permutation) {
|
|
384
|
+
const tested = results.filter(
|
|
385
|
+
(result) => "permutation" in result && result.permutation?.pValue !== undefined,
|
|
386
|
+
);
|
|
387
|
+
// Refused fields stay in the requested family as conservative p=1, never disappear.
|
|
388
|
+
const adjusted = holmAdjust(
|
|
389
|
+
results.map((result) =>
|
|
390
|
+
"permutation" in result ? (result.permutation?.pValue ?? 1) : 1,
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
for (const result of tested) {
|
|
394
|
+
if ("permutation" in result && result.permutation)
|
|
395
|
+
Object.assign(result.permutation, {
|
|
396
|
+
pValueHolm: adjusted[results.indexOf(result)],
|
|
397
|
+
adjustment:
|
|
398
|
+
names.length === 1 ? "none (one field)" : "Holm across requested fields",
|
|
420
399
|
});
|
|
421
|
-
}
|
|
422
400
|
}
|
|
423
401
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
// Build summary
|
|
429
|
-
const summary = `Filter curve for ${field}: ${thresholdsToTest.length} thresholds analyzed | ${sweetSpots.length} sweet spots found`;
|
|
430
|
-
|
|
431
|
-
const structuredData = {
|
|
432
|
-
blockId,
|
|
433
|
-
field,
|
|
434
|
-
...datasetAgeOutput(resolved.datasetAge, [field]),
|
|
435
|
-
mode,
|
|
436
|
-
filters: {
|
|
437
|
-
strategy: strategy ?? null,
|
|
438
|
-
startDate: startDate ?? null,
|
|
439
|
-
endDate: endDate ?? null,
|
|
440
|
-
},
|
|
441
|
-
tradesAnalyzed: tradesWithField.length,
|
|
442
|
-
baseline,
|
|
443
|
-
thresholds: thresholdResults,
|
|
444
|
-
sweetSpots,
|
|
445
|
-
sweetSpotCriteria: {
|
|
446
|
-
winRateDelta: "> 0 (win rate improves)",
|
|
447
|
-
avgPlDelta: "> 0 (average P/L improves)",
|
|
448
|
-
percentOfTrades: ">= 20% (retains meaningful sample)",
|
|
449
|
-
scoreFormula: "winRateDelta * avgPlDelta (higher = better)",
|
|
450
|
-
},
|
|
402
|
+
const filters = {
|
|
403
|
+
strategy: strategy ?? null,
|
|
404
|
+
startDate: startDate ?? null,
|
|
405
|
+
endDate: endDate ?? null,
|
|
451
406
|
};
|
|
452
|
-
|
|
453
|
-
|
|
407
|
+
if (fields)
|
|
408
|
+
return createToolOutput(`Filter curves for ${names.length} fields`, {
|
|
409
|
+
blockId,
|
|
410
|
+
mode,
|
|
411
|
+
filters,
|
|
412
|
+
fields: results,
|
|
413
|
+
});
|
|
414
|
+
const result = results[0];
|
|
415
|
+
if (result.error !== undefined)
|
|
416
|
+
return { content: [{ type: "text", text: result.error }] };
|
|
417
|
+
const { field: resultField, ...curve } = result;
|
|
418
|
+
return createToolOutput(
|
|
419
|
+
`Filter curve for ${field}: ${result.thresholds.length} thresholds analyzed | ${result.sweetSpots.length} sweet spots found`,
|
|
420
|
+
{
|
|
421
|
+
blockId,
|
|
422
|
+
field: resultField,
|
|
423
|
+
...datasetAgeOutput(resolved.datasetAge, [resultField]),
|
|
424
|
+
mode,
|
|
425
|
+
filters,
|
|
426
|
+
...curve,
|
|
427
|
+
},
|
|
428
|
+
);
|
|
454
429
|
} catch (error) {
|
|
455
430
|
return {
|
|
456
431
|
content: [
|