@sherwoodagent/cli 0.52.0 → 0.53.2

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/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  HyperliquidProvider,
4
+ TOKEN_TO_COIN,
5
+ calculateATR,
4
6
  getLatestSignals,
5
7
  hlCancelAllOrders,
6
8
  hlGetMeta,
7
9
  hlPlaceLimitOrder,
8
10
  resolveHLCoin
9
- } from "./chunk-OMW47WOO.js";
11
+ } from "./chunk-DBOINZYU.js";
10
12
  import {
11
13
  UniswapProvider,
12
14
  applySlippage,
@@ -93,7 +95,7 @@ import {
93
95
  setVotingPeriod,
94
96
  settleProposal,
95
97
  vote
96
- } from "./chunk-JY46LEIS.js";
98
+ } from "./chunk-4VXAOLRC.js";
97
99
  import {
98
100
  AERODROME,
99
101
  AGENT_REGISTRY,
@@ -1536,9 +1538,9 @@ function registerStrategyTemplateCommands(strategy2) {
1536
1538
  console.error(chalk.red("Missing --name, --performance-fee, or --duration. Use --write-calls to skip proposal submission."));
1537
1539
  process.exit(1);
1538
1540
  }
1539
- const { propose: propose2 } = await import("./governor-CQG4LA43.js");
1541
+ const { propose: propose2 } = await import("./governor-HZW5TEP2.js");
1540
1542
  const { pinJSON } = await import("./ipfs-IGXLLJCF.js");
1541
- const { parseDuration: parseDuration2 } = await import("./governor-CQG4LA43.js");
1543
+ const { parseDuration: parseDuration2 } = await import("./governor-HZW5TEP2.js");
1542
1544
  const performanceFeeBps = BigInt(opts.performanceFee);
1543
1545
  if (performanceFeeBps < 0n || performanceFeeBps > 10000n) {
1544
1546
  console.error(chalk.red("--performance-fee must be 0-10000 (basis points)"));
@@ -4762,7 +4764,9 @@ var GridPortfolio = class {
4762
4764
  allocation: allocation * (config.tokenSplit[token] ?? 1 / config.tokens.length),
4763
4765
  stats: emptyStats(),
4764
4766
  centerPrice: 0,
4765
- atr: 0
4767
+ atr: 0,
4768
+ trend: 0,
4769
+ lastTrendRefreshAt: 0
4766
4770
  }));
