@reefclaw/openclaw-plugin 0.1.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/.gitignore +8 -0
- package/audit/mode-transition-audit.d.ts +11 -0
- package/audit/mode-transition-audit.js +29 -0
- package/balance-utils.d.ts +36 -0
- package/balance-utils.js +98 -0
- package/bridge/bridge.d.ts +109 -0
- package/bridge/bridge.js +1036 -0
- package/bridge/config.d.ts +43 -0
- package/bridge/config.js +176 -0
- package/bridge/connector.d.ts +52 -0
- package/bridge/connector.js +293 -0
- package/bridge/event-replay-buffer.d.ts +43 -0
- package/bridge/event-replay-buffer.js +109 -0
- package/bridge/gateway/event-parser.d.ts +209 -0
- package/bridge/gateway/event-parser.js +793 -0
- package/bridge/gateway/gateway-config.d.ts +39 -0
- package/bridge/gateway/gateway-config.js +86 -0
- package/bridge/gateway/gateway-http-client.d.ts +50 -0
- package/bridge/gateway/gateway-http-client.js +165 -0
- package/bridge/gateway/gateway-ws-client.d.ts +116 -0
- package/bridge/gateway/gateway-ws-client.js +417 -0
- package/bridge/gateway/poller.d.ts +146 -0
- package/bridge/gateway/poller.js +505 -0
- package/bridge/gateway/tool-discovery.d.ts +25 -0
- package/bridge/gateway/tool-discovery.js +198 -0
- package/bridge/index.d.ts +2 -0
- package/bridge/index.js +253 -0
- package/bridge/logger.d.ts +2 -0
- package/bridge/logger.js +2 -0
- package/bridge/provider.d.ts +156 -0
- package/bridge/provider.js +2 -0
- package/bridge/providers/emergency-commands.d.ts +54 -0
- package/bridge/providers/emergency-commands.js +235 -0
- package/bridge/providers/gateway.d.ts +322 -0
- package/bridge/providers/gateway.js +2302 -0
- package/bridge/providers/mock.d.ts +37 -0
- package/bridge/providers/mock.js +385 -0
- package/bridge/providers/onboarding-commands.d.ts +83 -0
- package/bridge/providers/onboarding-commands.js +213 -0
- package/bridge/providers/risk-calculator.d.ts +96 -0
- package/bridge/providers/risk-calculator.js +369 -0
- package/bridge/setup.d.ts +32 -0
- package/bridge/setup.js +226 -0
- package/bridge/types.d.ts +584 -0
- package/bridge/types.js +50 -0
- package/bridge/utils/reconnect.d.ts +6 -0
- package/bridge/utils/reconnect.js +6 -0
- package/bridge/utils/skill-signing.d.ts +51 -0
- package/bridge/utils/skill-signing.js +138 -0
- package/bridge/utils/skill-version.d.ts +17 -0
- package/bridge/utils/skill-version.js +74 -0
- package/ccxt/binance-ban-gate.d.ts +47 -0
- package/ccxt/binance-ban-gate.js +409 -0
- package/ccxt/binance-private.d.ts +325 -0
- package/ccxt/binance-private.js +1415 -0
- package/ccxt/binance-public.d.ts +18 -0
- package/ccxt/binance-public.js +147 -0
- package/config/agent-config-client.d.ts +55 -0
- package/config/agent-config-client.js +145 -0
- package/config/agent-config-poller.d.ts +25 -0
- package/config/agent-config-poller.js +100 -0
- package/config/brackets-config.d.ts +22 -0
- package/config/brackets-config.js +58 -0
- package/config/gate-store.d.ts +18 -0
- package/config/gate-store.js +61 -0
- package/config/plugin-config-io.d.ts +181 -0
- package/config/plugin-config-io.js +84 -0
- package/config/position-review-config.d.ts +35 -0
- package/config/position-review-config.js +105 -0
- package/config/tool-gate.d.ts +53 -0
- package/config/tool-gate.js +125 -0
- package/config/user-data-stream-config.d.ts +85 -0
- package/config/user-data-stream-config.js +224 -0
- package/connector-supervisor.d.ts +36 -0
- package/connector-supervisor.js +149 -0
- package/exchange-adapter.d.ts +49 -0
- package/exchange-adapter.js +4 -0
- package/index.d.ts +30 -0
- package/index.js +2030 -0
- package/ingest/pending-entry-metadata.d.ts +52 -0
- package/ingest/pending-entry-metadata.js +182 -0
- package/ingest/position-auto-capture.d.ts +98 -0
- package/ingest/position-auto-capture.js +394 -0
- package/ingest/position-decisions-client.d.ts +318 -0
- package/ingest/position-decisions-client.js +296 -0
- package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
- package/ingest/reconcile-db-vs-exchange.js +114 -0
- package/ingest/reconciler-cleanup.d.ts +37 -0
- package/ingest/reconciler-cleanup.js +147 -0
- package/ingest/rest-gap-filler.d.ts +191 -0
- package/ingest/rest-gap-filler.js +565 -0
- package/ingest/touched-symbols-store.d.ts +25 -0
- package/ingest/touched-symbols-store.js +96 -0
- package/ingest/trade-store-client.d.ts +40 -0
- package/ingest/trade-store-client.js +125 -0
- package/ingest/ws-ingest.d.ts +43 -0
- package/ingest/ws-ingest.js +126 -0
- package/learning/setup-family.d.ts +21 -0
- package/learning/setup-family.js +103 -0
- package/lifecycle/install-signal-handlers.d.ts +33 -0
- package/lifecycle/install-signal-handlers.js +112 -0
- package/lifecycle/shutdown-coordinator.d.ts +43 -0
- package/lifecycle/shutdown-coordinator.js +131 -0
- package/live/bracket-id.d.ts +18 -0
- package/live/bracket-id.js +81 -0
- package/live/bracket-ledger.d.ts +54 -0
- package/live/bracket-ledger.js +267 -0
- package/live/bracket-manager.d.ts +82 -0
- package/live/bracket-manager.js +478 -0
- package/live/bracket-params.d.ts +22 -0
- package/live/bracket-params.js +124 -0
- package/live/bracket-reconciler.d.ts +95 -0
- package/live/bracket-reconciler.js +573 -0
- package/live/bracket-types.d.ts +102 -0
- package/live/bracket-types.js +8 -0
- package/live/deposit-tracker.d.ts +62 -0
- package/live/deposit-tracker.js +97 -0
- package/live/emergency-controls.d.ts +32 -0
- package/live/emergency-controls.js +226 -0
- package/live/exchange-errors.d.ts +12 -0
- package/live/exchange-errors.js +130 -0
- package/live/exchange-info-cache.d.ts +35 -0
- package/live/exchange-info-cache.js +119 -0
- package/live/fact-subscriber.d.ts +78 -0
- package/live/fact-subscriber.js +182 -0
- package/live/intent-journal.d.ts +42 -0
- package/live/intent-journal.js +122 -0
- package/live/listen-key-manager.d.ts +70 -0
- package/live/listen-key-manager.js +169 -0
- package/live/live-adapter.d.ts +264 -0
- package/live/live-adapter.js +1665 -0
- package/live/live-balance-enricher.d.ts +32 -0
- package/live/live-balance-enricher.js +104 -0
- package/live/live-bracket-api.d.ts +13 -0
- package/live/live-bracket-api.js +20 -0
- package/live/live-state-store.d.ts +194 -0
- package/live/live-state-store.js +450 -0
- package/live/local-signal-service.d.ts +57 -0
- package/live/local-signal-service.js +146 -0
- package/live/local-strategy-evaluator.d.ts +62 -0
- package/live/local-strategy-evaluator.js +127 -0
- package/live/microstructure-assembler.d.ts +54 -0
- package/live/microstructure-assembler.js +148 -0
- package/live/order-poller.d.ts +29 -0
- package/live/order-poller.js +125 -0
- package/live/position-state-store.d.ts +83 -0
- package/live/position-state-store.js +237 -0
- package/live/proposal-decision-listener.d.ts +64 -0
- package/live/proposal-decision-listener.js +288 -0
- package/live/proposal-manager.d.ts +76 -0
- package/live/proposal-manager.js +140 -0
- package/live/rate-limiter.d.ts +47 -0
- package/live/rate-limiter.js +159 -0
- package/live/reconciler.d.ts +39 -0
- package/live/reconciler.js +175 -0
- package/live/setup-buckets.d.ts +7 -0
- package/live/setup-buckets.js +33 -0
- package/live/slippage-tracker.d.ts +45 -0
- package/live/slippage-tracker.js +78 -0
- package/live/stop-watcher.d.ts +34 -0
- package/live/stop-watcher.js +158 -0
- package/live/user-data-active-probe.d.ts +54 -0
- package/live/user-data-active-probe.js +180 -0
- package/live/user-data-stream-controller.d.ts +200 -0
- package/live/user-data-stream-controller.js +579 -0
- package/live/user-data-stream-ws.d.ts +22 -0
- package/live/user-data-stream-ws.js +63 -0
- package/live/user-data-stream.d.ts +243 -0
- package/live/user-data-stream.js +704 -0
- package/logger.d.ts +2 -0
- package/logger.js +2 -0
- package/mfe.d.ts +21 -0
- package/mfe.js +68 -0
- package/onboarding/mode-ladder.d.ts +1 -0
- package/onboarding/mode-ladder.js +3 -0
- package/onboarding/runtime.d.ts +71 -0
- package/onboarding/runtime.js +153 -0
- package/openclaw.plugin.json +94 -0
- package/package.json +27 -0
- package/paper-adapter.d.ts +24 -0
- package/paper-adapter.js +91 -0
- package/persistence/state-manager.d.ts +42 -0
- package/persistence/state-manager.js +164 -0
- package/pinned-plan.d.ts +9 -0
- package/pinned-plan.js +23 -0
- package/risk/pre-trade-check.d.ts +38 -0
- package/risk/pre-trade-check.js +345 -0
- package/risk/pre-trade-types.d.ts +60 -0
- package/risk/pre-trade-types.js +3 -0
- package/scripts/assemble.mjs +114 -0
- package/shadow/shadow-tracker.d.ts +36 -0
- package/shadow/shadow-tracker.js +151 -0
- package/shadow/types.d.ts +42 -0
- package/shadow/types.js +20 -0
- package/shared/indicators-extended.d.ts +52 -0
- package/shared/indicators-extended.js +291 -0
- package/shared/indicators.d.ts +15 -0
- package/shared/indicators.js +114 -0
- package/signals/conditions/registry.d.ts +16 -0
- package/signals/conditions/registry.js +1274 -0
- package/signals/conditions/types.d.ts +1 -0
- package/signals/conditions/types.js +4 -0
- package/signals/direction-rules.d.ts +3 -0
- package/signals/direction-rules.js +24 -0
- package/signals/entry-rules.d.ts +6 -0
- package/signals/entry-rules.js +33 -0
- package/signals/serialize-context.d.ts +4 -0
- package/signals/serialize-context.js +39 -0
- package/signals/stop-rules.d.ts +3 -0
- package/signals/stop-rules.js +48 -0
- package/signals/strategy-adapter.d.ts +14 -0
- package/signals/strategy-adapter.js +122 -0
- package/signals/types.d.ts +1 -0
- package/signals/types.js +8 -0
- package/simulator/exchange-simulator.d.ts +93 -0
- package/simulator/exchange-simulator.js +684 -0
- package/simulator/fill-engine.d.ts +53 -0
- package/simulator/fill-engine.js +276 -0
- package/simulator/paper-market-feed.d.ts +26 -0
- package/simulator/paper-market-feed.js +104 -0
- package/simulator/realistic-fills.d.ts +59 -0
- package/simulator/realistic-fills.js +175 -0
- package/simulator/types.d.ts +219 -0
- package/simulator/types.js +43 -0
- package/skills/reefclaw/SKILL.md +97 -0
- package/strategy/builtin-strategies.d.ts +2 -0
- package/strategy/builtin-strategies.js +109 -0
- package/strategy/condition-registry.d.ts +3 -0
- package/strategy/condition-registry.js +153 -0
- package/strategy/evaluator.d.ts +67 -0
- package/strategy/evaluator.js +93 -0
- package/tools/assessment-validation.d.ts +118 -0
- package/tools/assessment-validation.js +415 -0
- package/tools/attach-brackets.d.ts +34 -0
- package/tools/attach-brackets.js +363 -0
- package/tools/audit-bracket-protection.d.ts +49 -0
- package/tools/audit-bracket-protection.js +527 -0
- package/tools/cancel-all-orders.d.ts +7 -0
- package/tools/cancel-all-orders.js +5 -0
- package/tools/cancel-order.d.ts +10 -0
- package/tools/cancel-order.js +14 -0
- package/tools/check-position-health.d.ts +46 -0
- package/tools/check-position-health.js +194 -0
- package/tools/clear-exchange-credentials.d.ts +24 -0
- package/tools/clear-exchange-credentials.js +70 -0
- package/tools/close-position.d.ts +22 -0
- package/tools/close-position.js +449 -0
- package/tools/create-order.d.ts +54 -0
- package/tools/create-order.js +338 -0
- package/tools/exit-gate.d.ts +58 -0
- package/tools/exit-gate.js +162 -0
- package/tools/fetch-balance.d.ts +5 -0
- package/tools/fetch-balance.js +4 -0
- package/tools/fetch-ohlcv.d.ts +11 -0
- package/tools/fetch-ohlcv.js +8 -0
- package/tools/fetch-open-orders.d.ts +7 -0
- package/tools/fetch-open-orders.js +4 -0
- package/tools/fetch-positions.d.ts +7 -0
- package/tools/fetch-positions.js +4 -0
- package/tools/fetch-ticker.d.ts +11 -0
- package/tools/fetch-ticker.js +5 -0
- package/tools/get-agent-profile.d.ts +4 -0
- package/tools/get-agent-profile.js +6 -0
- package/tools/get-analytics.d.ts +6 -0
- package/tools/get-analytics.js +7 -0
- package/tools/get-backtest.d.ts +12 -0
- package/tools/get-backtest.js +91 -0
- package/tools/get-basis.d.ts +7 -0
- package/tools/get-basis.js +7 -0
- package/tools/get-bracket-config.d.ts +11 -0
- package/tools/get-bracket-config.js +24 -0
- package/tools/get-cascade-risk.d.ts +7 -0
- package/tools/get-cascade-risk.js +8 -0
- package/tools/get-crypto-metrics.d.ts +18 -0
- package/tools/get-crypto-metrics.js +45 -0
- package/tools/get-cvd.d.ts +6 -0
- package/tools/get-cvd.js +6 -0
- package/tools/get-divergences.d.ts +6 -0
- package/tools/get-divergences.js +6 -0
- package/tools/get-funding-context.d.ts +6 -0
- package/tools/get-funding-context.js +16 -0
- package/tools/get-liquidation-levels.d.ts +7 -0
- package/tools/get-liquidation-levels.js +7 -0
- package/tools/get-liquidation-pulse.d.ts +9 -0
- package/tools/get-liquidation-pulse.js +22 -0
- package/tools/get-market-breadth.d.ts +6 -0
- package/tools/get-market-breadth.js +8 -0
- package/tools/get-market-intel.d.ts +19 -0
- package/tools/get-market-intel.js +116 -0
- package/tools/get-market-structure.d.ts +47 -0
- package/tools/get-market-structure.js +198 -0
- package/tools/get-my-mined-patterns.d.ts +20 -0
- package/tools/get-my-mined-patterns.js +61 -0
- package/tools/get-my-proposed-learnings.d.ts +20 -0
- package/tools/get-my-proposed-learnings.js +55 -0
- package/tools/get-my-recent-reviews.d.ts +22 -0
- package/tools/get-my-recent-reviews.js +66 -0
- package/tools/get-orderbook.d.ts +21 -0
- package/tools/get-orderbook.js +32 -0
- package/tools/get-pattern-scan.d.ts +7 -0
- package/tools/get-pattern-scan.js +8 -0
- package/tools/get-regime.d.ts +6 -0
- package/tools/get-regime.js +7 -0
- package/tools/get-relevant-learnings.d.ts +21 -0
- package/tools/get-relevant-learnings.js +65 -0
- package/tools/get-resting-liquidity.d.ts +6 -0
- package/tools/get-resting-liquidity.js +11 -0
- package/tools/get-risk-scenario.d.ts +29 -0
- package/tools/get-risk-scenario.js +47 -0
- package/tools/get-risk-summary.d.ts +51 -0
- package/tools/get-risk-summary.js +118 -0
- package/tools/get-sentiment.d.ts +4 -0
- package/tools/get-sentiment.js +6 -0
- package/tools/get-session-review.d.ts +7 -0
- package/tools/get-session-review.js +8 -0
- package/tools/get-setup-detail.d.ts +7 -0
- package/tools/get-setup-detail.js +303 -0
- package/tools/get-signals.d.ts +15 -0
- package/tools/get-signals.js +54 -0
- package/tools/get-sizing.d.ts +6 -0
- package/tools/get-sizing.js +6 -0
- package/tools/get-trade-feedback.d.ts +7 -0
- package/tools/get-trade-feedback.js +8 -0
- package/tools/get-trade-flow.d.ts +7 -0
- package/tools/get-trade-flow.js +7 -0
- package/tools/get-volume-analysis.d.ts +21 -0
- package/tools/get-volume-analysis.js +74 -0
- package/tools/get-volume-profile.d.ts +7 -0
- package/tools/get-volume-profile.js +7 -0
- package/tools/helpers.d.ts +25 -0
- package/tools/helpers.js +38 -0
- package/tools/intel-api.d.ts +14 -0
- package/tools/intel-api.js +52 -0
- package/tools/intel-cache.d.ts +25 -0
- package/tools/intel-cache.js +133 -0
- package/tools/list-strategies.d.ts +7 -0
- package/tools/list-strategies.js +6 -0
- package/tools/modify-stop.d.ts +17 -0
- package/tools/modify-stop.js +81 -0
- package/tools/modify-target.d.ts +17 -0
- package/tools/modify-target.js +71 -0
- package/tools/propose-learning.d.ts +22 -0
- package/tools/propose-learning.js +65 -0
- package/tools/query-review-outcomes.d.ts +30 -0
- package/tools/query-review-outcomes.js +64 -0
- package/tools/query-trades.d.ts +21 -0
- package/tools/query-trades.js +37 -0
- package/tools/record-position-reviews.d.ts +38 -0
- package/tools/record-position-reviews.js +147 -0
- package/tools/save-strategy.d.ts +16 -0
- package/tools/save-strategy.js +46 -0
- package/tools/scan-pairs.d.ts +18 -0
- package/tools/scan-pairs.js +220 -0
- package/tools/score-setup.d.ts +31 -0
- package/tools/score-setup.js +268 -0
- package/tools/set-bracket-requirement.d.ts +18 -0
- package/tools/set-bracket-requirement.js +81 -0
- package/tools/set-exchange-credentials.d.ts +25 -0
- package/tools/set-exchange-credentials.js +80 -0
- package/tools/set-trading-mode.d.ts +26 -0
- package/tools/set-trading-mode.js +135 -0
- package/tools/test-exchange-credentials.d.ts +16 -0
- package/tools/test-exchange-credentials.js +100 -0
- package/tools/toggle-strategy.d.ts +8 -0
- package/tools/toggle-strategy.js +8 -0
- package/trading-params-cache.d.ts +26 -0
- package/trading-params-cache.js +52 -0
- package/types.d.ts +110 -0
- package/types.js +7 -0
- package/util/plugin-paths.d.ts +3 -0
- package/util/plugin-paths.js +15 -0
|
@@ -0,0 +1,1415 @@
|
|
|
1
|
+
// Authenticated CCXT wrapper for Binance Futures.
|
|
2
|
+
// Used for shadow mode (read-only validation) and future live trading.
|
|
3
|
+
// API keys stay local — never transmitted to ReefClaw servers.
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { logger, formatError } from '../logger.js';
|
|
6
|
+
import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
|
|
7
|
+
import { parseBracketCid, isBracketCid } from '../live/bracket-id.js';
|
|
8
|
+
const TAG = 'binance-private';
|
|
9
|
+
// Load ccxt via CJS require — same pattern as binance-public.ts
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
let ccxtCjs;
|
|
12
|
+
try {
|
|
13
|
+
const _require = createRequire(import.meta.url);
|
|
14
|
+
ccxtCjs = _require('ccxt');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
18
|
+
ccxtCjs = require('ccxt');
|
|
19
|
+
}
|
|
20
|
+
/** Reverse-lookup a raw Binance market id (e.g. "ADAUSDT") to the CCXT
|
|
21
|
+
* unified symbol (e.g. "ADA/USDT:USDT") so algo orders come back in the
|
|
22
|
+
* same format as regular orders. Callers that already have a unified
|
|
23
|
+
* symbol pass it as `explicit` and it is used verbatim.
|
|
24
|
+
*
|
|
25
|
+
* Primary path: CCXT's `safeMarket(id, undefined, undefined, 'swap')` —
|
|
26
|
+
* the blessed internal helper. It handles the single-vs-array shape of
|
|
27
|
+
* `markets_by_id` and, critically, disambiguates shared spot/futures ids
|
|
28
|
+
* (Binance shares `BTCUSDT` between both) via the 'swap' marketType hint
|
|
29
|
+
* so we always land on the USDT-M perpetual entry.
|
|
30
|
+
*
|
|
31
|
+
* Fallback path: direct `markets_by_id` lookup with a `linear: true`
|
|
32
|
+
* preference. Covers CCXT versions that don't expose safeMarket and tests
|
|
33
|
+
* that stub markets_by_id without the helper.
|
|
34
|
+
*
|
|
35
|
+
* On any miss or error: return the raw id. Visible degradation (audit
|
|
36
|
+
* reverts to false-negative) is preferable to silently dropping the order. */
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
function resolveUnifiedSymbol(ex, explicit, rawSymbol) {
|
|
39
|
+
if (explicit)
|
|
40
|
+
return explicit;
|
|
41
|
+
const raw = String(rawSymbol ?? '');
|
|
42
|
+
if (!raw)
|
|
43
|
+
return '';
|
|
44
|
+
try {
|
|
45
|
+
if (typeof ex?.safeMarket === 'function') {
|
|
46
|
+
// safeMarket(marketId, market?, delimiter?, marketType?) — see CCXT
|
|
47
|
+
// base/Exchange.ts. For an unknown id it returns a synthetic market
|
|
48
|
+
// { id: raw, symbol: raw, ... }; detect that by checking whether
|
|
49
|
+
// symbol differs from the raw id before accepting the result.
|
|
50
|
+
const market = ex.safeMarket(raw, undefined, undefined, 'swap');
|
|
51
|
+
if (typeof market?.symbol === 'string' && market.symbol !== raw) {
|
|
52
|
+
return market.symbol;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const entry = ex?.markets_by_id?.[raw];
|
|
56
|
+
if (!entry)
|
|
57
|
+
return raw;
|
|
58
|
+
if (Array.isArray(entry)) {
|
|
59
|
+
const linear = entry.find((m) => m?.linear === true && typeof m?.symbol === 'string');
|
|
60
|
+
if (linear?.symbol)
|
|
61
|
+
return linear.symbol;
|
|
62
|
+
const fallback = entry.find((m) => typeof m?.symbol === 'string');
|
|
63
|
+
return fallback?.symbol ?? raw;
|
|
64
|
+
}
|
|
65
|
+
return typeof entry?.symbol === 'string' ? entry.symbol : raw;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class BinancePrivateApi {
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
exchange;
|
|
74
|
+
testnet;
|
|
75
|
+
constructor(credentials) {
|
|
76
|
+
this.testnet = credentials.testnet ?? false;
|
|
77
|
+
const BinanceClass = ccxtCjs.binance ?? ccxtCjs.default?.binance;
|
|
78
|
+
if (!BinanceClass) {
|
|
79
|
+
throw new Error('CCXT binance class not found — check ccxt version');
|
|
80
|
+
}
|
|
81
|
+
this.exchange = new BinanceClass({
|
|
82
|
+
apiKey: credentials.apiKey,
|
|
83
|
+
secret: credentials.secret,
|
|
84
|
+
enableRateLimit: true,
|
|
85
|
+
options: {
|
|
86
|
+
defaultType: 'future',
|
|
87
|
+
warnOnFetchOpenOrdersWithoutSymbol: false,
|
|
88
|
+
// CCXT 4.5 deprecated Binance Futures testnet and throws NotSupported
|
|
89
|
+
// on signed write calls unless this flag is set. testnet.binancefuture.com
|
|
90
|
+
// still works on the Binance side; the check is CCXT-side only. See
|
|
91
|
+
// node_modules/ccxt/dist/cjs/src/binance.js line ~12225.
|
|
92
|
+
disableFuturesSandboxWarning: true,
|
|
93
|
+
// Auto-sync request timestamps to server time. Without this, even a
|
|
94
|
+
// 1s clock drift on the client trips Binance's recvWindow check with
|
|
95
|
+
// "Timestamp for this request was Nms ahead of the server's time."
|
|
96
|
+
// CCXT pings the server on the first signed call to learn the offset.
|
|
97
|
+
adjustForTimeDifference: true,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
// Enable demo/testnet if configured
|
|
101
|
+
if (this.testnet) {
|
|
102
|
+
this.exchange.setSandboxMode(true);
|
|
103
|
+
}
|
|
104
|
+
// ---- WIDENED CANCEL TRACER (2026-05-16) ------------------------------
|
|
105
|
+
// The narrow chokepoint (fapiPrivateDeleteAlgoOrder + the
|
|
106
|
+
// cancelOrderByClientId ATTRIBUTION) is deployed and PROVEN working
|
|
107
|
+
// (it caught the one bracket_integrity flush with a full stack) yet is
|
|
108
|
+
// SILENT for the recurring ~20s rc-* bracket kills — while a clean
|
|
109
|
+
// NON-rc closePosition STOP+TP pair, identical in shape to the agent's,
|
|
110
|
+
// survived 3.6 min untouched on the SAME symbol (controlled prod test
|
|
111
|
+
// 2026-05-16). => the kill is keyed to the rc-* cid and BYPASSES every
|
|
112
|
+
// BinancePrivateApi cancel method, i.e. something cancels directly on
|
|
113
|
+
// the raw ccxt client. Wrap EVERY cancel surface on the raw instance so
|
|
114
|
+
// no caller (this class, the reconciler, the agent, a ccxt cross-call,
|
|
115
|
+
// anything holding `exchange`) can bypass it; log method + args + full
|
|
116
|
+
// caller stack. Pure observability — each wrapper calls straight through
|
|
117
|
+
// (same `this`, same return Promise), zero behaviour change. Remove
|
|
118
|
+
// once the proven source is fixed and soaked.
|
|
119
|
+
{
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
const ex = this.exchange;
|
|
122
|
+
const cancelSurfaces = [
|
|
123
|
+
'cancelOrder', 'cancelOrders', 'cancelAllOrders', 'cancelOrdersForSymbols',
|
|
124
|
+
'fapiPrivateDeleteOrder', 'fapiPrivateDeleteAllOpenOrders',
|
|
125
|
+
'fapiPrivateDeleteAlgoOrder', 'fapiPrivateDeleteBatchOrders',
|
|
126
|
+
'fapiPrivateDeleteAlgoFuturesOrder',
|
|
127
|
+
];
|
|
128
|
+
let armed = 0;
|
|
129
|
+
for (const m of cancelSurfaces) {
|
|
130
|
+
const orig = ex[m];
|
|
131
|
+
if (typeof orig !== 'function')
|
|
132
|
+
continue;
|
|
133
|
+
ex[m] = (...args) => {
|
|
134
|
+
let argStr;
|
|
135
|
+
try {
|
|
136
|
+
argStr = JSON.stringify(args).slice(0, 600);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
argStr = '(unserializable)';
|
|
140
|
+
}
|
|
141
|
+
const stack = new Error('wide-cancel-trace').stack ?? '(no stack)';
|
|
142
|
+
logger.warn(TAG, `WIDE-CANCEL-TRACE — raw ccxt ${m}(${argStr}) invoked. If this ` +
|
|
143
|
+
`carries an rc-*-{s,t} cid/algoId, THIS is the bracket ` +
|
|
144
|
+
`stripper (the narrow chokepoint never sees it). Caller ` +
|
|
145
|
+
`stack follows:\n${stack}`);
|
|
146
|
+
return orig.apply(ex, args);
|
|
147
|
+
};
|
|
148
|
+
armed++;
|
|
149
|
+
}
|
|
150
|
+
logger.info(TAG, `WIDE-CANCEL-TRACE armed on ${armed} raw cancel surface(s)`);
|
|
151
|
+
}
|
|
152
|
+
logger.info(TAG, `Binance private API initialized (${this.testnet ? 'testnet' : 'mainnet'})`);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Validate API key permissions by making test API calls.
|
|
156
|
+
* Best-effort — never throws.
|
|
157
|
+
*/
|
|
158
|
+
async validatePermissions() {
|
|
159
|
+
const result = {
|
|
160
|
+
canReadBalance: false,
|
|
161
|
+
canReadPositions: false,
|
|
162
|
+
canTrade: false,
|
|
163
|
+
errors: [],
|
|
164
|
+
};
|
|
165
|
+
// Test: read balance. Route through the gate like every other read —
|
|
166
|
+
// a permission probe during an active ban would otherwise extend it,
|
|
167
|
+
// and a 429/418 here must arm the gate for the pollers.
|
|
168
|
+
try {
|
|
169
|
+
assertNotBanned('fetchBalance');
|
|
170
|
+
await this.exchange.fetchBalance();
|
|
171
|
+
noteSuccess();
|
|
172
|
+
result.canReadBalance = true;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
noteBinanceError(err);
|
|
176
|
+
result.errors.push(`Balance read failed: ${formatError(err)}`);
|
|
177
|
+
}
|
|
178
|
+
// Test: read positions (same gate routing as the balance probe above).
|
|
179
|
+
try {
|
|
180
|
+
assertNotBanned('fetchPositions');
|
|
181
|
+
await this.exchange.fetchPositions();
|
|
182
|
+
noteSuccess();
|
|
183
|
+
result.canReadPositions = true;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
noteBinanceError(err);
|
|
187
|
+
result.errors.push(`Position read failed: ${formatError(err)}`);
|
|
188
|
+
}
|
|
189
|
+
// Note: We DON'T test trade permission here (would require placing a real order).
|
|
190
|
+
// Trade permission is validated implicitly when micro-live/live mode attempts the first order.
|
|
191
|
+
// For now, if balance + positions work, the key is likely valid.
|
|
192
|
+
result.canTrade = result.canReadBalance; // Optimistic — actual test in live mode
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
/** Fetch real account balance. Returns null on error. */
|
|
196
|
+
async fetchBalance() {
|
|
197
|
+
try {
|
|
198
|
+
assertNotBanned('fetchBalance');
|
|
199
|
+
const raw = await this.exchange.fetchBalance();
|
|
200
|
+
noteSuccess();
|
|
201
|
+
const free = {};
|
|
202
|
+
const used = {};
|
|
203
|
+
const total = {};
|
|
204
|
+
const result = { free, used, total };
|
|
205
|
+
for (const currency of Object.keys(raw.total ?? {})) {
|
|
206
|
+
// Keep any NON-ZERO balance — negative included. Binance multi-asset
|
|
207
|
+
// margin (BNFCR, live on prod) legitimately reports negative wallet
|
|
208
|
+
// lines (cumulative realized losses); the old `> 0` filter silently
|
|
209
|
+
// dropped them, overstating NAV for any consumer of the filtered
|
|
210
|
+
// total/free maps. `!== 0` preserves the original skip-empty intent
|
|
211
|
+
// without the sign bias.
|
|
212
|
+
if ((raw.total[currency] ?? 0) !== 0 || (raw.free[currency] ?? 0) !== 0) {
|
|
213
|
+
free[currency] = raw.free[currency] ?? 0;
|
|
214
|
+
used[currency] = raw.used[currency] ?? 0;
|
|
215
|
+
total[currency] = raw.total[currency] ?? 0;
|
|
216
|
+
result[currency] = {
|
|
217
|
+
free: free[currency],
|
|
218
|
+
used: used[currency],
|
|
219
|
+
total: total[currency],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Preserve CCXT's raw `info` field so downstream callers can read
|
|
224
|
+
// Binance-specific fields like info.assets[i].walletBalance for
|
|
225
|
+
// margin-aware balance normalisation (see
|
|
226
|
+
// UserDataStreamController.normalizeBinanceFuturesBalance). Without
|
|
227
|
+
// this, CCXT's fetchBalance.total returns marginBalance (wallet +
|
|
228
|
+
// uPnl) and WS store holds walletBalance only, causing persistent
|
|
229
|
+
// drift on any margin asset with open-position uPnl.
|
|
230
|
+
result.info = raw.info;
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
noteBinanceError(err);
|
|
235
|
+
logger.error(TAG, `fetchBalance failed: ${formatError(err)}`);
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Fetch account-transfer income entries (deposits/withdrawals into/out of the
|
|
241
|
+
* Futures wallet) between two timestamps. These surface as `incomeType=TRANSFER`
|
|
242
|
+
* on Binance's `/fapi/v1/income` endpoint; positive `income` = inflow to
|
|
243
|
+
* futures, negative = outflow. Used by DepositTracker to re-anchor
|
|
244
|
+
* sessionStartNav so capital flows never show up as Day P&L.
|
|
245
|
+
*/
|
|
246
|
+
async fetchTransfers(startMs, endMs) {
|
|
247
|
+
try {
|
|
248
|
+
const params = {
|
|
249
|
+
incomeType: 'TRANSFER',
|
|
250
|
+
startTime: Math.max(0, Math.floor(startMs)),
|
|
251
|
+
endTime: Math.floor(endMs),
|
|
252
|
+
limit: 1000,
|
|
253
|
+
};
|
|
254
|
+
assertNotBanned('fetchTransfers');
|
|
255
|
+
const raw = await this.exchange.fapiPrivateGetIncome(params);
|
|
256
|
+
noteSuccess();
|
|
257
|
+
if (!Array.isArray(raw))
|
|
258
|
+
return [];
|
|
259
|
+
return raw.map((r) => ({
|
|
260
|
+
// tranId is Binance's idempotency key for the transfer; fall back to a
|
|
261
|
+
// time+amount composite if the exchange omits it (shouldn't happen).
|
|
262
|
+
tranId: String(r.tranId ?? `${r.time}-${r.income}-${r.asset}`),
|
|
263
|
+
time: Number(r.time ?? 0),
|
|
264
|
+
amount: Number(r.income ?? 0),
|
|
265
|
+
asset: String(r.asset ?? ''),
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
noteBinanceError(err);
|
|
270
|
+
logger.error(TAG, `fetchTransfers failed: ${formatError(err)}`);
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Sum trading-effect income (every incomeType EXCEPT TRANSFER) since
|
|
276
|
+
* `sinceMs` — i.e. net wallet change from trading. This matches Binance
|
|
277
|
+
* mobile app's "Today's Realized PNL" line (verified 2026-05-14).
|
|
278
|
+
*
|
|
279
|
+
* An earlier comment here claimed Binance's UI uses GROSS REALIZED_PNL
|
|
280
|
+
* only and that the gross/net values "no longer match" — that was based
|
|
281
|
+
* on a faulty assumption that propagated into the live-adapter and
|
|
282
|
+
* caused dashboard Realized to show $2.28 less negative than Binance's
|
|
283
|
+
* own UI. Reverted: gross is for debug only, net is what the user sees.
|
|
284
|
+
*
|
|
285
|
+
* Single REST call, weight=30. Returns `null` (NOT 0) on a failed/paced
|
|
286
|
+
* fetch — null≠empty: a non-answer must never collapse to a zero the
|
|
287
|
+
* caller would treat as real (2026-05-16 KPI incident). Unused in prod
|
|
288
|
+
* (kept for parity / ad-hoc reconciliation).
|
|
289
|
+
*
|
|
290
|
+
* Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
|
|
291
|
+
*/
|
|
292
|
+
async fetchRealizedPnlSince(sinceMs) {
|
|
293
|
+
const b = await this.fetchTodayIncomeBreakdown(sinceMs);
|
|
294
|
+
return b ? b.netNonTransfer : null;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* One income-history call, two numbers:
|
|
298
|
+
* - `realizedPnlGross`: sum of `incomeType === 'REALIZED_PNL'` only.
|
|
299
|
+
* KEPT FOR DEBUG / RECONCILIATION ONLY — despite an earlier comment
|
|
300
|
+
* claiming this matches Binance's "Today's Realized PNL" UI line,
|
|
301
|
+
* the mobile app verification on 2026-05-14 showed Binance reports
|
|
302
|
+
* the NET figure (-$5.98 in the test) while gross was -$3.70. Do
|
|
303
|
+
* NOT surface this number in the dashboard Realized KPI.
|
|
304
|
+
* - `netNonTransfer`: sum of EVERY non-TRANSFER incomeType (REALIZED_PNL +
|
|
305
|
+
* COMMISSION + FUNDING_FEE + rebates). This equals today's net wallet
|
|
306
|
+
* change from trading and matches the Binance mobile app's
|
|
307
|
+
* "Today's Realized PNL" line. Used for BOTH the Realized KPI display
|
|
308
|
+
* AND the `sessionStartNav` anchor so Day P&L reflects total NAV
|
|
309
|
+
* change since UTC midnight.
|
|
310
|
+
*
|
|
311
|
+
* Both numbers refresh together. Caller throttles. Single page (limit=1000)
|
|
312
|
+
* — > 1000 income rows in a UTC day is unrealistic for a single account.
|
|
313
|
+
*
|
|
314
|
+
* Return contract (null≠empty — same rule as getOpenOrders /
|
|
315
|
+
* closePosition / the bracket reconciler):
|
|
316
|
+
* - `{realizedPnlGross, netNonTransfer}` on a successful parse, INCLUDING
|
|
317
|
+
* a genuine empty income history (`{0,0}` = Binance confirms no income
|
|
318
|
+
* today — a real zero the caller can anchor on: wallet − 0).
|
|
319
|
+
* - `null` on a failed/paced/garbled fetch (rate-limit, 418, network,
|
|
320
|
+
* non-array body). It is "today's income UNKNOWN", NOT "zero". The
|
|
321
|
+
* caller MUST keep its last good anchor. Coercing this to `{0,0}`
|
|
322
|
+
* pinned `sessionStartNav` to `wallet_now` and zeroed Day P&L — the
|
|
323
|
+
* 2026-05-16 KPI incident this contract exists to prevent.
|
|
324
|
+
*
|
|
325
|
+
* `opts.forced` selects the never-paced ban-gate context — pass it ONLY
|
|
326
|
+
* for the boot + UTC-day-rollover anchor (the values that actually change
|
|
327
|
+
* within a UTC day). Routine same-day refreshes leave it false so they
|
|
328
|
+
* stay cosmetic/sheddable; a shed there is now a harmless no-op because
|
|
329
|
+
* the caller keeps the (invariant) last good anchor.
|
|
330
|
+
*
|
|
331
|
+
* Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
|
|
332
|
+
*/
|
|
333
|
+
async fetchTodayIncomeBreakdown(sinceMs, opts) {
|
|
334
|
+
const context = opts?.forced
|
|
335
|
+
? 'fetchTodayIncomeBreakdownForced'
|
|
336
|
+
: 'fetchTodayIncomeBreakdown';
|
|
337
|
+
try {
|
|
338
|
+
const params = {
|
|
339
|
+
startTime: Math.max(0, Math.floor(sinceMs)),
|
|
340
|
+
endTime: Date.now(),
|
|
341
|
+
limit: 1000,
|
|
342
|
+
};
|
|
343
|
+
assertNotBanned(context);
|
|
344
|
+
const raw = await this.exchange.fapiPrivateGetIncome(params);
|
|
345
|
+
noteSuccess();
|
|
346
|
+
// null≠empty: a non-array is a garbled/non-answer (NOT "no income
|
|
347
|
+
// today"), so it must NOT be coerced into a zero anchor.
|
|
348
|
+
if (!Array.isArray(raw))
|
|
349
|
+
return null;
|
|
350
|
+
let realizedPnlGross = 0;
|
|
351
|
+
let netNonTransfer = 0;
|
|
352
|
+
for (const r of raw) {
|
|
353
|
+
const row = r;
|
|
354
|
+
const type = String(row.incomeType ?? '');
|
|
355
|
+
if (type === 'TRANSFER')
|
|
356
|
+
continue;
|
|
357
|
+
const amt = Number(row.income ?? 0);
|
|
358
|
+
if (!Number.isFinite(amt))
|
|
359
|
+
continue;
|
|
360
|
+
netNonTransfer += amt;
|
|
361
|
+
if (type === 'REALIZED_PNL')
|
|
362
|
+
realizedPnlGross += amt;
|
|
363
|
+
}
|
|
364
|
+
// A genuine empty income history IS a confirmed zero — {0,0} here is
|
|
365
|
+
// correct (the caller anchors sessionStartNav = wallet − 0).
|
|
366
|
+
return { realizedPnlGross, netNonTransfer };
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
noteBinanceError(err);
|
|
370
|
+
// null, NEVER {0,0}: a failed/paced fetch is "today's income UNKNOWN",
|
|
371
|
+
// not "zero". {0,0} pins sessionStartNav to wallet_now and zeroes
|
|
372
|
+
// Day P&L (2026-05-16 KPI incident). The caller keeps its last good
|
|
373
|
+
// anchor, which is invariant within a UTC day. Don't WARN-spam the
|
|
374
|
+
// EXPECTED paced/banned case — the ban gate already logs that once
|
|
375
|
+
// per window; only a genuine failure is notable here.
|
|
376
|
+
if (!(err instanceof BinanceBannedError)) {
|
|
377
|
+
logger.warn(TAG, `fetchTodayIncomeBreakdown failed: ${formatError(err)}`);
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Discover symbols that had any trade-tagged income activity since `sinceMs`.
|
|
384
|
+
*
|
|
385
|
+
* /fapi/v1/income (no incomeType filter) returns up to 1000 entries spanning
|
|
386
|
+
* REALIZED_PNL, COMMISSION, FUNDING_FEE, etc. — every entry that originates
|
|
387
|
+
* from a trade carries a non-empty `symbol`. We collect the unique symbols
|
|
388
|
+
* and translate each raw Binance id ('BTCUSDT') into the CCXT-unified form
|
|
389
|
+
* ('BTC/USDT:USDT') via the same `resolveUnifiedSymbol` helper used elsewhere
|
|
390
|
+
* in this file, so callers can store / compare them under one canonical key.
|
|
391
|
+
*
|
|
392
|
+
* Used by RestGapFiller at startup to recover symbols traded during plugin
|
|
393
|
+
* downtime that aren't in the persisted touched-symbols cache (Codex
|
|
394
|
+
* finding 4 follow-up). Single REST call, weight=30 — well below the 2400/min
|
|
395
|
+
* budget. Single page; > 1000 income rows in the window is rare and would
|
|
396
|
+
* indicate a multi-hour outage that needs an operator-driven backfill anyway.
|
|
397
|
+
*
|
|
398
|
+
* Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
|
|
399
|
+
*/
|
|
400
|
+
async fetchRecentTradedSymbols(sinceMs) {
|
|
401
|
+
try {
|
|
402
|
+
const params = {
|
|
403
|
+
startTime: Math.max(0, Math.floor(sinceMs)),
|
|
404
|
+
endTime: Date.now(),
|
|
405
|
+
limit: 1000,
|
|
406
|
+
};
|
|
407
|
+
assertNotBanned('fetchRecentTradedSymbols');
|
|
408
|
+
const raw = await this.exchange.fapiPrivateGetIncome(params);
|
|
409
|
+
noteSuccess();
|
|
410
|
+
if (!Array.isArray(raw))
|
|
411
|
+
return [];
|
|
412
|
+
const out = new Set();
|
|
413
|
+
for (const r of raw) {
|
|
414
|
+
const sym = r?.symbol;
|
|
415
|
+
if (typeof sym !== 'string' || sym.length === 0)
|
|
416
|
+
continue;
|
|
417
|
+
const unified = resolveUnifiedSymbol(this.exchange, undefined, sym);
|
|
418
|
+
if (unified)
|
|
419
|
+
out.add(unified);
|
|
420
|
+
}
|
|
421
|
+
return [...out];
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
noteBinanceError(err);
|
|
425
|
+
logger.error(TAG, `fetchRecentTradedSymbols failed: ${formatError(err)}`);
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Fetch real open positions. Returns null on error. */
|
|
430
|
+
async fetchPositions(symbol) {
|
|
431
|
+
try {
|
|
432
|
+
assertNotBanned('fetchPositions');
|
|
433
|
+
const symbols = symbol ? [symbol] : undefined;
|
|
434
|
+
const raw = await this.exchange.fetchPositions(symbols);
|
|
435
|
+
noteSuccess();
|
|
436
|
+
// Filter to positions with actual quantity
|
|
437
|
+
return raw
|
|
438
|
+
.filter((p) => {
|
|
439
|
+
const contracts = Number(p.contracts ?? 0);
|
|
440
|
+
return contracts > 0;
|
|
441
|
+
})
|
|
442
|
+
.map((p) => ({
|
|
443
|
+
symbol: String(p.symbol ?? ''),
|
|
444
|
+
side: String(p.side ?? 'long'),
|
|
445
|
+
contracts: Number(p.contracts ?? 0),
|
|
446
|
+
contractSize: Number(p.contractSize ?? 1),
|
|
447
|
+
entryPrice: Number(p.entryPrice ?? 0),
|
|
448
|
+
markPrice: Number(p.markPrice ?? 0),
|
|
449
|
+
notional: Number(p.notional ?? 0),
|
|
450
|
+
unrealizedPnl: Number(p.unrealizedPnl ?? 0),
|
|
451
|
+
percentage: Number(p.percentage ?? 0),
|
|
452
|
+
timestamp: Date.now(),
|
|
453
|
+
datetime: new Date().toISOString(),
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
noteBinanceError(err);
|
|
458
|
+
logger.error(TAG, `fetchPositions failed: ${formatError(err)}`);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Fetch real open orders — REGULAR + ALGO (conditional) orders merged.
|
|
463
|
+
*
|
|
464
|
+
* Binance Futures splits orders into two namespaces:
|
|
465
|
+
* - Regular orders (`/fapi/v1/openOrders`) — limit, market, etc.
|
|
466
|
+
* - Algo orders (`/fapi/v1/openAlgoOrders`) — STOP_MARKET,
|
|
467
|
+
* TAKE_PROFIT_MARKET, TRAILING_STOP_MARKET. CCXT submits these via
|
|
468
|
+
* `createOrder` but the unified `fetchOpenOrders` does NOT include them.
|
|
469
|
+
* The bracket-orders feature lives entirely in the algo bucket, so we
|
|
470
|
+
* fetch both and merge the results so downstream consumers (reconciler,
|
|
471
|
+
* dashboard) see a single list. Algo orders are normalised into the same
|
|
472
|
+
* CcxtOrder shape with `clientAlgoId` → `clientOrderId`. */
|
|
473
|
+
async fetchOpenOrders(symbol) {
|
|
474
|
+
try {
|
|
475
|
+
// This wrapper makes TWO Binance REST calls (regular /fapi/v1/openOrders
|
|
476
|
+
// + algo /fapi/v1/openAlgoOrders, both 1 scoped / 40 unscoped per
|
|
477
|
+
// developers.binance.com). Account the COMBINED cost up-front via a
|
|
478
|
+
// distinct context (CONTEXT_WEIGHT: fetchOpenOrders=2, …All=80) so the
|
|
479
|
+
// process-wide pacer doesn't undercount the single most expensive hot
|
|
480
|
+
// call ~16×. Gate once here (not in the algo sub-call) so a mid-wrapper
|
|
481
|
+
// pace can't return a regular-only partial that reads as "no brackets".
|
|
482
|
+
assertNotBanned(symbol ? 'fetchOpenOrders' : 'fetchOpenOrdersAll');
|
|
483
|
+
const raw = await this.exchange.fetchOpenOrders(symbol);
|
|
484
|
+
noteSuccess();
|
|
485
|
+
const regular = raw.map((o) => ({
|
|
486
|
+
id: String(o.id ?? ''),
|
|
487
|
+
symbol: String(o.symbol ?? ''),
|
|
488
|
+
side: String(o.side ?? 'buy'),
|
|
489
|
+
type: String(o.type ?? 'limit'),
|
|
490
|
+
status: String(o.status ?? 'open'),
|
|
491
|
+
amount: Number(o.amount ?? 0),
|
|
492
|
+
filled: Number(o.filled ?? 0),
|
|
493
|
+
remaining: Number(o.remaining ?? 0),
|
|
494
|
+
average: o.average != null ? Number(o.average) : null,
|
|
495
|
+
price: o.price != null ? Number(o.price) : null,
|
|
496
|
+
cost: Number(o.cost ?? 0),
|
|
497
|
+
fee: { cost: 0, currency: 'USDT' },
|
|
498
|
+
timestamp: Number(o.timestamp ?? Date.now()),
|
|
499
|
+
datetime: String(o.datetime ?? new Date().toISOString()),
|
|
500
|
+
timeInForce: String(o.timeInForce ?? 'GTC'),
|
|
501
|
+
clientOrderId: o.clientOrderId != null ? String(o.clientOrderId) : undefined,
|
|
502
|
+
// Preserve raw CCXT order shape (which preserves Binance's raw
|
|
503
|
+
// payload inside its own `info`). Downstream classifiers read
|
|
504
|
+
// `info.info?.orderType` / similar when they need authoritative
|
|
505
|
+
// order subtype beyond the narrow 'market'|'limit' typing above.
|
|
506
|
+
info: o.info != null && typeof o.info === 'object' ? o.info : undefined,
|
|
507
|
+
}));
|
|
508
|
+
// Merge algo/conditional orders (STOP_MARKET/TAKE_PROFIT_MARKET — our
|
|
509
|
+
// protective brackets) from the separate /fapi/v1/openAlgoOrders
|
|
510
|
+
// endpoint. fetchOpenAlgoOrders returns [] ONLY when the endpoint
|
|
511
|
+
// method is structurally absent (non-algo/paper key — a stable
|
|
512
|
+
// capability gap, safe to merge as empty); a real 429/network failure
|
|
513
|
+
// THROWS and is caught below → this whole call returns `null`
|
|
514
|
+
// ("exchange order state unknown — do not treat as zero brackets").
|
|
515
|
+
// A partial "regular-only" list that looks successful would make live
|
|
516
|
+
// SL/TP vanish from audit/reconciler → phantom-naked re-attach spiral.
|
|
517
|
+
const algo = await this.fetchOpenAlgoOrders(symbol);
|
|
518
|
+
return [...regular, ...algo];
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
noteBinanceError(err);
|
|
522
|
+
logger.error(TAG, `fetchOpenOrders failed: ${formatError(err)}`);
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Fetch open algo (conditional) orders. Used by fetchOpenOrders to merge
|
|
527
|
+
* STOP_MARKET / TAKE_PROFIT_MARKET orders into the unified list. Best-effort:
|
|
528
|
+
* returns [] on endpoint error so the caller degrades gracefully. */
|
|
529
|
+
async fetchOpenAlgoOrders(symbol) {
|
|
530
|
+
try {
|
|
531
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
532
|
+
const ex = this.exchange;
|
|
533
|
+
if (typeof ex.fapiPrivateGetOpenAlgoOrders !== 'function') {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
// Binance API expects the bare symbol ("BTCUSDT"), CCXT passes "BTC/USDT"
|
|
537
|
+
// in the unified layer. Use marketId() to translate when a symbol is given.
|
|
538
|
+
const params = {};
|
|
539
|
+
if (symbol) {
|
|
540
|
+
try {
|
|
541
|
+
params.symbol = ex.marketId(symbol);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
params.symbol = symbol.replace('/', '').split(':')[0];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const raw = await ex.fapiPrivateGetOpenAlgoOrders(params);
|
|
548
|
+
// A successful call to "open algo orders" returns an array (possibly
|
|
549
|
+
// empty). A non-array is an unexpected/garbled response — treat it as
|
|
550
|
+
// a FAILURE, not "zero algo orders". Returning [] here would make
|
|
551
|
+
// live SL/TP brackets silently vanish from the merged result while
|
|
552
|
+
// fetchOpenOrders still looks successful (non-null) — the exact
|
|
553
|
+
// partial-success-as-success trap the null contract forbids.
|
|
554
|
+
if (!Array.isArray(raw)) {
|
|
555
|
+
throw new Error('fetchOpenAlgoOrders: unexpected non-array response');
|
|
556
|
+
}
|
|
557
|
+
return raw.map((o) => {
|
|
558
|
+
// Translate algo side → CCXT side (Binance returns "SELL"/"BUY" uppercase)
|
|
559
|
+
const side = String(o.side ?? 'sell').toLowerCase();
|
|
560
|
+
const stopPrice = o.triggerPrice != null ? Number(o.triggerPrice) : null;
|
|
561
|
+
const createTime = o.createTime != null ? Number(o.createTime) : Date.now();
|
|
562
|
+
// Binance's algo endpoint returns raw market IDs (e.g. "ADAUSDT"). The
|
|
563
|
+
// regular (non-algo) fetchOpenOrders branch returns CCXT unified form
|
|
564
|
+
// (e.g. "ADA/USDT:USDT"). Portfolio-wide consumers that key orders by
|
|
565
|
+
// symbol (audit_bracket_protection, reconciler) see both in one list —
|
|
566
|
+
// if formats disagree, position↔order grouping silently loses matches.
|
|
567
|
+
// Before 2026-04-22 the no-explicit-symbol path fell back to `o.symbol`
|
|
568
|
+
// verbatim, producing 291 audit false-negatives in 24h on prod and the
|
|
569
|
+
// bracket_integrity death loop. Resolve the raw ID through CCXT's
|
|
570
|
+
// markets_by_id cache (populated by loadMarkets on startup).
|
|
571
|
+
const resolvedSymbol = resolveUnifiedSymbol(ex, symbol, o.symbol);
|
|
572
|
+
// CcxtOrder.type is narrow ('market'|'limit'); we preserve the original
|
|
573
|
+
// STOP_MARKET / TAKE_PROFIT_MARKET in `info` for consumers that care.
|
|
574
|
+
return {
|
|
575
|
+
id: String(o.algoId ?? ''),
|
|
576
|
+
symbol: resolvedSymbol,
|
|
577
|
+
side,
|
|
578
|
+
type: 'market',
|
|
579
|
+
status: String(o.algoStatus ?? 'open').toLowerCase() === 'new' ? 'open' : 'open',
|
|
580
|
+
amount: Number(o.quantity ?? 0),
|
|
581
|
+
filled: 0,
|
|
582
|
+
remaining: Number(o.quantity ?? 0),
|
|
583
|
+
average: null,
|
|
584
|
+
price: stopPrice,
|
|
585
|
+
cost: 0,
|
|
586
|
+
fee: { cost: 0, currency: 'USDT' },
|
|
587
|
+
timestamp: createTime,
|
|
588
|
+
datetime: new Date(createTime).toISOString(),
|
|
589
|
+
timeInForce: String(o.timeInForce ?? 'GTC'),
|
|
590
|
+
clientOrderId: o.clientAlgoId != null ? String(o.clientAlgoId) : undefined,
|
|
591
|
+
// Preserve the raw Binance algo response verbatim. Key fields
|
|
592
|
+
// consumers read:
|
|
593
|
+
// - `orderType`: 'STOP_MARKET' | 'TAKE_PROFIT_MARKET' | 'TRAILING_STOP_MARKET'
|
|
594
|
+
// - `algoType`: 'CONDITIONAL' (vs 'VP' / 'TWAP' on the algo/futures endpoint)
|
|
595
|
+
// - `triggerPrice`, `slTriggerPrice`, `tpTriggerPrice`: authoritative stop/tp levels
|
|
596
|
+
// Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/Current-All-Algo-Open-Orders
|
|
597
|
+
info: o,
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
// CRITICAL: do NOT degrade a real fetch failure to []. ReefClaw's
|
|
603
|
+
// protective brackets ARE algo orders (STOP_MARKET/TAKE_PROFIT_MARKET
|
|
604
|
+
// on /fapi/v1/openAlgoOrders). A transient 429/network failure here,
|
|
605
|
+
// swallowed as [], merges into fetchOpenOrders as a SUCCESSFUL-looking
|
|
606
|
+
// (non-null) list with every SL/TP missing → audit/reconciler read
|
|
607
|
+
// "unprotected" → phantom-naked re-attach / bracket clobber. That is
|
|
608
|
+
// exactly the null-collapse class the getOpenOrders→null contract
|
|
609
|
+
// exists to prevent. Propagate: fetchOpenOrders' outer catch converts
|
|
610
|
+
// this to `null` ("exchange state unknown — skip this cycle"), which
|
|
611
|
+
// every consumer already handles safely. noteBinanceError is left to
|
|
612
|
+
// that single outer catch so the ban gate isn't armed twice.
|
|
613
|
+
logger.warn(TAG, `fetchOpenAlgoOrders failed (propagating as unknown, NOT empty): ${formatError(err)}`);
|
|
614
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Query the live status of a SINGLE algo (conditional) order by its
|
|
618
|
+
* clientAlgoId via GET /fapi/v1/algoOrder (weight 1, doc-verified
|
|
619
|
+
* developers.binance.com/.../Query-Algo-Order, 2026-05-15).
|
|
620
|
+
*
|
|
621
|
+
* This is the authoritative per-order disambiguator for the bracket-spiral
|
|
622
|
+
* class. A broad /fapi/v1/openAlgoOrders empty result is NOT proof a
|
|
623
|
+
* specific bracket leg is gone — Binance returns a *successful* [] under
|
|
624
|
+
* weight pressure / eventual-consistency. This call asks Binance a DIRECT
|
|
625
|
+
* question about one exact order id and returns its `algoStatus`.
|
|
626
|
+
*
|
|
627
|
+
* Returns the UPPERCASE algoStatus on success — one of (doc-verified enum):
|
|
628
|
+
* NEW | WORKING | TRIGGERED | CANCELED | FINISHED | EXPIRED | REJECTED.
|
|
629
|
+
* Returns `null` on ANY non-answer: CCXT implicit method absent
|
|
630
|
+
* (non-algo/paper key — stable capability gap), ban / weight-paced,
|
|
631
|
+
* 429 / network, order-not-found (-2013 or aged-out per Binance's
|
|
632
|
+
* 3-day / 90-day retention rule), or a garbled / unparseable response.
|
|
633
|
+
*
|
|
634
|
+
* `null` is the explicit "could not get an answer" — the SAME null≠empty
|
|
635
|
+
* contract as the rest of the bracket path. Callers MUST map `null` to
|
|
636
|
+
* 'unknown' and never to a destructive decision. Best-effort by design:
|
|
637
|
+
* never throws into the caller (a thrown ban/pace is swallowed to `null`
|
|
638
|
+
* because "I was rate-limited" is just another flavour of "no answer").
|
|
639
|
+
* Ships dark in Phase 1 — no call sites yet. */
|
|
640
|
+
async queryAlgoOrderStatus(clientAlgoId) {
|
|
641
|
+
if (!clientAlgoId)
|
|
642
|
+
return null;
|
|
643
|
+
try {
|
|
644
|
+
// Read path → pre-gate (weight 1, paced like other reads; the gate
|
|
645
|
+
// throws BinanceBanned/WeightPaced which the catch below absorbs to
|
|
646
|
+
// `null` = safe "unknown", never a verdict).
|
|
647
|
+
assertNotBanned('queryAlgoOrder');
|
|
648
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
649
|
+
const ex = this.exchange;
|
|
650
|
+
if (typeof ex.fapiPrivateGetAlgoOrder !== 'function') {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
// CCXT signs fapiPrivate* implicit calls and injects timestamp/recvWindow.
|
|
654
|
+
// Binance requires algoId OR clientAlgoId; we always have the cid.
|
|
655
|
+
const raw = await ex.fapiPrivateGetAlgoOrder({ clientAlgoId });
|
|
656
|
+
noteSuccess();
|
|
657
|
+
// Doc response is a single object { algoId, clientAlgoId, algoStatus,
|
|
658
|
+
// ... }. Defensive: some CCXT/proxy layers wrap singletons in an array.
|
|
659
|
+
let row;
|
|
660
|
+
if (Array.isArray(raw)) {
|
|
661
|
+
const arr = raw;
|
|
662
|
+
row = arr.find(o => String(o.clientAlgoId ?? '') === clientAlgoId)
|
|
663
|
+
?? (arr.length === 1 ? arr[0] : undefined);
|
|
664
|
+
}
|
|
665
|
+
else if (raw && typeof raw === 'object') {
|
|
666
|
+
row = raw;
|
|
667
|
+
}
|
|
668
|
+
if (!row)
|
|
669
|
+
return null;
|
|
670
|
+
const status = row.algoStatus;
|
|
671
|
+
if (status == null || String(status).trim() === '')
|
|
672
|
+
return null;
|
|
673
|
+
return String(status).toUpperCase();
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
// Not-found / aged-out / 429 / network / ban / pace all collapse here.
|
|
677
|
+
// noteBinanceError is a no-op for BinanceBannedError (gate already
|
|
678
|
+
// open) and arms the gate for real Binance errors — same pattern as
|
|
679
|
+
// every other read. Best-effort: return null, never a verdict.
|
|
680
|
+
noteBinanceError(err);
|
|
681
|
+
logger.warn(TAG, `queryAlgoOrderStatus(${clientAlgoId}) → null/unknown (NOT a verdict): ${formatError(err)}`);
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** Fetch historical fills (trades) for a symbol via GET /fapi/v1/userTrades.
|
|
686
|
+
* Used by the REST gap-filler (rest-gap-filler.ts) to backfill any fills
|
|
687
|
+
* that landed during a WS blind window — when the user-data stream was
|
|
688
|
+
* between reconnects, ORDER_TRADE_UPDATE events for that interval are
|
|
689
|
+
* unreachable from the WS side. Binance requires a symbol per call; the
|
|
690
|
+
* gap-filler iterates over every symbol it's seen this session.
|
|
691
|
+
*
|
|
692
|
+
* Returns raw CCXT trade objects (permissive shape — caller extracts the
|
|
693
|
+
* fields it needs). `since` is epoch milliseconds. Binance caps `limit` at
|
|
694
|
+
* 1000 per request. Falls back to [] on error so the caller keeps going
|
|
695
|
+
* rather than aborting the whole reconnect flow.
|
|
696
|
+
*
|
|
697
|
+
* `opts.endTime` (epoch ms) bounds the upper end of the window. CCXT 4.5.x
|
|
698
|
+
* reads it from params (`safeInteger2(params, 'until', 'endTime')`) and maps
|
|
699
|
+
* it onto the `/fapi/v1/userTrades` `endTime` query param; the gap-filler's
|
|
700
|
+
* startup deep-fill passes it to chunk a multi-day lookback into the ≤7-day
|
|
701
|
+
* windows Binance requires (the span between startTime and endTime cannot
|
|
702
|
+
* exceed 7 days). Without it, CCXT auto-clamps a `since` older than 7 days to
|
|
703
|
+
* `since + 7d` for linear futures, so only the first week would ever return.
|
|
704
|
+
*
|
|
705
|
+
* Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/Account-Trade-List */
|
|
706
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
707
|
+
async fetchMyTrades(symbol, since, limit = 500, opts) {
|
|
708
|
+
// Legacy error-tolerant surface: [] on any failure so best-effort callers
|
|
709
|
+
// (reconnect gap-fill, active probe) keep going. Callers that must
|
|
710
|
+
// distinguish "read failed" from "genuinely no trades" use
|
|
711
|
+
// fetchMyTradesOrNull below — the [] here is NOT safe for cursor/window
|
|
712
|
+
// bookkeeping (an error-as-empty reads as "window exhausted").
|
|
713
|
+
return (await this.fetchMyTradesOrNull(symbol, since, limit, opts)) ?? [];
|
|
714
|
+
}
|
|
715
|
+
/** Null-returning variant of fetchMyTrades — the null≠empty contract
|
|
716
|
+
* (same rule as getPositionsOrNull / fetchOpenOrders):
|
|
717
|
+
* - array (possibly empty) ONLY on a successful response — a real `[]`
|
|
718
|
+
* means Binance CONFIRMS no trades in the queried range;
|
|
719
|
+
* - `null` on ANY non-answer (ban/pace, 429, network, garbled non-array
|
|
720
|
+
* body). Callers must treat null as "trades UNKNOWN" and must NOT
|
|
721
|
+
* advance a pagination cursor or mark a backfill window complete.
|
|
722
|
+
*
|
|
723
|
+
* `opts.fromId` pages by Binance trade id (ascending, inclusive) instead
|
|
724
|
+
* of by time. Doc-verified (Account-Trade-List, 2026-07-02): "The
|
|
725
|
+
* parameter fromId cannot be sent with startTime or endTime", so a fromId
|
|
726
|
+
* call must omit `since`/`endTime` — enforced here rather than silently
|
|
727
|
+
* sending a request Binance rejects. Weight is 5 in all variants (same
|
|
728
|
+
* endpoint), so the existing ban-gate context accounting is unchanged. */
|
|
729
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
730
|
+
async fetchMyTradesOrNull(symbol, since, limit = 500, opts) {
|
|
731
|
+
if (opts?.fromId != null && (since != null || opts?.endTime != null)) {
|
|
732
|
+
throw new Error('fetchMyTradesOrNull: fromId cannot be combined with since/endTime (Binance rejects the combination)');
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
assertNotBanned('fetchMyTrades');
|
|
736
|
+
const params = {};
|
|
737
|
+
if (opts?.endTime != null)
|
|
738
|
+
params.endTime = opts.endTime;
|
|
739
|
+
if (opts?.fromId != null)
|
|
740
|
+
params.fromId = opts.fromId;
|
|
741
|
+
const raw = await this.exchange.fetchMyTrades(symbol, since, limit, params);
|
|
742
|
+
noteSuccess();
|
|
743
|
+
// null≠empty: a non-array body is a garbled non-answer, NOT "no trades".
|
|
744
|
+
return Array.isArray(raw) ? raw : null;
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
noteBinanceError(err);
|
|
748
|
+
logger.warn(TAG, `fetchMyTrades(${symbol}, since=${since}) failed: ${formatError(err)}`);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/** Fetch order book depth (same data as public, validates auth path). */
|
|
753
|
+
async fetchOrderBook(symbol, limit = 20) {
|
|
754
|
+
try {
|
|
755
|
+
assertNotBanned('fetchOrderBook');
|
|
756
|
+
const raw = await this.exchange.fetchOrderBook(symbol, limit);
|
|
757
|
+
noteSuccess();
|
|
758
|
+
return {
|
|
759
|
+
bids: (raw.bids ?? []).map((l) => [l[0], l[1]]),
|
|
760
|
+
asks: (raw.asks ?? []).map((l) => [l[0], l[1]]),
|
|
761
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
noteBinanceError(err);
|
|
766
|
+
logger.error(TAG, `fetchOrderBook(${symbol}) failed: ${formatError(err)}`);
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/** Fetch ticker. Returns null on error. */
|
|
771
|
+
async fetchTicker(symbol) {
|
|
772
|
+
try {
|
|
773
|
+
assertNotBanned('fetchTicker');
|
|
774
|
+
const raw = await this.exchange.fetchTicker(symbol);
|
|
775
|
+
noteSuccess();
|
|
776
|
+
return {
|
|
777
|
+
symbol: raw.symbol,
|
|
778
|
+
last: raw.last ?? 0,
|
|
779
|
+
bid: raw.bid ?? 0,
|
|
780
|
+
ask: raw.ask ?? 0,
|
|
781
|
+
baseVolume: raw.baseVolume ?? 0,
|
|
782
|
+
quoteVolume: raw.quoteVolume ?? 0,
|
|
783
|
+
change: raw.change ?? 0,
|
|
784
|
+
percentage: raw.percentage ?? 0,
|
|
785
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
786
|
+
datetime: raw.datetime ?? new Date().toISOString(),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
noteBinanceError(err);
|
|
791
|
+
logger.error(TAG, `fetchTicker(${symbol}) failed: ${formatError(err)}`);
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Submit a real order to the exchange.
|
|
797
|
+
* Used in micro-live and live modes only. NOT called in shadow mode.
|
|
798
|
+
*
|
|
799
|
+
* @param params — extra CCXT params forwarded to Binance (e.g. newClientOrderId, reduceOnly, positionSide).
|
|
800
|
+
*/
|
|
801
|
+
async createOrder(symbol, side, type, amount, price,
|
|
802
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
803
|
+
params) {
|
|
804
|
+
// Order submission is intentionally NOT assertNotBanned-gated (orders
|
|
805
|
+
// must always reach the exchange — kill/flatten/bracket-arming can never
|
|
806
|
+
// be blocked). But it MUST still ARM the shared gate on a 429/418 so the
|
|
807
|
+
// *read* pollers back off — otherwise an order that trips the IP limit
|
|
808
|
+
// leaves every poller hammering into the ban (CLAUDE.md ban-gate
|
|
809
|
+
// contract). Catch → noteBinanceError → rethrow (preserve the throw).
|
|
810
|
+
let raw;
|
|
811
|
+
try {
|
|
812
|
+
raw = await this.exchange.createOrder(symbol, type, side, amount, price, params ?? {});
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
noteBinanceError(err);
|
|
816
|
+
throw err;
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
id: String(raw.id),
|
|
820
|
+
symbol: raw.symbol,
|
|
821
|
+
side: raw.side,
|
|
822
|
+
type: raw.type,
|
|
823
|
+
status: raw.status,
|
|
824
|
+
amount: raw.amount ?? amount,
|
|
825
|
+
filled: raw.filled ?? 0,
|
|
826
|
+
remaining: raw.remaining ?? amount,
|
|
827
|
+
average: raw.average ?? null,
|
|
828
|
+
price: raw.price ?? price ?? null,
|
|
829
|
+
cost: raw.cost ?? 0,
|
|
830
|
+
fee: raw.fee ?? { cost: 0, currency: 'USDT' },
|
|
831
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
832
|
+
datetime: raw.datetime ?? new Date().toISOString(),
|
|
833
|
+
timeInForce: raw.timeInForce ?? 'GTC',
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/** Fetch a single order by ID. Used for fill polling and idempotency checks. */
|
|
837
|
+
async fetchOrder(orderId, symbol) {
|
|
838
|
+
try {
|
|
839
|
+
// Pre-gate: OrderPoller calls this every 5s. During an active 418
|
|
840
|
+
// an ungated poll keeps hitting Binance and EXTENDS the ban. Throwing
|
|
841
|
+
// here (→ caught → null) makes the poller skip the tick instead.
|
|
842
|
+
assertNotBanned('fetchOrder');
|
|
843
|
+
const raw = await this.exchange.fetchOrder(orderId, symbol);
|
|
844
|
+
noteSuccess();
|
|
845
|
+
return {
|
|
846
|
+
id: String(raw.id),
|
|
847
|
+
symbol: raw.symbol ?? symbol ?? '',
|
|
848
|
+
side: (raw.side ?? 'buy'),
|
|
849
|
+
type: (raw.type ?? 'limit'),
|
|
850
|
+
status: raw.status,
|
|
851
|
+
amount: raw.amount ?? 0,
|
|
852
|
+
filled: raw.filled ?? 0,
|
|
853
|
+
remaining: raw.remaining ?? 0,
|
|
854
|
+
average: raw.average ?? null,
|
|
855
|
+
price: raw.price ?? null,
|
|
856
|
+
cost: raw.cost ?? 0,
|
|
857
|
+
fee: raw.fee ?? { cost: 0, currency: 'USDT' },
|
|
858
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
859
|
+
datetime: raw.datetime ?? new Date().toISOString(),
|
|
860
|
+
timeInForce: raw.timeInForce ?? 'GTC',
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
noteBinanceError(err);
|
|
865
|
+
logger.error(TAG, `fetchOrder(${orderId}) failed: ${formatError(err)}`);
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Fetch a single order by clientOrderId. Used for idempotency recovery
|
|
871
|
+
* after network timeouts — check if the order went through before retrying.
|
|
872
|
+
*/
|
|
873
|
+
async fetchOrderByClientId(clientOrderId, symbol) {
|
|
874
|
+
try {
|
|
875
|
+
// Pre-gate: never hammer Binance during a ban. Caveat: during a ban
|
|
876
|
+
// this returns null (= "not found"), so an idempotency caller could
|
|
877
|
+
// resubmit. Accepted tradeoff — extending the IP ban for everyone is
|
|
878
|
+
// worse, and the resubmit path is itself reduce-only/bounded.
|
|
879
|
+
assertNotBanned('fetchOrder');
|
|
880
|
+
const raw = await this.exchange.fetchOrder(undefined, symbol, {
|
|
881
|
+
origClientOrderId: clientOrderId,
|
|
882
|
+
});
|
|
883
|
+
noteSuccess();
|
|
884
|
+
return {
|
|
885
|
+
id: String(raw.id),
|
|
886
|
+
symbol: raw.symbol ?? symbol,
|
|
887
|
+
side: (raw.side ?? 'buy'),
|
|
888
|
+
type: (raw.type ?? 'limit'),
|
|
889
|
+
status: raw.status,
|
|
890
|
+
amount: raw.amount ?? 0,
|
|
891
|
+
filled: raw.filled ?? 0,
|
|
892
|
+
remaining: raw.remaining ?? 0,
|
|
893
|
+
average: raw.average ?? null,
|
|
894
|
+
price: raw.price ?? null,
|
|
895
|
+
cost: raw.cost ?? 0,
|
|
896
|
+
fee: raw.fee ?? { cost: 0, currency: 'USDT' },
|
|
897
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
898
|
+
datetime: raw.datetime ?? new Date().toISOString(),
|
|
899
|
+
timeInForce: raw.timeInForce ?? 'GTC',
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
noteBinanceError(err);
|
|
904
|
+
// Order not found is expected when checking idempotency — don't log as error
|
|
905
|
+
logger.info(TAG, `fetchOrderByClientId(${clientOrderId}) not found or failed: ${formatError(err)}`);
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/** Cancel a real order on the exchange. Binance Futures splits orders across
|
|
910
|
+
* two endpoints — regular (`/fapi/v1/order`) and algo/conditional
|
|
911
|
+
* (`/fapi/v1/algoOrder`). Bracket SL/TP live in the algo bucket.
|
|
912
|
+
* The caller usually can't tell which bucket a given id belongs to, so we
|
|
913
|
+
* try regular first, then fall back to algo by algoId on "not found".
|
|
914
|
+
* Symbol is resolved from open-orders snapshot if the caller omits it. */
|
|
915
|
+
async cancelOrder(orderId, symbol) {
|
|
916
|
+
const looksAlreadyGone = (msg) => msg.includes('-2011') || msg.includes('-2013') ||
|
|
917
|
+
/unknown order/i.test(msg) || /does not exist/i.test(msg);
|
|
918
|
+
let resolvedSymbol = symbol;
|
|
919
|
+
let hint;
|
|
920
|
+
if (!resolvedSymbol) {
|
|
921
|
+
// fetchOpenOrders() already merges regular + algo buckets, so a single
|
|
922
|
+
// probe is enough to locate the order and its symbol. If a TRUSTED
|
|
923
|
+
// (non-null) list doesn't contain it, the order is not open — treat the
|
|
924
|
+
// cancel as an idempotent no-op rather than throwing "symbol required".
|
|
925
|
+
const open = await this.fetchOpenOrders();
|
|
926
|
+
if (open === null) {
|
|
927
|
+
// null = order state UNKNOWN (read failed / weight-paced / banned),
|
|
928
|
+
// NOT "order gone". Fabricating status:'canceled' here reported a
|
|
929
|
+
// still-working live order as cancelled exactly when Binance was
|
|
930
|
+
// shedding us. Same null≠empty rule as cancelAllOrders below: only a
|
|
931
|
+
// trusted list licenses the idempotent not-found fabrication.
|
|
932
|
+
throw new Error(`cancelOrder(${orderId}): fetchOpenOrders returned null (order state unknown) — ` +
|
|
933
|
+
'cannot locate the order to cancel; refusing to fabricate a cancelled status');
|
|
934
|
+
}
|
|
935
|
+
const match = open.find((o) => o.id === orderId);
|
|
936
|
+
if (match?.symbol) {
|
|
937
|
+
resolvedSymbol = match.symbol;
|
|
938
|
+
hint = match;
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
logger.info(TAG, `cancelOrder(${orderId}): not in open-orders snapshot — already cancelled/filled`);
|
|
942
|
+
return this.fabricateCanceledOrder(orderId, '', undefined);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
const raw = await this.exchange.cancelOrder(orderId, resolvedSymbol);
|
|
947
|
+
return this.normalizeCanceledOrder(raw, resolvedSymbol);
|
|
948
|
+
}
|
|
949
|
+
catch (err) {
|
|
950
|
+
const msg = formatError(err);
|
|
951
|
+
if (!looksAlreadyGone(msg)) {
|
|
952
|
+
noteBinanceError(err);
|
|
953
|
+
throw err;
|
|
954
|
+
}
|
|
955
|
+
// Fall through to algo cancel.
|
|
956
|
+
}
|
|
957
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
958
|
+
const ex = this.exchange;
|
|
959
|
+
if (typeof ex.fapiPrivateDeleteAlgoOrder !== 'function') {
|
|
960
|
+
logger.info(TAG, `cancelOrder(${orderId}): already cancelled/filled (no algo endpoint)`);
|
|
961
|
+
return this.fabricateCanceledOrder(orderId, resolvedSymbol, hint);
|
|
962
|
+
}
|
|
963
|
+
let marketId;
|
|
964
|
+
try {
|
|
965
|
+
marketId = ex.marketId(resolvedSymbol);
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
marketId = resolvedSymbol.replace('/', '').split(':')[0];
|
|
969
|
+
}
|
|
970
|
+
this.logAlgoCancelChokepoint(`cancelOrder(algoId=${orderId})`, marketId);
|
|
971
|
+
try {
|
|
972
|
+
await ex.fapiPrivateDeleteAlgoOrder({ symbol: marketId, algoId: orderId });
|
|
973
|
+
logger.info(TAG, `cancelOrder(${orderId}): cancelled via algo endpoint`);
|
|
974
|
+
return this.fabricateCanceledOrder(orderId, resolvedSymbol, hint);
|
|
975
|
+
}
|
|
976
|
+
catch (algoErr) {
|
|
977
|
+
const algoMsg = formatError(algoErr);
|
|
978
|
+
if (looksAlreadyGone(algoMsg)) {
|
|
979
|
+
logger.info(TAG, `cancelOrder(${orderId}): already cancelled/filled`);
|
|
980
|
+
return this.fabricateCanceledOrder(orderId, resolvedSymbol, hint);
|
|
981
|
+
}
|
|
982
|
+
noteBinanceError(algoErr);
|
|
983
|
+
throw algoErr;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
987
|
+
normalizeCanceledOrder(raw, fallbackSymbol) {
|
|
988
|
+
return {
|
|
989
|
+
id: String(raw.id),
|
|
990
|
+
symbol: raw.symbol ?? fallbackSymbol ?? '',
|
|
991
|
+
side: (raw.side ?? 'buy'),
|
|
992
|
+
type: (raw.type ?? 'limit'),
|
|
993
|
+
status: 'canceled',
|
|
994
|
+
amount: raw.amount ?? 0,
|
|
995
|
+
filled: raw.filled ?? 0,
|
|
996
|
+
remaining: raw.remaining ?? 0,
|
|
997
|
+
average: raw.average ?? null,
|
|
998
|
+
price: raw.price ?? null,
|
|
999
|
+
cost: raw.cost ?? 0,
|
|
1000
|
+
fee: raw.fee ?? { cost: 0, currency: 'USDT' },
|
|
1001
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
1002
|
+
datetime: raw.datetime ?? new Date().toISOString(),
|
|
1003
|
+
timeInForce: raw.timeInForce ?? 'GTC',
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
fabricateCanceledOrder(orderId, symbol, hint) {
|
|
1007
|
+
return {
|
|
1008
|
+
id: orderId,
|
|
1009
|
+
symbol,
|
|
1010
|
+
side: hint?.side ?? 'buy',
|
|
1011
|
+
type: hint?.type ?? 'market',
|
|
1012
|
+
status: 'canceled',
|
|
1013
|
+
amount: hint?.amount ?? 0,
|
|
1014
|
+
filled: hint?.filled ?? 0,
|
|
1015
|
+
remaining: hint?.remaining ?? 0,
|
|
1016
|
+
average: hint?.average ?? null,
|
|
1017
|
+
price: hint?.price ?? null,
|
|
1018
|
+
cost: hint?.cost ?? 0,
|
|
1019
|
+
fee: { cost: 0, currency: 'USDT' },
|
|
1020
|
+
timestamp: Date.now(),
|
|
1021
|
+
datetime: new Date().toISOString(),
|
|
1022
|
+
timeInForce: 'GTC',
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Submit a bracket leg (STOP_MARKET or TAKE_PROFIT_MARKET). Sibling of
|
|
1027
|
+
* `createOrder` that widens the `type` parameter to the Binance-specific
|
|
1028
|
+
* trigger-order types. Used by BracketManager to attach SL/TP to an
|
|
1029
|
+
* already-open position.
|
|
1030
|
+
*/
|
|
1031
|
+
async createBracketOrder(symbol, type, side, amount,
|
|
1032
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1033
|
+
params) {
|
|
1034
|
+
// Order path: NOT pre-gated (bracket arming must never be blocked — a
|
|
1035
|
+
// naked position is worse than a ban) but MUST arm the shared gate on a
|
|
1036
|
+
// 429/418 so the read pollers back off. Catch → note → rethrow.
|
|
1037
|
+
let raw;
|
|
1038
|
+
try {
|
|
1039
|
+
// Binance ignores `price` on market-type triggers; pass undefined.
|
|
1040
|
+
raw = await this.exchange.createOrder(symbol, type, side, amount, undefined, params);
|
|
1041
|
+
}
|
|
1042
|
+
catch (err) {
|
|
1043
|
+
noteBinanceError(err);
|
|
1044
|
+
throw err;
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
id: String(raw.id),
|
|
1048
|
+
symbol: raw.symbol ?? symbol,
|
|
1049
|
+
side: (raw.side ?? side),
|
|
1050
|
+
// Normalise the bracket types into our narrower CcxtOrder.type so downstream
|
|
1051
|
+
// readers don't need to know about STOP_MARKET/TAKE_PROFIT_MARKET. The
|
|
1052
|
+
// bracket identity is carried by the clientOrderId.
|
|
1053
|
+
type: 'market',
|
|
1054
|
+
status: (raw.status ?? 'open'),
|
|
1055
|
+
amount: raw.amount ?? amount,
|
|
1056
|
+
filled: raw.filled ?? 0,
|
|
1057
|
+
remaining: raw.remaining ?? amount,
|
|
1058
|
+
average: raw.average ?? null,
|
|
1059
|
+
price: raw.price ?? null,
|
|
1060
|
+
cost: raw.cost ?? 0,
|
|
1061
|
+
fee: raw.fee ?? { cost: 0, currency: 'USDT' },
|
|
1062
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
1063
|
+
datetime: raw.datetime ?? new Date().toISOString(),
|
|
1064
|
+
timeInForce: raw.timeInForce ?? 'GTC',
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Cancel an order by its clientOrderId (not exchange id). Idempotent:
|
|
1069
|
+
* "order does not exist" is treated as success since the intent was to
|
|
1070
|
+
* remove the order. Binance returns -2011 when the order is already gone.
|
|
1071
|
+
*
|
|
1072
|
+
* Tries the regular-order cancel first, then the algo-order cancel on
|
|
1073
|
+
* failure, because Binance Futures splits the two endpoints and we can't
|
|
1074
|
+
* tell from the clientOrderId alone which bucket the order landed in.
|
|
1075
|
+
*/
|
|
1076
|
+
/** MANDATORY ALGO-CANCEL CHOKEPOINT TRACE (2026-05-16, widened after the
|
|
1077
|
+
* Dropbox-revert blinded the cancelOrderByClientId-only tracer).
|
|
1078
|
+
*
|
|
1079
|
+
* EVERY bracket SL/TP is a Binance *algo* order; EVERY cancel of one that
|
|
1080
|
+
* reaches the exchange goes through `fapiPrivateDeleteAlgoOrder` — no
|
|
1081
|
+
* matter which higher-level method ordered it (cancelOrder by algoId,
|
|
1082
|
+
* cancelOrderByClientId by cid, cancelSymbolBracketOrders, cancelBrackets,
|
|
1083
|
+
* modify_*, the reconciler, the agent, anything). Logging the caller stack
|
|
1084
|
+
* at THIS one REST chokepoint makes the next bracket disappearance
|
|
1085
|
+
* unambiguous, and — critically — if a bracket leg goes `X:CANCELED` on
|
|
1086
|
+
* the user-data WS with NO chokepoint log around that timestamp, then it
|
|
1087
|
+
* was NOT cancelled by our code at all (Binance-side auto-cancel — a
|
|
1088
|
+
* fundamentally different fix). Pure observability, zero behaviour change.
|
|
1089
|
+
* Remove once the proven source is fixed and soaked. */
|
|
1090
|
+
logAlgoCancelChokepoint(via, marketId) {
|
|
1091
|
+
const stack = new Error('algo-cancel-trace').stack ?? '(no stack)';
|
|
1092
|
+
logger.warn(TAG, `ALGO-CANCEL CHOKEPOINT — fapiPrivateDeleteAlgoOrder about to fire ` +
|
|
1093
|
+
`(via ${via}, symbol=${marketId}). If this id is an rc-*-{s,t} ` +
|
|
1094
|
+
`bracket leg, this is the cancel that strips protection. ` +
|
|
1095
|
+
`Caller stack follows:\n${stack}`);
|
|
1096
|
+
}
|
|
1097
|
+
async cancelOrderByClientId(clientOrderId, symbol) {
|
|
1098
|
+
// ---- MANDATORY BRACKET-CANCEL ATTRIBUTION (2026-05-16) --------------
|
|
1099
|
+
// Every bracket-protection cancel in the entire plugin funnels through
|
|
1100
|
+
// this one method. All prior fixes failed because each cancel path logs
|
|
1101
|
+
// NOTHING on success, so the rogue silent canceller could only be
|
|
1102
|
+
// INFERRED (and was, wrongly, repeatedly). This logs the exact caller
|
|
1103
|
+
// stack for ANY rc-*-{s,t} (stop/target protection-leg) cancel — zero
|
|
1104
|
+
// behaviour change, pure observability — so the very next time a live
|
|
1105
|
+
// position's bracket disappears, the journal names the precise function
|
|
1106
|
+
// that ordered it instead of leaving us to guess. Remove once the
|
|
1107
|
+
// proven source is fixed and soaked.
|
|
1108
|
+
if (isBracketCid(clientOrderId)) {
|
|
1109
|
+
const stack = new Error('bracket-cancel-trace').stack ?? '(no stack)';
|
|
1110
|
+
logger.warn(TAG, `BRACKET-CANCEL ATTRIBUTION — cancelOrderByClientId(${clientOrderId}, ` +
|
|
1111
|
+
`${symbol}) was called. This cancels a STOP/TP protection leg. ` +
|
|
1112
|
+
`Caller stack follows:\n${stack}`);
|
|
1113
|
+
}
|
|
1114
|
+
const looksAlreadyGone = (msg) => msg.includes('-2011') || msg.includes('-2013') ||
|
|
1115
|
+
/unknown order/i.test(msg) || /does not exist/i.test(msg);
|
|
1116
|
+
// Try regular-order cancel first.
|
|
1117
|
+
let regularErr;
|
|
1118
|
+
try {
|
|
1119
|
+
await this.exchange.cancelOrder(undefined, symbol, {
|
|
1120
|
+
origClientOrderId: clientOrderId,
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
const msg = formatError(err);
|
|
1126
|
+
if (looksAlreadyGone(msg)) {
|
|
1127
|
+
// Maybe it's an algo order instead — fall through to algo cancel.
|
|
1128
|
+
regularErr = err;
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
noteBinanceError(err);
|
|
1132
|
+
throw err;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Fall back to algo-order cancel by clientAlgoId.
|
|
1136
|
+
try {
|
|
1137
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1138
|
+
const ex = this.exchange;
|
|
1139
|
+
if (typeof ex.fapiPrivateDeleteAlgoOrder !== 'function') {
|
|
1140
|
+
// Older CCXT without algo support — treat regular "gone" as success.
|
|
1141
|
+
logger.info(TAG, `cancelOrderByClientId(${clientOrderId}): already cancelled/filled (no algo endpoint)`);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
let marketId;
|
|
1145
|
+
try {
|
|
1146
|
+
marketId = ex.marketId(symbol);
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
marketId = symbol.replace('/', '').split(':')[0];
|
|
1150
|
+
}
|
|
1151
|
+
this.logAlgoCancelChokepoint(`cancelOrderByClientId(cid=${clientOrderId})`, marketId);
|
|
1152
|
+
await ex.fapiPrivateDeleteAlgoOrder({
|
|
1153
|
+
symbol: marketId,
|
|
1154
|
+
clientAlgoId: clientOrderId,
|
|
1155
|
+
});
|
|
1156
|
+
logger.info(TAG, `cancelOrderByClientId(${clientOrderId}): cancelled via algo endpoint`);
|
|
1157
|
+
}
|
|
1158
|
+
catch (algoErr) {
|
|
1159
|
+
const algoMsg = formatError(algoErr);
|
|
1160
|
+
if (looksAlreadyGone(algoMsg)) {
|
|
1161
|
+
logger.info(TAG, `cancelOrderByClientId(${clientOrderId}): already cancelled/filled`);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
// Neither endpoint worked. Surface the original regular error so callers
|
|
1165
|
+
// see the most informative message.
|
|
1166
|
+
noteBinanceError(algoErr);
|
|
1167
|
+
throw regularErr ?? algoErr;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
/** Cancel all orders on the exchange for a symbol (or everything, if omitted).
|
|
1171
|
+
* Covers BOTH regular orders and algo/conditional orders via per-order
|
|
1172
|
+
* cancellation through `cancelOrderByClientId`, which internally falls
|
|
1173
|
+
* back across both buckets.
|
|
1174
|
+
*
|
|
1175
|
+
* **Bracket protection is preserved by default.** Orders whose
|
|
1176
|
+
* clientOrderId parses as a bracket stop/target (`rc-<16hex>-s` or `-t`)
|
|
1177
|
+
* are NEVER cancelled by this method — otherwise a kill-switch fire or an
|
|
1178
|
+
* agent-initiated `cancel_all_orders` would strip exchange-side protection
|
|
1179
|
+
* off open positions and leave them naked. This was the root cause of
|
|
1180
|
+
* the 2026-04-21 naked-position incident (4 live positions ran without
|
|
1181
|
+
* stops for hours after kill-switch cancels swept their brackets).
|
|
1182
|
+
*
|
|
1183
|
+
* Callers that explicitly need to cancel brackets (e.g. `closePosition`
|
|
1184
|
+
* after flattening the position) must go through
|
|
1185
|
+
* `BracketManager.cancelBrackets(symbol)` which is symbol-scoped and
|
|
1186
|
+
* tracks the cancellation in the ledger. */
|
|
1187
|
+
async cancelAllOrders(symbol) {
|
|
1188
|
+
try {
|
|
1189
|
+
// fetchOpenOrders merges regular + algo — every open order in either
|
|
1190
|
+
// bucket is visible here with its clientOrderId.
|
|
1191
|
+
const open = await this.fetchOpenOrders(symbol);
|
|
1192
|
+
if (open === null) {
|
|
1193
|
+
// null = order state UNKNOWN (read failed / weight-paced / banned),
|
|
1194
|
+
// NOT "no orders". This method cancels per-order (bracket
|
|
1195
|
+
// preservation), so without the list it cannot do its job — throw so
|
|
1196
|
+
// the emergency caller's retry loop escalates loudly instead of
|
|
1197
|
+
// reporting a silent "0 cancelled" success while orders are live.
|
|
1198
|
+
throw new Error('cancelAllOrders: fetchOpenOrders returned null (order state unknown) — cannot enumerate orders to cancel');
|
|
1199
|
+
}
|
|
1200
|
+
const all = open;
|
|
1201
|
+
const cancelable = [];
|
|
1202
|
+
let preservedBrackets = 0;
|
|
1203
|
+
for (const o of all) {
|
|
1204
|
+
if (!o.clientOrderId) {
|
|
1205
|
+
// No clientOrderId (rare; manual orders from the Binance UI) — we
|
|
1206
|
+
// can't identify it as a bracket protective order, but we also
|
|
1207
|
+
// can't cancel it via cancelOrderByClientId. Skip with a warning.
|
|
1208
|
+
logger.warn(TAG, `cancelAllOrders: order without clientOrderId skipped (id=${o.id} symbol=${o.symbol})`);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const parsed = parseBracketCid(o.clientOrderId);
|
|
1212
|
+
if (parsed && (parsed.role === 'stop' || parsed.role === 'target')) {
|
|
1213
|
+
preservedBrackets++;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
cancelable.push(o);
|
|
1217
|
+
}
|
|
1218
|
+
if (cancelable.length === 0) {
|
|
1219
|
+
if (preservedBrackets > 0) {
|
|
1220
|
+
logger.info(TAG, `cancelAllOrders: nothing to cancel; preserved ${preservedBrackets} bracket protective order(s)`);
|
|
1221
|
+
}
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
// Cancel each non-bracket order per-order. cancelOrderByClientId tries
|
|
1225
|
+
// the regular endpoint first and falls back to the algo endpoint, so
|
|
1226
|
+
// this one call covers either order type. A bulk sweep (the previous
|
|
1227
|
+
// implementation) couldn't do the bracket-preservation filtering.
|
|
1228
|
+
for (const o of cancelable) {
|
|
1229
|
+
if (!o.symbol || !o.clientOrderId)
|
|
1230
|
+
continue;
|
|
1231
|
+
try {
|
|
1232
|
+
await this.cancelOrderByClientId(o.clientOrderId, o.symbol);
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
logger.warn(TAG, `cancelAllOrders: cancel ${o.clientOrderId} (${o.symbol}) failed: ${formatError(err)}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (preservedBrackets > 0) {
|
|
1239
|
+
logger.info(TAG, `cancelAllOrders: cancelled ${cancelable.length}, preserved ${preservedBrackets} bracket protective order(s)`);
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
logger.info(TAG, `cancelAllOrders: cancelled ${cancelable.length} order(s)`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
noteBinanceError(err);
|
|
1247
|
+
logger.error(TAG, `cancelAllOrders failed: ${formatError(err)}`);
|
|
1248
|
+
throw err;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
/** Disable Binance's per-symbol `countdownCancelAll` "dead-man's-switch"
|
|
1252
|
+
* (POST /fapi/v1/countdownCancelAll, countdownTime=0).
|
|
1253
|
+
*
|
|
1254
|
+
* ROOT-CAUSE FIX 2026-05-16. A countdownCancelAll timer was armed on this
|
|
1255
|
+
* account from OUTSIDE our codebase (verified: nothing in plugin/skill/
|
|
1256
|
+
* scripts/OpenClaw-gateway/ccxt ever sets it). When armed, Binance purges
|
|
1257
|
+
* EVERY open order for the symbol when the countdown lapses — server-side,
|
|
1258
|
+
* empty reason, NOT via the cancel endpoint — which is why every bracket
|
|
1259
|
+
* (both order forms, all symbols) was auto-cancelled ~17-53s after
|
|
1260
|
+
* placement and no instrumentation ever caught a caller. Proven decisively:
|
|
1261
|
+
* a clean order placed with the switch disabled survived 5 min when every
|
|
1262
|
+
* churned bracket died in ~25s. countdownTime=0 is Binance's documented
|
|
1263
|
+
* DISABLE value; purely protective (cannot cancel/modify/close anything),
|
|
1264
|
+
* idempotent, harmless no-op if none is armed. We proactively disable it
|
|
1265
|
+
* per active symbol so an externally-armed switch can never again strip
|
|
1266
|
+
* protection. Best-effort + safety-critical: like order paths it is NOT
|
|
1267
|
+
* pre-gated (must never be blocked) but arms the ban gate on error. */
|
|
1268
|
+
async disableCountdownCancelAll(symbol) {
|
|
1269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1270
|
+
const ex = this.exchange;
|
|
1271
|
+
if (typeof ex.fapiPrivatePostCountdownCancelAll !== 'function') {
|
|
1272
|
+
logger.warn(TAG, `disableCountdownCancelAll(${symbol}): CCXT lacks fapiPrivatePostCountdownCancelAll — cannot neutralise the kill-switch; check ccxt version`);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
let marketId;
|
|
1276
|
+
try {
|
|
1277
|
+
marketId = ex.marketId(symbol);
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
marketId = symbol.replace('/', '').split(':')[0];
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1283
|
+
await ex.fapiPrivatePostCountdownCancelAll({ symbol: marketId, countdownTime: 0 });
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
// Never throw into the bracket-attach path — a failed disable must not
|
|
1287
|
+
// block protection placement. Arm the gate on 429/418 like order paths.
|
|
1288
|
+
noteBinanceError(err);
|
|
1289
|
+
logger.warn(TAG, `disableCountdownCancelAll(${marketId}) failed (non-fatal): ${formatError(err)}`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
/** Translate a Binance market ID (e.g. `BTCUSDT`) back to the unified
|
|
1293
|
+
* CCXT symbol (e.g. `BTC/USDT:USDT`, with the `:SETTLE` suffix stripped
|
|
1294
|
+
* to match the rest of the system's un-suffixed format). Used when parsing
|
|
1295
|
+
* user-data WebSocket events — Binance sends raw market IDs, but the
|
|
1296
|
+
* plugin's state store + downstream consumers use unified symbols.
|
|
1297
|
+
* Falls back to the raw id if the market isn't in CCXT's cache. */
|
|
1298
|
+
symbolFromMarketId(marketId) {
|
|
1299
|
+
try {
|
|
1300
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1301
|
+
const ex = this.exchange;
|
|
1302
|
+
const raw = ex.markets_by_id?.[marketId];
|
|
1303
|
+
// markets_by_id can return a single market or an array (shared ids).
|
|
1304
|
+
const market = Array.isArray(raw) ? raw[0] : raw;
|
|
1305
|
+
const unified = typeof market?.symbol === 'string' ? market.symbol : null;
|
|
1306
|
+
if (unified) {
|
|
1307
|
+
// Strip `:SETTLE` so every consumer sees the same format. See
|
|
1308
|
+
// memory/feedback_symbol_normalize_boundary.md.
|
|
1309
|
+
return unified.replace(/:[A-Z]+$/, '');
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
catch { /* fall through */ }
|
|
1313
|
+
return marketId;
|
|
1314
|
+
}
|
|
1315
|
+
/** Create a user-data listenKey via POST /fapi/v1/listenKey. The returned
|
|
1316
|
+
* key is the authentication token for the user-data WebSocket stream
|
|
1317
|
+
* (`wss://fstream.binance.com/private/ws/<listenKey>`). Keys expire after 60 min
|
|
1318
|
+
* of inactivity — callers must schedule `keepAliveListenKey` every ~25 min. */
|
|
1319
|
+
async createListenKey() {
|
|
1320
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1321
|
+
const ex = this.exchange;
|
|
1322
|
+
if (typeof ex.fapiPrivatePostListenKey !== 'function') {
|
|
1323
|
+
throw new Error('CCXT missing fapiPrivatePostListenKey — check ccxt version');
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
// Gate listenKey ops too: a POST during an active 418 ban EXTENDS the
|
|
1327
|
+
// ban (Binance extends on ANY request while banned). Better to fail the
|
|
1328
|
+
// mint fast and let UserDataStream back off than to keep the ban alive.
|
|
1329
|
+
assertNotBanned('createListenKey');
|
|
1330
|
+
const res = await ex.fapiPrivatePostListenKey();
|
|
1331
|
+
noteSuccess();
|
|
1332
|
+
const key = typeof res?.listenKey === 'string' ? res.listenKey : null;
|
|
1333
|
+
if (!key)
|
|
1334
|
+
throw new Error('listenKey missing from Binance response');
|
|
1335
|
+
return key;
|
|
1336
|
+
}
|
|
1337
|
+
catch (err) {
|
|
1338
|
+
noteBinanceError(err);
|
|
1339
|
+
throw err;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/** Keep-alive the current listenKey via PUT /fapi/v1/listenKey. Must be
|
|
1343
|
+
* called within 60 min of the last keep-alive; 25-min cadence gives a 2x
|
|
1344
|
+
* safety margin. Binance does not require the key to be passed — the
|
|
1345
|
+
* endpoint acts on whichever key the API key last minted. */
|
|
1346
|
+
async keepAliveListenKey() {
|
|
1347
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1348
|
+
const ex = this.exchange;
|
|
1349
|
+
if (typeof ex.fapiPrivatePutListenKey !== 'function') {
|
|
1350
|
+
throw new Error('CCXT missing fapiPrivatePutListenKey — check ccxt version');
|
|
1351
|
+
}
|
|
1352
|
+
try {
|
|
1353
|
+
assertNotBanned('keepAliveListenKey');
|
|
1354
|
+
await ex.fapiPrivatePutListenKey();
|
|
1355
|
+
noteSuccess();
|
|
1356
|
+
}
|
|
1357
|
+
catch (err) {
|
|
1358
|
+
noteBinanceError(err);
|
|
1359
|
+
throw err;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/** Close the current listenKey via DELETE /fapi/v1/listenKey. Polite
|
|
1363
|
+
* teardown on graceful shutdown; Binance will otherwise expire the key
|
|
1364
|
+
* after 60 min of inactivity. */
|
|
1365
|
+
async closeListenKey() {
|
|
1366
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1367
|
+
const ex = this.exchange;
|
|
1368
|
+
if (typeof ex.fapiPrivateDeleteListenKey !== 'function') {
|
|
1369
|
+
throw new Error('CCXT missing fapiPrivateDeleteListenKey — check ccxt version');
|
|
1370
|
+
}
|
|
1371
|
+
try {
|
|
1372
|
+
// During a ban, skip the polite DELETE entirely — it would extend the
|
|
1373
|
+
// ban. The key expires on its own after 60 min of inactivity anyway.
|
|
1374
|
+
assertNotBanned('closeListenKey');
|
|
1375
|
+
await ex.fapiPrivateDeleteListenKey();
|
|
1376
|
+
noteSuccess();
|
|
1377
|
+
}
|
|
1378
|
+
catch (err) {
|
|
1379
|
+
noteBinanceError(err);
|
|
1380
|
+
throw err;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
/** Whether this instance is connected to testnet. */
|
|
1384
|
+
isTestnet() {
|
|
1385
|
+
return this.testnet;
|
|
1386
|
+
}
|
|
1387
|
+
/** Get the underlying CCXT exchange instance (for loadMarkets, position mode detection, etc.). */
|
|
1388
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1389
|
+
getExchange() {
|
|
1390
|
+
return this.exchange;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Get rate-limit headers from the last exchange response.
|
|
1394
|
+
* CCXT stores these on the exchange instance after each request.
|
|
1395
|
+
* Returns empty object if not available.
|
|
1396
|
+
*/
|
|
1397
|
+
getLastResponseHeaders() {
|
|
1398
|
+
try {
|
|
1399
|
+
// CCXT stores last response headers in exchange.last_response_headers (Python)
|
|
1400
|
+
// or exchange.lastResponseHeaders (JS). Try both patterns.
|
|
1401
|
+
const headers = this.exchange.last_response_headers
|
|
1402
|
+
?? this.exchange.lastResponseHeaders
|
|
1403
|
+
?? {};
|
|
1404
|
+
// Normalize keys to lowercase
|
|
1405
|
+
const result = {};
|
|
1406
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1407
|
+
result[k.toLowerCase()] = String(v);
|
|
1408
|
+
}
|
|
1409
|
+
return result;
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
return {};
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|