@almanak/mcp-server 0.2.5 → 0.2.7

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/dist/tools.js CHANGED
@@ -48,8 +48,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.PlatformToolHandler = void 0;
49
49
  const node_fs_1 = require("node:fs");
50
50
  const path = __importStar(require("node:path"));
51
+ const client_1 = require("./client");
51
52
  const runner_1 = require("./testing/runner");
52
53
  const workspace_git_1 = require("./workspace-git");
54
+ const backtest_v3_1 = require("./backtest-v3");
53
55
  const EXPORT_COMMIT_MESSAGE = 'feat: initial strategy export from Almanak';
54
56
  const ROOT_DIR_ALLOWED_RE = /^[a-zA-Z0-9._/-]+$/;
55
57
  const WORKSPACE_ABSOLUTE_PATH_RE = /^\/(?:tmp\/)?workspaces\/[^/]+\/(.+)$/;
@@ -108,9 +110,9 @@ function parseSdkVersion(value) {
108
110
  phaseNumber: phaseNumber ? Number(phaseNumber) : 0,
109
111
  };
110
112
  }
111
- function isSdkVersionBacktestable(version) {
113
+ function isSdkVersionBacktestable(version, minimum = MIN_BACKTEST_SDK_VERSION) {
112
114
  const left = parseSdkVersion(version);
113
- const right = parseSdkVersion(MIN_BACKTEST_SDK_VERSION);
115
+ const right = parseSdkVersion(minimum);
114
116
  if (!left || !right)
115
117
  return false;
116
118
  for (const key of ['major', 'minor', 'patch']) {
@@ -121,6 +123,67 @@ function isSdkVersionBacktestable(version) {
121
123
  return SDK_PHASE_RANK[left.phase] > SDK_PHASE_RANK[right.phase];
122
124
  return left.phaseNumber >= right.phaseNumber;
123
125
  }
126
+ /**
127
+ * `backtests_run` as SDK v3 sessions see it. A v3 run executes a backtest YAML committed and pushed in the linked
128
+ * repository with that commit's config.yaml; the window, inventory, cadence and costs live in that file, so none of
129
+ * the v2 window inputs apply.
130
+ */
131
+ const BACKTESTS_RUN_V3_DEFINITION = {
132
+ name: 'backtests_run',
133
+ description: 'Run a hosted backtest of a linked SDK v3 strategy (SDK v3 backtests are a staging preview). The run executes a ' +
134
+ 'backtest YAML committed in the strategy repository together with config.yaml and strategy.py at the same commit. ' +
135
+ 'The mode, window, inventory, execution, costs and data policy all come from that file (schema: ' +
136
+ '`almanak support --schema backtest`); this tool takes no window or capital inputs. Only committed, pushed files ' +
137
+ 'run: local or unpushed edits are never used, so commit and push the backtest YAML (and any strategy change) first, ' +
138
+ 'then call this with commit_sha set to the pushed commit (git rev-parse HEAD after the push). ' +
139
+ 'backtest_config_path names the file; omitted, the only committed backtest.yaml or backtest-economic.yaml at the ' +
140
+ 'repository root is used. Economic mode (`mode: economic`) refuses banded liquidity allocations: a config.yaml with ' +
141
+ 'a `band` allocation is refused before submission (V3_BACKTEST_BAND_UNSUPPORTED). ' +
142
+ 'The call submits the run, renders a live backtest widget in the chat and PAUSES until the run finishes; it then ' +
143
+ 'resumes you with the outcome as _widgetResponse. A v3 summary carries no v2 metrics: call backtests_results with ' +
144
+ 'the backtest_id for the report (return, drawdown, positions, decision interval) or the runner failure (stage, code, ' +
145
+ 'message). Do not re-submit while a run is in progress and do not claim results before they arrive. If the user ' +
146
+ 'chooses "Continue in background", you are resumed early with status RUNNING; read it later with backtests_results. ' +
147
+ 'Errors carry errorCode: COMMIT_UNRESOLVED (the commit is not in the repository: push it), REPO_ACCESS or ' +
148
+ 'GITHUB_UNAVAILABLE (repository access, not a push problem), BACKTEST_CONFIG_NOT_FOUND (file not in that commit), ' +
149
+ 'BACKTEST_CONFIG_UNREADABLE, BACKTEST_CONFIG_AMBIGUOUS (several files, pass backtest_config_path), ' +
150
+ 'V3_BACKTEST_INPUT_INVALID, V3_BACKTEST_BAND_UNSUPPORTED, SDK_GENERATION_MISMATCH (the strategy link is not stored as ' +
151
+ 'SDK v3; do not add v2 inputs). Users can have at most 10 active backtests; a 429 BACKTEST_CONCURRENCY_LIMIT error ' +
152
+ 'means do not retry until one finishes or is cancelled.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ strategy_link_id: {
157
+ type: 'string',
158
+ description: 'The strategy link ID (from strategies_list or the strategies_export_workspace result).',
159
+ },
160
+ backtest_config_path: {
161
+ type: 'string',
162
+ description: 'Repository-relative path of the committed backtest YAML to run, for example "backtest-economic.yaml". ' +
163
+ 'Optional when exactly one of backtest.yaml or backtest-economic.yaml is committed at the repository root.',
164
+ },
165
+ commit_sha: {
166
+ type: 'string',
167
+ description: 'Optional 7-40 character hexadecimal commit SHA or unique prefix of a pushed commit; it is resolved to a full SHA ' +
168
+ 'in the linked repository. Pass the commit you just pushed. Omitted, the head of branch (or the default branch) runs.',
169
+ },
170
+ branch: {
171
+ type: 'string',
172
+ description: 'Optional branch whose head runs when commit_sha is omitted. Defaults to the repository default branch.',
173
+ },
174
+ show_widget: {
175
+ const: true,
176
+ description: 'Must be true. Renders the live backtest widget and pauses this call until the run completes.',
177
+ },
178
+ },
179
+ required: ['strategy_link_id', 'show_widget'],
180
+ },
181
+ };
182
+ /** Appended to the backtests_results description in SDK v3 sessions only; other sessions keep the v2 text. */
183
+ const BACKTESTS_RESULTS_V3_NOTE = ' For an SDK v3 run (sdk_generation "v3") it returns the committed backtest_config_path, progress while running, ' +
184
+ 'failure (stage preflight/run/infra, code, message) for a FAILED run, and for a COMPLETED run report_summary (mode, ' +
185
+ 'window, decision interval, evaluations, metrics, net_quote_return, positions and equity counts) with the full ' +
186
+ 'result.json written to .logs/.';
124
187
  /** One-line human summary of a spec's `content.exit` for the injected teardown-disclosure field. */
