perp-cli 0.12.15 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -31
- package/dist/_stderr-filter.js +0 -1
- package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
- package/dist/agent-wallet/lighter-keystore.js +92 -10
- package/dist/agent-wallet/passphrase.d.ts +1 -1
- package/dist/agent-wallet/passphrase.js +16 -3
- package/dist/agent-wallet/store.d.ts +37 -4
- package/dist/agent-wallet/store.js +132 -0
- package/dist/agent-wallet/venue-keystore.d.ts +14 -0
- package/dist/agent-wallet/venue-keystore.js +121 -0
- package/dist/api/public/hyperliquid.js +4 -4
- package/dist/api/public/lighter.js +5 -5
- package/dist/api/public/pacifica.d.ts +0 -1
- package/dist/api/public/pacifica.js +4 -13
- package/dist/api/public/urls.d.ts +9 -1
- package/dist/api/public/urls.js +35 -13
- package/dist/arb/file-lock.d.ts +11 -0
- package/dist/arb/file-lock.js +74 -0
- package/dist/arb/index.d.ts +2 -2
- package/dist/arb/index.js +2 -2
- package/dist/arb/pending-entry.d.ts +32 -0
- package/dist/arb/pending-entry.js +77 -0
- package/dist/arb/sizing.d.ts +6 -13
- package/dist/arb/sizing.js +12 -51
- package/dist/arb/state.d.ts +45 -12
- package/dist/arb/state.js +214 -42
- package/dist/arb/utils.d.ts +20 -6
- package/dist/arb/utils.js +43 -39
- package/dist/bot/conditions.d.ts +4 -4
- package/dist/bot/conditions.js +11 -6
- package/dist/bot/config.d.ts +17 -1
- package/dist/bot/config.js +48 -12
- package/dist/bot/engine.d.ts +61 -2
- package/dist/bot/engine.js +610 -144
- package/dist/bot/fill-size.d.ts +7 -0
- package/dist/bot/fill-size.js +18 -0
- package/dist/bot/index.d.ts +1 -1
- package/dist/bot/index.js +1 -1
- package/dist/bot/perp-order.d.ts +99 -0
- package/dist/bot/perp-order.js +299 -0
- package/dist/bot/presets.d.ts +7 -0
- package/dist/bot/presets.js +17 -0
- package/dist/bot/quarantine.d.ts +75 -0
- package/dist/bot/quarantine.js +190 -0
- package/dist/bot/quote-rules.d.ts +23 -0
- package/dist/bot/quote-rules.js +69 -0
- package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
- package/dist/bot/strategies/avellaneda-mm.js +72 -70
- package/dist/bot/strategies/basis-arb.js +2 -0
- package/dist/bot/strategies/dca-strategy.d.ts +2 -0
- package/dist/bot/strategies/dca-strategy.js +5 -0
- package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
- package/dist/bot/strategies/funding-arb-strategy.js +571 -380
- package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
- package/dist/bot/strategies/funding-arb-utils.js +159 -195
- package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
- package/dist/bot/strategies/funding-arb-v2.js +258 -47
- package/dist/bot/strategies/funding-auto.js +215 -194
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
- package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
- package/dist/bot/strategies/spot-perp-executor.js +319 -120
- package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-state.js +70 -0
- package/dist/bot/strategy-types.d.ts +6 -0
- package/dist/bot/tui/BotDashboard.d.ts +7 -2
- package/dist/bot/tui/BotDashboard.js +6 -3
- package/dist/bridge-engine.d.ts +120 -19
- package/dist/bridge-engine.js +710 -422
- package/dist/bridge-signers.d.ts +54 -0
- package/dist/bridge-signers.js +161 -0
- package/dist/cache.d.ts +4 -1
- package/dist/cache.js +11 -0
- package/dist/cli-flags.d.ts +50 -0
- package/dist/cli-flags.js +104 -0
- package/dist/cli-program.d.ts +35 -0
- package/dist/cli-program.js +286 -0
- package/dist/cli-spec.js +1 -1
- package/dist/client-id-tracker.js +11 -2
- package/dist/commands/account.d.ts +7 -1
- package/dist/commands/account.js +74 -84
- package/dist/commands/agent-import.d.ts +114 -0
- package/dist/commands/agent-import.js +794 -0
- package/dist/commands/agent.d.ts +232 -2
- package/dist/commands/agent.js +1858 -955
- package/dist/commands/alerts.d.ts +1 -0
- package/dist/commands/alerts.js +31 -28
- package/dist/commands/arb/index.d.ts +60 -1
- package/dist/commands/arb/index.js +381 -384
- package/dist/commands/arb-auto.d.ts +132 -4
- package/dist/commands/arb-auto.js +1474 -967
- package/dist/commands/arb.d.ts +3 -1
- package/dist/commands/arb.js +62 -30
- package/dist/commands/bot.js +127 -47
- package/dist/commands/bridge.d.ts +2 -1
- package/dist/commands/bridge.js +339 -192
- package/dist/commands/builder.d.ts +55 -0
- package/dist/commands/builder.js +387 -0
- package/dist/commands/funds-onchain.d.ts +32 -0
- package/dist/commands/funds-onchain.js +286 -0
- package/dist/commands/funds.d.ts +25 -0
- package/dist/commands/funds.js +939 -245
- package/dist/commands/history.js +72 -45
- package/dist/commands/init.d.ts +107 -15
- package/dist/commands/init.js +1116 -249
- package/dist/commands/jobs.d.ts +2 -1
- package/dist/commands/jobs.js +77 -15
- package/dist/commands/manage.js +136 -91
- package/dist/commands/market.d.ts +4 -1
- package/dist/commands/market.js +35 -32
- package/dist/commands/outcome.d.ts +10 -0
- package/dist/commands/outcome.js +396 -0
- package/dist/commands/plan.js +52 -12
- package/dist/commands/portfolio.d.ts +1 -0
- package/dist/commands/portfolio.js +60 -72
- package/dist/commands/rebalance.d.ts +2 -1
- package/dist/commands/rebalance.js +33 -43
- package/dist/commands/risk.js +46 -19
- package/dist/commands/settings.d.ts +1 -0
- package/dist/commands/settings.js +246 -51
- package/dist/commands/trade.d.ts +50 -0
- package/dist/commands/trade.js +1943 -695
- package/dist/commands/wallet.d.ts +30 -1
- package/dist/commands/wallet.js +316 -337
- package/dist/config.d.ts +60 -9
- package/dist/config.js +252 -82
- package/dist/constants.d.ts +29 -4
- package/dist/constants.js +62 -20
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +9 -6
- package/dist/dashboard/ui.js +6 -4
- package/dist/dashboard/ws-feeds.js +20 -8
- package/dist/dex-asset-map.js +5 -3
- package/dist/errors.d.ts +122 -0
- package/dist/errors.js +194 -0
- package/dist/event-stream.js +75 -19
- package/dist/evm-master-tx.d.ts +59 -0
- package/dist/evm-master-tx.js +154 -0
- package/dist/exchanges/adapter-network.d.ts +10 -0
- package/dist/exchanges/adapter-network.js +10 -0
- package/dist/exchanges/arcus/adapter.d.ts +196 -0
- package/dist/exchanges/arcus/adapter.js +1092 -0
- package/dist/exchanges/arcus/agent.d.ts +55 -0
- package/dist/exchanges/arcus/agent.js +188 -0
- package/dist/exchanges/arcus/client.d.ts +63 -0
- package/dist/exchanges/arcus/client.js +285 -0
- package/dist/exchanges/arcus/public.d.ts +65 -0
- package/dist/exchanges/arcus/public.js +138 -0
- package/dist/exchanges/arcus/signing.d.ts +176 -0
- package/dist/exchanges/arcus/signing.js +288 -0
- package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
- package/dist/exchanges/arcus/spot-chain.js +241 -0
- package/dist/exchanges/arcus/spot-router.d.ts +177 -0
- package/dist/exchanges/arcus/spot-router.js +183 -0
- package/dist/exchanges/arcus/spot.d.ts +109 -0
- package/dist/exchanges/arcus/spot.js +645 -0
- package/dist/exchanges/aster-funds.d.ts +127 -0
- package/dist/exchanges/aster-funds.js +252 -0
- package/dist/exchanges/aster-nonce.d.ts +2 -0
- package/dist/exchanges/aster-nonce.js +107 -0
- package/dist/exchanges/aster-spot.d.ts +117 -0
- package/dist/exchanges/aster-spot.js +686 -0
- package/dist/exchanges/aster-typed-data.d.ts +133 -7
- package/dist/exchanges/aster-typed-data.js +121 -7
- package/dist/exchanges/aster.d.ts +219 -6
- package/dist/exchanges/aster.js +1023 -153
- package/dist/exchanges/builder-fee.d.ts +254 -0
- package/dist/exchanges/builder-fee.js +421 -0
- package/dist/exchanges/canonical-symbols.d.ts +32 -0
- package/dist/exchanges/canonical-symbols.js +96 -0
- package/dist/exchanges/capabilities.d.ts +7 -1
- package/dist/exchanges/capabilities.js +7 -0
- package/dist/exchanges/hl-signing.d.ts +165 -0
- package/dist/exchanges/hl-signing.js +158 -0
- package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
- package/dist/exchanges/hyperliquid-outcome.js +646 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
- package/dist/exchanges/hyperliquid-spot.js +308 -117
- package/dist/exchanges/hyperliquid.d.ts +462 -57
- package/dist/exchanges/hyperliquid.js +1575 -449
- package/dist/exchanges/index.d.ts +6 -0
- package/dist/exchanges/index.js +4 -0
- package/dist/exchanges/interface.d.ts +63 -10
- package/dist/exchanges/interface.js +18 -3
- package/dist/exchanges/ioc-outcome.d.ts +44 -0
- package/dist/exchanges/ioc-outcome.js +57 -0
- package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
- package/dist/exchanges/legacy-trading-v2.js +940 -0
- package/dist/exchanges/lighter-public.d.ts +43 -0
- package/dist/exchanges/lighter-public.js +276 -0
- package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
- package/dist/exchanges/lighter-rh-deposit.js +185 -0
- package/dist/exchanges/lighter-signer.d.ts +123 -0
- package/dist/exchanges/lighter-signer.js +215 -0
- package/dist/exchanges/lighter-spot.d.ts +91 -13
- package/dist/exchanges/lighter-spot.js +595 -233
- package/dist/exchanges/lighter.d.ts +611 -39
- package/dist/exchanges/lighter.js +1890 -649
- package/dist/exchanges/ondo/adapter.d.ts +166 -0
- package/dist/exchanges/ondo/adapter.js +835 -0
- package/dist/exchanges/ondo/agent.d.ts +31 -0
- package/dist/exchanges/ondo/agent.js +101 -0
- package/dist/exchanges/ondo/client.d.ts +60 -0
- package/dist/exchanges/ondo/client.js +297 -0
- package/dist/exchanges/ondo/funds.d.ts +122 -0
- package/dist/exchanges/ondo/funds.js +290 -0
- package/dist/exchanges/ondo/public.d.ts +95 -0
- package/dist/exchanges/ondo/public.js +214 -0
- package/dist/exchanges/ondo/spot.d.ts +127 -0
- package/dist/exchanges/ondo/spot.js +392 -0
- package/dist/exchanges/outcome-interface.d.ts +140 -0
- package/dist/exchanges/outcome-interface.js +16 -0
- package/dist/exchanges/pacifica-spot.d.ts +83 -0
- package/dist/exchanges/pacifica-spot.js +324 -0
- package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
- package/dist/exchanges/pacifica-typed-data.js +57 -9
- package/dist/exchanges/pacifica.d.ts +288 -4
- package/dist/exchanges/pacifica.js +1013 -76
- package/dist/exchanges/precision.d.ts +47 -0
- package/dist/exchanges/precision.js +209 -0
- package/dist/exchanges/qfex/adapter.d.ts +341 -0
- package/dist/exchanges/qfex/adapter.js +1383 -0
- package/dist/exchanges/qfex/agent.d.ts +58 -0
- package/dist/exchanges/qfex/agent.js +188 -0
- package/dist/exchanges/qfex/client.d.ts +54 -0
- package/dist/exchanges/qfex/client.js +138 -0
- package/dist/exchanges/qfex/errors.d.ts +70 -0
- package/dist/exchanges/qfex/errors.js +236 -0
- package/dist/exchanges/qfex/public.d.ts +122 -0
- package/dist/exchanges/qfex/public.js +268 -0
- package/dist/exchanges/qfex/signing.d.ts +160 -0
- package/dist/exchanges/qfex/signing.js +283 -0
- package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
- package/dist/exchanges/qfex/ws-transport.js +798 -0
- package/dist/exchanges/rate-limiter.d.ts +12 -0
- package/dist/exchanges/rate-limiter.js +64 -0
- package/dist/exchanges/registry.d.ts +14 -2
- package/dist/exchanges/registry.js +20 -32
- package/dist/exchanges/slippage.d.ts +37 -0
- package/dist/exchanges/slippage.js +70 -0
- package/dist/exchanges/spot-factory.d.ts +166 -0
- package/dist/exchanges/spot-factory.js +431 -0
- package/dist/exchanges/spot-interface.d.ts +98 -4
- package/dist/exchanges/spot-interface.js +27 -1
- package/dist/exchanges/spot-result.d.ts +79 -0
- package/dist/exchanges/spot-result.js +273 -0
- package/dist/exchanges/trading-v2.d.ts +117 -0
- package/dist/exchanges/trading-v2.js +301 -0
- package/dist/exchanges/venue-types.d.ts +231 -0
- package/dist/exchanges/venue-types.js +87 -0
- package/dist/exchanges/venue.d.ts +211 -0
- package/dist/exchanges/venue.js +132 -0
- package/dist/exchanges/venues/arcus.d.ts +8 -0
- package/dist/exchanges/venues/arcus.js +109 -0
- package/dist/exchanges/venues/aster.d.ts +49 -0
- package/dist/exchanges/venues/aster.js +176 -0
- package/dist/exchanges/venues/create-helpers.d.ts +18 -0
- package/dist/exchanges/venues/create-helpers.js +199 -0
- package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
- package/dist/exchanges/venues/hyperliquid.js +126 -0
- package/dist/exchanges/venues/index.d.ts +16 -0
- package/dist/exchanges/venues/index.js +26 -0
- package/dist/exchanges/venues/lighter-family.d.ts +92 -0
- package/dist/exchanges/venues/lighter-family.js +166 -0
- package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
- package/dist/exchanges/venues/lighter-rh.js +58 -0
- package/dist/exchanges/venues/lighter.d.ts +6 -0
- package/dist/exchanges/venues/lighter.js +55 -0
- package/dist/exchanges/venues/ondo.d.ts +34 -0
- package/dist/exchanges/venues/ondo.js +140 -0
- package/dist/exchanges/venues/pacifica.d.ts +8 -0
- package/dist/exchanges/venues/pacifica.js +95 -0
- package/dist/exchanges/venues/qfex.d.ts +28 -0
- package/dist/exchanges/venues/qfex.js +132 -0
- package/dist/execution-log.js +10 -2
- package/dist/funding/legacy-public.d.ts +31 -0
- package/dist/funding/legacy-public.js +369 -0
- package/dist/funding/normalize.d.ts +28 -16
- package/dist/funding/normalize.js +61 -29
- package/dist/funding/rates.d.ts +46 -11
- package/dist/funding/rates.js +228 -277
- package/dist/guardrail/perp-guardrail.js +0 -0
- package/dist/index.js +157 -682
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +14 -0
- package/dist/landing.js +36 -0
- package/dist/mcp-server.d.ts +14 -4
- package/dist/mcp-server.js +820 -402
- package/dist/mcp-trade-guard.d.ts +16 -0
- package/dist/mcp-trade-guard.js +59 -0
- package/dist/pacifica/client.d.ts +41 -10
- package/dist/pacifica/client.js +167 -63
- package/dist/pacifica/constants.d.ts +0 -1
- package/dist/pacifica/constants.js +0 -5
- package/dist/pacifica/deposit.d.ts +30 -3
- package/dist/pacifica/deposit.js +119 -2
- package/dist/pacifica/faucet.d.ts +29 -0
- package/dist/pacifica/faucet.js +100 -0
- package/dist/pacifica/sign-tx.d.ts +15 -0
- package/dist/pacifica/sign-tx.js +41 -0
- package/dist/pacifica/types/account.d.ts +28 -4
- package/dist/pacifica/types/lake.d.ts +20 -5
- package/dist/pacifica/types/lake.js +4 -0
- package/dist/pacifica/types/market.d.ts +5 -0
- package/dist/pacifica/types/order.d.ts +4 -1
- package/dist/pacifica/types/position.d.ts +9 -1
- package/dist/plan-executor.d.ts +10 -5
- package/dist/plan-executor.js +165 -54
- package/dist/position-history.js +13 -0
- package/dist/rebalance.d.ts +26 -2
- package/dist/rebalance.js +86 -7
- package/dist/relay-swap.d.ts +232 -0
- package/dist/relay-swap.js +630 -0
- package/dist/risk.js +5 -1
- package/dist/secret-env.d.ts +15 -0
- package/dist/secret-env.js +36 -0
- package/dist/settings.d.ts +169 -6
- package/dist/settings.js +219 -38
- package/dist/signer/ows-evm.d.ts +13 -0
- package/dist/signer/ows-evm.js +22 -0
- package/dist/smart-order.d.ts +9 -0
- package/dist/smart-order.js +53 -8
- package/dist/strategies/dca.d.ts +6 -2
- package/dist/strategies/dca.js +66 -26
- package/dist/strategies/funding-arb.d.ts +11 -1
- package/dist/strategies/funding-arb.js +202 -89
- package/dist/strategies/grid.d.ts +31 -0
- package/dist/strategies/grid.js +89 -11
- package/dist/strategies/pending-stop.d.ts +8 -0
- package/dist/strategies/pending-stop.js +12 -0
- package/dist/strategies/split-order.d.ts +45 -17
- package/dist/strategies/split-order.js +157 -91
- package/dist/strategies/trailing-stop.d.ts +2 -1
- package/dist/strategies/trailing-stop.js +52 -6
- package/dist/strategies/twap.d.ts +6 -1
- package/dist/strategies/twap.js +39 -11
- package/dist/trade-validator.d.ts +22 -0
- package/dist/trade-validator.js +119 -15
- package/dist/utils/numeric.d.ts +46 -0
- package/dist/utils/numeric.js +79 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.js +96 -19
- package/dist/withdraw-gate.d.ts +49 -0
- package/dist/withdraw-gate.js +131 -0
- package/package.json +15 -6
- package/skills/perp-cli/SKILL.md +338 -134
- package/skills/perp-cli/references/agent-operations.md +181 -92
- package/skills/perp-cli/references/commands.md +475 -132
- package/skills/perp-cli/references/strategies.md +44 -38
- package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
- package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
- package/skills/perp-cli/scripts/preflight.sh +21 -14
- package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
- package/skills/perp-cli/scripts/validate-arb.sh +33 -16
- package/vendor/lighter-signer/SHA256SUMS +2 -0
- package/vendor/lighter-signer/VERSION +8 -0
- package/vendor/lighter-signer/lighter-signer.wasm +0 -0
- package/vendor/lighter-signer/wasm_exec.js +575 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
- package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
package/dist/bot/engine.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertCanClosePositions } from "../exchanges/trading-v2.js";
|
|
2
|
+
import { resolveAdapterSlippage } from "../exchanges/slippage.js";
|
|
3
|
+
import { filledOf } from "./fill-size.js";
|
|
4
|
+
import { PerpError } from "../errors.js";
|
|
5
|
+
import { getMarketSnapshot, evaluateAllConditions, evaluateCondition } from "./conditions.js";
|
|
2
6
|
import { updateJobState } from "../jobs.js";
|
|
3
7
|
import { getStrategy } from "./strategy-registry.js";
|
|
4
8
|
import chalk from "chalk";
|
|
5
9
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
6
10
|
import { homedir } from "node:os";
|
|
7
11
|
import { join } from "node:path";
|
|
12
|
+
import { prepareLimitQuote, prepareMarketSize, quoteRulesOf, alignComputedLimitPrice } from "./quote-rules.js";
|
|
8
13
|
// Auto-register built-in strategies
|
|
9
14
|
import "./strategies/grid-strategy.js";
|
|
10
15
|
import "./strategies/dca-strategy.js";
|
|
@@ -48,23 +53,82 @@ class TuiEmitter {
|
|
|
48
53
|
l(entry);
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Strategies that place/close orders themselves (multi-leg executors, spot legs) instead of
|
|
58
|
+
* returning StrategyActions — the engine cannot simulate them, so --dry-run is refused.
|
|
59
|
+
*/
|
|
60
|
+
export const NON_SIMULATABLE_STRATEGIES = new Set([
|
|
61
|
+
"funding-arb", "funding-arb-v2", "funding-auto", "spot-perp-arb",
|
|
62
|
+
]);
|
|
63
|
+
/**
|
|
64
|
+
* ExchangeAdapter methods a --dry-run bot may call: pure reads (plus resolveSymbol, a local
|
|
65
|
+
* lookup). Allowlist, not blocklist (review L4): any other method — every venue write, and any
|
|
66
|
+
* write added to an adapter later — throws, so a dry-run can never reach the venue's write path.
|
|
67
|
+
*/
|
|
68
|
+
export const ADAPTER_READ_METHODS = new Set([
|
|
69
|
+
"getMarkets", "getOrderbook", "getRecentTrades", "getFundingHistory", "getKlines",
|
|
70
|
+
"getBalance", "getPositions", "getOpenOrders", "getOrderHistory", "getTradeHistory", "getFundingPayments",
|
|
71
|
+
"getFundingHours", "resolveSymbol",
|
|
72
|
+
]);
|
|
73
|
+
/** Throw INVALID_PARAMS if `strategyType` cannot run under --dry-run (checked before any adapter call). */
|
|
74
|
+
export function assertStrategySimulatable(strategyType) {
|
|
75
|
+
if (!NON_SIMULATABLE_STRATEGIES.has(strategyType.toLowerCase()))
|
|
76
|
+
return;
|
|
77
|
+
throw new PerpError("INVALID_PARAMS", `Strategy "${strategyType}" executes orders directly and cannot simulate under --dry-run`, {
|
|
78
|
+
remediation: "Run it without --dry-run on a small size, or backtest it: perp backtest --help",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Wrap an adapter so only allowlisted reads run; every other method throws (defense in depth for --dry-run bots). */
|
|
82
|
+
export function dryRunAdapter(adapter) {
|
|
83
|
+
return new Proxy(adapter, {
|
|
84
|
+
get(target, prop, receiver) {
|
|
85
|
+
const v = Reflect.get(target, prop, receiver);
|
|
86
|
+
if (typeof v !== "function")
|
|
87
|
+
return v;
|
|
88
|
+
if (typeof prop === "string" && ADAPTER_READ_METHODS.has(prop)) {
|
|
89
|
+
return v.bind(target);
|
|
90
|
+
}
|
|
91
|
+
return () => {
|
|
92
|
+
throw new PerpError("INVALID_PARAMS", `--dry-run bot attempted ${String(prop)}() on ${target.name} — only read methods are allowed under --dry-run; blocked`, {
|
|
93
|
+
remediation: "This strategy cannot simulate; run it without --dry-run",
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function runBot(adapter, config, jobId, log, extraAdapters, // for funding-arb (multi-exchange)
|
|
100
|
+
mode,
|
|
101
|
+
/** Global --dry-run: strategy actions are logged, never sent; non-simulatable strategies are refused. */
|
|
102
|
+
isDryRun) {
|
|
103
|
+
if (isDryRun) {
|
|
104
|
+
assertStrategySimulatable(config.strategy.type);
|
|
105
|
+
adapter = dryRunAdapter(adapter);
|
|
106
|
+
if (extraAdapters)
|
|
107
|
+
extraAdapters = new Map([...extraAdapters].map(([k, a]) => [k, dryRunAdapter(a)]));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// A bot must be able to close what it opens: refuse before the first order.
|
|
111
|
+
assertCanClosePositions(adapter, `Bot "${config.strategy.type}" on ${adapter.name}`);
|
|
112
|
+
for (const a of extraAdapters?.values() ?? [])
|
|
113
|
+
assertCanClosePositions(a, `Bot "${config.strategy.type}" on ${a.name}`);
|
|
114
|
+
}
|
|
53
115
|
const state = {
|
|
54
116
|
phase: "monitoring",
|
|
55
117
|
startTime: Date.now(),
|
|
56
|
-
equity:
|
|
57
|
-
peakEquity:
|
|
118
|
+
equity: null,
|
|
119
|
+
peakEquity: null,
|
|
58
120
|
dailyPnl: 0,
|
|
59
121
|
dailyStartEquity: 0,
|
|
60
122
|
dailyStartDate: new Date().toISOString().slice(0, 10),
|
|
61
123
|
fills: 0,
|
|
124
|
+
ordersPlaced: 0,
|
|
62
125
|
totalPnl: 0,
|
|
63
126
|
initialEquity: 0,
|
|
64
127
|
rebalanceCount: 0,
|
|
65
128
|
lastRebalance: 0,
|
|
66
129
|
strategyActive: false,
|
|
67
130
|
riskBreached: false,
|
|
131
|
+
runtimeEnded: false,
|
|
68
132
|
};
|
|
69
133
|
// Resolve strategy from registry
|
|
70
134
|
const strategyFactory = getStrategy(config.strategy.type);
|
|
@@ -84,7 +148,18 @@ mode = "headless") {
|
|
|
84
148
|
strategyCtx.state.set("extraAdapters", extraAdapters);
|
|
85
149
|
}
|
|
86
150
|
strategyCtx.state.set("riskConfig", config.risk);
|
|
87
|
-
|
|
151
|
+
// The market's tick / lot / minimums, read once per run (a failed read is retried on the next order).
|
|
152
|
+
let rules = null;
|
|
153
|
+
const rulesOf = async () => {
|
|
154
|
+
if (rules === null)
|
|
155
|
+
rules = await quoteRulesOf(adapter, config.symbol);
|
|
156
|
+
return rules;
|
|
157
|
+
};
|
|
158
|
+
// Failure surfacing: a run whose strategy never started / never completed a tick fails with its last error.
|
|
159
|
+
let lastError = null;
|
|
160
|
+
let strategyTicksOk = 0;
|
|
161
|
+
/** Consecutive failed strategy attempts (start / tick / loop), reset by a successful one. */
|
|
162
|
+
let failureStreak = 0;
|
|
88
163
|
let running = true;
|
|
89
164
|
// ── TUI / JSON mode setup ──
|
|
90
165
|
const tuiEmitter = new TuiEmitter();
|
|
@@ -162,17 +237,91 @@ mode = "headless") {
|
|
|
162
237
|
log(` Exit: ${config.exit_conditions.map(c => c.type).join(" | ") || "manual"}`);
|
|
163
238
|
log("");
|
|
164
239
|
}
|
|
165
|
-
//
|
|
240
|
+
// Unknown equity is logged once per reason (and once when it is known again), not every tick;
|
|
241
|
+
// JSON mode also writes it to stderr (stdout carries only the JSON lines).
|
|
242
|
+
let equityError = null;
|
|
243
|
+
const equityUnknown = (e, what) => {
|
|
244
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
245
|
+
state.equity = null;
|
|
246
|
+
if (reason === equityError)
|
|
247
|
+
return;
|
|
248
|
+
equityError = reason;
|
|
249
|
+
const msg = chalk.yellow(` WARNING: ${what} (${reason}) — equity reported as unknown (null); drawdown / daily-loss limits are not evaluated until it is known`);
|
|
250
|
+
effectiveLog(msg);
|
|
251
|
+
if (mode === "json")
|
|
252
|
+
process.stderr.write(`${msg}\n`);
|
|
253
|
+
};
|
|
254
|
+
// Consecutive ticks whose end-of-tick equity read failed; at UNKNOWN_EQUITY_STOP_TICKS the bot stops.
|
|
255
|
+
let unknownEquityTicks = 0;
|
|
256
|
+
/** A condition that ends the run as a failure regardless of earlier successful ticks. */
|
|
257
|
+
let fatalError = null;
|
|
258
|
+
// Entries are blocked while equity is unknown (logged once per blocked stretch).
|
|
259
|
+
let entryBlockLogged = false;
|
|
260
|
+
const blockEntryLog = (what) => {
|
|
261
|
+
if (entryBlockLogged)
|
|
262
|
+
return;
|
|
263
|
+
entryBlockLogged = true;
|
|
264
|
+
effectiveLog(chalk.yellow(` Entries blocked: equity unknown (${equityError ?? "not read yet"}) — ${what}; reduce-only orders and cancels still go out`));
|
|
265
|
+
};
|
|
266
|
+
// Market data (orderbook / klines) a strategy needs but could not be read: the tick is skipped (logged once per cause).
|
|
267
|
+
let marketDataError = null;
|
|
268
|
+
const needsMarketData = MARKET_DATA_STRATEGIES.has(config.strategy.type.toLowerCase());
|
|
269
|
+
/** Last snapshot price (reference price for the stop actions' market orders). */
|
|
270
|
+
let lastPrice = 0;
|
|
271
|
+
// onStop runs once per started strategy, on every stop path (exit / risk / time_after / SIGINT / error).
|
|
272
|
+
let onStopPending = false;
|
|
273
|
+
/** `retry`: a failure is rethrown and onStop stays pending (the exit phase retries next tick); else logged. */
|
|
274
|
+
const runOnStop = async (why, retry) => {
|
|
275
|
+
if (!onStopPending)
|
|
276
|
+
return;
|
|
277
|
+
onStopPending = false;
|
|
278
|
+
try {
|
|
279
|
+
const stopActions = await strategy.onStop(strategyCtx);
|
|
280
|
+
await executeActions(adapter, config.symbol, stopActions, effectiveLog, isDryRun, rulesOf, lastPrice);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
if (retry) {
|
|
284
|
+
onStopPending = true;
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
effectiveLog(chalk.red(` WARNING: strategy stop (${why}) FAILED: ${e instanceof Error ? e.message : String(e)} — check positions / orders by hand: perp -e ${adapter.name} account positions; perp -e ${adapter.name} account orders`));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Cancel the orders this bot placed. Single-symbol bot: that symbol's orders on its venue.
|
|
292
|
+
* Multi-symbol ("ALL") bot: the engine never sends a symbol-less cancel (it would cancel the
|
|
293
|
+
* user's manual orders on every market) — the strategy's onStop cancels its own symbols.
|
|
294
|
+
* Returns true when nothing of the bot's is left to cancel.
|
|
295
|
+
*/
|
|
296
|
+
const cancelBotOrders = async () => {
|
|
297
|
+
if (isDryRun)
|
|
298
|
+
return true;
|
|
299
|
+
if (config.symbol.toUpperCase() === "ALL") {
|
|
300
|
+
effectiveLog(` Multi-symbol bot: no account-wide cancel sent — the strategy cancels the orders of the symbols it traded`);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
effectiveLog(` Cleaning up orders...`);
|
|
304
|
+
try {
|
|
305
|
+
await adapter.cancelAllOrders(config.symbol);
|
|
306
|
+
effectiveLog(` Remaining ${config.symbol} orders cancelled.`);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
effectiveLog(` WARNING: cancelling the remaining ${config.symbol} orders FAILED (${e instanceof Error ? e.message : String(e)}) — check: perp -e ${adapter.name} account orders`);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
// Get initial equity (every venue + spot holdings for a multi-venue bot — botEquity)
|
|
166
315
|
try {
|
|
167
|
-
const
|
|
168
|
-
state.equity =
|
|
169
|
-
state.peakEquity =
|
|
170
|
-
state.dailyStartEquity =
|
|
171
|
-
state.initialEquity =
|
|
172
|
-
effectiveLog(` Starting equity: $${
|
|
316
|
+
const eq = await botEquity(adapter, extraAdapters);
|
|
317
|
+
state.equity = eq;
|
|
318
|
+
state.peakEquity = eq;
|
|
319
|
+
state.dailyStartEquity = eq;
|
|
320
|
+
state.initialEquity = eq;
|
|
321
|
+
effectiveLog(` Starting equity: $${eq.toFixed(2)}`);
|
|
173
322
|
}
|
|
174
|
-
catch {
|
|
175
|
-
|
|
323
|
+
catch (e) {
|
|
324
|
+
equityUnknown(e, "could not read the starting equity — the baseline is set at the first successful read");
|
|
176
325
|
}
|
|
177
326
|
if (mode !== "tui") {
|
|
178
327
|
log(chalk.gray(`\n Monitoring conditions... (Ctrl+C to stop)\n`));
|
|
@@ -187,17 +336,44 @@ mode = "headless") {
|
|
|
187
336
|
const loopStart = Date.now();
|
|
188
337
|
try {
|
|
189
338
|
// Get market data (enriched for strategy use)
|
|
190
|
-
const snapshot = await getEnrichedSnapshot(adapter, config.symbol);
|
|
339
|
+
const { snapshot, unknown: dataUnknown } = await getEnrichedSnapshot(adapter, config.symbol);
|
|
340
|
+
if (snapshot.price > 0)
|
|
341
|
+
lastPrice = snapshot.price;
|
|
342
|
+
// A strategy that reads the orderbook / klines never runs on fake (empty) data.
|
|
343
|
+
const skipForData = needsMarketData && dataUnknown !== null;
|
|
344
|
+
if (skipForData && dataUnknown !== marketDataError) {
|
|
345
|
+
marketDataError = dataUnknown;
|
|
346
|
+
effectiveLog(chalk.yellow(` WARNING: market data unknown (${dataUnknown}) — ${config.strategy.type} ticks are skipped until it is read`));
|
|
347
|
+
}
|
|
348
|
+
else if (!skipForData && needsMarketData && marketDataError !== null) {
|
|
349
|
+
effectiveLog(` Market data known again — ${config.strategy.type} resumes`);
|
|
350
|
+
marketDataError = null;
|
|
351
|
+
}
|
|
352
|
+
strategyCtx.state.set("equityUnknown", state.equity === null);
|
|
191
353
|
const context = {
|
|
192
354
|
equity: state.equity,
|
|
193
355
|
startTime: state.startTime,
|
|
194
356
|
peakEquity: state.peakEquity,
|
|
195
357
|
dailyPnl: state.dailyPnl,
|
|
196
358
|
};
|
|
359
|
+
// Runtime limit while not running (never entered, or a failed start retrying): stop too —
|
|
360
|
+
// the `time_after` check in the running phase alone would never see it.
|
|
361
|
+
if (state.phase === "monitoring" && config.exit_conditions.some(c => c.type === "time_after" && evaluateCondition(c, snapshot, context))) {
|
|
362
|
+
effectiveLog(` Bot stopped (runtime limit reached before the strategy ran).`);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
197
365
|
// ── Phase: Monitoring (waiting for entry conditions) ──
|
|
198
366
|
if (state.phase === "monitoring") {
|
|
199
367
|
const shouldEnter = evaluateAllConditions(config.entry_conditions, snapshot, context, "all");
|
|
200
|
-
if (shouldEnter) {
|
|
368
|
+
if (shouldEnter && state.equity === null) {
|
|
369
|
+
// Unknown equity: no new entry (the start places opening orders); retried next tick.
|
|
370
|
+
blockEntryLog("the strategy is not started");
|
|
371
|
+
logStatus(effectiveLog, state, snapshot, "waiting");
|
|
372
|
+
}
|
|
373
|
+
else if (shouldEnter && skipForData) {
|
|
374
|
+
logStatus(effectiveLog, state, snapshot, "waiting");
|
|
375
|
+
}
|
|
376
|
+
else if (shouldEnter) {
|
|
201
377
|
effectiveLog(` Entry conditions met @ $${snapshot.price.toFixed(2)}`);
|
|
202
378
|
state.phase = "entering";
|
|
203
379
|
}
|
|
@@ -207,21 +383,39 @@ mode = "headless") {
|
|
|
207
383
|
}
|
|
208
384
|
// ── Phase: Entering (start strategy) ──
|
|
209
385
|
if (state.phase === "entering") {
|
|
386
|
+
// Leftover orders of a failed start: cancel them before a retry (never stack a second batch).
|
|
387
|
+
if (state.strategyActive && !(await cancelBotOrders())) {
|
|
388
|
+
effectiveLog(` Strategy start postponed: the previous failed start's orders could not be cancelled`);
|
|
389
|
+
state.phase = "monitoring";
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (state.phase === "entering") {
|
|
393
|
+
// Active BEFORE the first order: a failed start's leftover orders are cancelled at stop.
|
|
394
|
+
state.strategyActive = true;
|
|
210
395
|
try {
|
|
211
396
|
await strategy.init(strategyCtx, snapshot);
|
|
212
397
|
// Execute any initial actions (e.g., grid placement on first tick)
|
|
213
398
|
strategyCtx.tick = 0;
|
|
214
399
|
const initActions = await strategy.onTick(strategyCtx, snapshot);
|
|
215
|
-
|
|
216
|
-
|
|
400
|
+
onStopPending = true;
|
|
401
|
+
// Orders sent (an undersized side skipped for the tick is not counted) / venue-reported fills.
|
|
402
|
+
const sent = await executeActions(adapter, config.symbol, initActions, effectiveLog, isDryRun, rulesOf, snapshot.price);
|
|
403
|
+
state.ordersPlaced += sent.placed;
|
|
404
|
+
state.fills += sent.filled;
|
|
217
405
|
strategyCtx.tick++;
|
|
406
|
+
strategyTicksOk++;
|
|
407
|
+
failureStreak = 0;
|
|
218
408
|
state.phase = "running";
|
|
219
|
-
state.strategyActive = true;
|
|
220
409
|
effectiveLog(` Strategy started`);
|
|
221
410
|
}
|
|
222
411
|
catch (err) {
|
|
412
|
+
lastError = err;
|
|
413
|
+
failureStreak++;
|
|
414
|
+
onStopPending = false; // a failed start is not stopped (its leftovers are cancelled), it is retried
|
|
223
415
|
const msg = err instanceof Error ? err.message : String(err);
|
|
224
416
|
effectiveLog(` Strategy start failed: ${msg}`);
|
|
417
|
+
if (await cancelBotOrders())
|
|
418
|
+
state.strategyActive = false;
|
|
225
419
|
state.phase = "monitoring"; // retry
|
|
226
420
|
}
|
|
227
421
|
}
|
|
@@ -230,38 +424,70 @@ mode = "headless") {
|
|
|
230
424
|
// Check exit conditions (any = stop)
|
|
231
425
|
const shouldExit = config.exit_conditions.length > 0
|
|
232
426
|
&& evaluateAllConditions(config.exit_conditions, snapshot, context, "any");
|
|
427
|
+
// A met `time_after` (runtime limit, e.g. quick-grid --max-runtime) ends the bot: returning
|
|
428
|
+
// to monitoring would re-enter at once (entry "always") and the runtime would never end.
|
|
429
|
+
const runtimeEnded = config.exit_conditions.some(c => c.type === "time_after" && evaluateCondition(c, snapshot, context));
|
|
233
430
|
// Check risk limits
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
431
|
+
// Equity not known this tick (a balance / spot read failed): the drawdown / daily-loss
|
|
432
|
+
// limits are not judged on stale or partial numbers — logged once (equityUnknown), never a fake breach.
|
|
433
|
+
const drawdown = state.equity !== null && state.peakEquity !== null ? state.peakEquity - state.equity : null;
|
|
434
|
+
const dailyLossBreached = drawdown !== null && state.dailyPnl < -config.risk.max_daily_loss;
|
|
435
|
+
const riskBreached = drawdown !== null && (drawdown > config.risk.max_drawdown || dailyLossBreached);
|
|
436
|
+
const finished = strategy.isDone?.(strategyCtx) === true;
|
|
437
|
+
if (shouldExit || riskBreached || finished) {
|
|
438
|
+
const reason = finished ? "strategy finished (its order total is placed)" : dailyLossBreached
|
|
239
439
|
? `daily loss $${Math.abs(state.dailyPnl).toFixed(2)} > limit $${config.risk.max_daily_loss}`
|
|
240
|
-
: drawdown > config.risk.max_drawdown
|
|
440
|
+
: drawdown !== null && drawdown > config.risk.max_drawdown
|
|
241
441
|
? `drawdown $${drawdown.toFixed(2)} > limit $${config.risk.max_drawdown}`
|
|
242
|
-
: "exit condition met";
|
|
442
|
+
: runtimeEnded ? "runtime limit (time_after) reached" : "exit condition met";
|
|
243
443
|
effectiveLog(` Exiting: ${reason}`);
|
|
244
444
|
state.riskBreached = riskBreached;
|
|
445
|
+
state.runtimeEnded = runtimeEnded || finished;
|
|
245
446
|
state.phase = "exiting";
|
|
246
447
|
}
|
|
448
|
+
else if (skipForData) {
|
|
449
|
+
logStatus(effectiveLog, state, snapshot, "running");
|
|
450
|
+
}
|
|
247
451
|
else {
|
|
248
452
|
// Manage running strategy
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
453
|
+
let actions = await strategy.onTick(strategyCtx, snapshot);
|
|
454
|
+
if (state.equity === null) {
|
|
455
|
+
// Unknown equity: opening orders are dropped; reduce-only orders / cancels still go out.
|
|
456
|
+
const kept = actions.filter(a => a.type !== "place_order" || a.reduceOnly === true);
|
|
457
|
+
if (kept.length < actions.length)
|
|
458
|
+
blockEntryLog(`${actions.length - kept.length} opening order(s) dropped this tick`);
|
|
459
|
+
actions = kept;
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
entryBlockLogged = false;
|
|
463
|
+
}
|
|
464
|
+
// Orders sent (an undersized side skipped for the tick is not counted) / venue-reported fills.
|
|
465
|
+
const sent = await executeActions(adapter, config.symbol, actions, effectiveLog, isDryRun, rulesOf, snapshot.price);
|
|
466
|
+
state.ordersPlaced += sent.placed;
|
|
467
|
+
state.fills += sent.filled;
|
|
252
468
|
strategyCtx.tick++;
|
|
469
|
+
strategyTicksOk++;
|
|
470
|
+
failureStreak = 0;
|
|
253
471
|
logStatus(effectiveLog, state, snapshot, "running");
|
|
254
472
|
}
|
|
255
473
|
}
|
|
256
474
|
// ── Phase: Exiting (close everything) ──
|
|
257
475
|
if (state.phase === "exiting") {
|
|
258
|
-
|
|
259
|
-
await executeActions(adapter, config.symbol, stopActions, effectiveLog, isDryRun);
|
|
476
|
+
await runOnStop("exit", true);
|
|
260
477
|
state.strategyActive = false;
|
|
261
|
-
if (
|
|
478
|
+
if (state.runtimeEnded) {
|
|
479
|
+
effectiveLog(` Bot stopped (runtime limit reached or strategy finished).`);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
else if (config.risk.pause_after_loss_sec > 0 && state.dailyPnl < 0) {
|
|
262
483
|
effectiveLog(` Pausing ${config.risk.pause_after_loss_sec}s after loss`);
|
|
263
484
|
state.phase = "paused";
|
|
264
|
-
|
|
485
|
+
// Interruptible: a SIGINT during the pause ends the bot within 250 ms.
|
|
486
|
+
const pauseUntil = Date.now() + config.risk.pause_after_loss_sec * 1000;
|
|
487
|
+
while (running && Date.now() < pauseUntil)
|
|
488
|
+
await sleep(Math.min(250, pauseUntil - Date.now()));
|
|
489
|
+
if (!running)
|
|
490
|
+
break;
|
|
265
491
|
// Data will be refreshed on the next loop iteration (re-fetches snapshot + equity)
|
|
266
492
|
state.phase = "monitoring";
|
|
267
493
|
effectiveLog(` Resuming monitoring...`);
|
|
@@ -284,24 +510,46 @@ mode = "headless") {
|
|
|
284
510
|
fetchOpenOrders(adapter, config.symbol),
|
|
285
511
|
fetchExchangeBalances(adapter, extraAdapters),
|
|
286
512
|
]);
|
|
287
|
-
//
|
|
513
|
+
// Equity: the primary venue's (single-venue bot) or every venue + spot holdings (multi-venue:
|
|
514
|
+
// a spot-perp bot moves USDC into spot tokens — counting the perp account alone read that
|
|
515
|
+
// as a loss; live 2026-10-12 "daily loss $10.12" right after a $10 spot buy).
|
|
288
516
|
try {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
517
|
+
const eq = await botEquity(adapter, extraAdapters);
|
|
518
|
+
state.equity = eq;
|
|
519
|
+
if (state.peakEquity === null) {
|
|
520
|
+
// First successful read after a failed start read: this is the baseline.
|
|
521
|
+
state.peakEquity = eq;
|
|
522
|
+
state.dailyStartEquity = eq;
|
|
523
|
+
state.initialEquity = eq;
|
|
524
|
+
}
|
|
525
|
+
if (equityError !== null) {
|
|
526
|
+
effectiveLog(` Equity known again: $${eq.toFixed(2)} — risk limits evaluated`);
|
|
527
|
+
equityError = null;
|
|
528
|
+
}
|
|
529
|
+
if (eq > state.peakEquity)
|
|
530
|
+
state.peakEquity = eq;
|
|
531
|
+
// Reset daily baseline at day boundary
|
|
532
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
533
|
+
if (today !== state.dailyStartDate) {
|
|
534
|
+
state.dailyStartEquity = eq;
|
|
535
|
+
state.dailyStartDate = today;
|
|
536
|
+
}
|
|
537
|
+
state.dailyPnl = eq - state.dailyStartEquity;
|
|
538
|
+
state.totalPnl = eq - state.initialEquity;
|
|
539
|
+
unknownEquityTicks = 0;
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
equityUnknown(e, "equity read failed");
|
|
543
|
+
unknownEquityTicks++;
|
|
544
|
+
if (unknownEquityTicks >= UNKNOWN_EQUITY_STOP_TICKS) {
|
|
545
|
+
fatalError = new PerpError(e instanceof PerpError ? e.structured.code : "EXCHANGE_ERROR", `Bot "${config.name}": equity unknown for ${unknownEquityTicks} consecutive ticks (last: ${equityError}) — stopped (risk limits cannot be evaluated)`, {
|
|
546
|
+
exchange: adapter.name,
|
|
547
|
+
remediation: `Check the balances / spot holdings the bot reads (perp -e ${adapter.name} account balance${extraAdapters ? "; every venue of the bot" : ""}), fix the read or the unpriced holding, then restart the bot`,
|
|
548
|
+
});
|
|
549
|
+
effectiveLog(chalk.red(` ${fatalError.message}`));
|
|
550
|
+
running = false;
|
|
302
551
|
}
|
|
303
552
|
}
|
|
304
|
-
catch { /* non-critical */ }
|
|
305
553
|
if (mode === "tui" || mode === "json") {
|
|
306
554
|
const tuiState = buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx, exchangeBalances);
|
|
307
555
|
if (mode === "tui") {
|
|
@@ -312,7 +560,7 @@ mode = "headless") {
|
|
|
312
560
|
}
|
|
313
561
|
}
|
|
314
562
|
else {
|
|
315
|
-
effectiveLog(` tick: phase=${state.phase} equity
|
|
563
|
+
effectiveLog(` tick: phase=${state.phase} equity=${usdOrUnknown(state.equity)}`);
|
|
316
564
|
}
|
|
317
565
|
// Update job state
|
|
318
566
|
if (jobId) {
|
|
@@ -322,6 +570,7 @@ mode = "headless") {
|
|
|
322
570
|
equity: state.equity,
|
|
323
571
|
peakEquity: state.peakEquity,
|
|
324
572
|
fills: state.fills,
|
|
573
|
+
ordersPlaced: state.ordersPlaced,
|
|
325
574
|
totalPnl: state.totalPnl,
|
|
326
575
|
rebalances: state.rebalanceCount,
|
|
327
576
|
runtime: Math.floor((Date.now() - state.startTime) / 1000),
|
|
@@ -330,14 +579,18 @@ mode = "headless") {
|
|
|
330
579
|
}
|
|
331
580
|
}
|
|
332
581
|
catch (err) {
|
|
582
|
+
lastError = err;
|
|
583
|
+
failureStreak++;
|
|
333
584
|
const msg = err instanceof Error ? err.message : String(err);
|
|
334
585
|
effectiveLog(` Loop error: ${msg}`);
|
|
335
586
|
}
|
|
336
587
|
// Wait for next interval
|
|
337
588
|
const elapsed = Date.now() - loopStart;
|
|
338
589
|
const waitMs = Math.max(0, config.monitor_interval_sec * 1000 - elapsed);
|
|
339
|
-
|
|
340
|
-
|
|
590
|
+
// Interruptible wait: a SIGINT (background stop) ends it within 250 ms, not after a full interval.
|
|
591
|
+
const until = Date.now() + waitMs;
|
|
592
|
+
while (running && Date.now() < until)
|
|
593
|
+
await sleep(Math.min(250, until - Date.now()));
|
|
341
594
|
}
|
|
342
595
|
}
|
|
343
596
|
finally {
|
|
@@ -348,57 +601,265 @@ mode = "headless") {
|
|
|
348
601
|
process.stdin.setRawMode?.(false);
|
|
349
602
|
process.stdin.pause();
|
|
350
603
|
}
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
catch { /* best effort */ }
|
|
358
|
-
}
|
|
604
|
+
// Every stop path (SIGINT / time_after / error / unknown equity): the strategy's onStop runs
|
|
605
|
+
// once (guarded), then the bot's remaining orders are cancelled.
|
|
606
|
+
await runOnStop("shutdown", false);
|
|
607
|
+
if (state.strategyActive)
|
|
608
|
+
await cancelBotOrders();
|
|
359
609
|
// Unmount TUI if active
|
|
360
610
|
if (tuiUnmount) {
|
|
361
611
|
tuiUnmount();
|
|
362
612
|
}
|
|
363
613
|
}
|
|
364
614
|
const runtime = Math.floor((Date.now() - state.startTime) / 1000);
|
|
615
|
+
// What the bot leaves open: positions on its venue(s) (its symbol, or every symbol for a
|
|
616
|
+
// multi-symbol bot — those may include positions this bot did not open), with close commands.
|
|
617
|
+
// The stop summary must reach the user in every mode: JSON mode keeps stdout for JSON lines
|
|
618
|
+
// (→ stderr), and the TUI is unmounted by now (→ the console log).
|
|
619
|
+
const stopLog = mode === "json"
|
|
620
|
+
? (msg) => { writeLog(msg); process.stderr.write(`${msg}\n`); }
|
|
621
|
+
: mode === "tui" ? (msg) => { log(msg); writeLog(msg); } : effectiveLog;
|
|
622
|
+
const spotLegs = extraAdapters !== undefined && extraAdapters.size > 0 && SPOT_LEG_STRATEGIES.has(config.strategy.type);
|
|
623
|
+
const leftOpen = isDryRun ? [] : await openPositionsAtStop(adapter, config.symbol, extraAdapters, stopLog, spotLegs);
|
|
365
624
|
if (mode === "json") {
|
|
366
|
-
console.log(JSON.stringify({ event: "done", fills: state.fills, totalPnl: state.totalPnl, runtime, equity: state.equity, peakEquity: state.peakEquity }));
|
|
625
|
+
console.log(JSON.stringify({ event: "done", fills: state.fills, ordersPlaced: state.ordersPlaced, totalPnl: state.totalPnl, runtime, equity: state.equity, peakEquity: state.peakEquity, openPositions: leftOpen }));
|
|
367
626
|
}
|
|
368
627
|
else if (mode !== "tui") {
|
|
369
628
|
log(chalk.cyan.bold(`\n Bot "${config.name}" finished`));
|
|
370
|
-
log(` Runtime: ${formatDuration(runtime)} | Fills: ${state.fills} | PnL: $${state.totalPnl.toFixed(2)}`);
|
|
371
|
-
log(` Equity:
|
|
629
|
+
log(` Runtime: ${formatDuration(runtime)} | Orders: ${state.ordersPlaced} | Fills: ${state.fills} | PnL: $${state.totalPnl.toFixed(2)}`);
|
|
630
|
+
log(` Equity: ${usdOrUnknown(state.equity)} (peak: ${usdOrUnknown(state.peakEquity)})\n`);
|
|
631
|
+
}
|
|
632
|
+
if (fatalError !== null) {
|
|
633
|
+
if (jobId)
|
|
634
|
+
updateJobState(jobId, { status: "error", result: { error: fatalError.message, fills: state.fills, ordersPlaced: state.ordersPlaced, runtime } });
|
|
635
|
+
throw fatalError;
|
|
636
|
+
}
|
|
637
|
+
// A run whose strategy never completed a start / tick — or whose last FAILED_RUN_STREAK attempts
|
|
638
|
+
// all failed — is a failed run (non-zero exit / ok:false), never a clean "done".
|
|
639
|
+
if (lastError !== null && (strategyTicksOk === 0 || failureStreak >= FAILED_RUN_STREAK)) {
|
|
640
|
+
const msg = lastError instanceof Error ? lastError.message : String(lastError);
|
|
641
|
+
const why = strategyTicksOk === 0 ? "the strategy never started / completed a tick" : `the last ${failureStreak} attempts failed`;
|
|
642
|
+
if (jobId)
|
|
643
|
+
updateJobState(jobId, { status: "error", result: { error: msg, fills: state.fills, ordersPlaced: state.ordersPlaced, runtime } });
|
|
644
|
+
throw new PerpError(lastError instanceof PerpError ? lastError.structured.code : "EXCHANGE_ERROR", `Bot "${config.name}" (${config.strategy.type} on ${adapter.name}) failed: ${why}. Last error: ${msg}`, {
|
|
645
|
+
exchange: adapter.name, fills: state.fills, ordersPlaced: state.ordersPlaced, runtime,
|
|
646
|
+
remediation: `See the log: ${logFile}`,
|
|
647
|
+
});
|
|
372
648
|
}
|
|
373
649
|
if (jobId) {
|
|
374
650
|
updateJobState(jobId, { status: "done" });
|
|
375
651
|
}
|
|
376
|
-
return { fills: state.fills, totalPnl: state.totalPnl, runtime };
|
|
652
|
+
return { fills: state.fills, ordersPlaced: state.ordersPlaced, totalPnl: state.totalPnl, runtime };
|
|
653
|
+
}
|
|
654
|
+
/** Consecutive failed strategy starts / ticks at the end of a run that make the run a failure. */
|
|
655
|
+
const FAILED_RUN_STREAK = 3;
|
|
656
|
+
/** Consecutive ticks with unknown equity after which the bot stops (an error, never a silent run). */
|
|
657
|
+
export const UNKNOWN_EQUITY_STOP_TICKS = 10;
|
|
658
|
+
/** Strategies that read the snapshot's orderbook / klines: a tick whose read failed is skipped. */
|
|
659
|
+
export const MARKET_DATA_STRATEGIES = new Set([
|
|
660
|
+
"engine-mm", "avellaneda-mm", "regime-mm", "claude-agent", "momentum-breakout", "aggressive-taker", "mean-reversion",
|
|
661
|
+
]);
|
|
662
|
+
/** Strategies that hold spot legs (spot-perp): their stop summary lists the spot holdings too. */
|
|
663
|
+
export const SPOT_LEG_STRATEGIES = new Set(["spot-perp-arb", "funding-arb"]);
|
|
664
|
+
/**
|
|
665
|
+
* Open positions at stop (logged with the close command); a failed read is logged, never "none".
|
|
666
|
+
* `spotLegs` (a multi-venue spot-perp bot): the bot spot venues' holdings of the traded symbol(s)
|
|
667
|
+
* are listed too — a spot holding + a perp short of one symbol is closed as a pair
|
|
668
|
+
* (`arb close --spot-exch --perp-exch`), never the short alone (that leaves the spot unhedged).
|
|
669
|
+
*/
|
|
670
|
+
export async function openPositionsAtStop(primary, symbol, extra, log, spotLegs) {
|
|
671
|
+
const all = new Map([[primary.name, primary], ...[...(extra ?? new Map())].filter(([n]) => n !== primary.name)]);
|
|
672
|
+
const multi = symbol.toUpperCase() === "ALL";
|
|
673
|
+
const out = [];
|
|
674
|
+
for (const [name, a] of all) {
|
|
675
|
+
try {
|
|
676
|
+
for (const p of await a.getPositions()) {
|
|
677
|
+
if (Number(p.size) === 0)
|
|
678
|
+
continue;
|
|
679
|
+
const sym = p.symbol.toUpperCase().replace(/-PERP$/, "");
|
|
680
|
+
if (!multi && sym !== symbol.toUpperCase())
|
|
681
|
+
continue;
|
|
682
|
+
out.push({ exchange: name, symbol: sym, side: p.side, size: p.size.replace(/^-/, ""), close: null });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
log(` WARNING: could not read ${name} positions at stop (${e instanceof Error ? e.message : String(e)}) — check: perp -e ${name} account positions`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (spotLegs) {
|
|
690
|
+
const { getBotSpotAdapter, nonQuoteSpotBalances, spotNetworkOf, spotTokenBase } = await import("../exchanges/spot-factory.js");
|
|
691
|
+
const { SPOT_PERP_TOKEN_MAP } = await import("../exchanges/spot-interface.js");
|
|
692
|
+
const { getVenue } = await import("../exchanges/venue.js");
|
|
693
|
+
for (const [name, a] of all) {
|
|
694
|
+
try {
|
|
695
|
+
const spot = await getBotSpotAdapter(name, a);
|
|
696
|
+
if (spot === null)
|
|
697
|
+
continue;
|
|
698
|
+
const [balances, markets] = await Promise.all([spot.getSpotBalances(), spot.getSpotMarkets()]);
|
|
699
|
+
// Only tokens with a spot market can be a spot leg (e.g. HIP-4 outcome tokens are not).
|
|
700
|
+
const traded = new Set(markets.map(m => m.baseToken.toUpperCase()));
|
|
701
|
+
for (const b of nonQuoteSpotBalances(getVenue(name), spotNetworkOf(spot), balances)) {
|
|
702
|
+
const base = spotTokenBase(b.token);
|
|
703
|
+
if (!traded.has(base))
|
|
704
|
+
continue;
|
|
705
|
+
// Listed under the perp name (UBTC → BTC) so it pairs with its hedge.
|
|
706
|
+
const sym = SPOT_PERP_TOKEN_MAP[base] ?? base;
|
|
707
|
+
if (!multi && sym !== symbol.toUpperCase() && base !== symbol.toUpperCase())
|
|
708
|
+
continue;
|
|
709
|
+
out.push({ exchange: name, symbol: sym, side: "spot", size: b.total, close: `perp -e ${name} trade spot-sell ${base} ${b.total}` });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
log(` WARNING: could not read ${name} spot holdings at stop (${e instanceof Error ? e.message : String(e)}) — check: perp -e ${name} trade spot-balance`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (out.length === 0)
|
|
718
|
+
return [];
|
|
719
|
+
log(` Open positions at stop${multi ? ` on ${[...all.keys()].join(", ")} (may include positions this bot did not open)` : ""}:`);
|
|
720
|
+
const paired = new Set();
|
|
721
|
+
const shortFor = (sym, notOn) => out.find(p => p.side === "short" && p.symbol === sym && p.exchange !== notOn && !paired.has(p));
|
|
722
|
+
for (const sp of out.filter(p => p.side === "spot")) {
|
|
723
|
+
const s = shortFor(sp.symbol, null);
|
|
724
|
+
if (s === undefined)
|
|
725
|
+
continue;
|
|
726
|
+
paired.add(sp);
|
|
727
|
+
paired.add(s);
|
|
728
|
+
sp.close = s.close = `perp arb close ${sp.symbol} --spot-exch ${sp.exchange} --perp-exch ${s.exchange}`;
|
|
729
|
+
log(` ${sp.symbol}: spot ${sp.size} on ${sp.exchange} / short ${s.size} on ${s.exchange} — close the pair: ${sp.close}`);
|
|
730
|
+
}
|
|
731
|
+
for (const l of out.filter(p => p.side === "long")) {
|
|
732
|
+
const s = shortFor(l.symbol, l.exchange);
|
|
733
|
+
if (s === undefined)
|
|
734
|
+
continue;
|
|
735
|
+
paired.add(l);
|
|
736
|
+
paired.add(s);
|
|
737
|
+
l.close = s.close = `perp arb close ${l.symbol} --pair ${l.exchange}:${s.exchange}`;
|
|
738
|
+
log(` ${l.symbol}: long ${l.size} on ${l.exchange} / short ${s.size} on ${s.exchange} — close the pair: ${l.close}`);
|
|
739
|
+
}
|
|
740
|
+
for (const p of out) {
|
|
741
|
+
if (paired.has(p))
|
|
742
|
+
continue;
|
|
743
|
+
if (p.close === null)
|
|
744
|
+
p.close = `perp -e ${p.exchange} trade close ${p.symbol}`;
|
|
745
|
+
log(` ${p.symbol}: ${p.side} ${p.size} on ${p.exchange} — close: ${p.close}`);
|
|
746
|
+
}
|
|
747
|
+
return out.map(p => ({ ...p, close: p.close }));
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Bot equity in USD. Single-venue bot: the venue's account equity. Multi-venue bot (extra
|
|
751
|
+
* adapters): Σ every venue's account equity + its bot spot holdings — tokens at the shared
|
|
752
|
+
* valuation price (spotMarketsForValuation: spot market price, HIP-4 outcome tokens at their
|
|
753
|
+
* outcome mid), and the spot quote balance when the spot account is separate from the perp
|
|
754
|
+
* margin (spotSharesPerpMargin false). Any failed read or unpriced token → throws (equity unknown;
|
|
755
|
+
* never a partial sum that would read as a loss).
|
|
756
|
+
*/
|
|
757
|
+
async function botEquity(primary, extra) {
|
|
758
|
+
const eq = (v, who) => {
|
|
759
|
+
const n = Number(v);
|
|
760
|
+
if (!Number.isFinite(n))
|
|
761
|
+
throw new PerpError("EXCHANGE_ERROR", `${who} equity is not a number (${v})`);
|
|
762
|
+
return n;
|
|
763
|
+
};
|
|
764
|
+
if (extra === undefined || extra.size === 0)
|
|
765
|
+
return eq((await primary.getBalance()).equity, primary.name);
|
|
766
|
+
const { getBotSpotAdapter, isSpotQuoteAsset, spotMarketsForValuation, spotNetworkOf, spotTokenBase, spotSharesPerpMargin } = await import("../exchanges/spot-factory.js");
|
|
767
|
+
const { getVenue } = await import("../exchanges/venue.js");
|
|
768
|
+
const all = new Map([[primary.name, primary], ...[...extra].filter(([n]) => n !== primary.name)]);
|
|
769
|
+
let total = 0;
|
|
770
|
+
for (const [name, a] of all) {
|
|
771
|
+
total += eq((await a.getBalance()).equity, name);
|
|
772
|
+
const spot = await getBotSpotAdapter(name, a);
|
|
773
|
+
if (spot === null)
|
|
774
|
+
continue;
|
|
775
|
+
const def = getVenue(name);
|
|
776
|
+
const net = spotNetworkOf(spot);
|
|
777
|
+
const shared = spotSharesPerpMargin(def, a);
|
|
778
|
+
// Shared valuation prices (spot markets + held HIP-4 outcome tokens at their outcome mid).
|
|
779
|
+
const balances = await spot.getSpotBalances();
|
|
780
|
+
const markets = await spotMarketsForValuation(def, spot, balances, net);
|
|
781
|
+
const price = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
|
|
782
|
+
for (const b of balances) {
|
|
783
|
+
const qty = Number(b.total);
|
|
784
|
+
if (!(qty > 0))
|
|
785
|
+
continue;
|
|
786
|
+
if (isSpotQuoteAsset(def, net, b.token)) {
|
|
787
|
+
if (!shared)
|
|
788
|
+
total += qty;
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const px = Number(price.get(spotTokenBase(b.token)));
|
|
792
|
+
if (!(px > 0))
|
|
793
|
+
throw new PerpError("PRICE_STALE", `${name} spot ${b.token}: no market price — bot equity unknown`);
|
|
794
|
+
total += qty * px;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return total;
|
|
377
798
|
}
|
|
378
799
|
// ── Action executor ──
|
|
379
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Send strategy actions. Orders go out on the venue grid (quote-rules.ts): limit prices aligned
|
|
802
|
+
* passively to the tick (bid ↓, ask ↑), sizes floored to the lot, venue minimums refused before
|
|
803
|
+
* sending. `refPrice` = the tick's snapshot price (minimum order value of market orders).
|
|
804
|
+
* One order under the venue minimum (e.g. the inventory-skewed side of a market maker) is
|
|
805
|
+
* skipped for this tick with a log line — the other orders still go out; when EVERY order of
|
|
806
|
+
* the batch is under the minimum the last SIZE_TOO_SMALL is thrown (the tick fails: the
|
|
807
|
+
* strategy's base size itself is too small).
|
|
808
|
+
* Returns the orders sent (`placed`) and those the venue reported as (partly) filled (`filled`,
|
|
809
|
+
* filledOf) — an accepted resting limit order is a placement, not a fill. Under --dry-run every
|
|
810
|
+
* place_order is a simulated placement and nothing fills.
|
|
811
|
+
*/
|
|
812
|
+
export async function executeActions(adapter, symbol, actions, log, dryRun, rulesOf, refPrice) {
|
|
813
|
+
let orders = 0;
|
|
814
|
+
let filled = 0;
|
|
815
|
+
let undersized = null;
|
|
816
|
+
let skipped = 0;
|
|
380
817
|
for (const action of actions) {
|
|
381
818
|
if (dryRun) {
|
|
382
819
|
log(` [DRY-RUN] ${action.type}: ${JSON.stringify(action)}`);
|
|
820
|
+
if (action.type === "place_order")
|
|
821
|
+
orders++;
|
|
383
822
|
continue;
|
|
384
823
|
}
|
|
385
824
|
switch (action.type) {
|
|
386
|
-
case "place_order":
|
|
387
|
-
|
|
388
|
-
|
|
825
|
+
case "place_order": {
|
|
826
|
+
orders++;
|
|
827
|
+
const rules = await rulesOf();
|
|
828
|
+
let prepared;
|
|
829
|
+
try {
|
|
830
|
+
prepared = action.orderType === "market"
|
|
831
|
+
? { price: null, size: prepareMarketSize(rules, adapter.name, symbol, action.size, refPrice) }
|
|
832
|
+
: prepareLimitQuote(rules, adapter.name, symbol, action.side, action.price, action.size);
|
|
389
833
|
}
|
|
390
|
-
|
|
391
|
-
|
|
834
|
+
catch (e) {
|
|
835
|
+
if (!(e instanceof PerpError) || e.structured.code !== "SIZE_TOO_SMALL")
|
|
836
|
+
throw e;
|
|
837
|
+
undersized = e;
|
|
838
|
+
skipped++;
|
|
839
|
+
log(` SKIP ${action.side} ${action.size}${action.orderType === "limit" ? ` @ ${action.price}` : ""} this tick: ${e.message}`);
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
const raw = prepared.price === null
|
|
843
|
+
? await adapter.marketOrder(symbol, action.side, prepared.size, { reduceOnly: action.reduceOnly === true, slippage: resolveAdapterSlippage(adapter, null) })
|
|
844
|
+
: await adapter.limitOrder(symbol, action.side, rules.tickSize === null ? await alignComputedLimitPrice(adapter, symbol, action.side, prepared.price) : prepared.price, prepared.size, {
|
|
392
845
|
reduceOnly: action.reduceOnly,
|
|
393
846
|
tif: action.tif,
|
|
394
847
|
});
|
|
395
|
-
|
|
848
|
+
const f = filledOf(adapter, raw);
|
|
849
|
+
if (f !== null && f > 0)
|
|
850
|
+
filled++;
|
|
396
851
|
break;
|
|
852
|
+
}
|
|
397
853
|
case "cancel_order":
|
|
398
854
|
await adapter.cancelOrder(symbol, action.orderId);
|
|
399
855
|
break;
|
|
400
856
|
case "cancel_all":
|
|
401
|
-
|
|
857
|
+
// A multi-symbol bot never sends an account-wide cancel (it would cancel the user's
|
|
858
|
+
// manual orders on every market): its strategy cancels the symbols it traded.
|
|
859
|
+
if (symbol.toUpperCase() === "ALL")
|
|
860
|
+
log(` cancel_all not sent for a multi-symbol bot (account-wide) — the strategy cancels its own symbols`);
|
|
861
|
+
else
|
|
862
|
+
await adapter.cancelAllOrders(symbol);
|
|
402
863
|
break;
|
|
403
864
|
case "edit_order":
|
|
404
865
|
await adapter.editOrder(symbol, action.orderId, action.price, action.size);
|
|
@@ -410,37 +871,54 @@ async function executeActions(adapter, symbol, actions, log, dryRun = false) {
|
|
|
410
871
|
break;
|
|
411
872
|
}
|
|
412
873
|
}
|
|
874
|
+
if (undersized !== null && skipped === orders)
|
|
875
|
+
throw undersized;
|
|
876
|
+
return { placed: orders - skipped, filled };
|
|
413
877
|
}
|
|
878
|
+
export { filledOf };
|
|
414
879
|
// ── Enriched snapshot ──
|
|
880
|
+
/**
|
|
881
|
+
* The tick's market snapshot. `unknown` names a failed orderbook / klines read (null = both
|
|
882
|
+
* read): the empty arrays in `snapshot` then are NOT data — the engine skips the tick of a
|
|
883
|
+
* strategy that reads them (MARKET_DATA_STRATEGIES).
|
|
884
|
+
*/
|
|
415
885
|
async function getEnrichedSnapshot(adapter, symbol) {
|
|
416
886
|
// Multi-symbol strategies use "ALL" — skip market-specific data
|
|
417
887
|
if (symbol.toUpperCase() === "ALL") {
|
|
418
888
|
return {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
889
|
+
snapshot: {
|
|
890
|
+
price: 0, high24h: 0, low24h: 0, volume24h: 0,
|
|
891
|
+
fundingRate: 0, volatility24h: 0, rsi: NaN, spreadPct: 0,
|
|
892
|
+
klines: [], orderbook: { bids: [], asks: [] }, openInterest: "0",
|
|
893
|
+
},
|
|
894
|
+
unknown: null,
|
|
422
895
|
};
|
|
423
896
|
}
|
|
424
897
|
const base = await getMarketSnapshot(adapter, symbol);
|
|
898
|
+
const failed = [];
|
|
899
|
+
const why = (what) => (e) => { failed.push(`${what} read failed: ${e instanceof Error ? e.message : String(e)}`); return null; };
|
|
425
900
|
const [orderbook, klines] = await Promise.all([
|
|
426
|
-
adapter.getOrderbook(symbol).catch(()
|
|
427
|
-
adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(()
|
|
901
|
+
adapter.getOrderbook(symbol).catch(why("orderbook")),
|
|
902
|
+
adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(why("klines")),
|
|
428
903
|
]);
|
|
429
|
-
return {
|
|
904
|
+
return {
|
|
905
|
+
snapshot: { ...base, klines: klines ?? [], orderbook: orderbook ?? { bids: [], asks: [] }, openInterest: "0" },
|
|
906
|
+
unknown: failed.length > 0 ? failed.join("; ") : null,
|
|
907
|
+
};
|
|
430
908
|
}
|
|
431
909
|
// ── Utils ──
|
|
432
|
-
/** Count fill-producing actions (all order placements) */
|
|
433
|
-
function countFills(actions) {
|
|
434
|
-
return actions.filter(a => a.type === "place_order").length;
|
|
435
|
-
}
|
|
436
910
|
function logStatus(log, state, snapshot, phase) {
|
|
437
911
|
const runtime = formatDuration(Math.floor((Date.now() - state.startTime) / 1000));
|
|
438
912
|
const vol = snapshot.volatility24h.toFixed(1);
|
|
439
913
|
const fr = (snapshot.fundingRate * 100).toFixed(4);
|
|
440
|
-
const eq = state.equity
|
|
441
|
-
const dd = (state.peakEquity - state.equity).toFixed(2);
|
|
914
|
+
const eq = usdOrUnknown(state.equity);
|
|
915
|
+
const dd = state.equity !== null && state.peakEquity !== null ? `$${(state.peakEquity - state.equity).toFixed(2)}` : "unknown";
|
|
442
916
|
log(chalk.gray(` [${phase}] $${snapshot.price.toFixed(2)} | vol: ${vol}% | fr: ${fr}% | ` +
|
|
443
|
-
`eq:
|
|
917
|
+
`eq: ${eq} | dd: ${dd} | fills: ${state.fills} | ${runtime}`));
|
|
918
|
+
}
|
|
919
|
+
/** "$12.34", or "unknown" for a null (unknown) amount. */
|
|
920
|
+
function usdOrUnknown(v) {
|
|
921
|
+
return v === null ? "unknown" : `$${v.toFixed(2)}`;
|
|
444
922
|
}
|
|
445
923
|
function defaultLog(msg) {
|
|
446
924
|
const ts = new Date().toLocaleTimeString();
|
|
@@ -497,70 +975,57 @@ async function fetchExchangeBalances(primaryAdapter, extraAdapters) {
|
|
|
497
975
|
}));
|
|
498
976
|
return results;
|
|
499
977
|
}
|
|
978
|
+
const _spotPriceWarned = new Set();
|
|
500
979
|
async function fetchSpotPositions(adapter) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
980
|
+
// Bot scope (§12.4 #3): only separate/runtime spot venues with transfer.
|
|
981
|
+
const { getBotSpotAdapter, isSpotQuoteAsset, spotMarketsForValuation, spotNetworkOf, spotTokenBase } = await import("../exchanges/spot-factory.js");
|
|
982
|
+
const { getVenue } = await import("../exchanges/venue.js");
|
|
983
|
+
let raw;
|
|
984
|
+
let markets;
|
|
985
|
+
let net;
|
|
504
986
|
try {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
markPrice,
|
|
532
|
-
unrealizedPnl: avgEntry > 0 ? uPnL.toFixed(2) : "—",
|
|
533
|
-
exchange: adapter.name,
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
return hlPositions;
|
|
537
|
-
}
|
|
538
|
-
if (name === "lighter") {
|
|
539
|
-
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
540
|
-
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
541
|
-
if (!(adapter instanceof LighterAdapter))
|
|
542
|
-
return [];
|
|
543
|
-
const ltSpot = new LighterSpotAdapter(adapter);
|
|
544
|
-
await ltSpot.init();
|
|
545
|
-
const [raw, markets] = await Promise.all([ltSpot.getSpotBalances(), ltSpot.getSpotMarkets()]);
|
|
546
|
-
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
|
|
547
|
-
const ltPositions = [];
|
|
548
|
-
for (const b of raw) {
|
|
549
|
-
if (Number(b.total) <= 0)
|
|
550
|
-
continue;
|
|
551
|
-
const base = b.token.toUpperCase();
|
|
552
|
-
const markPrice = priceMap.get(base) ?? "0";
|
|
553
|
-
if (base === "USDC" || base === "USDC_SPOT" || markPrice === "0")
|
|
554
|
-
continue;
|
|
555
|
-
ltPositions.push({ symbol: base, side: "spot", size: b.total, entryPrice: "—", markPrice, unrealizedPnl: "—", exchange: adapter.name });
|
|
987
|
+
const spot = await getBotSpotAdapter(adapter.name, adapter);
|
|
988
|
+
if (spot === null)
|
|
989
|
+
return [];
|
|
990
|
+
net = spotNetworkOf(spot);
|
|
991
|
+
raw = await spot.getSpotBalances();
|
|
992
|
+
markets = await spotMarketsForValuation(getVenue(adapter.name), spot, raw, net);
|
|
993
|
+
}
|
|
994
|
+
catch (e) {
|
|
995
|
+
// Display-only path: surface the failure instead of hiding it, keep the TUI alive.
|
|
996
|
+
process.stderr.write(`[warn] ${adapter.name} spot positions unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
997
|
+
return [];
|
|
998
|
+
}
|
|
999
|
+
const def = getVenue(adapter.name);
|
|
1000
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
|
|
1001
|
+
const out = [];
|
|
1002
|
+
for (const b of raw) {
|
|
1003
|
+
if (Number(b.total) <= 0 || isSpotQuoteAsset(def, net, b.token))
|
|
1004
|
+
continue;
|
|
1005
|
+
const base = spotTokenBase(b.token);
|
|
1006
|
+
const markPrice = priceMap.get(base);
|
|
1007
|
+
if (markPrice === undefined || markPrice === null || !(Number(markPrice) > 0)) {
|
|
1008
|
+
// Unknown price: excluded (never valued at 0) — surfaced once per token.
|
|
1009
|
+
const k = `${def.id}:${base}`;
|
|
1010
|
+
if (!_spotPriceWarned.has(k)) {
|
|
1011
|
+
_spotPriceWarned.add(k);
|
|
1012
|
+
process.stderr.write(`[warn] ${def.displayName} spot ${base}: no market price — excluded from spot positions\n`);
|
|
556
1013
|
}
|
|
557
|
-
|
|
1014
|
+
continue;
|
|
558
1015
|
}
|
|
1016
|
+
const total = Number(b.total);
|
|
1017
|
+
const entryNtl = b.entryNtl ? Number(b.entryNtl) : 0;
|
|
1018
|
+
const avgEntry = (entryNtl > 0 && total > 0) ? (entryNtl / total) : 0;
|
|
1019
|
+
const uPnL = avgEntry > 0 ? (Number(markPrice) - avgEntry) * total : 0;
|
|
1020
|
+
out.push({
|
|
1021
|
+
symbol: base, side: "spot", size: b.total,
|
|
1022
|
+
entryPrice: avgEntry > 0 ? avgEntry.toPrecision(5) : "—",
|
|
1023
|
+
markPrice,
|
|
1024
|
+
unrealizedPnl: avgEntry > 0 ? uPnL.toFixed(2) : "—",
|
|
1025
|
+
exchange: adapter.name,
|
|
1026
|
+
});
|
|
559
1027
|
}
|
|
560
|
-
|
|
561
|
-
// Spot adapter unavailable — skip silently
|
|
562
|
-
}
|
|
563
|
-
return [];
|
|
1028
|
+
return out;
|
|
564
1029
|
}
|
|
565
1030
|
async function fetchAllPositions(primaryAdapter, symbol, extraAdapters) {
|
|
566
1031
|
const adapters = [[primaryAdapter.name, primaryAdapter]];
|
|
@@ -618,6 +1083,7 @@ function buildTuiState(state, config, snapshot, positions, openOrders, strategyC
|
|
|
618
1083
|
peakEquity: state.peakEquity,
|
|
619
1084
|
dailyPnl: state.dailyPnl,
|
|
620
1085
|
fills: state.fills,
|
|
1086
|
+
ordersPlaced: state.ordersPlaced,
|
|
621
1087
|
totalPnl: state.totalPnl,
|
|
622
1088
|
runtime: Math.floor((Date.now() - state.startTime) / 1000),
|
|
623
1089
|
strategy: config.strategy.type,
|