@sherwoodagent/cli 0.52.0 → 0.53.3
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/{addresses-CKPOWDDV.js → addresses-SB73E5XZ.js} +2 -2
- package/dist/{agent-E5GHURKJ.js → agent-ER5GYBVQ.js} +2 -2
- package/dist/{chat-3IW4XDZJ.js → chat-JMQNUJGF.js} +5 -5
- package/dist/{chunk-JY46LEIS.js → chunk-4SZU32RM.js} +10 -8
- package/dist/chunk-4SZU32RM.js.map +1 -0
- package/dist/{chunk-ENIUCMDV.js → chunk-AFLGHTOF.js} +2 -2
- package/dist/{chunk-OMW47WOO.js → chunk-DBOINZYU.js} +4 -2
- package/dist/chunk-DBOINZYU.js.map +1 -0
- package/dist/{chunk-ZRU43SUB.js → chunk-DICS6PQM.js} +2 -2
- package/dist/{chunk-LXUNJSB6.js → chunk-G6CHORMT.js} +2 -2
- package/dist/{chunk-LXUNJSB6.js.map → chunk-G6CHORMT.js.map} +1 -1
- package/dist/{chunk-D2D6BPBM.js → chunk-KIRNQZJS.js} +2 -2
- package/dist/{chunk-JEKXXHI5.js → chunk-MOU5FAEP.js} +2 -2
- package/dist/{eas-PFIAGWAB.js → eas-U6DMTANA.js} +3 -3
- package/dist/{governor-CQG4LA43.js → governor-4Y6XDEVH.js} +3 -3
- package/dist/index.js +1230 -83
- package/dist/index.js.map +1 -1
- package/dist/{price-UC2W6HYQ.js → price-RMH6KUTM.js} +2 -2
- package/dist/{research-2GOILSS7.js → research-AQE726DA.js} +3 -3
- package/dist/{session-ZMEKZRWL.js → session-MVLCQDLG.js} +5 -5
- package/dist/{trade-UXQOCNLJ.js → trade-FKC57GXZ.js} +6 -6
- package/dist/{xmtp-KEFA7F24.js → xmtp-VJMC2TR2.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-JY46LEIS.js.map +0 -1
- package/dist/chunk-OMW47WOO.js.map +0 -1
- /package/dist/{addresses-CKPOWDDV.js.map → addresses-SB73E5XZ.js.map} +0 -0
- /package/dist/{agent-E5GHURKJ.js.map → agent-ER5GYBVQ.js.map} +0 -0
- /package/dist/{chat-3IW4XDZJ.js.map → chat-JMQNUJGF.js.map} +0 -0
- /package/dist/{chunk-ENIUCMDV.js.map → chunk-AFLGHTOF.js.map} +0 -0
- /package/dist/{chunk-ZRU43SUB.js.map → chunk-DICS6PQM.js.map} +0 -0
- /package/dist/{chunk-D2D6BPBM.js.map → chunk-KIRNQZJS.js.map} +0 -0
- /package/dist/{chunk-JEKXXHI5.js.map → chunk-MOU5FAEP.js.map} +0 -0
- /package/dist/{eas-PFIAGWAB.js.map → eas-U6DMTANA.js.map} +0 -0
- /package/dist/{governor-CQG4LA43.js.map → governor-4Y6XDEVH.js.map} +0 -0
- /package/dist/{price-UC2W6HYQ.js.map → price-RMH6KUTM.js.map} +0 -0
- /package/dist/{research-2GOILSS7.js.map → research-AQE726DA.js.map} +0 -0
- /package/dist/{session-ZMEKZRWL.js.map → session-MVLCQDLG.js.map} +0 -0
- /package/dist/{trade-UXQOCNLJ.js.map → trade-FKC57GXZ.js.map} +0 -0
- /package/dist/{xmtp-KEFA7F24.js.map → xmtp-VJMC2TR2.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
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-
|
|
11
|
+
} from "./chunk-DBOINZYU.js";
|
|
10
12
|
import {
|
|
11
13
|
UniswapProvider,
|
|
12
14
|
applySlippage,
|
|
13
15
|
encodeSwapPath,
|
|
14
16
|
getMultiHopQuote,
|
|
15
17
|
getQuote
|
|
16
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-DICS6PQM.js";
|
|
17
19
|
import {
|
|
18
20
|
chatCompletion,
|
|
19
21
|
checkApiKeyValid,
|
|
@@ -30,14 +32,14 @@ import {
|
|
|
30
32
|
queryJoinRequests,
|
|
31
33
|
revokeJoinRequest,
|
|
32
34
|
stripReferrerPrefix
|
|
33
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-MOU5FAEP.js";
|
|
34
36
|
import {
|
|
35
37
|
createSyndicate,
|
|
36
38
|
getActiveSyndicates,
|
|
37
39
|
getSyndicate,
|
|
38
40
|
subdomainExists,
|
|
39
41
|
updateMetadata
|
|
40
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-KIRNQZJS.js";
|
|
41
43
|
import {
|
|
42
44
|
approveDepositor,
|
|
43
45
|
deposit,
|
|
@@ -61,7 +63,7 @@ import {
|
|
|
61
63
|
searchSyndicateAcrossChains,
|
|
62
64
|
setTextRecord,
|
|
63
65
|
setVaultAddress
|
|
64
|
-
} from "./chunk-
|
|
66
|
+
} from "./chunk-AFLGHTOF.js";
|
|
65
67
|
import {
|
|
66
68
|
PROPOSAL_STATE,
|
|
67
69
|
PROPOSAL_STATES,
|
|
@@ -93,7 +95,7 @@ import {
|
|
|
93
95
|
setVotingPeriod,
|
|
94
96
|
settleProposal,
|
|
95
97
|
vote
|
|
96
|
-
} from "./chunk-
|
|
98
|
+
} from "./chunk-4SZU32RM.js";
|
|
97
99
|
import {
|
|
98
100
|
AERODROME,
|
|
99
101
|
AGENT_REGISTRY,
|
|
@@ -105,7 +107,7 @@ import {
|
|
|
105
107
|
TOKENS,
|
|
106
108
|
UNISWAP,
|
|
107
109
|
VENICE
|
|
108
|
-
} from "./chunk-
|
|
110
|
+
} from "./chunk-G6CHORMT.js";
|
|
109
111
|
import {
|
|
110
112
|
formatContractError,
|
|
111
113
|
getAccount,
|
|
@@ -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-
|
|
1541
|
+
const { propose: propose2 } = await import("./governor-4Y6XDEVH.js");
|
|
1540
1542
|
const { pinJSON } = await import("./ipfs-IGXLLJCF.js");
|
|
1541
|
-
const { parseDuration: parseDuration2 } = await import("./governor-
|
|
1543
|
+
const { parseDuration: parseDuration2 } = await import("./governor-4Y6XDEVH.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)"));
|
|
@@ -1645,7 +1647,7 @@ function registerStrategyTemplateCommands(strategy2) {
|
|
|
1645
1647
|
let prices = [];
|
|
1646
1648
|
let priceSource = null;
|
|
1647
1649
|
try {
|
|
1648
|
-
const { getTokenPricesInAsset } = await import("./price-
|
|
1650
|
+
const { getTokenPricesInAsset } = await import("./price-RMH6KUTM.js");
|
|
1649
1651
|
prices = await getTokenPricesInAsset({
|
|
1650
1652
|
tokens: allocations.map((a, i) => ({
|
|
1651
1653
|
token: a.token,
|
|
@@ -2075,7 +2077,7 @@ function registerVeniceCommands(program2) {
|
|
|
2075
2077
|
console.log(chalk2.dim(` Key: ${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`));
|
|
2076
2078
|
console.log(chalk2.dim(" Saved to ~/.sherwood/config.json"));
|
|
2077
2079
|
try {
|
|
2078
|
-
const { createVeniceProvisionAttestation, getEasScanUrl: getEasScanUrl2 } = await import("./eas-
|
|
2080
|
+
const { createVeniceProvisionAttestation, getEasScanUrl: getEasScanUrl2 } = await import("./eas-U6DMTANA.js");
|
|
2079
2081
|
const { uid } = await createVeniceProvisionAttestation(account.address);
|
|
2080
2082
|
if (uid !== "0x0000000000000000000000000000000000000000000000000000000000000000") {
|
|
2081
2083
|
console.log(chalk2.dim(` Attested: ${getEasScanUrl2(uid)}`));
|
|
@@ -2431,7 +2433,7 @@ ${opts.prompt}`;
|
|
|
2431
2433
|
console.log(chalk2.dim(`Model: ${result.model} | Tokens: ${result.usage.promptTokens} in, ${result.usage.completionTokens} out, ${result.usage.totalTokens} total`));
|
|
2432
2434
|
}
|
|
2433
2435
|
try {
|
|
2434
|
-
const { createVeniceInferenceAttestation, getEasScanUrl: getEasScanUrl2 } = await import("./eas-
|
|
2436
|
+
const { createVeniceInferenceAttestation, getEasScanUrl: getEasScanUrl2 } = await import("./eas-U6DMTANA.js");
|
|
2435
2437
|
const { keccak256: keccak2562, toHex, isAddress: isAddr } = await import("viem");
|
|
2436
2438
|
const { getChainContracts: getChainContracts2 } = await import("./config-REASKDIK.js");
|
|
2437
2439
|
const { getChain: getActiveChain } = await import("./network-3ZU7UBDU.js");
|
|
@@ -3607,7 +3609,7 @@ function printSimulationResult(result, callType) {
|
|
|
3607
3609
|
}
|
|
3608
3610
|
async function sendSimulationAlert(subdomain, proposalId, vault, execResult, settleResult) {
|
|
3609
3611
|
try {
|
|
3610
|
-
const xmtp = await import("./xmtp-
|
|
3612
|
+
const xmtp = await import("./xmtp-VJMC2TR2.js");
|
|
3611
3613
|
const { getAccount: getAccount2 } = await import("./client-UHHCB6EZ.js");
|
|
3612
3614
|
const group = await xmtp.getGroup("", subdomain);
|
|
3613
3615
|
const allRisks = [...execResult.risks, ...settleResult?.risks ?? []];
|
|
@@ -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
|
-
|
|
4783
|
-
|
|
4784
|
-
const todayMidnight =
|
|
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
|
|
4799
|
-
|
|
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
|
|
4802
|
-
|
|
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
|
-
|
|
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 ||
|
|
4875
|
-
return { fills: 0, roundTrips: 0, pnlUsd: 0, paused:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
4991
|
-
const profit = (currentPrice - openFill.buyPrice) * openFill.quantity
|
|
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:
|
|
5240
|
+
lastDailyReset: now
|
|
5142
5241
|
};
|
|
5143
5242
|
}
|
|
5144
|
-
const todayMidnight =
|
|
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 =
|
|
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
|
|
5473
|
-
txs.push(
|
|
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
|
|
5482
|
-
txs.push(
|
|
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
|
|
5528
|
-
const
|
|
5529
|
-
const
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
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
|
|
5555
|
-
const
|
|
5556
|
-
|
|
5557
|
-
[
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
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
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
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
|
|
@@ -5844,7 +6991,7 @@ try {
|
|
|
5844
6991
|
var require2 = createRequire(import.meta.url);
|
|
5845
6992
|
var { version: CLI_VERSION } = require2("../package.json");
|
|
5846
6993
|
async function loadXmtp() {
|
|
5847
|
-
return import("./xmtp-
|
|
6994
|
+
return import("./xmtp-VJMC2TR2.js");
|
|
5848
6995
|
}
|
|
5849
6996
|
async function loadCron() {
|
|
5850
6997
|
return import("./cron-EOULYO3R.js");
|
|
@@ -6369,7 +7516,7 @@ syndicate.command("add").description("Register an agent on a syndicate vault (cr
|
|
|
6369
7516
|
if (opts.agentId) {
|
|
6370
7517
|
agentId = BigInt(opts.agentId);
|
|
6371
7518
|
} else {
|
|
6372
|
-
const { AGENT_REGISTRY: AGENT_REGISTRY2 } = await import("./addresses-
|
|
7519
|
+
const { AGENT_REGISTRY: AGENT_REGISTRY2 } = await import("./addresses-SB73E5XZ.js");
|
|
6373
7520
|
const registry2 = AGENT_REGISTRY2().IDENTITY_REGISTRY;
|
|
6374
7521
|
if (registry2 === "0x0000000000000000000000000000000000000000") {
|
|
6375
7522
|
agentId = 0n;
|
|
@@ -6994,7 +8141,7 @@ ${info.name} (${info.type})`);
|
|
|
6994
8141
|
}
|
|
6995
8142
|
});
|
|
6996
8143
|
try {
|
|
6997
|
-
const { registerChatCommands } = await import("./chat-
|
|
8144
|
+
const { registerChatCommands } = await import("./chat-JMQNUJGF.js");
|
|
6998
8145
|
registerChatCommands(program);
|
|
6999
8146
|
} catch {
|
|
7000
8147
|
program.command("chat <name> [action] [actionArgs...]").description("Syndicate chat (XMTP) \u2014 requires @xmtp/cli").action(() => {
|
|
@@ -7004,7 +8151,7 @@ try {
|
|
|
7004
8151
|
process.exit(1);
|
|
7005
8152
|
});
|
|
7006
8153
|
}
|
|
7007
|
-
var { registerSessionCommands } = await import("./session-
|
|
8154
|
+
var { registerSessionCommands } = await import("./session-MVLCQDLG.js");
|
|
7008
8155
|
registerSessionCommands(program);
|
|
7009
8156
|
registerVeniceCommands(program);
|
|
7010
8157
|
registerAllowanceCommands(program);
|
|
@@ -7012,11 +8159,11 @@ registerIdentityCommands(program);
|
|
|
7012
8159
|
registerProposalCommands(program);
|
|
7013
8160
|
registerGovernorCommands(program);
|
|
7014
8161
|
registerGuardianCommands(program);
|
|
7015
|
-
var { registerResearchCommands } = await import("./research-
|
|
8162
|
+
var { registerResearchCommands } = await import("./research-AQE726DA.js");
|
|
7016
8163
|
registerResearchCommands(program);
|
|
7017
|
-
var { registerAgentCommands } = await import("./agent-
|
|
8164
|
+
var { registerAgentCommands } = await import("./agent-ER5GYBVQ.js");
|
|
7018
8165
|
registerAgentCommands(program);
|
|
7019
|
-
var { registerTradeCommands } = await import("./trade-
|
|
8166
|
+
var { registerTradeCommands } = await import("./trade-FKC57GXZ.js");
|
|
7020
8167
|
registerTradeCommands(program);
|
|
7021
8168
|
registerGridCommand(program);
|
|
7022
8169
|
var configCmd = program.command("config");
|