125
188
  function summarizeSpecExit(exit) {
126
189
  if (exit === undefined || exit === null || (typeof exit === 'object' && Object.keys(exit).length === 0)) {
@@ -1217,7 +1280,10 @@ class PlatformToolHandler {
1217
1280
  required: ['strategy_link_id'],
1218
1281
  },
1219
1282
  }, (args) => this.linkWorkspace(args));
1220
- this.register('strategies', {
1283
+ // The session generation picks the schema the model sees: v2 sessions keep the v2 window inputs unchanged, v3
1284
+ // sessions see the committed-backtest-file inputs. The handler dispatches on the strategy's stored
1285
+ // framework_version either way, which is authoritative.
1286
+ const backtestsRunDefinition = this.options.sdkGeneration === 'v3' ? BACKTESTS_RUN_V3_DEFINITION : {
1221
1287
  name: 'backtests_run',
1222
1288
  description: 'Run a backtest of a linked strategy against historical market data. Use this after the strategy is in its ' +
1223
1289
  'repository (strategies_export_workspace, or git push on a linked chat) and before deploying — backtests run against a ' +
@@ -1281,7 +1347,8 @@ class PlatformToolHandler {
1281
1347
  },
1282
1348
  required: ['strategy_link_id', 'start_time', 'end_time', 'show_widget'],
1283
1349
  },
1284
- }, (args) => this.runBacktest(args));
1350
+ };
1351
+ this.register('strategies', backtestsRunDefinition, (args) => this.runBacktest(args));
1285
1352
  this.register('strategies', {
1286
1353
  name: 'backtests_results',
1287
1354
  description: "Fetch a backtest run's status and results for diagnosis — including WHY a run traded or held. Returns " +
@@ -1294,7 +1361,8 @@ class PlatformToolHandler {
1294
1361
  'returning its absolute path — use Grep/Read on it for the equity curve, trades, and price series (not ' +
1295
1362
  'returned inline). Works for any backtest of your linked strategies, including runs started from the ' +
1296
1363
  'platform UI and runs from earlier sessions. Use it BEFORE editing strategy code in response to a bad ' +
1297
- 'backtest, to attribute the cause first.',
1364
+ 'backtest, to attribute the cause first.' +
1365
+ (this.options.sdkGeneration === 'v3' ? BACKTESTS_RESULTS_V3_NOTE : ''),
1298
1366
  inputSchema: {
1299
1367
  type: 'object',
1300
1368
  properties: {
@@ -1539,7 +1607,11 @@ class PlatformToolHandler {
1539
1607
  'steps[3].summary. SDK v3 workspaces (config.yaml) execute (1) almanak check --json ' +
1540
1608
  '(its document is returned on steps[0].summary) and (2) pytest tests/; the on-chain ' +
1541
1609
  'steps are skipped because a v3 fork run needs provider credentials this container ' +
1542
- 'does not hold.',
1610
+ 'does not hold. The v3 check step passes only when the SDK reports ready: true. An ' +
1611
+ 'offline check here reports valid: true with ready: false, so the step is not passed, ' +
1612
+ 'all_passed is false and steps[0].not_ready_reason lists the not_checked stages: ' +
1613
+ 'production readiness, including the production gas policy (strategy.gas), is not ' +
1614
+ 'verified in this container.',
1543
1615
  inputSchema: {
1544
1616
  type: 'object',
1545
1617
  properties: {
@@ -1611,7 +1683,7 @@ class PlatformToolHandler {
1611
1683
  * turn into a widget (the error card auto-resolves the gate immediately).
1612
1684
  */
1613
1685
  async runBacktest(args) {
1614
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
1686
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1;
1615
1687
  const strategyLinkId = String((_a = args.strategy_link_id) !== null && _a !== void 0 ? _a : '').trim();
1616
1688
  const startTime = String((_b = args.start_time) !== null && _b !== void 0 ? _b : '').trim();
1617
1689
  // required[] only checks presence — an empty/whitespace id would call
@@ -1659,6 +1731,28 @@ class PlatformToolHandler {
1659
1731
  return fail((_e = strategyRes === null || strategyRes === void 0 ? void 0 : strategyRes.statusCode) !== null && _e !== void 0 ? _e : 404, (_g = (_f = strategyRes === null || strategyRes === void 0 ? void 0 : strategyRes.message) !== null && _f !== void 0 ? _f : strategyRes === null || strategyRes === void 0 ? void 0 : strategyRes.error) !== null && _g !== void 0 ? _g : 'Strategy not found');
1660
1732
  }
1661
1733
  const strategy = strategyRes.data;
1734
+ // The stored framework_version is authoritative for which runner family serves the strategy.
1735
+ const sessionIsV3 = this.options.sdkGeneration === 'v3';
1736
+ if (strategy.framework_version === 'V3') {
1737
+ if (!sessionIsV3) {
1738
+ // The v2 schema requires the window inputs a v3 run refuses; retrying with other inputs cannot succeed here.
1739
+ return fail(400, 'This strategy is stored as SDK v3, but this chat session is not on the SDK v3 lane, so its backtests_run takes ' +
1740
+ 'the SDK v2 window inputs that SDK v3 backtests refuse. Do not retry with other inputs: start the run from the ' +
1741
+ "strategy's Backtest page on the platform, or from an SDK v3 chat session attached to this strategy.", { errorCode: 'SDK_GENERATION_MISMATCH' });
1742
+ }
1743
+ return await this.runV3Backtest(args, strategyLinkId, strategy);
1744
+ }
1745
+ if (sessionIsV3) {
1746
+ return fail(400, `The strategy link is stored as SDK ${String((_h = strategy.framework_version) !== null && _h !== void 0 ? _h : 'v2').toLowerCase()}, but this chat session ` +
1747
+ 'runs the SDK v3 lane. The stored generation decides which backtest runs, and the two disagree, so nothing was ' +
1748
+ 'submitted. Do not add SDK v2 window inputs; tell the user the strategy link is not recognised as SDK v3 ' +
1749
+ '(its repository must resolve almanak 3.x and carry strategy.py and config.yaml at the root).', { errorCode: 'SDK_GENERATION_MISMATCH' });
1750
+ }
1751
+ const v3Inputs = backtest_v3_1.V3_ONLY_BACKTEST_ARGS.filter((key) => args[key] !== undefined);
1752
+ if (v3Inputs.length > 0) {
1753
+ return fail(400, `${v3Inputs.join(', ')} ${v3Inputs.length > 1 ? 'are SDK v3 inputs' : 'is an SDK v3 input'}, and this strategy is ` +
1754
+ 'stored as SDK v2: its backtest takes start_time and end_time (YYYY-MM-DD).', { errorCode: 'SDK_GENERATION_MISMATCH' });
1755
+ }
1662
1756
  const strategyName = (typeof strategy.display_name === 'string' && strategy.display_name) ||
1663
1757
  (typeof strategy.github_repo_name === 'string' && strategy.github_repo_name) ||
1664
1758
  null;
@@ -1684,19 +1778,19 @@ class PlatformToolHandler {
1684
1778
  const refType = explicitSha ? 'commit' : 'branch';
1685
1779
  const refConfigRes = await this.client.getStrategyRefConfig(strategyLinkId, ref, refType);
1686
1780
  if ((refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.valid) === false || !(refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.data)) {
1687
- const baseError = (_j = (_h = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.message) !== null && _h !== void 0 ? _h : refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.error) !== null && _j !== void 0 ? _j : `Could not resolve ref "${ref}" for the linked strategy repository.`;
1781
+ const baseError = (_k = (_j = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.message) !== null && _j !== void 0 ? _j : refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.error) !== null && _k !== void 0 ? _k : `Could not resolve ref "${ref}" for the linked strategy repository.`;
1688
1782
  const recoveryGuidance = explicitSha
1689
1783
  ? ` Omit commit_sha only if the user explicitly approves running the latest commit on ${defaultBranch}; ` +
1690
1784
  'do not retry with different code automatically.'
1691
1785
  : '';
1692
- return fail((_k = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.statusCode) !== null && _k !== void 0 ? _k : 400, `${baseError}${recoveryGuidance}`, {
1693
- errorCode: (_l = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.errorCode) !== null && _l !== void 0 ? _l : 'INTERNAL_ERROR',
1786
+ return fail((_l = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.statusCode) !== null && _l !== void 0 ? _l : 400, `${baseError}${recoveryGuidance}`, {
1787
+ errorCode: (_m = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.errorCode) !== null && _m !== void 0 ? _m : 'INTERNAL_ERROR',
1694
1788
  retryable: (refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.retryable) === true,
1695
1789
  ...recoveryContext,
1696
1790
  });
1697
1791
  }
1698
1792
  const refConfig = refConfigRes.data;
1699
- const commitSha = (_m = refConfig.github_commit_sha) !== null && _m !== void 0 ? _m : '';
1793
+ const commitSha = (_o = refConfig.github_commit_sha) !== null && _o !== void 0 ? _o : '';
1700
1794
  if (!FULL_COMMIT_SHA_RE.test(commitSha)) {
1701
1795
  return fail(400, `Could not resolve "${ref}" to a full commit SHA — has the strategy been pushed to its repository?`, {
1702
1796
  errorCode: 'COMMIT_UNRESOLVED',
@@ -1707,17 +1801,22 @@ class PlatformToolHandler {
1707
1801
  // /ref-config — pin it to the resolved commit so it matches the config.
1708
1802
  const refMetaRes = await this.client.getStrategyRefMetadata(strategyLinkId, commitSha);
1709
1803
  if ((refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.valid) === false || !(refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.data)) {
1710
- return fail((_o = refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.statusCode) !== null && _o !== void 0 ? _o : 400, (_q = (_p = refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.message) !== null && _p !== void 0 ? _p : refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.error) !== null && _q !== void 0 ? _q : 'Could not load strategy ref metadata');
1804
+ return fail((_p = refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.statusCode) !== null && _p !== void 0 ? _p : 400, (_r = (_q = refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.message) !== null && _q !== void 0 ? _q : refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.error) !== null && _r !== void 0 ? _r : 'Could not load strategy ref metadata');
1711
1805
  }
1712
- const sdkVersion = (_r = refMetaRes.data.almanak_sdk_version) !== null && _r !== void 0 ? _r : null;
1806
+ const sdkVersion = (_s = refMetaRes.data.almanak_sdk_version) !== null && _s !== void 0 ? _s : null;
1713
1807
  if (!sdkVersion) {
1714
1808
  return fail(400, 'Could not detect the Almanak SDK version from pyproject.toml at the selected commit.');
1715
1809
  }
1810
+ if (((_u = (_t = parseSdkVersion(sdkVersion)) === null || _t === void 0 ? void 0 : _t.major) !== null && _u !== void 0 ? _u : 0) >= 3) {
1811
+ // The backend refuses a v3 SDK against a V2 record; say why instead of submitting the v2 shape.
1812
+ return fail(400, `Commit ${commitSha.slice(0, 7)} resolves almanak ${sdkVersion} (SDK v3), but the strategy link is stored as SDK v2. ` +
1813
+ 'The generations disagree, so nothing was submitted; SDK v2 window inputs cannot backtest an SDK v3 repository.', { errorCode: 'SDK_GENERATION_MISMATCH', commit_sha: commitSha, sdk_version: sdkVersion });
1814
+ }
1716
1815
  if (!isSdkVersionBacktestable(sdkVersion)) {
1717
1816
  return fail(400, `Backtesting requires Almanak SDK >= ${MIN_BACKTEST_SDK_VERSION}; the repo pins ${sdkVersion}. ` +
1718
1817
  'Upgrade the almanak dependency in pyproject.toml, push, and re-run.');
1719
1818
  }
1720
- const strategyConfig = (_s = args.strategy_config) !== null && _s !== void 0 ? _s : refConfig.effective_config_json;
1819
+ const strategyConfig = (_v = args.strategy_config) !== null && _v !== void 0 ? _v : refConfig.effective_config_json;
1721
1820
  const submit = await this.client.submitBacktest({
1722
1821
  github_strategy_link_id: strategyLinkId,
1723
1822
  commit_sha: commitSha,
@@ -1726,7 +1825,7 @@ class PlatformToolHandler {
1726
1825
  backtest_config: backtestConfig,
1727
1826
  });
1728
1827
  if ((submit === null || submit === void 0 ? void 0 : submit.valid) === false || typeof (submit === null || submit === void 0 ? void 0 : submit.id) !== 'string') {
1729
- return fail((_t = submit === null || submit === void 0 ? void 0 : submit.statusCode) !== null && _t !== void 0 ? _t : 400, (_v = (_u = submit === null || submit === void 0 ? void 0 : submit.error) !== null && _u !== void 0 ? _u : submit === null || submit === void 0 ? void 0 : submit.message) !== null && _v !== void 0 ? _v : 'Backtest submission failed', {
1828
+ return fail((_w = submit === null || submit === void 0 ? void 0 : submit.statusCode) !== null && _w !== void 0 ? _w : 400, (_y = (_x = submit === null || submit === void 0 ? void 0 : submit.error) !== null && _x !== void 0 ? _x : submit === null || submit === void 0 ? void 0 : submit.message) !== null && _y !== void 0 ? _y : 'Backtest submission failed', {
1730
1829
  commit_sha: commitSha,
1731
1830
  sdk_version: sdkVersion,
1732
1831
  ...(typeof (submit === null || submit === void 0 ? void 0 : submit.errorCode) === 'string' ? { errorCode: submit.errorCode } : {}),
@@ -1738,8 +1837,8 @@ class PlatformToolHandler {
1738
1837
  }
1739
1838
  return {
1740
1839
  backtest_id: submit.id,
1741
- status: (_w = submit.status) !== null && _w !== void 0 ? _w : 'PENDING',
1742
- created_at: (_x = submit.created_at) !== null && _x !== void 0 ? _x : null,
1840
+ status: (_z = submit.status) !== null && _z !== void 0 ? _z : 'PENDING',
1841
+ created_at: (_0 = submit.created_at) !== null && _0 !== void 0 ? _0 : null,
1743
1842
  strategy_link_id: strategyLinkId,
1744
1843
  strategy_name: strategyName,
1745
1844
  commit_sha: commitSha,
@@ -1748,6 +1847,175 @@ class PlatformToolHandler {
1748
1847
  show_widget: true,
1749
1848
  };
1750
1849
  }
1850
+ catch (err) {
1851
+ return fail(502, `Backtest submission failed: ${(_1 = err === null || err === void 0 ? void 0 : err.message) !== null && _1 !== void 0 ? _1 : String(err)}`);
1852
+ }
1853
+ }
1854
+ /**
1855
+ * Submit an SDK v3 run: resolve the pushed commit, pick the committed backtest YAML the same way the strategy's
1856
+ * Backtest page does, and send `backtest_config_path` (never the v2 `backtest_config`). Like runBacktest it never
1857
+ * throws and every path returns a widget-renderable shape.
1858
+ */
1859
+ async runV3Backtest(args, strategyLinkId, strategy) {
1860
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
1861
+ const strategyName = (typeof strategy.display_name === 'string' && strategy.display_name) ||
1862
+ (typeof strategy.github_repo_name === 'string' && strategy.github_repo_name) ||
1863
+ null;
1864
+ const repoOwner = typeof strategy.github_repo_owner === 'string' ? strategy.github_repo_owner : '';
1865
+ const repoName = typeof strategy.github_repo_name === 'string' ? strategy.github_repo_name : '';
1866
+ const linkedRepo = repoOwner && repoName ? `${repoOwner}/${repoName}` : null;
1867
+ const defaultBranch = (typeof strategy.github_default_branch === 'string' && strategy.github_default_branch.trim()) || 'main';
1868
+ const context = { strategy_link_id: strategyLinkId, strategy_name: strategyName, sdk_generation: 'v3' };
1869
+ const fail = (statusCode, error, extra) => ({ statusCode, error, ...context, ...extra });
1870
+ const v2Inputs = backtest_v3_1.V2_ONLY_BACKTEST_ARGS.filter((key) => args[key] !== undefined);
1871
+ if (v2Inputs.length > 0) {
1872
+ return fail(400, `${v2Inputs.join(', ')} ${v2Inputs.length > 1 ? 'are SDK v2 inputs' : 'is an SDK v2 input'}. An SDK v3 backtest takes its ` +
1873
+ 'window, inventory, cadence and costs from the committed backtest YAML: set them in that file, commit and push it, ' +
1874
+ 'and call again with backtest_config_path and without the v2 inputs.', { errorCode: 'V3_BACKTEST_INPUT_INVALID' });
1875
+ }
1876
+ let requestedPath = null;
1877
+ if (args.backtest_config_path !== undefined) {
1878
+ const normalized = (0, backtest_v3_1.normalizeBacktestConfigPath)(args.backtest_config_path);
1879
+ if ('error' in normalized) {
1880
+ return fail(400, normalized.error, { errorCode: 'V3_BACKTEST_INPUT_INVALID' });
1881
+ }
1882
+ requestedPath = normalized.path;
1883
+ context.backtest_config_path = requestedPath;
1884
+ }
1885
+ const explicitSha = typeof args.commit_sha === 'string' ? args.commit_sha.trim() : '';
1886
+ const branch = typeof args.branch === 'string' ? args.branch.trim() : '';
1887
+ if (explicitSha && branch) {
1888
+ return fail(400, 'Pass commit_sha or branch, not both: commit_sha already names the exact code to run.', {
1889
+ errorCode: 'V3_BACKTEST_INPUT_INVALID',
1890
+ });
1891
+ }
1892
+ if (explicitSha && !COMMIT_SHA_PREFIX_RE.test(explicitSha)) {
1893
+ return fail(400, `commit_sha must contain 7 to 40 hexadecimal characters, got "${explicitSha}".`, {
1894
+ errorCode: 'INVALID_COMMIT_FORMAT',
1895
+ requestedCommit: explicitSha,
1896
+ linkedRepo,
1897
+ defaultBranch,
1898
+ });
1899
+ }
1900
+ if (args.branch !== undefined && !(0, backtest_v3_1.isValidBranchName)(branch)) {
1901
+ return fail(400, `branch must be a git branch name, got "${branch}".`, { errorCode: 'V3_BACKTEST_INPUT_INVALID' });
1902
+ }
1903
+ const ref = explicitSha ? explicitSha.toLowerCase() : branch || defaultBranch;
1904
+ const refType = explicitSha ? 'commit' : 'branch';
1905
+ const recoveryContext = { requestedCommit: explicitSha || null, linkedRepo, defaultBranch };
1906
+ try {
1907
+ const refConfigRes = await this.client.getStrategyRefConfig(strategyLinkId, ref, refType);
1908
+ const commitSha = (refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.valid) === false ? '' : String((_b = (_a = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.data) === null || _a === void 0 ? void 0 : _a.github_commit_sha) !== null && _b !== void 0 ? _b : '');
1909
+ if (!FULL_COMMIT_SHA_RE.test(commitSha)) {
1910
+ const baseError = (_d = (_c = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.message) !== null && _c !== void 0 ? _c : refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.error) !== null && _d !== void 0 ? _d : `Could not resolve ${refType} "${ref}" in the linked strategy repository.`;
1911
+ // Push advice fits only a ref the repository does not have; access and availability errors keep their own text.
1912
+ const errorCode = (refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.valid) === false ? ((_e = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.errorCode) !== null && _e !== void 0 ? _e : 'INTERNAL_ERROR') : 'COMMIT_UNRESOLVED';
1913
+ const guidance = errorCode !== 'COMMIT_UNRESOLVED'
1914
+ ? ''
1915
+ : explicitSha
1916
+ ? ` Only pushed commits can be backtested: make sure the commit is pushed to ${linkedRepo !== null && linkedRepo !== void 0 ? linkedRepo : 'the linked repository'} ` +
1917
+ '(git push, then git rev-parse HEAD) and call again with that SHA. Do not switch to a different commit without the user.'
1918
+ : ` Push the branch to ${linkedRepo !== null && linkedRepo !== void 0 ? linkedRepo : 'the linked repository'} first.`;
1919
+ return fail((_f = refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.statusCode) !== null && _f !== void 0 ? _f : 400, `${baseError}${guidance}`, {
1920
+ errorCode,
1921
+ retryable: (refConfigRes === null || refConfigRes === void 0 ? void 0 : refConfigRes.retryable) === true,
1922
+ ...recoveryContext,
1923
+ });
1924
+ }
1925
+ context.commit_sha = commitSha;
1926
+ const refMetaRes = await this.client.getStrategyRefMetadata(strategyLinkId, commitSha);
1927
+ if ((refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.valid) === false || !(refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.data)) {
1928
+ return fail((_g = refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.statusCode) !== null && _g !== void 0 ? _g : 400, (_j = (_h = refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.message) !== null && _h !== void 0 ? _h : refMetaRes === null || refMetaRes === void 0 ? void 0 : refMetaRes.error) !== null && _j !== void 0 ? _j : 'Could not load strategy ref metadata');
1929
+ }
1930
+ const meta = refMetaRes.data;
1931
+ const sdkVersion = typeof meta.almanak_sdk_version === 'string' ? meta.almanak_sdk_version : '';
1932
+ if (!sdkVersion) {
1933
+ return fail(400, 'Could not detect the almanak SDK version (uv.lock or pyproject.toml) at the selected commit.', {
1934
+ errorCode: 'V3_BACKTEST_INPUT_INVALID',
1935
+ });
1936
+ }
1937
+ context.sdk_version = sdkVersion;
1938
+ if (((_l = (_k = parseSdkVersion(sdkVersion)) === null || _k === void 0 ? void 0 : _k.major) !== null && _l !== void 0 ? _l : 0) < 3 || !isSdkVersionBacktestable(sdkVersion, backtest_v3_1.MIN_BACKTEST_SDK_VERSION_V3)) {
1939
+ return fail(400, `Hosted SDK v3 backtests need almanak ${backtest_v3_1.MIN_BACKTEST_SDK_VERSION_V3} or newer; commit ${commitSha.slice(0, 7)} resolves ` +
1940
+ `${sdkVersion}. Update the almanak pin (uv.lock / pyproject.toml), commit, push and re-run.`, { errorCode: 'V3_BACKTEST_INPUT_INVALID' });
1941
+ }
1942
+ const committed = {};
1943
+ const configs = meta.backtest_configs && typeof meta.backtest_configs === 'object' ? meta.backtest_configs : {};
1944
+ for (const [name, text] of Object.entries(configs)) {
1945
+ if (typeof text === 'string')
1946
+ committed[name] = text;
1947
+ }
1948
+ const discovered = Array.isArray(meta.backtest_config_paths)
1949
+ ? meta.backtest_config_paths.filter((name) => typeof name === 'string')
1950
+ : Object.keys(committed);
1951
+ const shortSha = commitSha.slice(0, 7);
1952
+ let backtestPath = requestedPath;
1953
+ if (!backtestPath) {
1954
+ if (discovered.length === 1) {
1955
+ backtestPath = discovered[0];
1956
+ }
1957
+ else if (discovered.length === 0) {
1958
+ return fail(404, `Commit ${shortSha} has no backtest.yaml or backtest-economic.yaml at the repository root. Write the backtest YAML ` +
1959
+ '(schema: almanak support --schema backtest), commit and push it, then call again with backtest_config_path.', { errorCode: 'BACKTEST_CONFIG_NOT_FOUND' });
1960
+ }
1961
+ else {
1962
+ return fail(400, `Commit ${shortSha} has several backtest files (${discovered.join(', ')}); pass backtest_config_path to choose one.`, { errorCode: 'BACKTEST_CONFIG_AMBIGUOUS', backtest_config_paths: discovered });
1963
+ }
1964
+ }
1965
+ context.backtest_config_path = backtestPath;
1966
+ let backtestYaml = (_m = committed[backtestPath]) !== null && _m !== void 0 ? _m : null;
1967
+ if (backtestYaml === null) {
1968
+ const fileRes = await this.client.getStrategyFileContent(strategyLinkId, backtestPath, commitSha);
1969
+ const file = (fileRes === null || fileRes === void 0 ? void 0 : fileRes.valid) === false ? null : (_o = fileRes === null || fileRes === void 0 ? void 0 : fileRes.data) === null || _o === void 0 ? void 0 : _o.file;
1970
+ const lookupError = (fileRes === null || fileRes === void 0 ? void 0 : fileRes.valid) === false ? String((_q = (_p = fileRes === null || fileRes === void 0 ? void 0 : fileRes.message) !== null && _p !== void 0 ? _p : fileRes === null || fileRes === void 0 ? void 0 : fileRes.error) !== null && _q !== void 0 ? _q : '') : '';
1971
+ if (typeof (file === null || file === void 0 ? void 0 : file.content) === 'string') {
1972
+ backtestYaml = file.content;
1973
+ }
1974
+ else if (file) {
1975
+ // The preview refuses some present files (build/dist/venv paths, directories, oversized files) with a reason.
1976
+ return fail(400, `Could not read ${backtestPath} at ${shortSha}: ${(_r = file.message) !== null && _r !== void 0 ? _r : 'the file is not readable as text'}. Keep the backtest ` +
1977
+ 'YAML a small text file outside build output directories, commit and push it, then call again.', { errorCode: 'BACKTEST_CONFIG_UNREADABLE' });
1978
+ }
1979
+ else if (lookupError !== 'File not found') {
1980
+ return fail((_s = fileRes === null || fileRes === void 0 ? void 0 : fileRes.statusCode) !== null && _s !== void 0 ? _s : 502, `Could not read ${backtestPath} at ${shortSha}: ${lookupError || 'lookup failed'}`, {
1981
+ errorCode: 'BACKTEST_CONFIG_UNREADABLE',
1982
+ });
1983
+ }
1984
+ else {
1985
+ return fail(404, `${backtestPath} is not in commit ${shortSha}. Only committed, pushed files run: commit and push it, then call ` +
1986
+ 'again with the pushed commit_sha.' +
1987
+ (discovered.length > 0 ? ` Committed backtest files at that commit: ${discovered.join(', ')}.` : ''), { errorCode: 'BACKTEST_CONFIG_NOT_FOUND', ...(discovered.length > 0 ? { backtest_config_paths: discovered } : {}) });
1988
+ }
1989
+ }
1990
+ const configYaml = typeof meta.config_yaml === 'string' ? meta.config_yaml : '';
1991
+ if ((0, backtest_v3_1.backtestMode)(backtestYaml) === 'economic' && (0, backtest_v3_1.declaresBandAllocation)(configYaml)) {
1992
+ return fail(400, `${backtestPath} selects the economic model, and config.yaml at ${shortSha} declares a banded liquidity allocation ` +
1993
+ `(band). The SDK refuses that combination ("${backtest_v3_1.SDK_BAND_ECONOMIC_REFUSAL}"), so the run was not submitted. ` +
1994
+ 'Tell the user; backtesting this strategy needs a fixed range allocation instead of a band.', { errorCode: 'V3_BACKTEST_BAND_UNSUPPORTED' });
1995
+ }
1996
+ const submit = await this.client.submitBacktest({
1997
+ github_strategy_link_id: strategyLinkId,
1998
+ commit_sha: commitSha,
1999
+ sdk_version: sdkVersion,
2000
+ sdk_generation: 'v3',
2001
+ backtest_config_path: backtestPath,
2002
+ });
2003
+ if ((submit === null || submit === void 0 ? void 0 : submit.valid) === false || typeof (submit === null || submit === void 0 ? void 0 : submit.id) !== 'string') {
2004
+ return fail((_t = submit === null || submit === void 0 ? void 0 : submit.statusCode) !== null && _t !== void 0 ? _t : 400, (_v = (_u = submit === null || submit === void 0 ? void 0 : submit.error) !== null && _u !== void 0 ? _u : submit === null || submit === void 0 ? void 0 : submit.message) !== null && _v !== void 0 ? _v : 'Backtest submission failed', {
2005
+ ...(typeof (submit === null || submit === void 0 ? void 0 : submit.errorCode) === 'string' ? { errorCode: submit.errorCode } : {}),
2006
+ ...(typeof (submit === null || submit === void 0 ? void 0 : submit.activeBacktests) === 'number' ? { activeBacktests: submit.activeBacktests } : {}),
2007
+ ...(typeof (submit === null || submit === void 0 ? void 0 : submit.maxActiveBacktests) === 'number' ? { maxActiveBacktests: submit.maxActiveBacktests } : {}),
2008
+ ...((submit === null || submit === void 0 ? void 0 : submit.errorCode) === 'BACKTEST_CONCURRENCY_LIMIT' ? { retryable: false } : {}),
2009
+ });
2010
+ }
2011
+ return {
2012
+ backtest_id: submit.id,
2013
+ status: (_w = submit.status) !== null && _w !== void 0 ? _w : 'PENDING',
2014
+ created_at: (_x = submit.created_at) !== null && _x !== void 0 ? _x : null,
2015
+ ...context,
2016
+ show_widget: true,
2017
+ };
2018
+ }
1751
2019
  catch (err) {
1752
2020
  return fail(502, `Backtest submission failed: ${(_y = err === null || err === void 0 ? void 0 : err.message) !== null && _y !== void 0 ? _y : String(err)}`);
1753
2021
  }
@@ -1761,7 +2029,7 @@ class PlatformToolHandler {
1761
2029
  * path returned — equity curves and per-trade detail are read on demand.
1762
2030
  */
1763
2031
  async getBacktestResults(args) {
1764
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
2032
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v;
1765
2033
  const backtestId = typeof args.backtest_id === 'string' ? args.backtest_id.trim() : '';
1766
2034
  if (!backtestId) {
1767
2035
  return {
@@ -1783,6 +2051,9 @@ class PlatformToolHandler {
1783
2051
  backtest_id: backtestId,
1784
2052
  };
1785
2053
  }
2054
+ if ((0, backtest_v3_1.isV3BacktestRun)(run)) {
2055
+ return this.getV3BacktestResults(backtestId, run);
2056
+ }
1786
2057
  const summaryVerdict = summarizeRunValidity((_d = run.result_summary) === null || _d === void 0 ? void 0 : _d.run_validity);
1787
2058
  const base = {
1788
2059
  backtest_id: (_e = run.id) !== null && _e !== void 0 ? _e : backtestId,
@@ -1853,34 +2124,117 @@ class PlatformToolHandler {
1853
2124
  'environment/data plane, not strategy logic.'
1854
2125
  : 'This run predates decision telemetry (older SDK), so per-tick hold reasons are unavailable — attribute ' +
1855
2126
  'zero-trade results by reproducing the decision path rather than assuming a strategy bug.');
1856
- // Same path-hardening rules as fetchAgentLogsToFile: the id is a UUID in
1857
- // normal usage, but strip anything that isn't hex/dash before using it in
1858
- // a filename.
2127
+ const written = await this.writeBacktestResultArtifact(backtestId, doc);
2128
+ if ('refused' in written) {
2129
+ return { error: written.refused, statusCode: 500 };
2130
+ }
2131
+ if ('error' in written) {
2132
+ return { ...inline, result_write_error: written.error, hint: telemetryHint };
2133
+ }
2134
+ const fpath = written.path;
2135
+ return {
2136
+ ...inline,
2137
+ result_path: fpath,
2138
+ hint: 'Full result.json written to result_path — Grep/Read it for equity_curve, trades, price_series, and ' +
2139
+ 'data_manifest entries (content is not returned inline). ' +
2140
+ telemetryHint,
2141
+ };
2142
+ }
2143
+ /**
2144
+ * Persist a backtest result document to `.logs/` for Grep/Read and return its path. Compact on purpose: the artifact
2145
+ * can be MBs and is a Grep/jq target. The id is a UUID in normal usage, but anything other than letters, digits and
2146
+ * dashes is stripped before it reaches a filename (the same hardening as fetchAgentLogsToFile), and a document above
2147
+ * the fetch cap is not written.
2148
+ */
2149
+ async writeBacktestResultArtifact(backtestId, doc) {
2150
+ var _a;
1859
2151
  const safeId = backtestId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 8);
1860
2152
  const logsDir = path.resolve(process.cwd(), '.logs');
1861
2153
  const fpath = path.join(logsDir, `backtest_${safeId}_result_${Date.now()}.json`);
1862
2154
  if (!fpath.startsWith(logsDir + path.sep)) {
1863
- return { error: 'refused to write outside .logs/', statusCode: 500 };
2155
+ return { refused: 'refused to write outside .logs/' };
1864
2156
  }
1865
2157
  try {
2158
+ const text = JSON.stringify(doc);
2159
+ const bytes = Buffer.byteLength(text, 'utf8');
2160
+ if (bytes > client_1.BACKTEST_RESULT_MAX_BYTES) {
2161
+ return { error: `result.json is ${bytes} bytes, above the ${client_1.BACKTEST_RESULT_MAX_BYTES}-byte cap; it was not written to .logs/` };
2162
+ }
1866
2163
  await node_fs_1.promises.mkdir(logsDir, { recursive: true });
1867
- // Compact on purpose: the artifact can be MBs and this is a Grep/jq
1868
- // target, so don't double its size (and the in-memory copy) with pretty-printing.
1869
- await node_fs_1.promises.writeFile(fpath, JSON.stringify(doc), 'utf8');
2164
+ await node_fs_1.promises.writeFile(fpath, text, 'utf8');
1870
2165
  }
1871
2166
  catch (err) {
2167
+ return { error: `Could not persist result.json to .logs/: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err)}` };
2168
+ }
2169
+ return { path: fpath };
2170
+ }
2171
+ /**
2172
+ * backtests_results for an SDK v3 run. The runner's summary carries run facts (or a failure block with stage, code
2173
+ * and message) instead of v2 metrics, and the result document embeds the SDK report under `report`; the v2 trade,
2174
+ * decision-telemetry and manifest fields do not exist, so they are not reported as empty.
2175
+ */
2176
+ async getV3BacktestResults(backtestId, run) {
2177
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
2178
+ const failure = (0, backtest_v3_1.v3BacktestFailure)(run);
2179
+ const base = {
2180
+ backtest_id: (_a = run.id) !== null && _a !== void 0 ? _a : backtestId,
2181
+ status: run.status,
2182
+ sdk_generation: 'v3',
2183
+ created_at: (_b = run.created_at) !== null && _b !== void 0 ? _b : null,
2184
+ completed_at: (_c = run.completed_at) !== null && _c !== void 0 ? _c : null,
2185
+ strategy_link_id: (_d = run.github_strategy_link_id) !== null && _d !== void 0 ? _d : null,
2186
+ commit_sha: (_e = run.commit_sha) !== null && _e !== void 0 ? _e : null,
2187
+ sdk_version: (_f = run.sdk_version) !== null && _f !== void 0 ? _f : null,
2188
+ ...(0, backtest_v3_1.v3BacktestSource)(run),
2189
+ result_summary: (_g = run.result_summary) !== null && _g !== void 0 ? _g : null,
2190
+ error_message: (_h = run.error_message) !== null && _h !== void 0 ? _h : null,
2191
+ };
2192
+ if (run.status !== 'COMPLETED') {
2193
+ if (run.status === 'FAILED') {
2194
+ return { ...base, failure, hint: (0, backtest_v3_1.v3FailureHint)(failure, typeof run.error_message === 'string' ? run.error_message : null) };
2195
+ }
2196
+ if (run.status === 'CANCELLED') {
2197
+ return { ...base, hint: 'The run was cancelled by the user; no results exist and none will. Do not re-run unless asked.' };
2198
+ }
1872
2199
  return {
1873
- ...inline,
1874
- result_write_error: `Could not persist result.json to .logs/: ${(_w = err === null || err === void 0 ? void 0 : err.message) !== null && _w !== void 0 ? _w : String(err)}`,
1875
- hint: telemetryHint,
2200
+ ...base,
2201
+ progress: (0, backtest_v3_1.backtestProgress)(run),
2202
+ hint: 'The run has not finished. progress (when present) is the runner snapshot: phase, completed_ticks of total_ticks ' +
2203
+ 'and simulation_elapsed_ms. Call again later; do not re-submit.',
1876
2204
  };
1877
2205
  }
2206
+ let doc;
2207
+ try {
2208
+ doc = await this.client.getBacktestResult(backtestId);
2209
+ }
2210
+ catch (err) {
2211
+ return { ...base, result_fetch_error: `Result fetch failed: ${(_j = err === null || err === void 0 ? void 0 : err.message) !== null && _j !== void 0 ? _j : String(err)}` };
2212
+ }
2213
+ if (!doc || doc.valid === false) {
2214
+ return { ...base, result_fetch_error: (_k = doc === null || doc === void 0 ? void 0 : doc.error) !== null && _k !== void 0 ? _k : 'Backtest result not found' };
2215
+ }
2216
+ const inline = {
2217
+ ...base,
2218
+ report_summary: (0, backtest_v3_1.summarizeV3Report)(doc),
2219
+ artifacts: doc.artifacts && typeof doc.artifacts === 'object' ? Object.keys(doc.artifacts) : [],
2220
+ };
2221
+ const reportHint = 'report_summary compacts the SDK report: metrics and net_quote_return are in the quote currency, and absent fields ' +
2222
+ 'mean the run did not measure them (for example no price bindings), never zero. incomplete_equity_points counts marks ' +
2223
+ 'where a held asset had no valid price.';
2224
+ const written = await this.writeBacktestResultArtifact(backtestId, doc);
2225
+ if ('refused' in written) {
2226
+ return { error: written.refused, statusCode: 500 };
2227
+ }
2228
+ if ('error' in written) {
2229
+ return { ...inline, result_write_error: written.error, hint: reportHint };
2230
+ }
2231
+ const fpath = written.path;
1878
2232
  return {
1879
2233
  ...inline,
1880
2234
  result_path: fpath,
1881
- hint: 'Full result.json written to result_path — Grep/Read it for equity_curve, trades, price_series, and ' +
1882
- 'data_manifest entries (content is not returned inline). ' +
1883
- telemetryHint,
2235
+ hint: 'Full result.json written to result_path; the SDK report is under `report` (equity_curve, price_series, positions, ' +
2236
+ 'wallets, assumptions). Grep/Read it for detail. ' +
2237
+ reportHint,
1884
2238
  };
1885
2239
  }
1886
2240
  /**