perp-cli 0.12.17 → 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 +247 -27
- 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 +70 -92
- 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 +156 -684
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +7 -2
- package/dist/landing.js +19 -3
- 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 +142 -55
- package/skills/perp-cli/references/agent-operations.md +110 -56
- package/skills/perp-cli/references/commands.md +266 -71
- package/skills/perp-cli/references/strategies.md +34 -31
- package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
- package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
- 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 +31 -15
- 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/exchanges/aster.js
CHANGED
|
@@ -18,9 +18,139 @@
|
|
|
18
18
|
* Docs: https://docs.asterdex.com/product/aster-perpetuals/api/api-documentation
|
|
19
19
|
* Base: https://fapi.asterdex.com
|
|
20
20
|
*/
|
|
21
|
+
import { accountCacheEpoch } from "../cache.js";
|
|
21
22
|
import { isExpired } from "../agent-wallet/expiry.js";
|
|
22
|
-
import { classifyError, PerpError } from "../errors.js";
|
|
23
|
-
import {
|
|
23
|
+
import { classifyError, DEFINITE_ORDER_FAILURE_CODES, isTransportFailure, orderNotSent, orderOutcomeUnknown, PerpError, sendFailure } from "../errors.js";
|
|
24
|
+
import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
|
|
25
|
+
import { buildOrderTypedData, buildWithdrawTypedData } from "./aster-typed-data.js";
|
|
26
|
+
import { alignProtectionPrice, requireOnTick, requirePositive, slippagePrice, subDecimal } from "./precision.js";
|
|
27
|
+
import { unsupported } from "./trading-v2.js";
|
|
28
|
+
import { networkConfig } from "./venue.js";
|
|
29
|
+
import { approvalFromMax, bpsToRate, builderGateOf, NO_BUILDER, rateToBps, requireFeeBps } from "./builder-fee.js";
|
|
30
|
+
import { ASTER_CLIENT_ID_RE, ASTER_FUNDS_CHAINS, asterVenue } from "./venues/aster.js";
|
|
31
|
+
import { assertAsterWithdrawAmount, assertAsterWithdrawSignature, parseAsterWithdrawLimits, requireAsterFundsNetwork, } from "./aster-funds.js";
|
|
32
|
+
import { nextAsterNonce } from "./aster-nonce.js";
|
|
33
|
+
/** Common TIF (§13.3) → Aster perp timeInForce. Live exchangeInfo lists GTC/IOC/GTX/HIDDEN — no FOK. */
|
|
34
|
+
function asterTif(raw) {
|
|
35
|
+
const t = raw.toUpperCase();
|
|
36
|
+
if (t === "GTC" || t === "IOC" || t === "GTX")
|
|
37
|
+
return t;
|
|
38
|
+
if (t === "ALO")
|
|
39
|
+
return "GTX";
|
|
40
|
+
if (t === "FOK") {
|
|
41
|
+
throw unsupported("Aster", "fok", "perp symbols offer timeInForce GTC/IOC/GTX only (exchangeInfo has no FOK)", "Use --tif IOC, GTC or ALO (post-only)");
|
|
42
|
+
}
|
|
43
|
+
throw new PerpError("INVALID_PARAMS", `Unsupported time-in-force "${raw}" for Aster`, {
|
|
44
|
+
exchange: "aster",
|
|
45
|
+
remediation: "Use --tif GTC, IOC or ALO (post-only)",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Plain non-negative decimal string of a venue quantity field, or null when absent/unreadable. */
|
|
49
|
+
function plainQty(v) {
|
|
50
|
+
const s = typeof v === "number" && Number.isFinite(v) ? String(v) : v;
|
|
51
|
+
if (typeof s !== "string" || !/^\d+(\.\d+)?$/.test(s))
|
|
52
|
+
return null;
|
|
53
|
+
return subDecimal(s, "0");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Definite IOC outcome of a raw Aster order response (perp and spot share the
|
|
57
|
+
* Binance-compatible shape) — contract in ./ioc-outcome.ts. Final only for a terminal
|
|
58
|
+
* status (FILLED / EXPIRED / CANCELED) with a reported executedQty, on an IOC (or
|
|
59
|
+
* MARKET) order; anything else → null (caller measures).
|
|
60
|
+
*/
|
|
61
|
+
export function asterIocOutcome(raw) {
|
|
62
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
63
|
+
return null;
|
|
64
|
+
const r = raw;
|
|
65
|
+
const status = typeof r.status === "string" ? r.status.toUpperCase() : "";
|
|
66
|
+
if (status !== "FILLED" && status !== "EXPIRED" && status !== "CANCELED")
|
|
67
|
+
return null;
|
|
68
|
+
const tif = typeof r.timeInForce === "string" ? r.timeInForce.toUpperCase() : null;
|
|
69
|
+
const type = typeof r.type === "string" ? r.type.toUpperCase() : null;
|
|
70
|
+
if (tif !== null && tif !== "IOC" && type !== "MARKET")
|
|
71
|
+
return null;
|
|
72
|
+
const filledSize = plainQty(r.executedQty);
|
|
73
|
+
return filledSize === null ? null : { filledSize, final: true };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Order-placement refusals by documented venue code (asterdex/api-docs V3 futures "Error
|
|
77
|
+
* Codes"; "Codes are universal,but messages can vary"). [PerpError code, docs name, remediation].
|
|
78
|
+
* A documented code that is not listed here is a plain ORDER_REJECTED.
|
|
79
|
+
*/
|
|
80
|
+
const ASTER_ORDER_REJECTIONS = {
|
|
81
|
+
[-1003]: ["ORDER_REJECTED", "TOO_MANY_REQUESTS", "The order was not placed. Wait, then place it again at a lower request rate"],
|
|
82
|
+
[-1015]: ["ORDER_REJECTED", "TOO_MANY_ORDERS", "The order was not placed. Wait, then place it again at a lower order rate"],
|
|
83
|
+
[-1121]: ["SYMBOL_NOT_FOUND", "BAD_SYMBOL", "Check the symbol: perp market list -e aster"],
|
|
84
|
+
[-2010]: ["ORDER_REJECTED", "NEW_ORDER_REJECTED", null],
|
|
85
|
+
[-2018]: ["INSUFFICIENT_BALANCE", "BALANCE_NOT_SUFFICIENT", "Check the balance: perp account balance -e aster"],
|
|
86
|
+
[-2019]: ["MARGIN_INSUFFICIENT", "MARGIN_NOT_SUFFICIEN", "Reduce the size, raise the leverage or add margin: perp account balance -e aster"],
|
|
87
|
+
[-2020]: ["ORDER_REJECTED", "UNABLE_TO_FILL", null],
|
|
88
|
+
[-2021]: ["ORDER_REJECTED", "ORDER_WOULD_IMMEDIATELY_TRIGGER", "Use a trigger price on the other side of the current mark / last price"],
|
|
89
|
+
[-2022]: ["ORDER_REJECTED", "REDUCE_ONLY_REJECT", "A reduce-only order needs an open position on the opposite side (and no larger than it): perp account positions -e aster"],
|
|
90
|
+
[-2024]: ["ORDER_REJECTED", "POSITION_NOT_SUFFICIENT", "Check the position size: perp account positions -e aster"],
|
|
91
|
+
[-2025]: ["ORDER_REJECTED", "MAX_OPEN_ORDER_EXCEEDED", "Cancel open orders first: perp account orders -e aster"],
|
|
92
|
+
[-2027]: ["ORDER_REJECTED", "MAX_LEVERAGE_RATIO", "Lower the leverage or the position size for this market"],
|
|
93
|
+
[-4004]: ["SIZE_TOO_SMALL", "QTY_LESS_THAN_MIN_QTY", "Increase the size: perp market info <symbol> -e aster"],
|
|
94
|
+
[-4005]: ["SIZE_TOO_LARGE", "QTY_GREATER_THAN_MAX_QTY", "Reduce the size: perp market info <symbol> -e aster"],
|
|
95
|
+
[-4014]: ["INVALID_TICK", "PRICE_NOT_INCREASED_BY_TICK_SIZE", "Use a price on the market's tick size"],
|
|
96
|
+
[-4023]: ["ORDER_REJECTED", "QTY_NOT_INCREASED_BY_STEP_SIZE", "Use a size on the market's lot step"],
|
|
97
|
+
[-4050]: ["MARGIN_INSUFFICIENT", "CROSS_BALANCE_INSUFFICIENT", "Check the balance: perp account balance -e aster"],
|
|
98
|
+
[-4051]: ["MARGIN_INSUFFICIENT", "ISOLATED_BALANCE_INSUFFICIENT", "Check the balance: perp account balance -e aster"],
|
|
99
|
+
[-4164]: ["SIZE_TOO_SMALL", "MIN_NOTIONAL", "Increase the order notional (size × price)"],
|
|
100
|
+
[-5050]: ["DEPOSIT_REQUIRED", "DEPOSIT_REQUIRED", "Deposit first: perp funds deposit aster <amount>"],
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Codes whose docs text says the request may still have been processed — never a definite
|
|
104
|
+
* refusal: -1000 UNKNOWN "An unknown error occured while processing the request." · -1001
|
|
105
|
+
* DISCONNECTED "Internal error; unable to process your request." · -1006 UNEXPECTED_RESP
|
|
106
|
+
* "Execution status unknown." · -1007 TIMEOUT "Send status unknown; execution status unknown."
|
|
107
|
+
*/
|
|
108
|
+
const ASTER_UNKNOWN_OUTCOME_CODES = new Set([-1000, -1001, -1006, -1007]);
|
|
109
|
+
/** The docs' error-code sections: 10xx / 11xx, 20xx, 40xx (incl. 41xx), 50xx. */
|
|
110
|
+
function asterDocumentedCode(code) {
|
|
111
|
+
return Number.isInteger(code) && ((code <= -1000 && code >= -1199) || (code <= -2000 && code >= -2099)
|
|
112
|
+
|| (code <= -4000 && code >= -4199) || (code <= -5000 && code >= -5099));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Verdict of a failed POST / PUT /fapi/v3/order that the venue ANSWERED with an HTTP 4xx and a
|
|
116
|
+
* documented error code: the order was refused — a definite, non-retryable PerpError (never the
|
|
117
|
+
* retryable EXCHANGE_ERROR, which callers must treat as an unknown write outcome). Live mainnet
|
|
118
|
+
* 2026-10-05: a reduce-only order without a position → 400 {"code":-2022,"msg":"ReduceOnly Order
|
|
119
|
+
* is rejected."}. Unchanged: HTTP 5xx, no / undocumented code, and the "status unknown" codes —
|
|
120
|
+
* those become ORDER_PENDING unless the error was already classified definite (e.g. the venue's
|
|
121
|
+
* -1000 "Signature check failed" → SIGNATURE_FAILED).
|
|
122
|
+
*/
|
|
123
|
+
export function asterOrderRejection(e) {
|
|
124
|
+
const d = e.structured.details;
|
|
125
|
+
const http = typeof d?.httpStatus === "number" ? d.httpStatus : null;
|
|
126
|
+
const code = typeof d?.venueCode === "number" ? d.venueCode : null;
|
|
127
|
+
if (http === null || http < 400 || http >= 500 || code === null || !asterDocumentedCode(code))
|
|
128
|
+
return e;
|
|
129
|
+
const definite = DEFINITE_ORDER_FAILURE_CODES.has(e.structured.code);
|
|
130
|
+
if (ASTER_UNKNOWN_OUTCOME_CODES.has(code))
|
|
131
|
+
return definite ? e : orderOutcomeUnknown("aster", "order", e);
|
|
132
|
+
const known = ASTER_ORDER_REJECTIONS[code];
|
|
133
|
+
if (known === undefined && definite)
|
|
134
|
+
return e;
|
|
135
|
+
const [perpCode, name, remediation] = known ?? ["ORDER_REJECTED", null, null];
|
|
136
|
+
return new PerpError(perpCode, `Aster rejected the order (${code}${name !== null ? ` ${name}` : ""}): ${e.message}`, {
|
|
137
|
+
exchange: "aster", httpStatus: http, venueCode: code,
|
|
138
|
+
...(remediation !== null ? { remediation } : e.structured.remediation !== undefined ? { remediation: e.structured.remediation } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* An Aster client order id as sent / queried. V3 futures "New Order" `newClientOrderId`: "A unique
|
|
143
|
+
* id among open orders. Automatically generated if not sent. Can only be string following the
|
|
144
|
+
* rule: ^[\.A-Z\:/a-z0-9_-]{1,36}$". Anything else → INVALID_PARAMS, nothing sent.
|
|
145
|
+
*/
|
|
146
|
+
export function requireAsterClientOrderId(id) {
|
|
147
|
+
if (!ASTER_CLIENT_ID_RE.test(id)) {
|
|
148
|
+
throw new PerpError("INVALID_PARAMS", `Aster client order id "${id}" must be 1-36 chars of A-Z a-z 0-9 . : / _ -`, {
|
|
149
|
+
exchange: "aster", remediation: "Pass a shorter id of that charset as --client-id, or use --auto-id",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return id;
|
|
153
|
+
}
|
|
24
154
|
// ── AsterAdapter ──────────────────────────────────────────────────────────────
|
|
25
155
|
export class AsterAdapter {
|
|
26
156
|
name = "aster";
|
|
@@ -40,18 +170,25 @@ export class AsterAdapter {
|
|
|
40
170
|
_marketsCache = null;
|
|
41
171
|
_marketsCacheTime = 0;
|
|
42
172
|
_fundingHoursCache = new Map();
|
|
173
|
+
/** When the venue-wide funding-interval table was last loaded (0 = never). */
|
|
174
|
+
_fundingInfoLoadedAt = 0;
|
|
175
|
+
/** API symbol → price filters; null = listed but PRICE_FILTER / PERCENT_PRICE unreadable. */
|
|
176
|
+
_priceFilters = new Map();
|
|
177
|
+
_priceFiltersLoadedAt = 0;
|
|
43
178
|
_accountCache = null;
|
|
44
179
|
_positionsCache = null;
|
|
45
180
|
_ordersCache = null;
|
|
46
181
|
static CACHE_TTL = 30_000;
|
|
47
182
|
static ACCOUNT_CACHE_TTL = 5_000;
|
|
48
|
-
|
|
49
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Builder-fee gate (settings builder.aster + confirmed master approveBuilder); null = not
|
|
185
|
+
* configured. Set by createAster — the adapter never reads settings.
|
|
186
|
+
*/
|
|
187
|
+
builderFee = null;
|
|
50
188
|
constructor(privateKey, testnet = false) {
|
|
51
189
|
this._testnet = testnet;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
: "https://fapi.asterdex.com";
|
|
190
|
+
// SSOT: REST host from the venue definition (venues/aster.ts networks).
|
|
191
|
+
this._baseUrl = networkConfig(asterVenue, testnet ? "testnet" : "mainnet").restUrl;
|
|
55
192
|
if (privateKey) {
|
|
56
193
|
this._pendingPk = privateKey;
|
|
57
194
|
}
|
|
@@ -59,7 +196,7 @@ export class AsterAdapter {
|
|
|
59
196
|
// ── Lifecycle ──
|
|
60
197
|
async init() {
|
|
61
198
|
// Verify connectivity by fetching server time
|
|
62
|
-
await this._publicGet("/fapi/
|
|
199
|
+
await this._publicGet("/fapi/v3/time");
|
|
63
200
|
// Build LocalEvmSigner from ctor PK now that we're in an async context
|
|
64
201
|
if (this._pendingPk) {
|
|
65
202
|
const { LocalEvmSigner } = await import("../signer/index.js");
|
|
@@ -81,6 +218,48 @@ export class AsterAdapter {
|
|
|
81
218
|
setNoAgent(noAgent) {
|
|
82
219
|
this._useNoAgent = noAgent;
|
|
83
220
|
}
|
|
221
|
+
// ── Builder fee ──
|
|
222
|
+
/** Builder decision for the next perp order (cached approval read; never throws). */
|
|
223
|
+
async builderDecision() {
|
|
224
|
+
const gate = builderGateOf(this);
|
|
225
|
+
return gate === null ? NO_BUILDER : gate.decide({ hip3: false });
|
|
226
|
+
}
|
|
227
|
+
/** `builder` + `feeRate` (decimal) order params, or none (not configured / not approved). */
|
|
228
|
+
async _builderParams() {
|
|
229
|
+
const d = await this.builderDecision();
|
|
230
|
+
return d.applied === null ? {} : { builder: d.applied.identity, feeRate: bpsToRate(requireFeeBps(d.applied)) };
|
|
231
|
+
}
|
|
232
|
+
/** Owner (`user`) address of the active signer — the account a builder approval belongs to. */
|
|
233
|
+
get userAddress() {
|
|
234
|
+
return this._resolveSigner().userAddress;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Builder approvals of this account: GET /fapi/v3/builder (asterdex/api-docs demo
|
|
238
|
+
* `getBuilders`, signed by the API wallet). The response shape is not documented, so
|
|
239
|
+
* anything but a list of rows carrying an address + maxFeeRate is "unknown" (never guessed).
|
|
240
|
+
*/
|
|
241
|
+
async builderApprovalOf(target) {
|
|
242
|
+
const raw = await this.signedRequest(this._baseUrl, "GET", "/fapi/v3/builder", {});
|
|
243
|
+
const rec = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
244
|
+
const list = Array.isArray(raw) ? raw : rec !== null && Array.isArray(rec.rows) ? rec.rows : rec !== null && Array.isArray(rec.data) ? rec.data : null;
|
|
245
|
+
if (list === null)
|
|
246
|
+
return { status: "unknown", reason: `unrecognized GET /fapi/v3/builder response ${JSON.stringify(raw).slice(0, 120)}` };
|
|
247
|
+
for (const r of list) {
|
|
248
|
+
if (r === null || typeof r !== "object")
|
|
249
|
+
return { status: "unknown", reason: "GET /fapi/v3/builder row is not an object" };
|
|
250
|
+
const row = r;
|
|
251
|
+
const addr = typeof row.builder === "string" ? row.builder : typeof row.builderAddress === "string" ? row.builderAddress : null;
|
|
252
|
+
if (addr === null)
|
|
253
|
+
return { status: "unknown", reason: "GET /fapi/v3/builder row has no builder address" };
|
|
254
|
+
if (addr.toLowerCase() !== target.identity.toLowerCase())
|
|
255
|
+
continue;
|
|
256
|
+
const max = rateToBps(row.maxFeeRate);
|
|
257
|
+
if (max === null)
|
|
258
|
+
return { status: "unknown", reason: `unreadable maxFeeRate ${JSON.stringify(row.maxFeeRate)}` };
|
|
259
|
+
return approvalFromMax(max, requireFeeBps(target));
|
|
260
|
+
}
|
|
261
|
+
return approvalFromMax(null, requireFeeBps(target));
|
|
262
|
+
}
|
|
84
263
|
// ── Status accessors ──
|
|
85
264
|
get isReadOnly() {
|
|
86
265
|
return !this._agentSigner && !this._masterSigner && !this._pkSigner;
|
|
@@ -93,6 +272,38 @@ export class AsterAdapter {
|
|
|
93
272
|
return null;
|
|
94
273
|
}
|
|
95
274
|
}
|
|
275
|
+
// ── Spot host access (design §12, used by AsterSpotAdapter) ──
|
|
276
|
+
/**
|
|
277
|
+
* Aster spot REST host. Spot shares the perp EIP-712 Domain B signing
|
|
278
|
+
* (chainId 1666 mainnet / 714 testnet) and the per-agent nonce space.
|
|
279
|
+
* Hosts per the official spot V3 docs (mainnet + testnet variants).
|
|
280
|
+
*/
|
|
281
|
+
get spotBaseUrl() {
|
|
282
|
+
return this._testnet ? "https://sapi.asterdex-testnet.com" : "https://sapi.asterdex.com";
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Signed request against an arbitrary Aster host with the active signer.
|
|
286
|
+
* POST is single-shot; GET/DELETE use the same 429 retry loop as perp.
|
|
287
|
+
*/
|
|
288
|
+
async signedRequest(host, method, path, params) {
|
|
289
|
+
const r = this._resolveSigner();
|
|
290
|
+
if (method === "POST")
|
|
291
|
+
return this._signedPostTo(host, path, params, r);
|
|
292
|
+
return this._signedRequestWithRetry(host, method, path, params, r);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Agent meta of the active signer, or null when the active signer is not an
|
|
296
|
+
* agent. Throws exactly like signed calls when no signer is usable.
|
|
297
|
+
*/
|
|
298
|
+
activeAgentMeta() {
|
|
299
|
+
const r = this._resolveSigner();
|
|
300
|
+
if (r.tier !== "agent")
|
|
301
|
+
return null;
|
|
302
|
+
if (!this._agentMeta) {
|
|
303
|
+
throw new PerpError("AGENT_NOT_REGISTERED", "Aster agent signer is active but its metadata is missing", { exchange: "aster" });
|
|
304
|
+
}
|
|
305
|
+
return this._agentMeta;
|
|
306
|
+
}
|
|
96
307
|
// ── Symbol helpers ──
|
|
97
308
|
/** CLI symbol → Aster API symbol (ETH → ETHUSDT) */
|
|
98
309
|
_toApi(symbol) {
|
|
@@ -106,32 +317,56 @@ export class AsterAdapter {
|
|
|
106
317
|
return symbol.replace(/USDT$/, "").replace(/BUSD$/, "");
|
|
107
318
|
}
|
|
108
319
|
// ── Market Data ──
|
|
109
|
-
/**
|
|
320
|
+
/**
|
|
321
|
+
* Get funding interval for a symbol.
|
|
322
|
+
*
|
|
323
|
+
* Source of truth is `GET /fapi/v3/fundingInfo` (weight 1), which states
|
|
324
|
+
* `fundingIntervalHours` per symbol for the whole venue in a single call.
|
|
325
|
+
* This previously inferred the interval by differencing the last two
|
|
326
|
+
* `fundingRate` timestamps and bucketing to 1/4/8h — an estimate that is
|
|
327
|
+
* wrong for a newly listed symbol (fewer than two settlements), across a
|
|
328
|
+
* schedule change, or whenever a settlement is skipped/delayed, and Aster
|
|
329
|
+
* really does mix 1h, 4h and 8h symbols. Funding-interval misconfiguration is
|
|
330
|
+
* exactly what produced the v0.7.23 Aster funding bug, so it is read, not
|
|
331
|
+
* guessed.
|
|
332
|
+
*/
|
|
110
333
|
async getFundingHours(symbol) {
|
|
111
334
|
const key = symbol.toUpperCase();
|
|
112
335
|
const cached = this._fundingHoursCache.get(key);
|
|
113
336
|
if (cached !== undefined)
|
|
114
337
|
return cached;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
338
|
+
await this._loadFundingInfo();
|
|
339
|
+
return this._fundingHoursCache.get(key);
|
|
340
|
+
}
|
|
341
|
+
/** Populate the funding-interval cache for every symbol in one call. */
|
|
342
|
+
async _loadFundingInfo() {
|
|
343
|
+
if (this._fundingInfoLoadedAt && Date.now() - this._fundingInfoLoadedAt < AsterAdapter.CACHE_TTL)
|
|
344
|
+
return;
|
|
345
|
+
const rows = await this._publicGet("/fapi/v3/fundingInfo", {});
|
|
346
|
+
if (!Array.isArray(rows)) {
|
|
347
|
+
throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/fundingInfo did not return an array", { exchange: "aster" });
|
|
124
348
|
}
|
|
125
|
-
|
|
126
|
-
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
const hours = Number(row?.fundingIntervalHours);
|
|
351
|
+
if (!row?.symbol || !Number.isFinite(hours) || hours <= 0)
|
|
352
|
+
continue;
|
|
353
|
+
this._fundingHoursCache.set(this._fromApi(String(row.symbol)), hours);
|
|
354
|
+
}
|
|
355
|
+
this._fundingInfoLoadedAt = Date.now();
|
|
127
356
|
}
|
|
128
357
|
async getMarkets() {
|
|
129
358
|
if (this._marketsCache && Date.now() - this._marketsCacheTime < AsterAdapter.CACHE_TTL) {
|
|
130
359
|
return this._marketsCache;
|
|
131
360
|
}
|
|
132
361
|
const [info, tickers] = await Promise.all([
|
|
133
|
-
this._publicGet("/fapi/
|
|
134
|
-
this._publicGet("/fapi/
|
|
362
|
+
this._publicGet("/fapi/v3/exchangeInfo"),
|
|
363
|
+
this._publicGet("/fapi/v3/ticker/24hr"),
|
|
364
|
+
// Populate the funding-interval table (weight 1, whole venue in one call).
|
|
365
|
+
// The per-symbol read below consulted this cache but nothing ever filled
|
|
366
|
+
// it during getMarkets, so `fundingHours` came back undefined for every
|
|
367
|
+
// market — and Aster genuinely mixes 1h / 4h / 8h symbols, which funding
|
|
368
|
+
// math depends on.
|
|
369
|
+
this._loadFundingInfo(),
|
|
135
370
|
]);
|
|
136
371
|
const tickerMap = new Map();
|
|
137
372
|
for (const t of tickers ?? []) {
|
|
@@ -139,10 +374,11 @@ export class AsterAdapter {
|
|
|
139
374
|
}
|
|
140
375
|
let premiumMap = new Map();
|
|
141
376
|
try {
|
|
142
|
-
const premiums = await this._publicGet("/fapi/
|
|
377
|
+
const premiums = await this._publicGet("/fapi/v3/premiumIndex");
|
|
143
378
|
premiumMap = new Map(premiums.map(p => [String(p.symbol), p]));
|
|
144
379
|
}
|
|
145
380
|
catch { /* non-critical */ }
|
|
381
|
+
this._ingestPriceFilters(info?.symbols ?? []);
|
|
146
382
|
const tradingSymbols = (info?.symbols ?? [])
|
|
147
383
|
.filter((s) => String(s.contractType) === "PERPETUAL" && String(s.status) === "TRADING");
|
|
148
384
|
const result = tradingSymbols.map((s) => {
|
|
@@ -156,29 +392,136 @@ export class AsterAdapter {
|
|
|
156
392
|
return {
|
|
157
393
|
symbol: this._fromApi(sym),
|
|
158
394
|
markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
|
|
159
|
-
indexPrice: String(premium
|
|
395
|
+
indexPrice: premium?.indexPrice != null ? String(premium.indexPrice) : null,
|
|
160
396
|
fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
|
|
161
|
-
volume24h:
|
|
162
|
-
|
|
397
|
+
volume24h: ticker?.quoteVolume != null ? String(ticker.quoteVolume) : null,
|
|
398
|
+
// /fapi/v3/exchangeInfo and ticker/24hr carry no open interest.
|
|
399
|
+
openInterest: null,
|
|
163
400
|
maxLeverage: maxLev,
|
|
164
|
-
sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) :
|
|
165
|
-
stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) :
|
|
166
|
-
fundingHours,
|
|
401
|
+
sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : null,
|
|
402
|
+
stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : null,
|
|
403
|
+
fundingHours: fundingHours ?? null,
|
|
404
|
+
marginTiers: null,
|
|
167
405
|
};
|
|
168
406
|
});
|
|
169
407
|
this._marketsCache = result;
|
|
170
408
|
this._marketsCacheTime = Date.now();
|
|
171
409
|
return result;
|
|
172
410
|
}
|
|
411
|
+
/** Parse PRICE_FILTER + PERCENT_PRICE for every exchangeInfo symbol into the cache. */
|
|
412
|
+
_ingestPriceFilters(symbols) {
|
|
413
|
+
this._priceFilters.clear();
|
|
414
|
+
for (const s of symbols) {
|
|
415
|
+
const filters = (Array.isArray(s.filters) ? s.filters : []);
|
|
416
|
+
const pf = filters.find(f => f.filterType === "PRICE_FILTER");
|
|
417
|
+
const pp = filters.find(f => f.filterType === "PERCENT_PRICE");
|
|
418
|
+
let entry = null;
|
|
419
|
+
const tick = pf?.tickSize, minP = pf?.minPrice, maxP = pf?.maxPrice;
|
|
420
|
+
if (typeof tick === "string" && Number(tick) > 0 && typeof minP === "string" && typeof maxP === "string") {
|
|
421
|
+
if (!pp) {
|
|
422
|
+
entry = { tickSize: tick, minPrice: minP, maxPrice: maxP, percent: null };
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
const up = Number(pp.multiplierUp), down = Number(pp.multiplierDown);
|
|
426
|
+
if (up > 0 && down > 0 && Number.isFinite(up) && Number.isFinite(down)) {
|
|
427
|
+
entry = { tickSize: tick, minPrice: minP, maxPrice: maxP, percent: { up, down } };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
this._priceFilters.set(String(s.symbol), entry);
|
|
432
|
+
}
|
|
433
|
+
this._priceFiltersLoadedAt = Date.now();
|
|
434
|
+
}
|
|
435
|
+
/** PRICE_FILTER / PERCENT_PRICE of an API symbol (exchangeInfo, cached CACHE_TTL). */
|
|
436
|
+
async _symbolPriceFilters(apiSymbol) {
|
|
437
|
+
if (!this._priceFiltersLoadedAt || Date.now() - this._priceFiltersLoadedAt >= AsterAdapter.CACHE_TTL) {
|
|
438
|
+
const info = await this._publicGet("/fapi/v3/exchangeInfo");
|
|
439
|
+
if (!Array.isArray(info?.symbols)) {
|
|
440
|
+
throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/exchangeInfo returned no symbols", { exchange: "aster" });
|
|
441
|
+
}
|
|
442
|
+
this._ingestPriceFilters(info.symbols);
|
|
443
|
+
}
|
|
444
|
+
if (!this._priceFilters.has(apiSymbol)) {
|
|
445
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `Aster market ${apiSymbol} not found in exchangeInfo`, { exchange: "aster" });
|
|
446
|
+
}
|
|
447
|
+
const f = this._priceFilters.get(apiSymbol);
|
|
448
|
+
if (!f) {
|
|
449
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster ${apiSymbol}: PRICE_FILTER or PERCENT_PRICE missing/unreadable in exchangeInfo`, { exchange: "aster" });
|
|
450
|
+
}
|
|
451
|
+
return f;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Declared protection-price reference (§13.10 #5): the venue mark price from
|
|
455
|
+
* GET /fapi/v3/premiumIndex — the same price PERCENT_PRICE is evaluated against.
|
|
456
|
+
* No runtime substitute (no mid / last fallback): unavailable → PRICE_STALE.
|
|
457
|
+
*/
|
|
458
|
+
async referencePrice(symbol) {
|
|
459
|
+
const apiSymbol = this._toApi(symbol);
|
|
460
|
+
let mark;
|
|
461
|
+
try {
|
|
462
|
+
const res = await this._publicGet("/fapi/v3/premiumIndex", { symbol: apiSymbol });
|
|
463
|
+
mark = res?.markPrice;
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} mark price unavailable: ${e instanceof Error ? e.message : String(e)}`, { exchange: "aster" });
|
|
467
|
+
}
|
|
468
|
+
const str = typeof mark === "number" ? String(mark) : mark;
|
|
469
|
+
if (typeof str !== "string" || !(Number(str) > 0) || !Number.isFinite(Number(str))) {
|
|
470
|
+
throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} premiumIndex returned no usable markPrice (${String(mark)})`, { exchange: "aster" });
|
|
471
|
+
}
|
|
472
|
+
return str;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Reject (never clamp) a price outside PRICE_FILTER min/max or the side's PERCENT_PRICE
|
|
476
|
+
* bound around `mark` — checked before anything is sent.
|
|
477
|
+
*/
|
|
478
|
+
_checkPriceBand(apiSymbol, f, price, side, mark) {
|
|
479
|
+
const p = Number(price);
|
|
480
|
+
if (Number(f.minPrice) > 0 && p < Number(f.minPrice)) {
|
|
481
|
+
throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: price ${price} below PRICE_FILTER minPrice ${f.minPrice}`, { exchange: "aster" });
|
|
482
|
+
}
|
|
483
|
+
if (Number(f.maxPrice) > 0 && p > Number(f.maxPrice)) {
|
|
484
|
+
throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: price ${price} above PRICE_FILTER maxPrice ${f.maxPrice}`, { exchange: "aster" });
|
|
485
|
+
}
|
|
486
|
+
if (f.percent === null)
|
|
487
|
+
return;
|
|
488
|
+
const m = Number(mark);
|
|
489
|
+
const bound = side === "buy" ? m * f.percent.up : m * f.percent.down;
|
|
490
|
+
if (side === "buy" ? p > bound : p < bound) {
|
|
491
|
+
const bandBps = Math.round(Math.abs((side === "buy" ? f.percent.up : f.percent.down) - 1) * 10_000);
|
|
492
|
+
throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: ${side} protection price ${price} is outside PERCENT_PRICE (${side === "buy" ? "≤" : "≥"} mark ${mark} × ${side === "buy" ? f.percent.up : f.percent.down})`, {
|
|
493
|
+
exchange: "aster",
|
|
494
|
+
remediation: `Use --slippage-bps below ${bandBps} for ${this._fromApi(apiSymbol)} (PERCENT_PRICE band)`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Protection price an IOC market order on `symbol` would be sent with now:
|
|
500
|
+
* mark ± slippage.bps, aligned toward crossing on PRICE_FILTER.tickSize, validated
|
|
501
|
+
* against PRICE_FILTER / PERCENT_PRICE. appliedBps = the bound after tick alignment.
|
|
502
|
+
*/
|
|
503
|
+
async computeProtectionPrice(symbol, side, slippage) {
|
|
504
|
+
const apiSymbol = this._toApi(symbol);
|
|
505
|
+
const f = await this._symbolPriceFilters(apiSymbol);
|
|
506
|
+
const ref = await this.referencePrice(symbol);
|
|
507
|
+
const protectionPrice = alignProtectionPrice(slippagePrice(ref, side, slippage.bps), f.tickSize, side);
|
|
508
|
+
this._checkPriceBand(apiSymbol, f, protectionPrice, side, ref);
|
|
509
|
+
const appliedBps = Math.abs(Number(protectionPrice) - Number(ref)) / Number(ref) * 10_000;
|
|
510
|
+
return { referencePrice: ref, protectionPrice, appliedBps };
|
|
511
|
+
}
|
|
512
|
+
/** Definite IOC outcome of a raw order response (see asterIocOutcome). */
|
|
513
|
+
iocOutcomeOf(raw) {
|
|
514
|
+
return asterIocOutcome(raw);
|
|
515
|
+
}
|
|
173
516
|
async getOrderbook(symbol) {
|
|
174
|
-
const res = await this._publicGet("/fapi/
|
|
517
|
+
const res = await this._publicGet("/fapi/v3/depth", { symbol: this._toApi(symbol), limit: "50" });
|
|
175
518
|
return {
|
|
176
519
|
bids: res?.bids ?? [],
|
|
177
520
|
asks: res?.asks ?? [],
|
|
178
521
|
};
|
|
179
522
|
}
|
|
180
523
|
async getRecentTrades(symbol, limit = 20) {
|
|
181
|
-
const trades = await this._publicGet("/fapi/
|
|
524
|
+
const trades = await this._publicGet("/fapi/v3/trades", {
|
|
182
525
|
symbol: this._toApi(symbol),
|
|
183
526
|
limit: String(limit),
|
|
184
527
|
});
|
|
@@ -192,18 +535,18 @@ export class AsterAdapter {
|
|
|
192
535
|
}));
|
|
193
536
|
}
|
|
194
537
|
async getFundingHistory(symbol, limit = 10) {
|
|
195
|
-
const data = await this._publicGet("/fapi/
|
|
538
|
+
const data = await this._publicGet("/fapi/v3/fundingRate", {
|
|
196
539
|
symbol: this._toApi(symbol),
|
|
197
540
|
limit: String(limit),
|
|
198
541
|
});
|
|
199
542
|
return (data ?? []).map((d) => ({
|
|
200
543
|
time: Number(d.fundingTime ?? 0),
|
|
201
|
-
rate:
|
|
544
|
+
rate: venueRateField(d.fundingRate, "fundingRate fundingRate", "aster"),
|
|
202
545
|
price: d.markPrice ? String(d.markPrice) : null,
|
|
203
546
|
}));
|
|
204
547
|
}
|
|
205
548
|
async getKlines(symbol, interval, startTime, endTime) {
|
|
206
|
-
const data = await this._publicGet("/fapi/
|
|
549
|
+
const data = await this._publicGet("/fapi/v3/klines", {
|
|
207
550
|
symbol: this._toApi(symbol),
|
|
208
551
|
interval,
|
|
209
552
|
startTime: String(startTime),
|
|
@@ -222,66 +565,138 @@ export class AsterAdapter {
|
|
|
222
565
|
}
|
|
223
566
|
// ── Account ──
|
|
224
567
|
async getBalance() {
|
|
225
|
-
if (this._accountCache && Date.now() - this._accountCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
|
|
568
|
+
if (this._accountCache && this._accountCache.epoch === accountCacheEpoch() && Date.now() - this._accountCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
|
|
226
569
|
return this._accountCache.data;
|
|
227
570
|
}
|
|
228
571
|
const r = this._resolveSigner();
|
|
229
|
-
// v3
|
|
572
|
+
// v3 EIP-712 account endpoint. (The legacy /fapi/v2/account was HMAC-only and
|
|
573
|
+
// is no longer documented in the Aster V3 spec.)
|
|
230
574
|
const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
|
|
231
|
-
const totalWallet =
|
|
232
|
-
const unrealizedPnl =
|
|
233
|
-
const available =
|
|
234
|
-
const marginUsed =
|
|
575
|
+
const totalWallet = parseFiniteVenueNumber(account.totalWalletBalance, "totalWalletBalance", "aster");
|
|
576
|
+
const unrealizedPnl = parseFiniteVenueNumber(account.totalUnrealizedProfit, "totalUnrealizedProfit", "aster");
|
|
577
|
+
const available = parseFiniteVenueNumber(account.availableBalance, "availableBalance", "aster");
|
|
578
|
+
const marginUsed = parseFiniteVenueNumber(account.totalInitialMargin, "totalInitialMargin", "aster");
|
|
235
579
|
const result = {
|
|
236
580
|
equity: String(totalWallet + unrealizedPnl),
|
|
237
581
|
available: String(available),
|
|
238
582
|
marginUsed: String(marginUsed),
|
|
239
583
|
unrealizedPnl: String(unrealizedPnl),
|
|
240
584
|
};
|
|
241
|
-
this._accountCache = { data: result, time: Date.now() };
|
|
585
|
+
this._accountCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
|
|
242
586
|
return result;
|
|
243
587
|
}
|
|
588
|
+
/**
|
|
589
|
+
* How much of ONE asset the perp account can transfer out: that asset's row of
|
|
590
|
+
* GET /fapi/v3/accountWithJoinMargin `assets[]` — `maxWithdrawAmount` ("maximum amount for
|
|
591
|
+
* transfer out", Aster futures API v3 "Account Information v3"). The account-level
|
|
592
|
+
* `availableBalance` (getBalance) is margin across ALL collateral assets in multi-asset mode
|
|
593
|
+
* and is not an amount of any one asset. Read uncached. A missing row / field is an error,
|
|
594
|
+
* never 0.
|
|
595
|
+
*/
|
|
596
|
+
async getPerpAssetTransferable(asset) {
|
|
597
|
+
const r = this._resolveSigner();
|
|
598
|
+
const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
|
|
599
|
+
if (!Array.isArray(account.assets)) {
|
|
600
|
+
throw new PerpError("EXCHANGE_ERROR", "aster /fapi/v3/accountWithJoinMargin: response has no assets list", { exchange: "aster" });
|
|
601
|
+
}
|
|
602
|
+
const row = account.assets.find(a => a.asset === asset);
|
|
603
|
+
if (row === undefined || row.maxWithdrawAmount === undefined || row.maxWithdrawAmount === null) {
|
|
604
|
+
throw new PerpError("EXCHANGE_ERROR", `aster /fapi/v3/accountWithJoinMargin: no ${asset} maxWithdrawAmount in the assets list`, { exchange: "aster" });
|
|
605
|
+
}
|
|
606
|
+
return parseFiniteVenueNumber(row.maxWithdrawAmount, `assets[${asset}].maxWithdrawAmount`, "aster");
|
|
607
|
+
}
|
|
244
608
|
async getPositions() {
|
|
245
|
-
if (this._positionsCache && Date.now() - this._positionsCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
|
|
609
|
+
if (this._positionsCache && this._positionsCache.epoch === accountCacheEpoch() && Date.now() - this._positionsCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
|
|
246
610
|
return this._positionsCache.data;
|
|
247
611
|
}
|
|
248
612
|
const r = this._resolveSigner();
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
613
|
+
// Positions are enumerated from /fapi/v3/accountWithJoinMargin (`positions`
|
|
614
|
+
// array); that payload omits both mark and liquidation price. The signed
|
|
615
|
+
// Position Information v3 endpoint (GET /fapi/v3/positionRisk) supplies both
|
|
616
|
+
// per open symbol, so it replaces the older public premiumIndex mark-price probe.
|
|
252
617
|
const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
|
|
253
618
|
const positions = account.positions ?? [];
|
|
254
|
-
const open = positions.filter((p) =>
|
|
255
|
-
//
|
|
256
|
-
|
|
619
|
+
const open = positions.filter((p) => parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster") !== 0);
|
|
620
|
+
// Hedge mode returns TWO rows per symbol (LONG and SHORT). This adapter
|
|
621
|
+
// keys everything by symbol alone — the riskMap below, and the mapped
|
|
622
|
+
// result — so the two legs collide and one silently inherits the other's
|
|
623
|
+
// mark/liquidation price. Orders are safe (the venue rejects them with
|
|
624
|
+
// -4061 because we never send positionSide), but positions and liquidation
|
|
625
|
+
// distance would be quietly wrong, so fail honestly instead.
|
|
626
|
+
const bySymbol = new Map();
|
|
627
|
+
for (const p of open) {
|
|
628
|
+
const k = String(p.symbol ?? "");
|
|
629
|
+
bySymbol.set(k, (bySymbol.get(k) ?? 0) + 1);
|
|
630
|
+
}
|
|
631
|
+
const dupes = [...bySymbol.entries()].filter(([, n]) => n > 1).map(([s]) => s);
|
|
632
|
+
if (dupes.length > 0) {
|
|
633
|
+
throw new PerpError("NOT_IMPLEMENTED", `Aster hedge mode is not supported: ${dupes.join(", ")} has both LONG and SHORT positions, which this adapter cannot represent (positions are keyed by symbol).`, {
|
|
634
|
+
exchange: "aster",
|
|
635
|
+
remediation: "Switch the account to One-way Mode (POST /fapi/v3/positionSide/dual with dualSidePosition=false) or close one side, then retry.",
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
// Enrich open positions with mark + liquidation price from /fapi/v3/positionRisk
|
|
639
|
+
// (best-effort; a transient failure leaves them "0" rather than failing the whole
|
|
640
|
+
// positions read). Response fields per Aster V3 spec: markPrice, liquidationPrice.
|
|
641
|
+
const riskMap = new Map();
|
|
257
642
|
if (open.length > 0) {
|
|
258
643
|
try {
|
|
259
|
-
const
|
|
260
|
-
for (const
|
|
261
|
-
|
|
644
|
+
const risk = await this._signedGetEip712("/fapi/v3/positionRisk", {}, r);
|
|
645
|
+
for (const pr of risk ?? []) {
|
|
646
|
+
// Aster V3 spec (userPositionRisk notes, same field): "`liquidationPrice` is reported as `0`
|
|
647
|
+
// whenever the underlying value would be negative" — i.e. the position has no liquidation
|
|
648
|
+
// price (e.g. a small cross long the margin fully covers). 0 is not a price → null.
|
|
649
|
+
const liq = pr.liquidationPrice != null ? String(pr.liquidationPrice) : null;
|
|
650
|
+
riskMap.set(String(pr.symbol), {
|
|
651
|
+
mark: String(pr.markPrice ?? "0"),
|
|
652
|
+
liq: liq !== null && liq.trim() !== "" && Number(liq) === 0 ? null : liq,
|
|
653
|
+
});
|
|
262
654
|
}
|
|
263
655
|
}
|
|
264
|
-
catch {
|
|
656
|
+
catch (e) {
|
|
657
|
+
// NOT non-critical. A "0" liquidation price is not a neutral default:
|
|
658
|
+
// risk.ts filters positions on `liquidationPrice !== null && > 0` and
|
|
659
|
+
// event-stream.ts gates the liquidation warning on `liq > 0`, so a
|
|
660
|
+
// silent "0" drops EVERY Aster position out of liquidation monitoring —
|
|
661
|
+
// and the likeliest cause of this call failing (rate limiting)
|
|
662
|
+
// correlates with the volatility that makes those alerts matter most.
|
|
663
|
+
// Surface it, and report the values as unknown rather than as a price.
|
|
664
|
+
process.stderr.write(`[aster] positionRisk fetch failed — mark and liquidation prices are UNAVAILABLE this cycle, ` +
|
|
665
|
+
`so liquidation warnings are suppressed for Aster positions: ` +
|
|
666
|
+
`${e instanceof Error ? e.message : String(e)}\n`);
|
|
667
|
+
}
|
|
265
668
|
}
|
|
266
669
|
const result = open.map((p) => {
|
|
267
|
-
const amt =
|
|
670
|
+
const amt = parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster");
|
|
268
671
|
const apiSym = String(p.symbol ?? "");
|
|
672
|
+
const risk = riskMap.get(apiSym);
|
|
269
673
|
return {
|
|
270
674
|
symbol: this._fromApi(apiSym),
|
|
271
675
|
side: amt > 0 ? "long" : "short",
|
|
272
676
|
size: String(Math.abs(amt)),
|
|
273
677
|
entryPrice: String(p.entryPrice ?? "0"),
|
|
274
|
-
markPrice
|
|
275
|
-
|
|
678
|
+
// Unknown mark is "N/A" (markPrice is a required string); unknown liq is null. Never "0" — a zero reads downstream as a real price.
|
|
679
|
+
// Matches the Pacifica adapter's convention for an absent liq price.
|
|
680
|
+
markPrice: risk?.mark ?? "N/A",
|
|
681
|
+
liquidationPrice: risk ? risk.liq : null,
|
|
276
682
|
unrealizedPnl: String(p.unrealizedProfit ?? "0"),
|
|
277
|
-
leverage:
|
|
683
|
+
leverage: parseFiniteVenueNumber(p.leverage, "position.leverage", "aster", { defaultValue: 1 }),
|
|
278
684
|
};
|
|
279
685
|
});
|
|
280
|
-
this._positionsCache = { data: result, time: Date.now() };
|
|
686
|
+
this._positionsCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
|
|
281
687
|
return result;
|
|
282
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Drop the in-process open-orders snapshot (after any order write, or before a verifying
|
|
691
|
+
* re-list). An order write also changes positions and balances, so those snapshots go too.
|
|
692
|
+
*/
|
|
693
|
+
invalidateOpenOrdersCache() {
|
|
694
|
+
this._ordersCache = null;
|
|
695
|
+
this._positionsCache = null;
|
|
696
|
+
this._accountCache = null;
|
|
697
|
+
}
|
|
283
698
|
async getOpenOrders() {
|
|
284
|
-
if (this._ordersCache && Date.now() - this._ordersCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
|
|
699
|
+
if (this._ordersCache && this._ordersCache.epoch === accountCacheEpoch() && Date.now() - this._ordersCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
|
|
285
700
|
return this._ordersCache.data;
|
|
286
701
|
}
|
|
287
702
|
const r = this._resolveSigner();
|
|
@@ -295,8 +710,9 @@ export class AsterAdapter {
|
|
|
295
710
|
filled: String(o.executedQty ?? "0"),
|
|
296
711
|
status: String(o.status ?? ""),
|
|
297
712
|
type: String(o.type ?? ""),
|
|
713
|
+
triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
|
|
298
714
|
}));
|
|
299
|
-
this._ordersCache = { data: result, time: Date.now() };
|
|
715
|
+
this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
|
|
300
716
|
return result;
|
|
301
717
|
}
|
|
302
718
|
async getOrderHistory(limit = 30) {
|
|
@@ -325,6 +741,7 @@ export class AsterAdapter {
|
|
|
325
741
|
filled: String(o.executedQty ?? "0"),
|
|
326
742
|
status: String(o.status ?? ""),
|
|
327
743
|
type: String(o.type ?? ""),
|
|
744
|
+
triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
|
|
328
745
|
});
|
|
329
746
|
}
|
|
330
747
|
}
|
|
@@ -372,24 +789,71 @@ export class AsterAdapter {
|
|
|
372
789
|
payment: String(f.income ?? "0"),
|
|
373
790
|
}));
|
|
374
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
|
|
794
|
+
* Information v3", one row per symbol, position or not — docs example:
|
|
795
|
+
* `"marginType": "isolated"`, `"leverage": "10"`, `"positionAmt": "0.000"`; rows[].marginType
|
|
796
|
+
* "Margin type: `isolated` or `cross`"). An unreadable row throws — never a default.
|
|
797
|
+
*/
|
|
798
|
+
async getMarketSettings() {
|
|
799
|
+
const rows = await this._signedGetEip712("/fapi/v3/positionRisk", {}, this._resolveSigner());
|
|
800
|
+
if (!Array.isArray(rows)) {
|
|
801
|
+
throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/positionRisk did not return an array — per-market settings unknown", { exchange: "aster" });
|
|
802
|
+
}
|
|
803
|
+
const seen = new Map();
|
|
804
|
+
for (const row of rows) {
|
|
805
|
+
const leverage = Number(row.leverage);
|
|
806
|
+
const type = typeof row.marginType === "string" ? row.marginType.toLowerCase() : "";
|
|
807
|
+
// "crossed" is the request enum spelling (CROSSED); the documented read value is "cross".
|
|
808
|
+
const marginMode = type === "isolated" ? "isolated" : type === "cross" || type === "crossed" ? "cross" : null;
|
|
809
|
+
if (typeof row.symbol !== "string" || !(leverage > 0) || marginMode === null) {
|
|
810
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster positionRisk row without a readable symbol / leverage / marginType: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
|
|
811
|
+
}
|
|
812
|
+
// Hedge mode lists a symbol once per position side; leverage / margin type are per symbol.
|
|
813
|
+
seen.set(row.symbol, { symbol: this._fromApi(row.symbol), leverage, marginMode });
|
|
814
|
+
}
|
|
815
|
+
return [...seen.values()];
|
|
816
|
+
}
|
|
375
817
|
// ── Trading ──
|
|
376
818
|
async marketOrder(symbol, side, size, opts) {
|
|
819
|
+
// Design §13.2 / §13.10 #6: never native MARKET (no price cap) — an IOC LIMIT at the
|
|
820
|
+
// protection price (mark ± opts.slippage.bps, tick-aligned toward crossing, inside
|
|
821
|
+
// PRICE_FILTER / PERCENT_PRICE). newOrderRespType=RESULT makes the response carry the
|
|
822
|
+
// final IOC status (FILLED / EXPIRED) and executedQty.
|
|
377
823
|
const r = this._resolveSigner();
|
|
378
824
|
const apiSymbol = this._toApi(symbol);
|
|
825
|
+
const clientOrderId = opts.clientOrderId === undefined ? null : requireAsterClientOrderId(opts.clientOrderId);
|
|
826
|
+
let protectionPrice;
|
|
827
|
+
let protection;
|
|
828
|
+
try {
|
|
829
|
+
protection = await this.computeProtectionPrice(symbol, side, opts.slippage);
|
|
830
|
+
({ protectionPrice } = protection);
|
|
831
|
+
}
|
|
832
|
+
catch (e) {
|
|
833
|
+
throw orderNotSent("aster", `market ${side} ${size} ${symbol}`, e);
|
|
834
|
+
}
|
|
379
835
|
const params = {
|
|
380
836
|
symbol: apiSymbol,
|
|
381
837
|
side: side.toUpperCase(),
|
|
382
|
-
type: "
|
|
838
|
+
type: "LIMIT",
|
|
839
|
+
timeInForce: "IOC",
|
|
840
|
+
price: protectionPrice,
|
|
383
841
|
quantity: size,
|
|
842
|
+
newOrderRespType: "RESULT",
|
|
384
843
|
};
|
|
385
|
-
if (
|
|
844
|
+
if (clientOrderId !== null)
|
|
845
|
+
params.newClientOrderId = clientOrderId;
|
|
846
|
+
if (opts.reduceOnly)
|
|
386
847
|
params.reduceOnly = "true";
|
|
848
|
+
Object.assign(params, await this._builderParams());
|
|
387
849
|
const result = await this._signedPostEip712("/fapi/v3/order", params, r);
|
|
388
850
|
const ro = result;
|
|
389
851
|
const executedQty = Number(ro.executedQty ?? 0);
|
|
390
852
|
const orderId = String(ro.orderId ?? "");
|
|
853
|
+
// The protection actually sent rides along with the venue response (`protection`), read by
|
|
854
|
+
// the TradingV2 wrapper's execution record (the venue does not echo the reference).
|
|
391
855
|
if (executedQty > 0 && ro.status === "FILLED")
|
|
392
|
-
return
|
|
856
|
+
return { ...ro, protection };
|
|
393
857
|
if (ro.status === "NEW" && orderId) {
|
|
394
858
|
for (let i = 0; i < 3; i++) {
|
|
395
859
|
await new Promise(res => setTimeout(res, 1000));
|
|
@@ -400,9 +864,10 @@ export class AsterAdapter {
|
|
|
400
864
|
}, r);
|
|
401
865
|
const filledQty = Number(check.executedQty ?? 0);
|
|
402
866
|
if (filledQty > 0)
|
|
403
|
-
return check;
|
|
404
|
-
if (check.status === "CANCELED" || check.status === "EXPIRED" || check.status === "REJECTED") {
|
|
405
|
-
|
|
867
|
+
return { ...check, protection };
|
|
868
|
+
if ((check.status === "CANCELED" || check.status === "EXPIRED" || check.status === "REJECTED") && check.executedQty !== undefined && check.executedQty !== null) {
|
|
869
|
+
// Terminal status with a REPORTED 0 executed: nothing traded (definite).
|
|
870
|
+
throw new PerpError("ORDER_REJECTED", `Market ${side} ${symbol}: order ${check.status} (orderId: ${orderId})`, { exchange: "aster", orderId });
|
|
406
871
|
}
|
|
407
872
|
}
|
|
408
873
|
catch (e) {
|
|
@@ -410,45 +875,165 @@ export class AsterAdapter {
|
|
|
410
875
|
throw e;
|
|
411
876
|
}
|
|
412
877
|
}
|
|
878
|
+
let cancelledQty = null;
|
|
879
|
+
let cancelRow = null;
|
|
413
880
|
try {
|
|
414
|
-
await this._signedDeleteEip712("/fapi/v3/order", { symbol: apiSymbol, orderId }, r);
|
|
881
|
+
const c = await this._signedDeleteEip712("/fapi/v3/order", { symbol: apiSymbol, orderId }, r);
|
|
882
|
+
const q = Number(c?.executedQty);
|
|
883
|
+
cancelledQty = Number.isFinite(q) ? q : null;
|
|
884
|
+
cancelRow = c;
|
|
415
885
|
}
|
|
416
|
-
catch { /*
|
|
417
|
-
|
|
886
|
+
catch { /* cancel failed: the order may still be live — outcome stays unknown below */ }
|
|
887
|
+
// The cancel answered with executedQty > 0: the order PARTIALLY filled before the cancel —
|
|
888
|
+
// report that fill (the cancel row carries executedQty / status CANCELED; asterIocOutcome
|
|
889
|
+
// reads it), never "not filled".
|
|
890
|
+
if (cancelledQty !== null && cancelledQty > 0 && cancelRow !== null) {
|
|
891
|
+
console.error(`[aster] market ${side} ${symbol}: order ${orderId} partially filled (${String(cancelRow.executedQty)}) before the remainder was cancelled`);
|
|
892
|
+
return { ...cancelRow, protection };
|
|
893
|
+
}
|
|
894
|
+
const msg = `Market ${side} ${symbol}: order not filled after 3s, cancelled (orderId: ${orderId})`;
|
|
895
|
+
// Definite only when the cancel was confirmed with 0 executed; otherwise it may have filled / be live.
|
|
896
|
+
if (cancelledQty === 0)
|
|
897
|
+
throw new PerpError("ORDER_REJECTED", msg, { exchange: "aster", orderId });
|
|
898
|
+
throw new Error(msg);
|
|
418
899
|
}
|
|
419
900
|
if (executedQty === 0) {
|
|
420
|
-
|
|
901
|
+
const msg = `Market ${side} ${symbol}: order accepted but 0 filled (status: ${ro.status}, orderId: ${orderId})`;
|
|
902
|
+
// A terminal status (EXPIRED IOC / CANCELED / REJECTED) with 0 executed is a definite no-fill.
|
|
903
|
+
if ((ro.status === "EXPIRED" || ro.status === "CANCELED" || ro.status === "REJECTED") && ro.executedQty !== undefined && ro.executedQty !== null) {
|
|
904
|
+
throw new PerpError("ORDER_REJECTED", msg, { exchange: "aster", orderId });
|
|
905
|
+
}
|
|
906
|
+
throw new Error(msg);
|
|
421
907
|
}
|
|
422
|
-
return
|
|
908
|
+
return { ...ro, protection };
|
|
423
909
|
}
|
|
424
910
|
async limitOrder(symbol, side, price, size, opts) {
|
|
425
911
|
const r = this._resolveSigner();
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
912
|
+
const apiSymbol = this._toApi(symbol);
|
|
913
|
+
const clientOrderId = opts?.clientOrderId === undefined ? null : requireAsterClientOrderId(opts.clientOrderId);
|
|
914
|
+
let params;
|
|
915
|
+
try {
|
|
916
|
+
// Common TIF names (§13.3): GTC / IOC / ALO→GTX (GTX accepted as alias); FOK unsupported.
|
|
917
|
+
const tif = asterTif(opts?.tif ?? "GTC");
|
|
918
|
+
// Price must already be on PRICE_FILTER.tickSize — validated, never rounded (INVALID_TICK).
|
|
919
|
+
const px = requireOnTick(price, (await this._symbolPriceFilters(apiSymbol)).tickSize);
|
|
920
|
+
params = {
|
|
921
|
+
symbol: apiSymbol,
|
|
922
|
+
side: side.toUpperCase(),
|
|
923
|
+
type: "LIMIT",
|
|
924
|
+
price: px,
|
|
925
|
+
quantity: size,
|
|
926
|
+
timeInForce: tif,
|
|
927
|
+
};
|
|
928
|
+
// IOC / GTX: ask for the final status + executedQty in the response (iocOutcomeOf). Docs (futures
|
|
929
|
+
// V3, New Order): with `newOrderRespType` RESULT, "`LIMIT` order with special `timeInForce`: the
|
|
930
|
+
// final status result of the order(FILLED or EXPIRED) will be returned directly". The default ACK
|
|
931
|
+
// answers NEW for a post-only order that then expires because it would cross (live mainnet 2026-10-05).
|
|
932
|
+
if (tif === "IOC" || tif === "GTX")
|
|
933
|
+
params.newOrderRespType = "RESULT";
|
|
934
|
+
if (clientOrderId !== null)
|
|
935
|
+
params.newClientOrderId = clientOrderId;
|
|
936
|
+
if (opts?.reduceOnly)
|
|
937
|
+
params.reduceOnly = "true";
|
|
938
|
+
Object.assign(params, await this._builderParams());
|
|
939
|
+
}
|
|
940
|
+
catch (e) {
|
|
941
|
+
throw orderNotSent("aster", `limit ${side} ${size} ${symbol} @ ${price}`, e);
|
|
942
|
+
}
|
|
436
943
|
return this._signedPostEip712("/fapi/v3/order", params, r);
|
|
437
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Native modify (PUT /fapi/v3/order, V3 futures docs "Modify Order"): LIMIT orders
|
|
947
|
+
* only; TIF (incl. GTX) and reduceOnly stay on the order. `size` is the NEW UNFILLED
|
|
948
|
+
* quantity (§13.10 #10); the venue's `quantity` is the order total, so
|
|
949
|
+
* quantity = size + executedQty (read just before). A fill landing between the read
|
|
950
|
+
* and the PUT can only shrink the remainder, never over-size it. A venue rejection
|
|
951
|
+
* leaves the original order untouched (docs).
|
|
952
|
+
*/
|
|
438
953
|
async editOrder(symbol, orderId, price, size) {
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
954
|
+
const r = this._resolveSigner();
|
|
955
|
+
const apiSymbol = this._toApi(symbol);
|
|
956
|
+
const rawOrders = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
|
|
957
|
+
const existing = (rawOrders ?? []).find(o => String(o.orderId ?? "") === orderId);
|
|
958
|
+
if (!existing) {
|
|
959
|
+
// Rule #2: never guess the order — fail honestly if it is no longer open.
|
|
960
|
+
throw new PerpError("ORDER_NOT_FOUND", `Aster order ${orderId} is not among the open orders; refusing to edit (cannot guess its side).`, { exchange: "aster" });
|
|
961
|
+
}
|
|
962
|
+
const type = String(existing.type ?? "").toUpperCase();
|
|
963
|
+
if (type !== "LIMIT") {
|
|
964
|
+
// Venue modify supports LIMIT only; a STOP/TP must be cancelled and re-placed explicitly.
|
|
965
|
+
throw new PerpError("INVALID_PARAMS", `Aster editOrder only supports plain LIMIT orders (order ${orderId} is ${type || "unknown"}). Cancel and re-place it explicitly.`, { exchange: "aster" });
|
|
966
|
+
}
|
|
967
|
+
const executed = plainQty(existing.executedQty);
|
|
968
|
+
if (executed === null) {
|
|
969
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster order ${orderId}: openOrders row has no readable executedQty (${String(existing.executedQty)})`, { exchange: "aster" });
|
|
970
|
+
}
|
|
971
|
+
requirePositive(size, "size");
|
|
972
|
+
const px = requireOnTick(price, (await this._symbolPriceFilters(apiSymbol)).tickSize);
|
|
973
|
+
return this._perpWrite("PUT /fapi/v3/order", () => this._signedPostTo(this._baseUrl, "/fapi/v3/order", {
|
|
974
|
+
symbol: apiSymbol,
|
|
975
|
+
orderId,
|
|
976
|
+
side: String(existing.side ?? "").toUpperCase(),
|
|
977
|
+
quantity: subDecimal(size, `-${executed}`),
|
|
978
|
+
price: px,
|
|
979
|
+
}, r, "PUT"), true);
|
|
445
980
|
}
|
|
446
981
|
async cancelOrder(symbol, orderId) {
|
|
982
|
+
return this._cancelOne(symbol, { orderId }, orderId);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Cancel one order by client order id — V3 futures "Cancel Order" (DELETE /fapi/v3/order):
|
|
986
|
+
* "Either `orderId` or `origClientOrderId` must be sent."
|
|
987
|
+
*/
|
|
988
|
+
async cancelOrderByClientId(symbol, clientOrderId) {
|
|
989
|
+
const id = requireAsterClientOrderId(clientOrderId);
|
|
990
|
+
return this._cancelOne(symbol, { origClientOrderId: id }, `c:${id}`);
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* One order's row — V3 futures "Query Order" (GET /fapi/v3/order, symbol + orderId or
|
|
994
|
+
* origClientOrderId). Docs: CANCELED / EXPIRED orders with no fill older than 7 days "will not
|
|
995
|
+
* be found". -2013 NO_SUCH_ORDER → ORDER_NOT_FOUND; any other failure throws as is.
|
|
996
|
+
*/
|
|
997
|
+
async queryOrderRow(symbol, ref) {
|
|
998
|
+
const key = ref.kind === "orderId" ? { orderId: ref.value } : { origClientOrderId: requireAsterClientOrderId(ref.value) };
|
|
999
|
+
const label = ref.kind === "orderId" ? ref.value : `c:${ref.value}`;
|
|
1000
|
+
let row;
|
|
1001
|
+
try {
|
|
1002
|
+
row = await this._signedGetEip712("/fapi/v3/order", { symbol: this._toApi(symbol), ...key }, this._resolveSigner());
|
|
1003
|
+
}
|
|
1004
|
+
catch (e) {
|
|
1005
|
+
if (e instanceof PerpError && e.structured.details?.venueCode === -2013) {
|
|
1006
|
+
throw new PerpError("ORDER_NOT_FOUND", `Aster has no order ${label} on ${symbol.toUpperCase()} (-2013 NO_SUCH_ORDER)`, {
|
|
1007
|
+
exchange: "aster", venueCode: -2013, remediation: "List orders: perp account orders -e aster / perp history orders -e aster",
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
throw e;
|
|
1011
|
+
}
|
|
1012
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) {
|
|
1013
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster GET /fapi/v3/order ${label}: unexpected response shape`, { exchange: "aster" });
|
|
1014
|
+
}
|
|
1015
|
+
return row;
|
|
1016
|
+
}
|
|
1017
|
+
async _cancelOne(symbol, key, orderId) {
|
|
447
1018
|
const r = this._resolveSigner();
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
1019
|
+
try {
|
|
1020
|
+
return await this._signedDeleteEip712("/fapi/v3/order", {
|
|
1021
|
+
symbol: this._toApi(symbol),
|
|
1022
|
+
...key,
|
|
1023
|
+
}, r);
|
|
1024
|
+
}
|
|
1025
|
+
catch (e) {
|
|
1026
|
+
// Docs (futures V3 error codes): "-2011 CANCEL_REJECTED" — the venue refused the cancel; the
|
|
1027
|
+
// order is not open (already filled / cancelled, or unknown). Live mainnet 2026-10-05, cancel of
|
|
1028
|
+
// a filled order: 400 {"code":-2011,"msg":"Unknown order sent."}. Definite: nothing was cancelled.
|
|
1029
|
+
if (e instanceof PerpError && e.structured.details?.venueCode === -2011) {
|
|
1030
|
+
throw new PerpError("ORDER_REJECTED", `Aster refused to cancel order ${orderId} on ${symbol} (-2011 CANCEL_REJECTED): it is not an open order`, {
|
|
1031
|
+
exchange: "aster", orderId, venueCode: -2011,
|
|
1032
|
+
remediation: `Check its final state: perp trade status ${orderId} --symbol ${symbol} -e aster`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
throw e;
|
|
1036
|
+
}
|
|
452
1037
|
}
|
|
453
1038
|
async cancelAllOrders(symbol) {
|
|
454
1039
|
const r = this._resolveSigner();
|
|
@@ -473,39 +1058,229 @@ export class AsterAdapter {
|
|
|
473
1058
|
marginType: marginMode === "cross" ? "CROSSED" : "ISOLATED",
|
|
474
1059
|
}, r);
|
|
475
1060
|
}
|
|
476
|
-
catch {
|
|
1061
|
+
catch (e) {
|
|
1062
|
+
// Rule #2: only -4046 NO_NEED_TO_CHANGE_MARGIN_TYPE ("No need to change
|
|
1063
|
+
// margin type") is a benign no-op. Every other rejection — notably a
|
|
1064
|
+
// refusal because the symbol has an open position or open orders —
|
|
1065
|
+
// means the account is STILL on the previous margin mode. Swallowing
|
|
1066
|
+
// those left the user believing they had switched to isolated while the
|
|
1067
|
+
// position stayed cross, and the leverage change below then applied to
|
|
1068
|
+
// a cross position.
|
|
1069
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1070
|
+
const benign = msg.includes("-4046") || /no need to change margin type/i.test(msg);
|
|
1071
|
+
if (benign) { /* already on the requested margin type */ }
|
|
1072
|
+
else if (e instanceof PerpError && typeof e.structured.details?.venueCode === "number" && [-4047, -4048, -4050, -4051].includes(e.structured.details.venueCode)) {
|
|
1073
|
+
// The venue refused the margin-type change; the leverage request below is NOT sent, so
|
|
1074
|
+
// nothing changed. Docs ("Error Codes"): -4047 "Margin type cannot be changed if there
|
|
1075
|
+
// exists open orders." · -4048 "Margin type cannot be changed if there exists position."
|
|
1076
|
+
// · -4050 "Cross balance insufficient." · -4051 "Isolated balance insufficient." The
|
|
1077
|
+
// "Change Margin Type" section states no balance or Multi-Assets-mode condition, so
|
|
1078
|
+
// for -4050 / -4051 the venue's own text is reported as is (live mainnet 2026-10-05:
|
|
1079
|
+
// ISOLATED on a flat SOL → 400 -4050) with the two account facts to check.
|
|
1080
|
+
const code = e.structured.details.venueCode;
|
|
1081
|
+
const why = {
|
|
1082
|
+
[-4047]: ["THERE_EXISTS_OPEN_ORDERS", `Cancel the ${symbol} open orders first: perp trade cancel ${symbol} -e aster`],
|
|
1083
|
+
[-4048]: ["THERE_EXISTS_QUANTITY", `Close the ${symbol} position first: perp trade close ${symbol} -e aster`],
|
|
1084
|
+
[-4050]: ["CROSS_BALANCE_INSUFFICIENT", `The venue docs do not state the condition. Check the account's margin-asset balance (perp account balance -e aster) and whether it is in Multi-Assets mode (GET /fapi/v3/multiAssetsMargin; set in the Aster app) — or keep cross: perp trade leverage ${symbol} ${leverage} -e aster`],
|
|
1085
|
+
[-4051]: ["ISOLATED_BALANCE_INSUFFICIENT", `The venue docs do not state the condition. Check the account's margin-asset balance: perp account balance -e aster`],
|
|
1086
|
+
};
|
|
1087
|
+
throw new PerpError("ACTION_REJECTED", `Aster refused to switch ${symbol} to ${marginMode} margin (${code} ${why[code][0]}: ${msg}) — margin mode and leverage are unchanged`, {
|
|
1088
|
+
exchange: "aster", venueCode: code, httpStatus: e.structured.details.httpStatus, remediation: why[code][1],
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
else
|
|
1092
|
+
throw e;
|
|
1093
|
+
}
|
|
477
1094
|
}
|
|
478
1095
|
return this._signedPostEip712("/fapi/v3/leverage", {
|
|
479
1096
|
symbol: this._toApi(symbol),
|
|
480
1097
|
leverage: String(leverage),
|
|
481
1098
|
}, r);
|
|
482
1099
|
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Trigger order. Type follows the venue trigger rules (V3 docs, "Condition orders"):
|
|
1102
|
+
* STOP*: BUY fires at price >= stopPrice, SELL at <= ; TAKE_PROFIT*: the reverse. So the
|
|
1103
|
+
* type is chosen from the trigger vs the current trigger-source price and the order side
|
|
1104
|
+
* (the side closing a long is SELL): BUY above / SELL below → STOP, otherwise
|
|
1105
|
+
* TAKE_PROFIT. (Always STOP_MARKET made every TP fail with -2021.)
|
|
1106
|
+
* Trigger source: workingType MARK_PRICE compared with the premiumIndex mark (default),
|
|
1107
|
+
* or CONTRACT_PRICE with the last price when triggerType is "last".
|
|
1108
|
+
* size "0" = whole position → closePosition=true (STOP_MARKET / TAKE_PROFIT_MARKET only;
|
|
1109
|
+
* the venue forbids quantity and reduceOnly with it).
|
|
1110
|
+
* `opts.kind` (TP/SL legs): sl → STOP(_MARKET), tp → TAKE_PROFIT(_MARKET) as given, with
|
|
1111
|
+
* no price read; a trigger already crossed is rejected by the venue (-2021).
|
|
1112
|
+
*/
|
|
483
1113
|
async stopOrder(symbol, side, size, triggerPrice, opts) {
|
|
484
1114
|
const r = this._resolveSigner();
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1115
|
+
const apiSymbol = this._toApi(symbol);
|
|
1116
|
+
const what = `stop ${side} ${size} ${symbol} @ ${triggerPrice}`;
|
|
1117
|
+
let params;
|
|
1118
|
+
try {
|
|
1119
|
+
if (opts?.triggerType === "mid") {
|
|
1120
|
+
throw unsupported("Aster", "triggerType", "stop orders trigger on MARK_PRICE or CONTRACT_PRICE only", "Use triggerType mark or last");
|
|
1121
|
+
}
|
|
1122
|
+
const f = await this._symbolPriceFilters(apiSymbol);
|
|
1123
|
+
const stopPrice = requireOnTick(triggerPrice, f.tickSize);
|
|
1124
|
+
const useLast = opts?.triggerType === "last";
|
|
1125
|
+
let isStop;
|
|
1126
|
+
if (opts?.kind !== undefined) {
|
|
1127
|
+
isStop = opts.kind === "sl";
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
const ref = useLast ? await this._lastPrice(apiSymbol) : await this.referencePrice(symbol);
|
|
1131
|
+
const t = Number(stopPrice), p = Number(ref);
|
|
1132
|
+
if (t === p) {
|
|
1133
|
+
throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: trigger ${stopPrice} equals the current ${useLast ? "last" : "mark"} price — it would trigger immediately`, { exchange: "aster" });
|
|
1134
|
+
}
|
|
1135
|
+
isStop = side === "buy" ? t > p : t < p;
|
|
1136
|
+
}
|
|
1137
|
+
const closeAll = Number(size) === 0;
|
|
1138
|
+
if (!closeAll)
|
|
1139
|
+
requirePositive(size, "size");
|
|
1140
|
+
params = {
|
|
1141
|
+
symbol: apiSymbol,
|
|
1142
|
+
side: side.toUpperCase(),
|
|
1143
|
+
stopPrice,
|
|
1144
|
+
workingType: useLast ? "CONTRACT_PRICE" : "MARK_PRICE",
|
|
1145
|
+
};
|
|
1146
|
+
if (opts?.limitPrice) {
|
|
1147
|
+
if (closeAll) {
|
|
1148
|
+
throw new PerpError("INVALID_PARAMS", "Aster closePosition (whole-position trigger) only exists for market triggers — pass an explicit size with a limit price", { exchange: "aster" });
|
|
1149
|
+
}
|
|
1150
|
+
params.type = isStop ? "STOP" : "TAKE_PROFIT";
|
|
1151
|
+
params.price = requireOnTick(opts.limitPrice, f.tickSize);
|
|
1152
|
+
params.timeInForce = "GTC";
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
params.type = isStop ? "STOP_MARKET" : "TAKE_PROFIT_MARKET";
|
|
1156
|
+
}
|
|
1157
|
+
if (closeAll) {
|
|
1158
|
+
params.closePosition = "true";
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
params.quantity = size;
|
|
1162
|
+
if (opts?.reduceOnly)
|
|
1163
|
+
params.reduceOnly = "true";
|
|
1164
|
+
}
|
|
1165
|
+
Object.assign(params, await this._builderParams());
|
|
1166
|
+
}
|
|
1167
|
+
catch (e) {
|
|
1168
|
+
throw orderNotSent("aster", what, e);
|
|
495
1169
|
}
|
|
496
|
-
if (opts?.reduceOnly)
|
|
497
|
-
params.reduceOnly = "true";
|
|
498
1170
|
return this._signedPostEip712("/fapi/v3/order", params, r);
|
|
499
1171
|
}
|
|
500
|
-
|
|
501
|
-
async
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1172
|
+
/** Last traded price (GET /fapi/v3/ticker/price) — the CONTRACT_PRICE trigger source. */
|
|
1173
|
+
async _lastPrice(apiSymbol) {
|
|
1174
|
+
let price;
|
|
1175
|
+
try {
|
|
1176
|
+
price = (await this._publicGet("/fapi/v3/ticker/price", { symbol: apiSymbol }))?.price;
|
|
1177
|
+
}
|
|
1178
|
+
catch (e) {
|
|
1179
|
+
throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} last price unavailable: ${e instanceof Error ? e.message : String(e)}`, { exchange: "aster" });
|
|
1180
|
+
}
|
|
1181
|
+
const str = typeof price === "number" ? String(price) : price;
|
|
1182
|
+
if (typeof str !== "string" || !(Number(str) > 0) || !Number.isFinite(Number(str))) {
|
|
1183
|
+
throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} ticker/price returned no usable price (${String(price)})`, { exchange: "aster" });
|
|
1184
|
+
}
|
|
1185
|
+
return str;
|
|
1186
|
+
}
|
|
1187
|
+
// ── Withdraw (mainnet only): POST /fapi/v3/aster/user-withdraw ──
|
|
1188
|
+
/** Asset withdrawn by `withdraw`: `requested` (must be in capabilities.withdrawAssets), else withdrawAssets[0]. */
|
|
1189
|
+
_withdrawAsset(requested) {
|
|
1190
|
+
const assets = asterVenue.capabilities?.withdrawAssets;
|
|
1191
|
+
if (assets === null || assets === undefined || assets.length === 0)
|
|
1192
|
+
throw new Error("aster venue definition declares no withdrawAssets");
|
|
1193
|
+
if (requested === undefined)
|
|
1194
|
+
return assets[0];
|
|
1195
|
+
const a = requested.toUpperCase();
|
|
1196
|
+
if (!assets.includes(a)) {
|
|
1197
|
+
throw new PerpError("INVALID_PARAMS", `Aster withdraws ${assets.join(" / ")} only (got "${requested}")`, { exchange: "aster", remediation: `Use --asset ${assets.join(" or --asset ")}` });
|
|
1198
|
+
}
|
|
1199
|
+
return a;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Withdrawable perp balance + fee for the withdraw asset on `chain`
|
|
1203
|
+
* (POST /fapi/v3/aster/user-withdraw-info, signed by the API wallet). Read-only.
|
|
1204
|
+
*/
|
|
1205
|
+
async withdrawLimits(chain, asset) {
|
|
1206
|
+
requireAsterFundsNetwork(this._testnet ? "testnet" : "mainnet", "withdraw");
|
|
1207
|
+
const raw = await this.signedRequest(this._baseUrl, "POST", "/fapi/v3/aster/user-withdraw-info", {});
|
|
1208
|
+
return parseAsterWithdrawLimits(raw, this._withdrawAsset(asset), ASTER_FUNDS_CHAINS[chain].chainId);
|
|
1209
|
+
}
|
|
1210
|
+
/** Deposit / withdraw history (POST /fapi/v3/aster/deposit-withdraw-history, signed by the API wallet). Read-only. */
|
|
1211
|
+
async depositWithdrawHistory() {
|
|
1212
|
+
return this.signedRequest(this._baseUrl, "POST", "/fapi/v3/aster/deposit-withdraw-history", {});
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Withdraw the venue collateral (USDT) from the perp account to the account owner's OWN address
|
|
1216
|
+
* on BNB Chain (default) or Arbitrum. Two signatures (asterdex/api-docs
|
|
1217
|
+
* demo/aster-deposit-withdrawal.md, API-demo futures-v3-demo/58_withdrawEvm.js):
|
|
1218
|
+
* 1. `userSignature` — the master wallet's EIP-712 "Aster" Action over receiver / chain / token /
|
|
1219
|
+
* amount / fee / nonce, recovered locally and compared to the account owner before sending;
|
|
1220
|
+
* 2. the V3 envelope signed by the API wallet, which must have canWithdraw (and an IP whitelist).
|
|
1221
|
+
* The fee and the maximum come from user-withdraw-info; the identical amount / fee strings go
|
|
1222
|
+
* into the signature and the request. Once the request left, an unreadable result is
|
|
1223
|
+
* ORDER_PENDING (never resubmit).
|
|
1224
|
+
*/
|
|
1225
|
+
async withdraw(amount, destination, opts) {
|
|
1226
|
+
requireAsterFundsNetwork(this._testnet ? "testnet" : "mainnet", "withdraw");
|
|
1227
|
+
if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
|
|
1228
|
+
throw new PerpError("INVALID_PARAMS", "Aster withdraw takes no assetId/routeType (use --asset USDT|USDC and --chain bsc|arbitrum)");
|
|
1229
|
+
}
|
|
1230
|
+
const chain = opts?.chain === undefined ? "bsc" : opts.chain;
|
|
1231
|
+
const pins = ASTER_FUNDS_CHAINS[chain];
|
|
1232
|
+
const asset = this._withdrawAsset(opts?.asset);
|
|
1233
|
+
const r = this._resolveSigner();
|
|
1234
|
+
if (this._agentMeta === undefined || this._agentMeta.permissions.canWithdraw !== true) {
|
|
1235
|
+
throw new PerpError("POLICY_DENIED", "The active Aster API wallet was approved without the withdraw permission", {
|
|
1236
|
+
exchange: "aster",
|
|
1237
|
+
remediation: "Re-approve it with withdraw + a mandatory IP whitelist: perp wallet agent approve aster --can-withdraw --ip-whitelist <your public ip> --rotate — or withdraw in the Aster web app",
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
const user = r.userAddress;
|
|
1241
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(destination) || destination.toLowerCase() !== user.toLowerCase()) {
|
|
1242
|
+
throw new PerpError("INVALID_PARAMS", `Aster only withdraws to the account's own address ${user} (got "${destination}")`, {
|
|
1243
|
+
exchange: "aster", remediation: `Omit --to (defaults to ${user})`,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
const master = this._masterSigner ?? this._pkSigner;
|
|
1247
|
+
if (master === undefined) {
|
|
1248
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", "Aster withdraw needs the master wallet to sign the withdraw message; only the API wallet is loaded", {
|
|
1249
|
+
exchange: "aster", remediation: "Run with the master wallet: perp --ows <wallet> funds withdraw aster <amount> — or withdraw in the Aster web app",
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
if (master.getAddress().toLowerCase() !== user.toLowerCase()) {
|
|
1253
|
+
throw new PerpError("INVALID_PARAMS", `The loaded master wallet ${master.getAddress()} is not the Aster account owner ${user}`, {
|
|
1254
|
+
exchange: "aster", remediation: "Select the wallet that owns the Aster account (--ows <wallet>)",
|
|
505
1255
|
});
|
|
506
1256
|
}
|
|
507
|
-
|
|
508
|
-
|
|
1257
|
+
const limits = await this.withdrawLimits(chain, asset);
|
|
1258
|
+
assertAsterWithdrawAmount(amount, limits, asset);
|
|
1259
|
+
const userNonce = (BigInt(Date.now()) * 1000n).toString();
|
|
1260
|
+
const typed = buildWithdrawTypedData({
|
|
1261
|
+
chainId: pins.chainId, chainName: pins.withdrawChainName, receiver: user, asset, amount, fee: limits.fee, userNonce,
|
|
1262
|
+
});
|
|
1263
|
+
let userSignature;
|
|
1264
|
+
try {
|
|
1265
|
+
userSignature = await master.signTypedData(typed.domain, typed.types, typed.message);
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
throw orderNotSent("aster", "withdraw", e);
|
|
1269
|
+
}
|
|
1270
|
+
assertAsterWithdrawSignature(typed, userSignature, user);
|
|
1271
|
+
const path = "/fapi/v3/aster/user-withdraw";
|
|
1272
|
+
const raw = await this._perpWrite(`POST ${path}`, () => this._signedPostTo(this._baseUrl, path, {
|
|
1273
|
+
chainId: String(pins.chainId), asset, amount, fee: limits.fee, receiver: user, userNonce, userSignature,
|
|
1274
|
+
}, r));
|
|
1275
|
+
const res = (raw !== null && typeof raw === "object" ? raw : {});
|
|
1276
|
+
if (typeof res.withdrawId !== "string" && typeof res.withdrawId !== "number") {
|
|
1277
|
+
// 2xx without the documented withdrawId: the request was accepted on the wire — outcome unknown.
|
|
1278
|
+
throw orderOutcomeUnknown("aster", `POST ${path}`, new Error(`unrecognized response ${JSON.stringify(raw).slice(0, 160)}`), "Do NOT resubmit — the withdrawal may have been accepted. Check the Aster app → Assets → History");
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
withdrawId: String(res.withdrawId), hash: typeof res.hash === "string" ? res.hash : "",
|
|
1282
|
+
chain, chainId: pins.chainId, asset, amount, fee: limits.fee, receiver: user,
|
|
1283
|
+
};
|
|
509
1284
|
}
|
|
510
1285
|
// ── Private: signer resolution ────────────────────────────────────────────
|
|
511
1286
|
/**
|
|
@@ -517,23 +1292,20 @@ export class AsterAdapter {
|
|
|
517
1292
|
_resolveSigner() {
|
|
518
1293
|
// Tier 1: agent (when registered, not expired, --no-agent NOT set)
|
|
519
1294
|
if (!this._useNoAgent && this._agentSigner && this._agentMeta) {
|
|
1295
|
+
// An expired agent record is an explicit error — never a fall-through to the master tier
|
|
1296
|
+
// (as on Hyperliquid / Pacifica; the master cannot sign Aster requests anyway).
|
|
520
1297
|
if (isExpired(this._agentMeta)) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
// fall through to Tier 2/3
|
|
528
|
-
}
|
|
529
|
-
else {
|
|
530
|
-
return {
|
|
531
|
-
tier: "agent",
|
|
532
|
-
signer: this._agentSigner,
|
|
533
|
-
signerAddress: this._agentMeta.agentEvmAddress,
|
|
534
|
-
userAddress: this._agentMeta.userEvmAddress,
|
|
535
|
-
};
|
|
1298
|
+
throw new PerpError("AGENT_EXPIRED", `Aster agent wallet ${this._agentMeta.agentEvmAddress} expired at ${this._agentMeta.expiresAt}`, {
|
|
1299
|
+
exchange: "aster",
|
|
1300
|
+
remediation: "perp wallet agent approve aster --rotate",
|
|
1301
|
+
});
|
|
536
1302
|
}
|
|
1303
|
+
return {
|
|
1304
|
+
tier: "agent",
|
|
1305
|
+
signer: this._agentSigner,
|
|
1306
|
+
signerAddress: this._agentMeta.agentEvmAddress,
|
|
1307
|
+
userAddress: this._agentMeta.userEvmAddress,
|
|
1308
|
+
};
|
|
537
1309
|
}
|
|
538
1310
|
// Tier 2/3: master self-signing is not supported by the venue. Per Aster
|
|
539
1311
|
// V3 spec the `signer` field MUST be a registered API_WALLET (agent);
|
|
@@ -555,9 +1327,9 @@ export class AsterAdapter {
|
|
|
555
1327
|
});
|
|
556
1328
|
}
|
|
557
1329
|
// ── Private: nonce ────────────────────────────────────────────────────────
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
return
|
|
1330
|
+
/** Monotonic µs nonce per signer, persisted across processes (aster-nonce.ts). */
|
|
1331
|
+
_nextNonce(signer) {
|
|
1332
|
+
return nextAsterNonce(signer);
|
|
561
1333
|
}
|
|
562
1334
|
// ── Private: EIP-712 signed HTTP helpers ─────────────────────────────────
|
|
563
1335
|
/**
|
|
@@ -586,7 +1358,7 @@ export class AsterAdapter {
|
|
|
586
1358
|
// Insertion order (matches Python reference: nonce, user, signer last).
|
|
587
1359
|
const fullParams = {
|
|
588
1360
|
...params,
|
|
589
|
-
nonce: this._nextNonce(),
|
|
1361
|
+
nonce: this._nextNonce(resolved.signerAddress),
|
|
590
1362
|
user: resolved.userAddress,
|
|
591
1363
|
signer: resolved.signerAddress,
|
|
592
1364
|
};
|
|
@@ -603,14 +1375,54 @@ export class AsterAdapter {
|
|
|
603
1375
|
* Sign and POST to a path using EIP-712 Domain B.
|
|
604
1376
|
*/
|
|
605
1377
|
async _signedPostEip712(path, params, resolved) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1378
|
+
return this._perpWrite(`POST ${path}`, () => this._signedPostTo(this._baseUrl, path, params, resolved), path === "/fapi/v3/order");
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* A perp account WRITE (order / modify / cancel / margin type / leverage). Once the request is
|
|
1382
|
+
* on the wire, a transport failure, an HTTP 5xx or an unreadable 2xx body leaves the outcome
|
|
1383
|
+
* unknown → ORDER_PENDING (never the retryable EXCHANGE_ERROR / EXCHANGE_UNREACHABLE); no
|
|
1384
|
+
* connection at all → not sent. Venue refusals (4xx, error envelopes) pass through unchanged.
|
|
1385
|
+
*/
|
|
1386
|
+
async _perpWrite(what, send, orderPlacement = false) {
|
|
1387
|
+
try {
|
|
1388
|
+
return await send();
|
|
1389
|
+
}
|
|
1390
|
+
catch (e) {
|
|
1391
|
+
if (e instanceof PerpError) {
|
|
1392
|
+
const d = e.structured.details;
|
|
1393
|
+
const http = typeof d?.httpStatus === "number" ? d.httpStatus : null;
|
|
1394
|
+
if (d?.responseUnreadable === true || (http !== null && http >= 500))
|
|
1395
|
+
throw orderOutcomeUnknown("aster", what, e);
|
|
1396
|
+
// Order placement / modify: a documented 4xx business code is a definite refusal (asterOrderRejection).
|
|
1397
|
+
throw orderPlacement ? asterOrderRejection(e) : e;
|
|
1398
|
+
}
|
|
1399
|
+
throw isTransportFailure(e) ? sendFailure("aster", what, e) : e;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
/** Sign and POST (or PUT) to `host + path` (perp `fapi` or spot `sapi` — same Domain B signing). */
|
|
1403
|
+
async _signedPostTo(host, path, params, resolved, method = "POST") {
|
|
1404
|
+
let qs;
|
|
1405
|
+
try {
|
|
1406
|
+
qs = await this._buildSignedQueryString(params, resolved);
|
|
1407
|
+
}
|
|
1408
|
+
catch (e) {
|
|
1409
|
+
// Signing failed before the request was sent: definitely not placed (never ORDER_PENDING).
|
|
1410
|
+
throw orderNotSent("aster", `${method} ${path}`, e);
|
|
1411
|
+
}
|
|
1412
|
+
const url = `${host}${path}?${qs}`;
|
|
1413
|
+
// Any order write (place / modify) makes the open-orders snapshot stale — before and after the request.
|
|
1414
|
+
this.invalidateOpenOrdersCache();
|
|
1415
|
+
try {
|
|
1416
|
+
const res = await fetch(url, {
|
|
1417
|
+
method,
|
|
1418
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1419
|
+
body: "",
|
|
1420
|
+
});
|
|
1421
|
+
return await this._handleAsterResponse(res, method, path);
|
|
1422
|
+
}
|
|
1423
|
+
finally {
|
|
1424
|
+
this.invalidateOpenOrdersCache();
|
|
1425
|
+
}
|
|
614
1426
|
}
|
|
615
1427
|
/**
|
|
616
1428
|
* Sign and GET from a path using EIP-712 Domain B.
|
|
@@ -625,27 +1437,40 @@ export class AsterAdapter {
|
|
|
625
1437
|
* one would itself be rejected. Non-429 venue errors throw immediately.
|
|
626
1438
|
*/
|
|
627
1439
|
async _signedGetEip712(path, params, resolved) {
|
|
628
|
-
return this._signedRequestWithRetry("GET", path, params, resolved);
|
|
1440
|
+
return this._signedRequestWithRetry(this._baseUrl, "GET", path, params, resolved);
|
|
629
1441
|
}
|
|
630
1442
|
/**
|
|
631
1443
|
* Sign and DELETE from a path using EIP-712 Domain B.
|
|
632
1444
|
* Same retry behavior as _signedGetEip712.
|
|
633
1445
|
*/
|
|
634
1446
|
async _signedDeleteEip712(path, params, resolved) {
|
|
635
|
-
return this._signedRequestWithRetry("DELETE", path, params, resolved);
|
|
1447
|
+
return this._perpWrite(`DELETE ${path}`, () => this._signedRequestWithRetry(this._baseUrl, "DELETE", path, params, resolved));
|
|
636
1448
|
}
|
|
637
1449
|
/** Shared GET/DELETE retry loop with fresh-nonce per attempt.
|
|
638
1450
|
*
|
|
639
1451
|
* Total 4 attempts: initial + 3 retries with backoffs 2s, 4s, 8s.
|
|
640
1452
|
* Codex v0.12.12 final QA #3: previously MAX_ATTEMPTS = 3 truncated the
|
|
641
1453
|
* documented curve so the 8s backoff was never reachable in practice. */
|
|
642
|
-
async _signedRequestWithRetry(method, path, params, resolved) {
|
|
1454
|
+
async _signedRequestWithRetry(host, method, path, params, resolved) {
|
|
1455
|
+
if (method === "DELETE") {
|
|
1456
|
+
// A cancel makes the open-orders snapshot stale — before and after the request.
|
|
1457
|
+
this.invalidateOpenOrdersCache();
|
|
1458
|
+
try {
|
|
1459
|
+
return await this._signedRequestLoop(host, method, path, params, resolved);
|
|
1460
|
+
}
|
|
1461
|
+
finally {
|
|
1462
|
+
this.invalidateOpenOrdersCache();
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return this._signedRequestLoop(host, method, path, params, resolved);
|
|
1466
|
+
}
|
|
1467
|
+
async _signedRequestLoop(host, method, path, params, resolved) {
|
|
643
1468
|
const MAX_ATTEMPTS = 4;
|
|
644
1469
|
let lastErr;
|
|
645
1470
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
646
1471
|
// Rebuild signed query string with fresh nonce on each attempt.
|
|
647
1472
|
const qs = await this._buildSignedQueryString(params, resolved);
|
|
648
|
-
const url = `${
|
|
1473
|
+
const url = `${host}${path}?${qs}`;
|
|
649
1474
|
const res = await fetch(url, method === "DELETE" ? { method: "DELETE" } : undefined);
|
|
650
1475
|
// 429 → backoff + retry. All other paths (including non-429 venue
|
|
651
1476
|
// errors wrapped in HTTP 200 with code≠0) throw immediately via
|
|
@@ -685,32 +1510,77 @@ export class AsterAdapter {
|
|
|
685
1510
|
*/
|
|
686
1511
|
async _handleAsterResponse(res, method, path) {
|
|
687
1512
|
if (res.status === 429) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1513
|
+
// HTTP 429 = refused before processing: the request was definitely NOT executed
|
|
1514
|
+
// (details.sent === false → isDefiniteOrderFailure), and RATE_LIMITED stays retryable —
|
|
1515
|
+
// after Retry-After (carried as retryAfterMs when the venue sends it).
|
|
1516
|
+
const retryAfterSec = Number(res.headers.get("Retry-After"));
|
|
1517
|
+
throw new PerpError("RATE_LIMITED", `Aster ${method} ${path} rate limited (HTTP 429); nothing was executed`, {
|
|
1518
|
+
exchange: "aster", httpStatus: 429, sent: false,
|
|
1519
|
+
...(Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? { retryAfterMs: retryAfterSec * 1000 } : {}),
|
|
1520
|
+
remediation: "Nothing was sent to the book. Wait and retry, or reduce request frequency",
|
|
1521
|
+
});
|
|
691
1522
|
}
|
|
692
1523
|
if (!res.ok) {
|
|
693
|
-
const text = await res.text()
|
|
1524
|
+
const text = await res.text();
|
|
1525
|
+
// Aster returns non-2xx with a JSON {code, msg} body for venue errors (e.g. HTTP 401
|
|
1526
|
+
// -2015 / -1002). Carry the venue code so callers (spot permission mapping) can key on it.
|
|
1527
|
+
let venueCode = null;
|
|
1528
|
+
try {
|
|
1529
|
+
const j = JSON.parse(text);
|
|
1530
|
+
if (typeof j.code === "string" || typeof j.code === "number")
|
|
1531
|
+
venueCode = j.code;
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
// Non-JSON body (HTML error page): no venue code — the HTTP status + text are reported.
|
|
1535
|
+
}
|
|
694
1536
|
const s = classifyError(new Error(`${method} ${path} failed (${res.status}): ${text.slice(0, 200)}`), "aster");
|
|
695
|
-
|
|
1537
|
+
if (res.status >= 500) {
|
|
1538
|
+
// A 5xx can arrive after the venue accepted the request: never a definite code
|
|
1539
|
+
// (substring classification could otherwise yield e.g. SIGNATURE_FAILED / INSUFFICIENT_BALANCE).
|
|
1540
|
+
throw new PerpError("EXCHANGE_ERROR", s.message, {
|
|
1541
|
+
exchange: "aster",
|
|
1542
|
+
httpStatus: res.status,
|
|
1543
|
+
...(venueCode !== null ? { venueCode } : {}),
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
throw new PerpError(s.code, s.message, {
|
|
1547
|
+
exchange: s.exchange,
|
|
1548
|
+
httpStatus: res.status,
|
|
1549
|
+
...(venueCode !== null ? { venueCode } : {}),
|
|
1550
|
+
...(s.remediation ? { remediation: s.remediation } : {}),
|
|
1551
|
+
});
|
|
696
1552
|
}
|
|
697
|
-
// Parse JSON, then validate venue error envelope. Aster success returns
|
|
698
|
-
//
|
|
699
|
-
//
|
|
1553
|
+
// Parse JSON, then validate venue error envelope. Aster success returns an array
|
|
1554
|
+
// (e.g., GET /fapi/v3/openOrders, GET /fapi/v3/userTrades) or an object whose `code`
|
|
1555
|
+
// is absent or one of the documented success values (asterdex/api-docs):
|
|
1556
|
+
// 200 — futures/spot V3, e.g. Change Margin Type: {"code": 200, "msg": "success"}
|
|
1557
|
+
// "200" — futures V3 "Cancel All Open Orders (TRADE)":
|
|
1558
|
+
// {"code": "200", "msg": "The operation of cancel all open order is done."}
|
|
1559
|
+
// "000000" — bapi envelope (bapi/aster-bapi-en.md): {"code": "000000", "message": null, ...}
|
|
1560
|
+
// Anything else (incl. 0, which the docs only list as a WebSocket error code) is an error.
|
|
700
1561
|
const text = await res.text();
|
|
701
1562
|
let parsed;
|
|
702
1563
|
try {
|
|
703
1564
|
parsed = JSON.parse(text);
|
|
704
1565
|
}
|
|
705
1566
|
catch {
|
|
706
|
-
|
|
1567
|
+
// httpStatus + responseUnreadable: the request WAS accepted (2xx) — order callers map this to "pending".
|
|
1568
|
+
throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster", httpStatus: res.status, responseUnreadable: true });
|
|
707
1569
|
}
|
|
708
1570
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
709
1571
|
const json = parsed;
|
|
710
|
-
if (json.code !== undefined && json.code !==
|
|
1572
|
+
if (json.code !== undefined && json.code !== 200 && json.code !== "200" && json.code !== "000000") {
|
|
711
1573
|
const rawMsg = typeof json.msg === "string" ? json.msg : JSON.stringify(json);
|
|
712
|
-
|
|
713
|
-
|
|
1574
|
+
// Classify on the venue code AND the message: Aster's terse messages
|
|
1575
|
+
// ("This function can only be used after deposit.") are the only signal
|
|
1576
|
+
// for some codes, but the numeric code is the stable key — feed both so
|
|
1577
|
+
// a message reword upstream does not silently drop the mapping.
|
|
1578
|
+
const s = classifyError(new Error(`[${json.code}] ${rawMsg}`), "aster");
|
|
1579
|
+
throw new PerpError(s.code, rawMsg, {
|
|
1580
|
+
exchange: s.exchange,
|
|
1581
|
+
venueCode: json.code,
|
|
1582
|
+
...(s.remediation ? { remediation: s.remediation } : {}),
|
|
1583
|
+
});
|
|
714
1584
|
}
|
|
715
1585
|
}
|
|
716
1586
|
return parsed;
|