4767
4771
  const state = {
4768
4772
  totalAllocation: allocation,
@@ -4778,10 +4782,10 @@ var GridPortfolio = class {
4778
4782
  getState() {
4779
4783
  return this.state;
4780
4784
  }
4781
- /** Reset daily counters if UTC day boundary crossed. */
4782
- resetDailyStats(state) {
4783
- const now = Date.now();
4784
- const todayMidnight = /* @__PURE__ */ new Date();
4785
+ /** Reset daily counters if UTC day boundary crossed. `now` is injectable
4786
+ * so the backtester can drive resets off backtest time, not wall-clock. */
4787
+ resetDailyStats(state, now = Date.now()) {
4788
+ const todayMidnight = new Date(now);
4785
4789
  todayMidnight.setUTCHours(0, 0, 0, 0);
4786
4790
  const todayMs = todayMidnight.getTime();
4787
4791
  let changed = false;
@@ -4795,16 +4799,25 @@ var GridPortfolio = class {
4795
4799
  }
4796
4800
  return changed;
4797
4801
  }
4798
- /** Check if grid should be paused (pool dropped below threshold). */
4799
- checkPauseThreshold(state, config) {
4802
+ /** Check if grid should be paused or resumed (pool equity dropped/recovered).
4803
+ * `prices` is required to mark open buys to market. Hysteresis: pause at
4804
+ * `pauseThresholdPct` drop, resume only when drop recovers below
4805
+ * `unpauseRecoveryPct`. Returns true iff state.paused was changed. */
4806
+ checkPauseThreshold(state, config, prices) {
4800
4807
  const currentValue = state.grids.reduce((sum, g) => {
4801
- const openFillValue = g.openFills.filter((f) => !f.closed).reduce((s, f) => s + f.quantity * f.buyPrice, 0);
4802
- return sum + g.allocation + openFillValue;
4808
+ const price = prices[g.token];
4809
+ const unrealized = price && price > 0 ? g.openFills.filter((f) => !f.closed).reduce((s, f) => s + (price - f.buyPrice) * f.quantity, 0) : 0;
4810
+ return sum + g.allocation + unrealized;
4803
4811
  }, 0);
4804
4812
  const dropPct = 1 - currentValue / state.totalAllocation;
4805
- if (dropPct >= config.pauseThresholdPct) {
4813
+ if (!state.paused && dropPct >= config.pauseThresholdPct) {
4806
4814
  state.paused = true;
4807
- state.pauseReason = `Grid pool dropped ${(dropPct * 100).toFixed(1)}% (threshold: ${(config.pauseThresholdPct * 100).toFixed(0)}%)`;
4815
+ state.pauseReason = `Grid pool dropped ${(dropPct * 100).toFixed(1)}% (pause threshold: ${(config.pauseThresholdPct * 100).toFixed(0)}%, resume when \u2264 ${(config.unpauseRecoveryPct * 100).toFixed(0)}%)`;
4816
+ return true;
4817
+ }
4818
+ if (state.paused && dropPct <= config.unpauseRecoveryPct) {
4819
+ state.paused = false;
4820
+ state.pauseReason = "";
4808
4821
  return true;
4809
4822
  }
4810
4823
  return false;
@@ -4821,14 +4834,24 @@ var GridPortfolio = class {
4821
4834
  };
4822
4835
 
4823
4836
  // src/grid/manager.ts
4837
+ var defaultFillDetector = (level, currentPrice) => level.side === "buy" ? currentPrice <= level.price : currentPrice >= level.price;
4838
+ var defaultCloseFillDetector = (openFill, currentPrice) => currentPrice >= openFill.targetSellPrice;
4824
4839
  var GridManager = class {
4825
4840
  config;
4826
4841
  portfolio;
4827
4842
  hl;
4828
- constructor(config) {
4843
+ candleFetcher;
4844
+ fillDetector;
4845
+ closeFillDetector;
4846
+ nowProvider;
4847
+ constructor(config, candleFetcher, fillDetector, closeFillDetector, portfolio, nowProvider) {
4829
4848
  this.config = config;
4830
- this.portfolio = new GridPortfolio();
4849
+ this.portfolio = portfolio ?? new GridPortfolio();
4831
4850
  this.hl = new HyperliquidProvider();
4851
+ this.candleFetcher = candleFetcher ?? ((tokenId, interval, lookbackMs) => this.hl.getCandles(tokenId, interval, lookbackMs));
4852
+ this.fillDetector = fillDetector ?? defaultFillDetector;
4853
+ this.closeFillDetector = closeFillDetector ?? defaultCloseFillDetector;
4854
+ this.nowProvider = nowProvider ?? (() => Date.now());
4832
4855
  }
4833
4856
  /** Load or initialize grid state.
4834
4857
  * Also syncs new tokens added to config after initial grid setup. */
@@ -4850,7 +4873,9 @@ var GridManager = class {
4850
4873
  allocation: alloc,
4851
4874
  stats: { totalRoundTrips: 0, totalPnlUsd: 0, todayPnlUsd: 0, totalFills: 0, todayFills: 0, lastDailyReset: 0, lastRebalanceAt: 0 },
4852
4875
  centerPrice: 0,
4853
- atr: 0
4876
+ atr: 0,
4877
+ trend: 0,
4878
+ lastTrendRefreshAt: 0
4854
4879
  });
4855
4880
  addedAllocation += alloc;
4856
4881
  console.error(` [grid] Added new token grid: ${token} ($${alloc.toFixed(0)} allocation)`);
@@ -4871,8 +4896,15 @@ var GridManager = class {
4871
4896
  */
4872
4897
  async tick(prices, regime) {
4873
4898
  const state = await this.portfolio.load();
4874
- if (!state || state.paused || !this.config.enabled) {
4875
- return { fills: 0, roundTrips: 0, pnlUsd: 0, paused: state?.paused ?? false };
4899
+ if (!state || !this.config.enabled) {
4900
+ return { fills: 0, roundTrips: 0, pnlUsd: 0, paused: false };
4901
+ }
4902
+ if (state.paused) {
4903
+ const resumed = this.portfolio.checkPauseThreshold(state, this.config, prices);
4904
+ if (resumed) {
4905
+ await this.portfolio.save(state);
4906
+ }
4907
+ return { fills: 0, roundTrips: 0, pnlUsd: 0, paused: state.paused };
4876
4908
  }
4877
4909
  if (regime === "high-volatility") {
4878
4910
  console.error(chalk9.dim(` [grid] Paused: high-volatility regime \u2014 too risky for grid`));
@@ -4885,6 +4917,7 @@ var GridManager = class {
4885
4917
  for (const grid of state.grids) {
4886
4918
  const price = prices[grid.token];
4887
4919
  if (!price || price <= 0) continue;
4920
+ await this.maybeRefreshTrend(grid);
4888
4921
  if (grid.levels.length === 0 || this.needsFullRebuild(grid)) {
4889
4922
  await this.buildGrid(grid, price);
4890
4923
  }
@@ -4896,13 +4929,29 @@ var GridManager = class {
4896
4929
  await this.shiftGrid(grid, price);
4897
4930
  }
4898
4931
  }
4899
- this.portfolio.checkPauseThreshold(state, this.config);
4932
+ this.portfolio.checkPauseThreshold(state, this.config, prices);
4900
4933
  await this.portfolio.save(state);
4901
4934
  return { fills: totalFills, roundTrips: totalRoundTrips, pnlUsd: totalPnl, paused: false };
4902
4935
  }
4936
+ /** Hourly refresh of `grid.trend` from recent 4h candles. Called from
4937
+ * tick() so the downtrend filter sees fresh data between buildGrid runs. */
4938
+ async maybeRefreshTrend(grid) {
4939
+ const now = this.nowProvider();
4940
+ const ONE_HOUR = 60 * 60 * 1e3;
4941
+ if (now - grid.lastTrendRefreshAt < ONE_HOUR) return;
4942
+ const candles = await this.candleFetcher(grid.token, "4h", 14 * 4 * 60 * 60 * 1e3);
4943
+ if (!candles || candles.length < 2) return;
4944
+ const TREND_LOOKBACK_BARS = 14;
4945
+ const sliceStart = Math.max(0, candles.length - TREND_LOOKBACK_BARS);
4946
+ const first = candles[sliceStart].close;
4947
+ const last = candles[candles.length - 1].close;
4948
+ if (first <= 0) return;
4949
+ grid.trend = (last - first) / first;
4950
+ grid.lastTrendRefreshAt = now;
4951
+ }
4903
4952
  /** Build a fresh grid centered on the current price using ATR. */
4904
4953
  async buildGrid(grid, currentPrice) {
4905
- const candles = await this.hl.getCandles(grid.token, "4h", 14 * 24 * 60 * 60 * 1e3);
4954
+ const candles = await this.candleFetcher(grid.token, "4h", 14 * 24 * 60 * 60 * 1e3);
4906
4955
  if (!candles || candles.length < this.config.atrPeriod) {
4907
4956
  console.error(chalk9.dim(` [grid] Cannot build grid for ${grid.token}: insufficient candle data`));
4908
4957
  return;
@@ -4913,12 +4962,24 @@ var GridManager = class {
4913
4962
  console.error(chalk9.dim(` [grid] Cannot build grid for ${grid.token}: invalid ATR (${atr})`));
4914
4963
  return;
4915
4964
  }
4965
+ const TREND_LOOKBACK_BARS = 14;
4966
+ let trend = 0;
4967
+ if (candles.length >= 2) {
4968
+ const sliceStart = Math.max(0, candles.length - TREND_LOOKBACK_BARS);
4969
+ const first = candles[sliceStart].close;
4970
+ const last = candles[candles.length - 1].close;
4971
+ if (first > 0) {
4972
+ trend = (last - first) / first;
4973
+ }
4974
+ }
4916
4975
  const range = atr * this.config.atrMultiplier;
4917
4976
  const spacing = range / this.config.levelsPerSide;
4918
4977
  const effectiveCapital = grid.allocation * this.config.leverage;
4919
4978
  const quantity = effectiveCapital / (this.config.levelsPerSide * currentPrice);
4920
4979
  const levels = [];
4921
- for (let i = 1; i <= this.config.levelsPerSide; i++) {
4980
+ const inDowntrend = this.config.downtrendBlockPct > 0 && trend < -this.config.downtrendBlockPct;
4981
+ const buyCount = inDowntrend ? 1 : this.config.levelsPerSide;
4982
+ for (let i = 1; i <= buyCount; i++) {
4922
4983
  levels.push({
4923
4984
  price: currentPrice - spacing * i,
4924
4985
  side: "buy",
@@ -4939,9 +5000,10 @@ var GridManager = class {
4939
5000
  grid.levels = levels;
4940
5001
  grid.centerPrice = currentPrice;
4941
5002
  grid.atr = atr;
5003
+ grid.trend = trend;
4942
5004
  grid.stats.lastRebalanceAt = Date.now();
4943
5005
  console.error(chalk9.dim(
4944
- ` [grid] Built ${grid.token} grid: center=$${currentPrice.toFixed(2)} ATR=$${atr.toFixed(2)} range=$${(currentPrice - range).toFixed(2)}-$${(currentPrice + range).toFixed(2)} spacing=$${spacing.toFixed(2)} qty=${quantity.toFixed(6)}`
5006
+ ` [grid] Built ${grid.token} grid: center=$${currentPrice.toFixed(2)} ATR=$${atr.toFixed(2)} range=$${(currentPrice - range).toFixed(2)}-$${(currentPrice + range).toFixed(2)} spacing=$${spacing.toFixed(2)} qty=${quantity.toFixed(6)} trend=${(trend * 100).toFixed(1)}%${inDowntrend ? " [DOWNTREND: 1 buy / " + this.config.levelsPerSide + " sells]" : ""}`
4945
5007
  ));
4946
5008
  }
4947
5009
  /** Simulate order fills against the current price. */
@@ -4952,9 +5014,19 @@ var GridManager = class {
4952
5014
  const now = Date.now();
4953
5015
  const spacing = grid.atr > 0 ? grid.atr * this.config.atrMultiplier / this.config.levelsPerSide : 0;
4954
5016
  if (spacing <= 0) return { fills: 0, roundTrips: 0, pnlUsd: 0 };
5017
+ const exposureCap = grid.allocation * this.config.leverage * this.config.maxOpenNotionalMultiple;
5018
+ const currentOpenNotional = grid.openFills.filter((f) => !f.closed).reduce((s, f) => s + f.quantity * f.buyPrice, 0);
5019
+ const buyExposureFull = currentOpenNotional >= exposureCap;
5020
+ const inDowntrend = this.config.downtrendBlockPct > 0 && grid.trend < -this.config.downtrendBlockPct;
4955
5021
  for (const level of grid.levels) {
4956
5022
  if (level.filled) continue;
4957
- if (level.side === "buy" && currentPrice <= level.price) {
5023
+ if (level.side === "buy" && this.fillDetector(level, currentPrice)) {
5024
+ if (buyExposureFull) {
5025
+ continue;
5026
+ }
5027
+ if (inDowntrend) {
5028
+ continue;
5029
+ }
4958
5030
  level.filled = true;
4959
5031
  level.filledAt = now;
4960
5032
  fills++;
@@ -4974,7 +5046,7 @@ var GridManager = class {
4974
5046
  ` [grid] BUY fill ${grid.token} @ $${level.price.toFixed(2)} qty=${level.quantity.toFixed(6)}`
4975
5047
  ));
4976
5048
  }
4977
- if (level.side === "sell" && currentPrice >= level.price) {
5049
+ if (level.side === "sell" && this.fillDetector(level, currentPrice)) {
4978
5050
  level.filled = true;
4979
5051
  level.filledAt = now;
4980
5052
  fills++;
@@ -4987,8 +5059,8 @@ var GridManager = class {
4987
5059
  }
4988
5060
  for (const openFill of grid.openFills) {
4989
5061
  if (openFill.closed) continue;
4990
- if (currentPrice >= openFill.targetSellPrice) {
4991
- const profit = (currentPrice - openFill.buyPrice) * openFill.quantity * this.config.leverage;
5062
+ if (this.closeFillDetector(openFill, currentPrice)) {
5063
+ const profit = (currentPrice - openFill.buyPrice) * openFill.quantity;
4992
5064
  if (profit >= this.config.minProfitPerFillUsd) {
4993
5065
  openFill.closed = true;
4994
5066
  openFill.pnlUsd = profit;
@@ -5005,6 +5077,25 @@ var GridManager = class {
5005
5077
  }
5006
5078
  }
5007
5079
  }
5080
+ if (this.config.stopLossPct > 0) {
5081
+ for (const openFill of grid.openFills) {
5082
+ if (openFill.closed) continue;
5083
+ const stopPrice = openFill.buyPrice * (1 - this.config.stopLossPct);
5084
+ if (currentPrice <= stopPrice) {
5085
+ const realizedLoss = (currentPrice - openFill.buyPrice) * openFill.quantity;
5086
+ openFill.closed = true;
5087
+ openFill.pnlUsd = realizedLoss;
5088
+ openFill.closedAt = now;
5089
+ pnlUsd += realizedLoss;
5090
+ grid.stats.totalPnlUsd += realizedLoss;
5091
+ grid.stats.todayPnlUsd += realizedLoss;
5092
+ grid.allocation += realizedLoss;
5093
+ console.error(chalk9.red(
5094
+ ` [grid] STOP-LOSS ${grid.token}: buy $${openFill.buyPrice.toFixed(2)} \u2192 close $${currentPrice.toFixed(2)} = $${realizedLoss.toFixed(2)}`
5095
+ ));
5096
+ }
5097
+ }
5098
+ }
5008
5099
  const pruneThreshold = now - 24 * 60 * 60 * 1e3;
5009
5100
  grid.openFills = grid.openFills.filter((f) => !f.closed || f.closedAt > pruneThreshold);
5010
5101
  return { fills, roundTrips, pnlUsd };
@@ -5114,7 +5205,15 @@ var DEFAULT_GRID_CONFIG = {
5114
5205
  // 12h
5115
5206
  tokenSplit: { bitcoin: 0.45, ethereum: 0.3, solana: 0.25 },
5116
5207
  minProfitPerFillUsd: 0.5,
5117
- pauseThresholdPct: 0.2
5208
+ pauseThresholdPct: 0.2,
5209
+ // post leverage-fix calibration: fires at ~4% adverse move on 5x (real dollar terms); old 0.40 required ~8% which essentially never fired before liquidation
5210
+ unpauseRecoveryPct: 0.05,
5211
+ // hysteresis: resume when pool recovers to within 5% of initial (half of pause threshold to avoid ping-pong)
5212
+ maintenanceMarginPct: 0.02,
5213
+ maxOpenNotionalMultiple: 2,
5214
+ downtrendBlockPct: 0.1,
5215
+ stopLossPct: 0.1
5216
+ // empirical Nov-May: 10% saves the strategy (-4%); 30% lets liquidations through (-37%)
5118
5217
  };
5119
5218
 
5120
5219
  // src/grid/hedge.ts
@@ -5128,7 +5227,7 @@ var ADJUSTMENT_THRESHOLD = 0.1;
5128
5227
  var HEDGE_STATE_PATH = join2(homedir2(), ".sherwood", "grid", "hedge.json");
5129
5228
  var GridHedgeManager = class {
5130
5229
  state = null;
5131
- async load() {
5230
+ async load(now = Date.now()) {
5132
5231
  if (this.state) return this.state;
5133
5232
  try {
5134
5233
  const raw = await readFile2(HEDGE_STATE_PATH, "utf-8");
@@ -5138,14 +5237,14 @@ var GridHedgeManager = class {
5138
5237
  positions: [],
5139
5238
  totalRealizedPnl: 0,
5140
5239
  todayRealizedPnl: 0,
5141
- lastDailyReset: Date.now()
5240
+ lastDailyReset: now
5142
5241
  };
5143
5242
  }
5144
- const todayMidnight = /* @__PURE__ */ new Date();
5243
+ const todayMidnight = new Date(now);
5145
5244
  todayMidnight.setUTCHours(0, 0, 0, 0);
5146
5245
  if (this.state.lastDailyReset < todayMidnight.getTime()) {
5147
5246
  this.state.todayRealizedPnl = 0;
5148
- this.state.lastDailyReset = Date.now();
5247
+ this.state.lastDailyReset = now;
5149
5248
  }
5150
5249
  return this.state;
5151
5250
  }
@@ -5163,8 +5262,8 @@ var GridHedgeManager = class {
5163
5262
  * @param openFills - Per-token open fill exposure from the grid portfolio
5164
5263
  * @param prices - Current mark prices per token
5165
5264
  */
5166
- async tick(openFills, prices) {
5167
- const state = await this.load();
5265
+ async tick(openFills, prices, now = Date.now()) {
5266
+ const state = await this.load(now);
5168
5267
  let adjustments = 0;
5169
5268
  let unrealizedPnl = 0;
5170
5269
  for (const fill of openFills) {
@@ -5357,6 +5456,13 @@ var HYPERLIQUID_GRID_STRATEGY_ABI = [
5357
5456
  inputs: [{ name: "data", type: "bytes" }],
5358
5457
  outputs: [],
5359
5458
  stateMutability: "nonpayable"
5459
+ },
5460
+ {
5461
+ type: "function",
5462
+ name: "maxOrdersPerTick",
5463
+ inputs: [],
5464
+ outputs: [{ type: "uint32" }],
5465
+ stateMutability: "view"
5360
5466
  }
5361
5467
  ];
5362
5468
 
@@ -5366,6 +5472,13 @@ var UINT64_MAX = 18446744073709551615n;
5366
5472
  var ACTION_PLACE_GRID = 1;
5367
5473
  var ACTION_CANCEL_ALL = 2;
5368
5474
  var ACTION_CANCEL_AND_PLACE = 3;
5475
+ function chunkArray(arr, size2) {
5476
+ if (size2 <= 0) throw new Error(`chunk size must be > 0 (got ${size2})`);
5477
+ if (arr.length === 0) return [];
5478
+ const out = [];
5479
+ for (let i = 0; i < arr.length; i += size2) out.push(arr.slice(i, i + size2));
5480
+ return out;
5481
+ }
5369
5482
  var GRID_ORDER_COMPONENTS = [
5370
5483
  { name: "assetIndex", type: "uint32" },
5371
5484
  { name: "isBuy", type: "bool" },
@@ -5379,9 +5492,24 @@ var OnchainGridExecutor = class {
5379
5492
  placedCloids = /* @__PURE__ */ new Map();
5380
5493
  meta = null;
5381
5494
  busy = false;
5495
+ /** On-chain `maxOrdersPerTick`; cached at load(). Used to chunk batches so
5496
+ * the strategy never reverts with TooManyOrders(actual, max). */
5497
+ maxOrdersPerTick = null;
5382
5498
  constructor(cfg) {
5383
5499
  this.cfg = cfg;
5384
5500
  }
5501
+ /** Read the strategy's per-call order cap and cache it. */
5502
+ async fetchMaxOrdersPerTick() {
5503
+ if (this.maxOrdersPerTick !== null) return this.maxOrdersPerTick;
5504
+ const pub = getPublicClient();
5505
+ const value = await pub.readContract({
5506
+ address: this.cfg.strategyAddress,
5507
+ abi: HYPERLIQUID_GRID_STRATEGY_ABI,
5508
+ functionName: "maxOrdersPerTick"
5509
+ });
5510
+ this.maxOrdersPerTick = Number(value);
5511
+ return this.maxOrdersPerTick;
5512
+ }
5385
5513
  /** Load persisted state. Call once before the first execute(). */
5386
5514
  async load() {
5387
5515
  try {
@@ -5404,6 +5532,7 @@ var OnchainGridExecutor = class {
5404
5532
  }
5405
5533
  }
5406
5534
  if (!this.meta) this.meta = await hlGetMeta();
5535
+ await this.fetchMaxOrdersPerTick();
5407
5536
  }
5408
5537
  /**
5409
5538
  * Atomic save: write to .tmp, then POSIX-rename. A crash mid-write leaves
@@ -5469,8 +5598,8 @@ var OnchainGridExecutor = class {
5469
5598
  const wantPlace = orders.length > 0;
5470
5599
  try {
5471
5600
  if (wantCancel && wantPlace) {
5472
- const tx = await this.cancelAndPlace(token, assetIndex, orders, meta);
5473
- txs.push(tx);
5601
+ const txArr = await this.cancelAndPlace(token, assetIndex, orders, meta);
5602
+ txs.push(...txArr);
5474
5603
  cancelled += 1;
5475
5604
  placed += orders.length;
5476
5605
  } else if (wantCancel) {
@@ -5478,8 +5607,8 @@ var OnchainGridExecutor = class {
5478
5607
  txs.push(tx);
5479
5608
  cancelled += 1;
5480
5609
  } else if (wantPlace) {
5481
- const tx = await this.placeGrid(token, assetIndex, orders, meta);
5482
- txs.push(tx);
5610
+ const txArr = await this.placeGrid(token, assetIndex, orders, meta);
5611
+ txs.push(...txArr);
5483
5612
  placed += orders.length;
5484
5613
  }
5485
5614
  await this.save();
@@ -5523,16 +5652,31 @@ var OnchainGridExecutor = class {
5523
5652
  });
5524
5653
  return { encoded, cloids };
5525
5654
  }
5655
+ /**
5656
+ * Place orders, chunking into batches of ≤ maxOrdersPerTick per tx.
5657
+ * Each chunk uses its own nonce so CLOIDs stay unique across chunks.
5658
+ * placedCloids is committed once after the last chunk lands; if any
5659
+ * chunk reverts, the partial cloids stay tracked via the per-chunk save.
5660
+ */
5526
5661
  async placeGrid(token, assetIndex, orders, meta) {
5527
- const nonce = this.bumpNonce(token);
5528
- const { encoded, cloids } = this.encodeOrders(assetIndex, orders, meta, nonce);
5529
- const data = encodeAbiParameters11(
5530
- [{ type: "uint8" }, { type: "tuple[]", components: [...GRID_ORDER_COMPONENTS] }],
5531
- [ACTION_PLACE_GRID, encoded]
5532
- );
5533
- const tx = await this.send(data);
5534
- this.placedCloids.set(token, cloids);
5535
- return tx;
5662
+ const max = await this.fetchMaxOrdersPerTick();
5663
+ const chunks = chunkArray(orders, max);
5664
+ const txs = [];
5665
+ const allCloids = [...this.placedCloids.get(token) ?? []];
5666
+ for (const chunk of chunks) {
5667
+ const nonce = this.bumpNonce(token);
5668
+ const { encoded, cloids } = this.encodeOrders(assetIndex, chunk, meta, nonce);
5669
+ const data = encodeAbiParameters11(
5670
+ [{ type: "uint8" }, { type: "tuple[]", components: [...GRID_ORDER_COMPONENTS] }],
5671
+ [ACTION_PLACE_GRID, encoded]
5672
+ );
5673
+ const tx = await this.send(data);
5674
+ txs.push(tx);
5675
+ allCloids.push(...cloids);
5676
+ this.placedCloids.set(token, allCloids);
5677
+ await this.save();
5678
+ }
5679
+ return txs;
5536
5680
  }
5537
5681
  async cancelAll(token, assetIndex) {
5538
5682
  const cloids = this.placedCloids.get(token) ?? [];
@@ -5549,22 +5693,48 @@ var OnchainGridExecutor = class {
5549
5693
  this.bumpNonce(token);
5550
5694
  return tx;
5551
5695
  }
5696
+ /**
5697
+ * Atomic cancel-old + place-new on rebalance. When `orders` exceeds
5698
+ * maxOrdersPerTick, the first chunk uses ACTION_CANCEL_AND_PLACE (so the
5699
+ * old orders are cleared in the same tx as the first wave of new orders);
5700
+ * subsequent chunks use ACTION_PLACE_GRID. Atomicity holds for chunk #1;
5701
+ * after that there's a brief window where some new orders are live before
5702
+ * the rest are placed — acceptable for a 60s rebuild cycle.
5703
+ */
5552
5704
  async cancelAndPlace(token, assetIndex, orders, meta) {
5705
+ const max = await this.fetchMaxOrdersPerTick();
5706
+ const chunks = chunkArray(orders, max);
5553
5707
  const oldCloids = this.placedCloids.get(token) ?? [];
5554
- const newNonce = this.bumpNonce(token);
5555
- const { encoded, cloids: newCloids } = this.encodeOrders(assetIndex, orders, meta, newNonce);
5556
- const data = encodeAbiParameters11(
5557
- [
5558
- { type: "uint8" },
5559
- { type: "uint32" },
5560
- { type: "uint128[]" },
5561
- { type: "tuple[]", components: [...GRID_ORDER_COMPONENTS] }
5562
- ],
5563
- [ACTION_CANCEL_AND_PLACE, assetIndex, oldCloids, encoded]
5564
- );
5565
- const tx = await this.send(data);
5566
- this.placedCloids.set(token, newCloids);
5567
- return tx;
5708
+ const txs = [];
5709
+ const allNewCloids = [];
5710
+ for (let i = 0; i < chunks.length; i++) {
5711
+ const chunk = chunks[i];
5712
+ const newNonce = this.bumpNonce(token);
5713
+ const { encoded, cloids } = this.encodeOrders(assetIndex, chunk, meta, newNonce);
5714
+ let data;
5715
+ if (i === 0) {
5716
+ data = encodeAbiParameters11(
5717
+ [
5718
+ { type: "uint8" },
5719
+ { type: "uint32" },
5720
+ { type: "uint128[]" },
5721
+ { type: "tuple[]", components: [...GRID_ORDER_COMPONENTS] }
5722
+ ],
5723
+ [ACTION_CANCEL_AND_PLACE, assetIndex, oldCloids, encoded]
5724
+ );
5725
+ } else {
5726
+ data = encodeAbiParameters11(
5727
+ [{ type: "uint8" }, { type: "tuple[]", components: [...GRID_ORDER_COMPONENTS] }],
5728
+ [ACTION_PLACE_GRID, encoded]
5729
+ );
5730
+ }
5731
+ const tx = await this.send(data);
5732
+ txs.push(tx);
5733
+ allNewCloids.push(...cloids);
5734
+ this.placedCloids.set(token, [...allNewCloids]);
5735
+ await this.save();
5736
+ }
5737
+ return txs;
5568
5738
  }
5569
5739
  /**
5570
5740
  * Submit and wait for receipt. Throwing here surfaces to the per-token
@@ -5737,15 +5907,862 @@ var GridLoop = class {
5737
5907
  }
5738
5908
  };
5739
5909
 
5910
+ // src/grid/backtest.ts
5911
+ import { writeFile as writeFile5, mkdir as mkdir6 } from "fs/promises";
5912
+ import { join as join6, dirname as dirname4 } from "path";
5913
+ import { homedir as homedir6 } from "os";
5914
+ import { createHash } from "crypto";
5915
+
5916
+ // src/grid/backtest-portfolio.ts
5917
+ var BacktestPortfolio = class extends GridPortfolio {
5918
+ nowProvider;
5919
+ inMemoryState = null;
5920
+ constructor(nowProvider) {
5921
+ super();
5922
+ this.nowProvider = nowProvider;
5923
+ }
5924
+ /** Override: never read from disk. Returns the in-memory state. */
5925
+ async load() {
5926
+ return this.inMemoryState;
5927
+ }
5928
+ /** Override: never write to disk. Stores the state in memory. */
5929
+ async save(state) {
5930
+ this.inMemoryState = state;
5931
+ }
5932
+ /** Override: read in-memory state instead of parent's private field. */
5933
+ getState() {
5934
+ return this.inMemoryState;
5935
+ }
5936
+ /** Override: use the injected clock instead of Date.now(). */
5937
+ resetDailyStats(state) {
5938
+ return super.resetDailyStats(state, this.nowProvider());
5939
+ }
5940
+ };
5941
+
5942
+ // src/grid/historical-data-loader.ts
5943
+ import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir5 } from "fs/promises";
5944
+ import { join as join5 } from "path";
5945
+ import { homedir as homedir5 } from "os";
5946
+ var BINANCE_BASE = "https://api.binance.com/api/v3/klines";
5947
+ var DEFAULT_CACHE_DIR = join5(homedir5(), ".sherwood", "grid", "backtest-cache");
5948
+ var FOUR_HOURS_MS = 4 * 60 * 60 * 1e3;
5949
+ var ONE_MIN_MS = 60 * 1e3;
5950
+ var ATR_PERIOD = 14;
5951
+ var ATR_WARMUP_MS = ATR_PERIOD * FOUR_HOURS_MS;
5952
+ var MAX_BARS_PER_REQUEST = 1e3;
5953
+ var COIN_TO_BINANCE_SYMBOL = {
5954
+ BTC: "BTCUSDT",
5955
+ ETH: "ETHUSDT",
5956
+ SOL: "SOLUSDT",
5957
+ ARB: "ARBUSDT",
5958
+ LINK: "LINKUSDT",
5959
+ AAVE: "AAVEUSDT",
5960
+ UNI: "UNIUSDT",
5961
+ DOGE: "DOGEUSDT"
5962
+ };
5963
+ var HistoricalDataLoader = class {
5964
+ cacheDir;
5965
+ noCache;
5966
+ fetchImpl;
5967
+ constructor(opts = {}) {
5968
+ this.cacheDir = opts.cacheDir ?? DEFAULT_CACHE_DIR;
5969
+ this.noCache = opts.noCache ?? false;
5970
+ this.fetchImpl = opts.fetchImpl ?? fetch;
5971
+ }
5972
+ /**
5973
+ * Load 1-minute bars and an aligned ATR(14) series for `coinTokenId`
5974
+ * (CoinGecko ID; resolved internally to HL coin symbol).
5975
+ *
5976
+ * The 4-hour series is fetched with ATR_WARMUP_MS extra prefix so ATR
5977
+ * is well-defined at fromMs.
5978
+ */
5979
+ async load(coinTokenId, fromMs, toMs) {
5980
+ const coin = TOKEN_TO_COIN[coinTokenId];
5981
+ if (!coin) throw new Error(`Unknown token: ${coinTokenId}`);
5982
+ const minutes = await this.loadInterval(coin, "1m", fromMs, toMs);
5983
+ if (minutes.length === 0) {
5984
+ throw new Error(`no data for ${coinTokenId} in window \u2014 check symbol mapping or date range`);
5985
+ }
5986
+ const fourHour = await this.loadInterval(coin, "4h", fromMs - ATR_WARMUP_MS, toMs);
5987
+ const atrSeries = computeAtrSeries(fourHour);
5988
+ return { minutes, fourHour, atrSeries };
5989
+ }
5990
+ /** Fetch + cache one interval. Public for unit testing. */
5991
+ async loadInterval(coin, interval, fromMs, toMs) {
5992
+ if (!this.noCache) {
5993
+ const cached = await this.tryReadCache(coin, interval, fromMs, toMs);
5994
+ if (cached) return cached;
5995
+ }
5996
+ const fresh = await this.fetchPaginated(coin, interval, fromMs, toMs);
5997
+ if (!this.noCache && fresh.length > 0) {
5998
+ await this.writeCache(coin, interval, fromMs, toMs, fresh);
5999
+ }
6000
+ return fresh;
6001
+ }
6002
+ async tryReadCache(coin, interval, fromMs, toMs) {
6003
+ const path = this.cachePath(coin, interval, fromMs, toMs);
6004
+ try {
6005
+ const raw = await readFile4(path, "utf-8");
6006
+ const parsed = JSON.parse(raw);
6007
+ if (!Array.isArray(parsed.bars)) return null;
6008
+ return parsed.bars;
6009
+ } catch {
6010
+ return null;
6011
+ }
6012
+ }
6013
+ async writeCache(coin, interval, fromMs, toMs, bars) {
6014
+ await mkdir5(this.cacheDir, { recursive: true });
6015
+ const path = this.cachePath(coin, interval, fromMs, toMs);
6016
+ const body = JSON.stringify({
6017
+ coin,
6018
+ interval,
6019
+ fetchedAt: Date.now(),
6020
+ bars
6021
+ });
6022
+ await writeFile4(path, body, "utf-8");
6023
+ }
6024
+ cachePath(coin, interval, fromMs, toMs) {
6025
+ return join5(this.cacheDir, `${coin}-${interval}-${fromMs}-${toMs}.json`);
6026
+ }
6027
+ /** Paginate Binance klines until we cover [fromMs, toMs]. Dedup on merge. */
6028
+ async fetchPaginated(coin, interval, fromMs, toMs) {
6029
+ const intervalMs = interval === "1m" ? ONE_MIN_MS : FOUR_HOURS_MS;
6030
+ const pageSpanMs = MAX_BARS_PER_REQUEST * intervalMs;
6031
+ const seen = /* @__PURE__ */ new Set();
6032
+ const all = [];
6033
+ let cursor = fromMs;
6034
+ while (cursor < toMs) {
6035
+ const pageEnd = Math.min(cursor + pageSpanMs, toMs);
6036
+ const page = await this.fetchPageWithRetry(coin, interval, cursor, pageEnd);
6037
+ if (page.length === 0) break;
6038
+ for (const bar of page) {
6039
+ if (!seen.has(bar.t)) {
6040
+ seen.add(bar.t);
6041
+ all.push(bar);
6042
+ }
6043
+ }
6044
+ const last = page[page.length - 1];
6045
+ const next = last.t + intervalMs;
6046
+ if (next <= cursor) break;
6047
+ cursor = next;
6048
+ }
6049
+ all.sort((a, b) => a.t - b.t);
6050
+ return all;
6051
+ }
6052
+ async fetchPageWithRetry(coin, interval, startTime, endTime) {
6053
+ const delays = [1e3, 2e3, 4e3];
6054
+ let lastErr = null;
6055
+ for (let attempt = 0; attempt < delays.length + 1; attempt++) {
6056
+ try {
6057
+ return await this.fetchPage(coin, interval, startTime, endTime);
6058
+ } catch (err) {
6059
+ lastErr = err;
6060
+ if (attempt < delays.length) {
6061
+ await sleep(delays[attempt]);
6062
+ }
6063
+ }
6064
+ }
6065
+ throw lastErr ?? new Error("fetchPage failed");
6066
+ }
6067
+ async fetchPage(coin, interval, startTime, endTime) {
6068
+ const symbol = COIN_TO_BINANCE_SYMBOL[coin];
6069
+ if (!symbol) throw new Error(`No Binance symbol for coin: ${coin}`);
6070
+ const url = `${BINANCE_BASE}?symbol=${symbol}&interval=${interval}&startTime=${startTime}&endTime=${endTime}&limit=${MAX_BARS_PER_REQUEST}`;
6071
+ const res = await this.fetchImpl(url);
6072
+ if (!res.ok) throw new Error(`Binance ${res.status}: ${await res.text()}`);
6073
+ const raw = await res.json();
6074
+ if (!Array.isArray(raw)) return [];
6075
+ return raw.map((c) => ({
6076
+ t: c[0],
6077
+ o: Number(c[1]),
6078
+ h: Number(c[2]),
6079
+ l: Number(c[3]),
6080
+ c: Number(c[4]),
6081
+ v: Number(c[5])
6082
+ })).filter(
6083
+ (b) => Number.isFinite(b.o) && Number.isFinite(b.h) && Number.isFinite(b.l) && Number.isFinite(b.c)
6084
+ );
6085
+ }
6086
+ };
6087
+ function computeAtrSeries(fourHourBars) {
6088
+ if (fourHourBars.length < ATR_PERIOD) return [];
6089
+ const candles = fourHourBars.map((b) => ({
6090
+ timestamp: b.t,
6091
+ open: b.o,
6092
+ high: b.h,
6093
+ low: b.l,
6094
+ close: b.c,
6095
+ volume: b.v
6096
+ }));
6097
+ const atrArr = calculateATR(candles, ATR_PERIOD);
6098
+ const out = [];
6099
+ for (let i = 0; i < atrArr.length; i++) {
6100
+ if (Number.isFinite(atrArr[i])) {
6101
+ out.push({ ts: fourHourBars[i].t, atr: atrArr[i] });
6102
+ }
6103
+ }
6104
+ return out;
6105
+ }
6106
+ function sleep(ms) {
6107
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
6108
+ }
6109
+
6110
+ // src/grid/backtest-hedge.ts
6111
+ var BacktestHedgeManager = class extends GridHedgeManager {
6112
+ nowProvider;
6113
+ inMemoryState = null;
6114
+ constructor(nowProvider) {
6115
+ super();
6116
+ this.nowProvider = nowProvider;
6117
+ }
6118
+ async load(_now) {
6119
+ if (this.inMemoryState) {
6120
+ const now2 = this.nowProvider();
6121
+ const todayMidnight = new Date(now2);
6122
+ todayMidnight.setUTCHours(0, 0, 0, 0);
6123
+ if (this.inMemoryState.lastDailyReset < todayMidnight.getTime()) {
6124
+ this.inMemoryState.todayRealizedPnl = 0;
6125
+ this.inMemoryState.lastDailyReset = now2;
6126
+ }
6127
+ this.state = this.inMemoryState;
6128
+ return this.inMemoryState;
6129
+ }
6130
+ const now = this.nowProvider();
6131
+ this.inMemoryState = {
6132
+ positions: [],
6133
+ totalRealizedPnl: 0,
6134
+ todayRealizedPnl: 0,
6135
+ lastDailyReset: now
6136
+ };
6137
+ this.state = this.inMemoryState;
6138
+ return this.inMemoryState;
6139
+ }
6140
+ async save() {
6141
+ }
6142
+ };
6143
+
6144
+ // src/grid/backtest.ts
6145
+ var DEFAULT_OUT_DIR = join6(homedir6(), ".sherwood", "grid", "backtests");
6146
+ function shortHash(input2) {
6147
+ const h = createHash("sha256").update(JSON.stringify(input2)).digest("hex");
6148
+ return h.slice(0, 8);
6149
+ }
6150
+ function computeDrawdown(curve) {
6151
+ if (curve.length === 0) {
6152
+ return { maxUsd: 0, maxPct: 0, peakAt: 0, troughAt: 0 };
6153
+ }
6154
+ const equityAt = (p) => p.totalAllocation + p.totalPnl;
6155
+ let peak = equityAt(curve[0]);
6156
+ let peakAt = curve[0].t;
6157
+ let maxDrop = 0;
6158
+ let dropPeakAt = peakAt;
6159
+ let dropTroughAt = peakAt;
6160
+ for (const point of curve) {
6161
+ const eq = equityAt(point);
6162
+ if (eq > peak) {
6163
+ peak = eq;
6164
+ peakAt = point.t;
6165
+ }
6166
+ const drop = peak - eq;
6167
+ if (drop > maxDrop) {
6168
+ maxDrop = drop;
6169
+ dropPeakAt = peakAt;
6170
+ dropTroughAt = point.t;
6171
+ }
6172
+ }
6173
+ const peakPoint = curve.find((p) => p.t === dropPeakAt);
6174
+ const peakValue = peakPoint ? equityAt(peakPoint) : 0;
6175
+ return {
6176
+ maxUsd: maxDrop,
6177
+ maxPct: peakValue > 0 ? maxDrop / peakValue : 0,
6178
+ peakAt: dropPeakAt,
6179
+ troughAt: dropTroughAt
6180
+ };
6181
+ }
6182
+ function isoToYmdHms(ms) {
6183
+ const d = new Date(ms);
6184
+ const pad = (n) => n.toString().padStart(2, "0");
6185
+ return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
6186
+ }
6187
+ function makeRunId(fromMs, toMs, config) {
6188
+ return `bt-${isoToYmdHms(Date.now())}-${shortHash({ fromMs, toMs, config })}`;
6189
+ }
6190
+ async function runBacktest(opts) {
6191
+ const startedAt = Date.now();
6192
+ if (opts.fromMs >= opts.toMs) throw new Error("--from must be before --to");
6193
+ const FIFTY_SIX_HOURS = 56 * 60 * 60 * 1e3;
6194
+ if (opts.toMs - opts.fromMs < FIFTY_SIX_HOURS) {
6195
+ throw new Error("window too short \u2014 need \u2265 56h for ATR(14) warmup");
6196
+ }
6197
+ const splitSum = Object.values(opts.config.tokenSplit).reduce((a, b) => a + b, 0);
6198
+ if (Math.abs(splitSum - 1) > 1e-6) {
6199
+ throw new Error(`tokenSplit must sum to 1.0, got ${splitSum.toFixed(6)}`);
6200
+ }
6201
+ const loader = opts.loader ?? new HistoricalDataLoader({ noCache: opts.noCache });
6202
+ const snapshotEvery = opts.snapshotEveryMinutes ?? 60;
6203
+ const seriesByToken = {};
6204
+ await Promise.all(opts.config.tokens.map(async (token) => {
6205
+ seriesByToken[token] = await loader.load(token, opts.fromMs, opts.toMs);
6206
+ }));
6207
+ const minuteSets = opts.config.tokens.map((t) => new Set(seriesByToken[t].minutes.map((b) => b.t)));
6208
+ const masterMinutes = seriesByToken[opts.config.tokens[0]].minutes;
6209
+ const sharedTimestamps = [];
6210
+ let skippedSteps = 0;
6211
+ for (const bar of masterMinutes) {
6212
+ if (bar.t < opts.fromMs || bar.t >= opts.toMs) continue;
6213
+ if (minuteSets.every((s) => s.has(bar.t))) {
6214
+ sharedTimestamps.push(bar.t);
6215
+ } else {
6216
+ skippedSteps++;
6217
+ }
6218
+ }
6219
+ if (sharedTimestamps.length === 0) {
6220
+ throw new Error("no overlapping bars across requested tokens");
6221
+ }
6222
+ const barIndex = {};
6223
+ for (const token of opts.config.tokens) {
6224
+ const m = /* @__PURE__ */ new Map();
6225
+ for (const bar of seriesByToken[token].minutes) m.set(bar.t, bar);
6226
+ barIndex[token] = m;
6227
+ }
6228
+ let currentT = sharedTimestamps[0];
6229
+ const nowProvider = () => currentT;
6230
+ const candleFetcher = async (tokenId, interval, lookbackMs) => {
6231
+ if (interval !== "4h") return null;
6232
+ const series = seriesByToken[tokenId];
6233
+ if (!series) return null;
6234
+ const cutoff = currentT - lookbackMs;
6235
+ return series.fourHour.filter((b) => b.t <= currentT && b.t >= cutoff).map((b) => ({
6236
+ timestamp: b.t,
6237
+ open: b.o,
6238
+ high: b.h,
6239
+ low: b.l,
6240
+ close: b.c,
6241
+ volume: b.v
6242
+ }));
6243
+ };
6244
+ const portfolio = new BacktestPortfolio(nowProvider);
6245
+ const manager = new GridManager(opts.config, candleFetcher, void 0, void 0, portfolio, nowProvider);
6246
+ await manager.init(opts.capital);
6247
+ const initialAllocations = {};
6248
+ {
6249
+ const initState = portfolio.getState();
6250
+ if (initState) {
6251
+ for (const grid of initState.grids) {
6252
+ initialAllocations[grid.token] = grid.allocation;
6253
+ }
6254
+ }
6255
+ }
6256
+ const feeBps = opts.feeBps ?? 5;
6257
+ const notionalPerFillByToken = {};
6258
+ for (const token of opts.config.tokens) {
6259
+ const tokenInitial = initialAllocations[token] ?? 0;
6260
+ notionalPerFillByToken[token] = tokenInitial * opts.config.leverage / opts.config.levelsPerSide;
6261
+ }
6262
+ const feesByToken = {};
6263
+ for (const token of opts.config.tokens) feesByToken[token] = 0;
6264
+ const lastSeenFills = {};
6265
+ for (const token of opts.config.tokens) lastSeenFills[token] = 0;
6266
+ const hedgeEnabled = opts.hedge ?? true;
6267
+ const hedge = new BacktestHedgeManager(nowProvider);
6268
+ let hedgeAdjustments = 0;
6269
+ let hedgeRealized = 0;
6270
+ let hedgeUnrealized = 0;
6271
+ let totalRebuilds = 0;
6272
+ const lastRebalanceAt = {};
6273
+ for (const t of opts.config.tokens) lastRebalanceAt[t] = 0;
6274
+ const liquidatedTokens = /* @__PURE__ */ new Set();
6275
+ const liquidationEvents = [];
6276
+ let haltedAt = null;
6277
+ const equityCurve = [];
6278
+ let pausedSteps = 0;
6279
+ const originalConsoleError = console.error;
6280
+ if (!opts.verbose) {
6281
+ console.error = () => {
6282
+ };
6283
+ }
6284
+ try {
6285
+ let cycleCount = 0;
6286
+ const totalSteps = sharedTimestamps.length;
6287
+ const decileSize = Math.max(1, Math.floor(totalSteps / 10));
6288
+ for (const t of sharedTimestamps) {
6289
+ currentT = t;
6290
+ const passAPrices = {};
6291
+ const passBPrices = {};
6292
+ for (const token of opts.config.tokens) {
6293
+ const bar = barIndex[token].get(t);
6294
+ const isGreen = bar.c >= bar.o;
6295
+ passAPrices[token] = isGreen ? bar.l : bar.h;
6296
+ passBPrices[token] = isGreen ? bar.h : bar.l;
6297
+ }
6298
+ const r1 = await manager.tick(passAPrices);
6299
+ const r2 = await manager.tick(passBPrices);
6300
+ const stateAfter = portfolio.getState();
6301
+ if (stateAfter) {
6302
+ for (const grid of stateAfter.grids) {
6303
+ if (grid.stats.lastRebalanceAt > lastRebalanceAt[grid.token]) {
6304
+ totalRebuilds++;
6305
+ lastRebalanceAt[grid.token] = grid.stats.lastRebalanceAt;
6306
+ }
6307
+ }
6308
+ }
6309
+ if (r1.paused || r2.paused) pausedSteps++;
6310
+ if (hedgeEnabled) {
6311
+ const hedgePrices = {};
6312
+ for (const token of opts.config.tokens) {
6313
+ const bar = barIndex[token].get(t);
6314
+ hedgePrices[token] = bar.c;
6315
+ }
6316
+ const exposure = manager.getOpenFillExposure();
6317
+ const hedgeResult = await hedge.tick(exposure, hedgePrices, t);
6318
+ hedgeAdjustments += hedgeResult.adjustments;
6319
+ hedgeRealized = hedgeResult.totalRealizedPnl;
6320
+ hedgeUnrealized = hedgeResult.unrealizedPnl;
6321
+ }
6322
+ if (stateAfter) {
6323
+ for (const grid of stateAfter.grids) {
6324
+ const newFills = grid.stats.totalFills - lastSeenFills[grid.token];
6325
+ if (newFills > 0) {
6326
+ feesByToken[grid.token] += newFills * (notionalPerFillByToken[grid.token] ?? 0) * feeBps / 1e4;
6327
+ lastSeenFills[grid.token] = grid.stats.totalFills;
6328
+ }
6329
+ }
6330
+ }
6331
+ const stateLiq = portfolio.getState();
6332
+ if (stateLiq && haltedAt === null) {
6333
+ const lev = opts.config.leverage;
6334
+ const maint = opts.config.maintenanceMarginPct;
6335
+ const liquidationCoefficient = 1 - lev * maint;
6336
+ for (const grid of stateLiq.grids) {
6337
+ if (liquidatedTokens.has(grid.token)) continue;
6338
+ const close = barIndex[grid.token]?.get(t)?.c;
6339
+ if (close === void 0) continue;
6340
+ let unrealized = 0;
6341
+ for (const f of grid.openFills) {
6342
+ if (f.closed) continue;
6343
+ unrealized += (close - f.buyPrice) * f.quantity;
6344
+ }
6345
+ const tokenAlloc = initialAllocations[grid.token] ?? 0;
6346
+ const threshold = -tokenAlloc * liquidationCoefficient;
6347
+ if (tokenAlloc > 0 && unrealized <= threshold) {
6348
+ grid.stats.totalPnlUsd += unrealized;
6349
+ grid.stats.todayPnlUsd += unrealized;
6350
+ grid.allocation += unrealized;
6351
+ for (const f of grid.openFills) {
6352
+ if (!f.closed) {
6353
+ f.closed = true;
6354
+ f.closedAt = t;
6355
+ f.pnlUsd = (close - f.buyPrice) * f.quantity;
6356
+ }
6357
+ }
6358
+ grid.levels = [];
6359
+ liquidatedTokens.add(grid.token);
6360
+ liquidationEvents.push({
6361
+ token: grid.token,
6362
+ timestamp: t,
6363
+ tokenAllocation: tokenAlloc,
6364
+ unrealizedPnlAtLiquidation: unrealized,
6365
+ thresholdUsd: threshold
6366
+ });
6367
+ process.stderr.write(
6368
+ ` [backtest] LIQUIDATED ${grid.token} at t=${new Date(t).toISOString()}: unrealized=$${unrealized.toFixed(0)} (threshold=$${threshold.toFixed(0)})
6369
+ `
6370
+ );
6371
+ }
6372
+ }
6373
+ if (liquidatedTokens.size === opts.config.tokens.length) {
6374
+ haltedAt = t;
6375
+ process.stderr.write(` [backtest] All tokens liquidated; halting run at t=${new Date(t).toISOString()}
6376
+ `);
6377
+ }
6378
+ }
6379
+ if (haltedAt !== null) {
6380
+ const sFinal = portfolio.getState();
6381
+ if (sFinal) {
6382
+ const aggFinal = portfolio.aggregateStats(sFinal);
6383
+ const ofcFinal = sFinal.grids.reduce((sum, g) => sum + g.openFills.filter((f) => !f.closed).length, 0);
6384
+ const runningFeesFinal = Object.values(feesByToken).reduce((a, b) => a + b, 0);
6385
+ equityCurve.push({
6386
+ t,
6387
+ totalAllocation: sFinal.totalAllocation,
6388
+ totalPnl: aggFinal.totalPnlUsd - runningFeesFinal + hedgeRealized + hedgeUnrealized,
6389
+ totalRoundTrips: aggFinal.totalRoundTrips,
6390
+ openFillCount: ofcFinal,
6391
+ paused: sFinal.paused
6392
+ });
6393
+ }
6394
+ break;
6395
+ }
6396
+ cycleCount++;
6397
+ if (cycleCount % snapshotEvery === 0 || cycleCount === totalSteps) {
6398
+ const s = portfolio.getState();
6399
+ if (s) {
6400
+ const agg2 = portfolio.aggregateStats(s);
6401
+ const openFillCount = s.grids.reduce(
6402
+ (sum, g) => sum + g.openFills.filter((f) => !f.closed).length,
6403
+ 0
6404
+ );
6405
+ let unrealizedPnl = 0;
6406
+ for (const grid of s.grids) {
6407
+ const close = barIndex[grid.token]?.get(t)?.c;
6408
+ if (close === void 0) continue;
6409
+ for (const f of grid.openFills) {
6410
+ if (f.closed) continue;
6411
+ unrealizedPnl += (close - f.buyPrice) * f.quantity;
6412
+ }
6413
+ }
6414
+ const runningFees = Object.values(feesByToken).reduce((a, b) => a + b, 0);
6415
+ equityCurve.push({
6416
+ t,
6417
+ totalAllocation: s.totalAllocation,
6418
+ totalPnl: agg2.totalPnlUsd + unrealizedPnl - runningFees + hedgeRealized + hedgeUnrealized,
6419
+ totalRoundTrips: agg2.totalRoundTrips,
6420
+ openFillCount,
6421
+ paused: s.paused
6422
+ });
6423
+ }
6424
+ }
6425
+ if (cycleCount % decileSize === 0) {
6426
+ if (opts.verbose) {
6427
+ originalConsoleError(` [backtest] ${Math.round(cycleCount / totalSteps * 100)}% step ${cycleCount}/${totalSteps}`);
6428
+ } else {
6429
+ process.stderr.write(` [backtest] ${Math.round(cycleCount / totalSteps * 100)}% step ${cycleCount}/${totalSteps}
6430
+ `);
6431
+ }
6432
+ }
6433
+ }
6434
+ } finally {
6435
+ console.error = originalConsoleError;
6436
+ }
6437
+ const finalState = portfolio.getState();
6438
+ const agg = portfolio.aggregateStats(finalState);
6439
+ const finishedAt = Date.now();
6440
+ const runId = makeRunId(opts.fromMs, opts.toMs, opts.config);
6441
+ const initialPerToken = {};
6442
+ for (const token of opts.config.tokens) {
6443
+ initialPerToken[token] = opts.capital * (opts.config.tokenSplit[token] ?? 0);
6444
+ }
6445
+ const totalFeesUsd = Object.values(feesByToken).reduce((a, b) => a + b, 0);
6446
+ const totalFills = finalState.grids.reduce((s, g) => s + g.stats.totalFills, 0);
6447
+ const avgFeePerFill = totalFills > 0 ? totalFeesUsd / totalFills : 0;
6448
+ const grossPnl = agg.totalPnlUsd;
6449
+ const netPnl = grossPnl - totalFeesUsd;
6450
+ const hedgeStatus = hedgeEnabled ? hedge.getStatus() : null;
6451
+ const hedgePnl = hedgeRealized + hedgeUnrealized;
6452
+ const finalNetPnl = netPnl + hedgePnl;
6453
+ const perToken = finalState.grids.map((g) => ({
6454
+ token: g.token,
6455
+ allocation: { initial: initialPerToken[g.token] ?? 0, final: g.allocation },
6456
+ roundTrips: g.stats.totalRoundTrips,
6457
+ fills: g.stats.totalFills,
6458
+ pnlUsd: g.stats.totalPnlUsd,
6459
+ rebuilds: 0
6460
+ }));
6461
+ const result = {
6462
+ runId,
6463
+ startedAt,
6464
+ finishedAt,
6465
+ durationMs: finishedAt - startedAt,
6466
+ window: {
6467
+ fromMs: opts.fromMs,
6468
+ toMs: opts.toMs,
6469
+ fromIso: new Date(opts.fromMs).toISOString(),
6470
+ toIso: new Date(opts.toMs).toISOString(),
6471
+ days: (opts.toMs - opts.fromMs) / (24 * 60 * 60 * 1e3)
6472
+ },
6473
+ config: opts.config,
6474
+ capital: {
6475
+ initialUsd: opts.capital,
6476
+ finalUsd: opts.capital + finalNetPnl,
6477
+ pnlUsd: finalNetPnl,
6478
+ pnlPct: finalNetPnl / opts.capital,
6479
+ grossPnlUsd: grossPnl
6480
+ },
6481
+ fees: {
6482
+ bps: feeBps,
6483
+ totalUsd: totalFeesUsd,
6484
+ perFill: avgFeePerFill
6485
+ },
6486
+ hedge: {
6487
+ enabled: hedgeEnabled,
6488
+ realizedPnlUsd: hedgeRealized,
6489
+ unrealizedPnlUsd: hedgeUnrealized,
6490
+ adjustments: hedgeAdjustments,
6491
+ finalOpenPositions: hedgeStatus ? hedgeStatus.positions.length : 0
6492
+ },
6493
+ liquidations: {
6494
+ events: liquidationEvents,
6495
+ haltedAt
6496
+ },
6497
+ totals: {
6498
+ roundTrips: agg.totalRoundTrips,
6499
+ fills: finalState.grids.reduce((s, g) => s + g.stats.totalFills, 0),
6500
+ rebuilds: totalRebuilds,
6501
+ pausedSteps,
6502
+ skippedSteps,
6503
+ totalSteps: sharedTimestamps.length
6504
+ },
6505
+ perToken,
6506
+ drawdown: computeDrawdown(equityCurve),
6507
+ equityCurve
6508
+ };
6509
+ if (opts.outPath !== "") {
6510
+ const outPath = opts.outPath ?? join6(DEFAULT_OUT_DIR, `${runId}.json`);
6511
+ await mkdir6(dirname4(outPath), { recursive: true });
6512
+ await writeFile5(outPath, JSON.stringify(result, null, 2), "utf-8");
6513
+ }
6514
+ return result;
6515
+ }
6516
+
6517
+ // src/grid/sweep.ts
6518
+ import { writeFile as writeFile6, mkdir as mkdir7 } from "fs/promises";
6519
+ import { join as join7 } from "path";
6520
+ import { homedir as homedir7 } from "os";
6521
+ import { createHash as createHash2 } from "crypto";
6522
+ var DEFAULT_SWEEP_DIR = join7(homedir7(), ".sherwood", "grid", "sweeps");
6523
+ function expandSweep(sweep, base2) {
6524
+ const fields = ["leverage", "levelsPerSide", "atrMultiplier", "rebalanceDriftPct"];
6525
+ const valueLists = {
6526
+ leverage: sweep.leverage && sweep.leverage.length > 0 ? sweep.leverage : [base2.leverage],
6527
+ levelsPerSide: sweep.levelsPerSide && sweep.levelsPerSide.length > 0 ? sweep.levelsPerSide : [base2.levelsPerSide],
6528
+ atrMultiplier: sweep.atrMultiplier && sweep.atrMultiplier.length > 0 ? sweep.atrMultiplier : [base2.atrMultiplier],
6529
+ rebalanceDriftPct: sweep.rebalanceDriftPct && sweep.rebalanceDriftPct.length > 0 ? sweep.rebalanceDriftPct : [base2.rebalanceDriftPct]
6530
+ };
6531
+ let combos = [{}];
6532
+ for (const field of fields) {
6533
+ const next = [];
6534
+ for (const combo of combos) {
6535
+ for (const value of valueLists[field]) {
6536
+ next.push({ ...combo, [field]: value });
6537
+ }
6538
+ }
6539
+ combos = next;
6540
+ }
6541
+ return combos;
6542
+ }
6543
+ function makeSweepId(opts) {
6544
+ const d = /* @__PURE__ */ new Date();
6545
+ const pad = (n) => n.toString().padStart(2, "0");
6546
+ const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
6547
+ const hash = createHash2("sha256").update(JSON.stringify({
6548
+ fromMs: opts.fromMs,
6549
+ toMs: opts.toMs,
6550
+ sweep: opts.sweep,
6551
+ tokens: opts.tokens
6552
+ })).digest("hex").slice(0, 8);
6553
+ return `sw-${stamp}-${hash}`;
6554
+ }
6555
+ async function runSweep(opts) {
6556
+ const startedAt = Date.now();
6557
+ const sweepId = makeSweepId(opts);
6558
+ const outDir = opts.outDir ?? DEFAULT_SWEEP_DIR;
6559
+ const sweepDir = join7(outDir, sweepId);
6560
+ let tokenSplit;
6561
+ if (opts.tokenSplit) {
6562
+ tokenSplit = opts.tokenSplit;
6563
+ } else {
6564
+ const weight = 1 / opts.tokens.length;
6565
+ tokenSplit = {};
6566
+ for (const t of opts.tokens) tokenSplit[t] = weight;
6567
+ }
6568
+ const base2 = {
6569
+ ...DEFAULT_GRID_CONFIG,
6570
+ ...opts.baseConfig,
6571
+ tokens: opts.tokens,
6572
+ tokenSplit
6573
+ };
6574
+ const combos = expandSweep(opts.sweep, base2);
6575
+ if (combos.length === 0) throw new Error("sweep produced no combinations");
6576
+ const loader = new HistoricalDataLoader({ noCache: opts.noCache });
6577
+ if (opts.verbose !== false) {
6578
+ process.stderr.write(` [sweep] ${sweepId}: running ${combos.length} backtests\u2026
6579
+ `);
6580
+ }
6581
+ await mkdir7(sweepDir, { recursive: true });
6582
+ const runs = [];
6583
+ for (let i = 0; i < combos.length; i++) {
6584
+ const combo = combos[i];
6585
+ const config = { ...base2, ...combo };
6586
+ const runOutPath = join7(sweepDir, `run-${i.toString().padStart(3, "0")}.json`);
6587
+ if (opts.verbose !== false) {
6588
+ process.stderr.write(` [sweep] [${i + 1}/${combos.length}] ${JSON.stringify(combo)}
6589
+ `);
6590
+ }
6591
+ const result = await runBacktest({
6592
+ fromMs: opts.fromMs,
6593
+ toMs: opts.toMs,
6594
+ capital: opts.capital,
6595
+ config,
6596
+ loader,
6597
+ noCache: opts.noCache,
6598
+ feeBps: opts.feeBps,
6599
+ outPath: runOutPath
6600
+ });
6601
+ const ddPct = Math.abs(result.drawdown.maxPct * 100);
6602
+ const pnlPct = result.capital.pnlPct * 100;
6603
+ const riskAdjusted = pnlPct / Math.max(ddPct, 1);
6604
+ const survived = result.liquidations.haltedAt === null;
6605
+ runs.push({
6606
+ index: i,
6607
+ config: combo,
6608
+ runId: result.runId,
6609
+ resultPath: runOutPath,
6610
+ capital: result.capital,
6611
+ fees: { bps: result.fees.bps, totalUsd: result.fees.totalUsd },
6612
+ totals: {
6613
+ roundTrips: result.totals.roundTrips,
6614
+ fills: result.totals.fills,
6615
+ pausedSteps: result.totals.pausedSteps
6616
+ },
6617
+ drawdown: result.drawdown,
6618
+ riskAdjusted,
6619
+ survived,
6620
+ durationMs: result.durationMs
6621
+ });
6622
+ }
6623
+ runs.sort((a, b) => {
6624
+ if (a.survived !== b.survived) return a.survived ? -1 : 1;
6625
+ return b.riskAdjusted - a.riskAdjusted;
6626
+ });
6627
+ const finishedAt = Date.now();
6628
+ const sweep = {
6629
+ sweepId,
6630
+ startedAt,
6631
+ finishedAt,
6632
+ durationMs: finishedAt - startedAt,
6633
+ window: {
6634
+ fromMs: opts.fromMs,
6635
+ toMs: opts.toMs,
6636
+ days: (opts.toMs - opts.fromMs) / (24 * 60 * 60 * 1e3)
6637
+ },
6638
+ tokens: opts.tokens,
6639
+ capital: opts.capital,
6640
+ feeBps: opts.feeBps ?? 5,
6641
+ runs
6642
+ };
6643
+ const sweepJsonPath = join7(sweepDir, "sweep.json");
6644
+ await writeFile6(sweepJsonPath, JSON.stringify(sweep, null, 2), "utf-8");
6645
+ return sweep;
6646
+ }
6647
+
5740
6648
  // src/commands/grid.ts
5741
6649
  var DIM5 = chalk14.gray;
5742
6650
  var G5 = chalk14.green;
5743
6651
  var BOLD5 = chalk14.white.bold;
5744
6652
  var W5 = chalk14.white;
5745
6653
  var SEP5 = () => console.log(DIM5("\u2500".repeat(60)));
6654
+ function printSummary(r) {
6655
+ const dd = r.drawdown;
6656
+ const days = r.window.days;
6657
+ const rtPerDay = (r.totals.roundTrips / Math.max(days, 1)).toFixed(1);
6658
+ const fillPerDay = (r.totals.fills / Math.max(days, 1)).toFixed(1);
6659
+ const pnlSign = r.capital.pnlUsd >= 0 ? "+" : "-";
6660
+ const pnlAbs = Math.abs(r.capital.pnlUsd).toFixed(2);
6661
+ const pnlPctStr = (r.capital.pnlPct * 100).toFixed(2);
6662
+ console.log();
6663
+ console.log(G5.bold(` Grid Backtest \u2014 ${r.runId}`));
6664
+ console.log(DIM5("\u2500".repeat(64)));
6665
+ console.log(W5(` Window: ${r.window.fromIso.slice(0, 10)} \u2192 ${r.window.toIso.slice(0, 10)} (${days.toFixed(0)} days)`));
6666
+ console.log(W5(` Capital: $${r.capital.initialUsd.toLocaleString()} \u2192 $${r.capital.finalUsd.toFixed(2)} (${pnlSign}$${pnlAbs}, ${pnlSign}${pnlPctStr}%)`));
6667
+ console.log(W5(` Gross PnL: $${r.capital.grossPnlUsd.toFixed(2)} (before fees)`));
6668
+ console.log(W5(` Fees (${r.fees.bps}bps): -$${r.fees.totalUsd.toFixed(2)} ($${r.fees.perFill.toFixed(4)}/fill)`));
6669
+ if (r.hedge.enabled) {
6670
+ const hedgeNet = r.hedge.realizedPnlUsd + r.hedge.unrealizedPnlUsd;
6671
+ const hedgeSign = hedgeNet >= 0 ? "+" : "-";
6672
+ const hedgeAbs = Math.abs(hedgeNet).toFixed(2);
6673
+ console.log(W5(` Hedge: ${hedgeSign}$${hedgeAbs} (realized $${r.hedge.realizedPnlUsd.toFixed(2)} + unrealized $${r.hedge.unrealizedPnlUsd.toFixed(2)}, ${r.hedge.adjustments} adj)`));
6674
+ } else {
6675
+ console.log(W5(` Hedge: disabled`));
6676
+ }
6677
+ console.log(W5(` Round trips: ${r.totals.roundTrips} (${rtPerDay}/day)`));
6678
+ console.log(W5(` Fills: ${r.totals.fills} (${fillPerDay}/day)`));
6679
+ console.log(W5(` Rebuilds: ${r.totals.rebuilds}`));
6680
+ console.log(W5(` Max drawdown: -$${dd.maxUsd.toFixed(2)} (-${(dd.maxPct * 100).toFixed(2)}%)`));
6681
+ console.log(W5(` Paused: ${r.totals.pausedSteps} steps (${(r.totals.pausedSteps / Math.max(r.totals.totalSteps, 1) * 100).toFixed(1)}%)`));
6682
+ console.log(W5(` Skipped: ${r.totals.skippedSteps} steps`));
6683
+ console.log();
6684
+ console.log(BOLD5(" Per token:"));
6685
+ for (const t of r.perToken) {
6686
+ const tokPnl = t.pnlUsd >= 0 ? G5(`+$${t.pnlUsd.toFixed(2)}`) : chalk14.red(`-$${Math.abs(t.pnlUsd).toFixed(2)}`);
6687
+ console.log(W5(` ${t.token.padEnd(10)} $${t.allocation.initial.toFixed(0).padStart(6)} \u2192 $${t.allocation.final.toFixed(0).padStart(6)} ${tokPnl} RTs=${t.roundTrips} fills=${t.fills}`));
6688
+ }
6689
+ if (r.liquidations.events.length > 0) {
6690
+ console.log();
6691
+ console.log(BOLD5(" Liquidations:"));
6692
+ for (const ev of r.liquidations.events) {
6693
+ const date = new Date(ev.timestamp).toISOString().slice(0, 10);
6694
+ console.log(W5(` ${ev.token.padEnd(10)} ${date} unrealized=$${ev.unrealizedPnlAtLiquidation.toFixed(0)} threshold=$${ev.thresholdUsd.toFixed(0)}`));
6695
+ }
6696
+ if (r.liquidations.haltedAt !== null) {
6697
+ const haltDate = new Date(r.liquidations.haltedAt).toISOString().slice(0, 10);
6698
+ console.log(chalk14.red.bold(` RUN HALTED on ${haltDate} \u2014 all tokens liquidated.`));
6699
+ }
6700
+ }
6701
+ console.log();
6702
+ console.log(W5(` Wall time: ${(r.durationMs / 1e3).toFixed(1)}s`));
6703
+ console.log(DIM5("\u2500".repeat(64)));
6704
+ }
6705
+ function printSweepSummary(s) {
6706
+ const days = s.window.days;
6707
+ console.log();
6708
+ console.log(G5.bold(` Grid Sweep \u2014 ${s.sweepId}`));
6709
+ console.log(DIM5("\u2500".repeat(96)));
6710
+ console.log(W5(` Window: ${new Date(s.window.fromMs).toISOString().slice(0, 10)} \u2192 ${new Date(s.window.toMs).toISOString().slice(0, 10)} (${days.toFixed(0)} days)`));
6711
+ console.log(W5(` Tokens: ${s.tokens.join(", ")}`));
6712
+ console.log(W5(` Capital: $${s.capital.toLocaleString()}`));
6713
+ console.log(W5(` Fees: ${s.feeBps} bps/fill`));
6714
+ console.log(W5(` Runs: ${s.runs.length}`));
6715
+ console.log(W5(` Wall: ${(s.durationMs / 1e3).toFixed(1)}s`));
6716
+ console.log(DIM5("\u2500".repeat(96)));
6717
+ console.log();
6718
+ console.log(BOLD5(" Rank Surv Lev Levels ATR\xD7 Drift RTs NetPnL$ NetPnL% DD% Risk-Adj"));
6719
+ console.log(DIM5(" " + "\u2500".repeat(102)));
6720
+ for (let i = 0; i < s.runs.length; i++) {
6721
+ const r = s.runs[i];
6722
+ const rank = String(i + 1).padStart(4);
6723
+ const surv = r.survived ? G5(" \u2713 ") : chalk14.red(" \u2717 ");
6724
+ const lev = String(r.config.leverage ?? "").padStart(3);
6725
+ const lvls = String(r.config.levelsPerSide ?? "").padStart(6);
6726
+ const atr = String(r.config.atrMultiplier ?? "").padStart(4);
6727
+ const drift = String(r.config.rebalanceDriftPct ?? "").padStart(5);
6728
+ const rts = String(r.totals.roundTrips).padStart(5);
6729
+ const netPnl = `$${r.capital.pnlUsd.toFixed(0)}`.padStart(11);
6730
+ const netPct = `${(r.capital.pnlPct * 100).toFixed(1)}%`.padStart(8);
6731
+ const ddPct = `${(r.drawdown.maxPct * 100).toFixed(1)}%`.padStart(8);
6732
+ const ra = r.riskAdjusted.toFixed(2).padStart(8);
6733
+ const pnlColor = r.capital.pnlUsd >= 0 ? G5 : chalk14.red;
6734
+ console.log(` ${rank} ${surv} ${lev} ${lvls} ${atr} ${drift} ${rts} ${pnlColor(netPnl)} ${pnlColor(netPct)} ${ddPct} ${ra}`);
6735
+ }
6736
+ console.log(DIM5(" " + "\u2500".repeat(102)));
6737
+ console.log();
6738
+ console.log(W5(` Saved: ${s.sweepId}/sweep.json + per-run JSONs in ~/.sherwood/grid/sweeps/`));
6739
+ console.log();
6740
+ }
6741
+ function parseTokenSplit(raw, tokens) {
6742
+ const split = {};
6743
+ for (const pair of raw.split(",")) {
6744
+ const [tok, w] = pair.split("=");
6745
+ if (!tok || !w) throw new Error(`Bad token-split pair: '${pair}' (expected token=weight)`);
6746
+ const weight = Number(w.trim());
6747
+ if (!Number.isFinite(weight) || weight < 0) {
6748
+ throw new Error(`Bad token-split weight for '${tok}': '${w}'`);
6749
+ }
6750
+ split[tok.trim()] = weight;
6751
+ }
6752
+ for (const t of tokens) {
6753
+ if (!(t in split)) {
6754
+ throw new Error(`--token-split missing weight for '${t}'`);
6755
+ }
6756
+ }
6757
+ const sum = Object.values(split).reduce((a, b) => a + b, 0);
6758
+ if (Math.abs(sum - 1) > 1e-6) {
6759
+ throw new Error(`--token-split weights must sum to 1.0, got ${sum.toFixed(6)}`);
6760
+ }
6761
+ return split;
6762
+ }
5746
6763
  function registerGridCommand(program2) {
5747
6764
  const grid = program2.command("grid").description("Grid trading strategy \u2014 ATR-based grid on BTC/ETH/SOL");
5748
- grid.command("start").description("Start the grid trading loop").option("--capital <usd>", "Starting capital in USD", "5000").option("--cycle <seconds>", "Cycle interval in seconds", "60").option("--tokens <list>", "Comma-separated token list", "bitcoin,ethereum,solana").option("--leverage <n>", "Leverage multiplier", "5").option("--levels <n>", "Grid levels per side", "15").option("--live", "enable live execution (places real orders on Hyperliquid)").option("--asset-indices <pairs>", "comma-separated token=index pairs (e.g. bitcoin=3,ethereum=4,solana=5)").option("--strategy <address>", "on-chain strategy contract address (enables on-chain executor)").action(async (opts) => {
6765
+ grid.command("start").description("Start the grid trading loop").option("--capital <usd>", "Starting capital in USD", "5000").option("--cycle <seconds>", "Cycle interval in seconds", "60").option("--tokens <list>", "Comma-separated token list", "bitcoin,ethereum,solana").option("--leverage <n>", "Leverage multiplier", "5").option("--levels <n>", "Grid levels per side", "15").option("--live", "enable live execution (places real orders on Hyperliquid)").option("--asset-indices <pairs>", "comma-separated token=index pairs (e.g. bitcoin=3,ethereum=4,solana=5)").option("--strategy <address>", "on-chain strategy contract address (enables on-chain executor)").option("--token-split <pairs>", "Comma-separated token=weight pairs (must sum to 1.0). Default: equal-weight.").action(async (opts) => {
5749
6766
  const capital = parseFloat(opts.capital);
5750
6767
  const cycleMs = parseInt(opts.cycle, 10) * 1e3;
5751
6768
  const tokens = opts.tokens.split(",").map((t) => t.trim());
@@ -5768,10 +6785,15 @@ function registerGridCommand(program2) {
5768
6785
  if (strategyAddress && !live) {
5769
6786
  throw new Error("--strategy requires --live");
5770
6787
  }
5771
- const weight = 1 / tokens.length;
5772
- const tokenSplit = {};
5773
- for (const t of tokens) {
5774
- tokenSplit[t] = weight;
6788
+ let tokenSplit;
6789
+ if (opts.tokenSplit) {
6790
+ tokenSplit = parseTokenSplit(opts.tokenSplit, tokens);
6791
+ } else {
6792
+ const weight = 1 / tokens.length;
6793
+ tokenSplit = {};
6794
+ for (const t of tokens) {
6795
+ tokenSplit[t] = weight;
6796
+ }
5775
6797
  }
5776
6798
  console.log();
5777
6799
  console.log(G5.bold(" Grid Strategy"));
@@ -5834,6 +6856,131 @@ function registerGridCommand(program2) {
5834
6856
  console.log(` ${BOLD5("TOTAL".padEnd(13))} ${`$${state.totalAllocation.toFixed(0)}`.padStart(8)} ${String(agg.totalRoundTrips).padStart(6)} ${totalPnlColor(`$${agg.totalPnlUsd.toFixed(2)}`.padStart(10))} ${todayTotalColor(`$${agg.todayPnlUsd.toFixed(2)}`.padStart(10))} ${String(agg.todayFills).padStart(6)}`);
5835
6857
  console.log();
5836
6858
  });
6859
+ grid.command("backtest").description("Replay historical Hyperliquid prices through the grid strategy").option("--from <iso>", "Window start (ISO date). Default: 30d ago.").option("--to <iso>", "Window end (ISO date). Default: now.").option("--capital <usd>", "Starting capital in USD", "5000").option("--tokens <list>", "Comma-separated token list", "bitcoin,ethereum,solana").option("--leverage <n>", "Override leverage").option("--levels <n>", "Override levels per side").option("--atr-multiplier <n>", "Override ATR multiplier").option("--rebalance-drift <n>", "Override rebalanceDriftPct").option("--snapshot-every <min>", "Equity-curve snapshot cadence (minutes)", "60").option("--verbose", "Print manager fill logs during replay").option("--no-cache", "Skip cache; always fetch fresh data").option("--out <path>", "Override output path").option("--fee-bps <n>", "Trading fee in basis points per fill (default 5 = 0.05%)", "5").option("--no-hedge", "Disable hedge simulation (hedge is ON by default to match live grid)").option("--maintenance-pct <n>", "Per-token maintenance margin fraction (default 0.02 = 2%, typical Hyperliquid)", "0.02").option("--pause-threshold <n>", "Pool drop fraction that triggers pause (default 0.40, may be too lenient post leverage-fix)").option("--unpause-recovery <n>", "Pool drop fraction below which paused grid resumes (default 0.10)").option("--token-split <pairs>", "Comma-separated token=weight pairs (must sum to 1.0). Default: equal-weight or 0.45/0.30/0.25 for BTC/ETH/SOL.").option("--stop-loss <n>", "Per-fill stop-loss fraction (default 0.10 = 10%, 0 disables)").action(async (opts) => {
6860
+ const now = Date.now();
6861
+ const toMs = opts.to ? Date.parse(opts.to) : now;
6862
+ const fromMs = opts.from ? Date.parse(opts.from) : now - 30 * 24 * 36e5;
6863
+ if (Number.isNaN(toMs) || Number.isNaN(fromMs)) {
6864
+ throw new Error("--from / --to must be ISO dates (e.g. 2026-04-01)");
6865
+ }
6866
+ const tokens = opts.tokens.split(",").map((t) => t.trim());
6867
+ let tokenSplit;
6868
+ if (opts.tokenSplit) {
6869
+ tokenSplit = parseTokenSplit(opts.tokenSplit, tokens);
6870
+ } else {
6871
+ const weight = 1 / tokens.length;
6872
+ tokenSplit = {};
6873
+ for (const t of tokens) tokenSplit[t] = weight;
6874
+ }
6875
+ const config = {
6876
+ ...DEFAULT_GRID_CONFIG,
6877
+ tokens,
6878
+ tokenSplit,
6879
+ leverage: opts.leverage ? Number(opts.leverage) : DEFAULT_GRID_CONFIG.leverage,
6880
+ levelsPerSide: opts.levels ? Number(opts.levels) : DEFAULT_GRID_CONFIG.levelsPerSide,
6881
+ atrMultiplier: opts.atrMultiplier ? Number(opts.atrMultiplier) : DEFAULT_GRID_CONFIG.atrMultiplier,
6882
+ rebalanceDriftPct: opts.rebalanceDrift ? Number(opts.rebalanceDrift) : DEFAULT_GRID_CONFIG.rebalanceDriftPct,
6883
+ maintenanceMarginPct: opts.maintenancePct ? Number(opts.maintenancePct) : DEFAULT_GRID_CONFIG.maintenanceMarginPct,
6884
+ pauseThresholdPct: opts.pauseThreshold ? Number(opts.pauseThreshold) : DEFAULT_GRID_CONFIG.pauseThresholdPct,
6885
+ unpauseRecoveryPct: opts.unpauseRecovery ? Number(opts.unpauseRecovery) : DEFAULT_GRID_CONFIG.unpauseRecoveryPct,
6886
+ stopLossPct: opts.stopLoss !== void 0 ? Number(opts.stopLoss) : DEFAULT_GRID_CONFIG.stopLossPct
6887
+ };
6888
+ const capital = Number(opts.capital);
6889
+ const snapshotEveryMinutes = Number(opts.snapshotEvery);
6890
+ console.log();
6891
+ console.log(G5.bold(" Grid Backtest"));
6892
+ SEP5();
6893
+ console.log(W5(` Window: ${new Date(fromMs).toISOString().slice(0, 10)} \u2192 ${new Date(toMs).toISOString().slice(0, 10)}`));
6894
+ console.log(W5(` Capital: $${capital.toLocaleString()}`));
6895
+ console.log(W5(` Tokens: ${tokens.join(", ")}`));
6896
+ console.log(W5(` Leverage: ${config.leverage}x`));
6897
+ console.log(W5(` Levels: ${config.levelsPerSide}/side`));
6898
+ SEP5();
6899
+ const result = await runBacktest({
6900
+ fromMs,
6901
+ toMs,
6902
+ capital,
6903
+ config,
6904
+ snapshotEveryMinutes,
6905
+ verbose: !!opts.verbose,
6906
+ noCache: opts.cache === false,
6907
+ outPath: opts.out,
6908
+ feeBps: Number(opts.feeBps),
6909
+ hedge: opts.hedge !== false
6910
+ });
6911
+ printSummary(result);
6912
+ });
6913
+ grid.command("sweep").description("Run a parameter sweep over multiple grid configurations").option("--from <iso>", "Window start (ISO date). Default: 30d ago.").option("--to <iso>", "Window end (ISO date). Default: now.").option("--capital <usd>", "Starting capital in USD", "5000").option("--tokens <list>", "Comma-separated token list", "bitcoin,ethereum,solana").option("--leverage <list>", "Comma-separated leverage values to sweep").option("--levels <list>", "Comma-separated levels-per-side values to sweep").option("--atr-multiplier <list>", "Comma-separated ATR multiplier values to sweep").option("--rebalance-drift <list>", "Comma-separated rebalance drift values to sweep").option("--fee-bps <n>", "Trading fee in basis points per fill (default 5)", "5").option("--no-cache", "Skip cache; always fetch fresh data").option("--token-split <pairs>", "Comma-separated token=weight pairs (must sum to 1.0). Default: equal-weight or 0.45/0.30/0.25 for BTC/ETH/SOL.").action(async (opts) => {
6914
+ const now = Date.now();
6915
+ const toMs = opts.to ? Date.parse(opts.to) : now;
6916
+ const fromMs = opts.from ? Date.parse(opts.from) : now - 30 * 24 * 36e5;
6917
+ if (Number.isNaN(toMs) || Number.isNaN(fromMs)) {
6918
+ throw new Error("--from / --to must be ISO dates (e.g. 2026-04-01)");
6919
+ }
6920
+ const tokens = opts.tokens.split(",").map((t) => t.trim());
6921
+ const parseList = (s) => {
6922
+ if (!s) return void 0;
6923
+ return s.split(",").map((v) => Number(v.trim())).filter((n) => Number.isFinite(n));
6924
+ };
6925
+ const sweep = {
6926
+ leverage: parseList(opts.leverage),
6927
+ levelsPerSide: parseList(opts.levels),
6928
+ atrMultiplier: parseList(opts.atrMultiplier),
6929
+ rebalanceDriftPct: parseList(opts.rebalanceDrift)
6930
+ };
6931
+ const tokenSplit = opts.tokenSplit ? parseTokenSplit(opts.tokenSplit, tokens) : void 0;
6932
+ const result = await runSweep({
6933
+ fromMs,
6934
+ toMs,
6935
+ capital: Number(opts.capital),
6936
+ tokens,
6937
+ tokenSplit,
6938
+ sweep,
6939
+ feeBps: Number(opts.feeBps),
6940
+ noCache: opts.cache === false
6941
+ });
6942
+ printSweepSummary(result);
6943
+ });
6944
+ grid.command("pause").description("Manually pause the live grid (sets state.paused=true)").option("--reason <text>", "Reason for the pause (shown in status output)", "Manually paused by operator").action(async (opts) => {
6945
+ const portfolio = new GridPortfolio();
6946
+ const state = await portfolio.load();
6947
+ if (!state) {
6948
+ console.log(DIM5("\n No grid portfolio found. Nothing to pause.\n"));
6949
+ return;
6950
+ }
6951
+ if (state.paused) {
6952
+ console.log(DIM5(`
6953
+ Grid already paused: ${state.pauseReason}
6954
+ `));
6955
+ return;
6956
+ }
6957
+ state.paused = true;
6958
+ state.pauseReason = opts.reason;
6959
+ await portfolio.save(state);
6960
+ console.log();
6961
+ console.log(chalk14.yellow(` Grid paused. Reason: ${state.pauseReason}`));
6962
+ console.log(DIM5(" Next manager.tick will be a no-op until you run `sherwood grid resume`."));
6963
+ console.log();
6964
+ });
6965
+ grid.command("resume").description("Resume a paused grid (clears state.paused)").action(async () => {
6966
+ const portfolio = new GridPortfolio();
6967
+ const state = await portfolio.load();
6968
+ if (!state) {
6969
+ console.log(DIM5("\n No grid portfolio found. Nothing to resume.\n"));
6970
+ return;
6971
+ }
6972
+ if (!state.paused) {
6973
+ console.log(DIM5("\n Grid is not paused. Nothing to do.\n"));
6974
+ return;
6975
+ }
6976
+ const wasReason = state.pauseReason;
6977
+ state.paused = false;
6978
+ state.pauseReason = "";
6979
+ await portfolio.save(state);
6980
+ console.log();
6981
+ console.log(G5(` Grid resumed. (was paused: ${wasReason || "<unknown>"})`));
6982
+ console.log();
6983
+ });
5837
6984
  }
5838
6985
 
5839
6986
  // src/index.ts
@@ -7004,7 +8151,7 @@ try {
7004
8151
  process.exit(1);
7005
8152
  });
7006
8153
  }
7007
- var { registerSessionCommands } = await import("./session-ZMEKZRWL.js");
8154
+ var { registerSessionCommands } = await import("./session-3QAL5VEV.js");
7008
8155
  registerSessionCommands(program);
7009
8156
  registerVeniceCommands(program);
7010
8157
  registerAllowanceCommands(program);
@@ -7014,7 +8161,7 @@ registerGovernorCommands(program);
7014
8161
  registerGuardianCommands(program);
7015
8162
  var { registerResearchCommands } = await import("./research-2GOILSS7.js");
7016
8163
  registerResearchCommands(program);
7017
- var { registerAgentCommands } = await import("./agent-E5GHURKJ.js");
8164
+ var { registerAgentCommands } = await import("./agent-ER5GYBVQ.js");
7018
8165
  registerAgentCommands(program);
7019
8166
  var { registerTradeCommands } = await import("./trade-UXQOCNLJ.js");
7020
8167
  registerTradeCommands(program);