@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,1665 @@
|
|
|
1
|
+
// LiveAdapter — wraps BinancePrivateApi to implement IExchangeAdapter.
|
|
2
|
+
// Integrates all Sprint 2 safety modules: rate limiter, intent journal,
|
|
3
|
+
// order poller, slippage tracker, and position reconciler.
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { BinancePrivateApi } from '../ccxt/binance-private.js';
|
|
7
|
+
import { ExchangeInfoCache } from './exchange-info-cache.js';
|
|
8
|
+
import { BinanceRateLimiter, ENDPOINT_WEIGHTS } from './rate-limiter.js';
|
|
9
|
+
import { IntentJournal } from './intent-journal.js';
|
|
10
|
+
import { OrderPoller } from './order-poller.js';
|
|
11
|
+
import { SlippageTracker } from './slippage-tracker.js';
|
|
12
|
+
import { PositionReconciler } from './reconciler.js';
|
|
13
|
+
import { parseExchangeError } from './exchange-errors.js';
|
|
14
|
+
import { logger, formatError } from '../logger.js';
|
|
15
|
+
import { getQuoteWalletBalance } from '../balance-utils.js';
|
|
16
|
+
import { LiveBalanceEnricher, utcDateString, utcMidnightMs } from './live-balance-enricher.js';
|
|
17
|
+
import { DepositTracker } from './deposit-tracker.js';
|
|
18
|
+
import { BracketLedger, normalizeBracketSymbol } from './bracket-ledger.js';
|
|
19
|
+
import { noteResponseWeight, assertNotBanned, noteBinanceError, noteSuccess } from '../ccxt/binance-ban-gate.js';
|
|
20
|
+
import { BracketManager } from './bracket-manager.js';
|
|
21
|
+
import { BracketReconciler } from './bracket-reconciler.js';
|
|
22
|
+
import { LiveBracketApi } from './live-bracket-api.js';
|
|
23
|
+
import { generateBracketId, buildBracketCid, parseBracketCid } from './bracket-id.js';
|
|
24
|
+
import { validateStopDirection, validateTargetDirection } from './bracket-params.js';
|
|
25
|
+
import { bracketsEnabled } from '../config/brackets-config.js';
|
|
26
|
+
import { userDataStreamEnabled, userDataStreamAuthoritative, DEFAULT_TUNABLES as USER_DATA_STREAM_DEFAULT_TUNABLES, } from '../config/user-data-stream-config.js';
|
|
27
|
+
import { UserDataStreamController } from './user-data-stream-controller.js';
|
|
28
|
+
import { assertNotShuttingDown, registerOp } from '../lifecycle/shutdown-coordinator.js';
|
|
29
|
+
import { updateMfe } from '../mfe.js';
|
|
30
|
+
import { computeInvalidationHit } from '../pinned-plan.js';
|
|
31
|
+
const TAG = 'live-adapter';
|
|
32
|
+
/** Minimum interval between /fapi/v1/income re-anchor calls.
|
|
33
|
+
*
|
|
34
|
+
* Endpoint: GET /fapi/v1/income, weight=30, no per-call cap — verified
|
|
35
|
+
* against developers.binance.com/.../Get-Income-History (2026-05-16).
|
|
36
|
+
*
|
|
37
|
+
* Why this can safely be long (NOT 60s):
|
|
38
|
+
* - `sessionStartNav = wallet_now − netNonTransfer = wallet_at_midnight`
|
|
39
|
+
* is INVARIANT under non-transfer activity within a UTC day — wallet
|
|
40
|
+
* grows by exactly `netNonTransfer` (realized PnL + fees + funding) as
|
|
41
|
+
* trades close, so a re-poll recomputes the same constant.
|
|
42
|
+
* - The dashboard's Realized + Day P&L are derived by the webapp from
|
|
43
|
+
* WS-fresh wallet + unrealized (`totalPnl − unrealizedPnl`), NOT from
|
|
44
|
+
* this seed (see live-balance-enricher.seedRealizedPnlToday doc +
|
|
45
|
+
* webapp slices/trades.ts:updateLiveKpi). `recordFill` is unused in
|
|
46
|
+
* prod, so the seed never moves between re-anchors anyway.
|
|
47
|
+
* The only re-anchors that change anything: startup (done once in
|
|
48
|
+
* initialize()), UTC-midnight rollover (FORCED — bypasses this throttle
|
|
49
|
+
* via the dayRolled branch), and catching a BNFCR/multi-asset transfer
|
|
50
|
+
* type DepositTracker can't see (rare; a long safety reconcile suffices —
|
|
51
|
+
* DepositTracker handles ordinary transfers within its own poll).
|
|
52
|
+
*
|
|
53
|
+
* Polling this every 60s was ~30 weight/min of permanent, redundant IP
|
|
54
|
+
* weight that fed the 2026-05-14→16 steady-state weight floor which paced
|
|
55
|
+
* out the bracket audit's getOpenOrders and blinded the agent on live
|
|
56
|
+
* protection. 30 min preserves the BNFCR safety reconcile while cutting
|
|
57
|
+
* the load ~97% (30w/60s → ~1w/min). Env-overridable for emergency tuning
|
|
58
|
+
* without a redeploy; clamp 60s–6h. */
|
|
59
|
+
const INCOME_REFRESH_MIN_INTERVAL_MS = (() => {
|
|
60
|
+
const raw = Number(process.env.RC_INCOME_REFRESH_MS);
|
|
61
|
+
return Number.isFinite(raw) && raw >= 60_000 && raw <= 21_600_000
|
|
62
|
+
? raw
|
|
63
|
+
: 30 * 60_000;
|
|
64
|
+
})();
|
|
65
|
+
/** Generate a Binance-compatible client order ID (max 36 chars, alphanumeric). */
|
|
66
|
+
function generateClientOrderId() {
|
|
67
|
+
return randomUUID();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Backfill `balance.free[cur] = total[cur]` for currencies where Binance's CCXT
|
|
71
|
+
* response left the per-currency `free` and `used` at zero despite a non-zero
|
|
72
|
+
* total. Only safe when `used` is zero — otherwise the exchange really is
|
|
73
|
+
* reserving margin and we must not inflate availability.
|
|
74
|
+
*/
|
|
75
|
+
export function repairFreeFromTotal(balance) {
|
|
76
|
+
if (!balance.total || !balance.free)
|
|
77
|
+
return;
|
|
78
|
+
for (const cur of Object.keys(balance.total)) {
|
|
79
|
+
const total = balance.total[cur] ?? 0;
|
|
80
|
+
const used = balance.used?.[cur] ?? 0;
|
|
81
|
+
const free = balance.free[cur] ?? 0;
|
|
82
|
+
if (total > 0 && free <= 0 && used <= 0) {
|
|
83
|
+
balance.free[cur] = total;
|
|
84
|
+
// Mirror into the per-currency object shape if present.
|
|
85
|
+
const perCur = balance[cur];
|
|
86
|
+
if (perCur && typeof perCur === 'object' && 'free' in perCur) {
|
|
87
|
+
perCur.free = total;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export class LiveAdapter extends EventEmitter {
|
|
93
|
+
mode;
|
|
94
|
+
isLive = true;
|
|
95
|
+
api;
|
|
96
|
+
sizeCapMultiplier;
|
|
97
|
+
maxPositionUSDT;
|
|
98
|
+
exchangeInfo = new ExchangeInfoCache();
|
|
99
|
+
isHedgeMode = false;
|
|
100
|
+
// Sticky cooldown for getOpenOrders. When fetchOpenOrders is rate-limited
|
|
101
|
+
// (null / paced / banned), the agent's SKILL.md-mandated recovery loop
|
|
102
|
+
// (audit_bracket_protection → attach_brackets) re-calls it every ~3 s.
|
|
103
|
+
// Each unscoped call is 40 weight, so the loop ALONE pins the IP weight at
|
|
104
|
+
// the pacer ceiling → every other read is shed → the agent never completes
|
|
105
|
+
// a heartbeat → no operator messages, naked position never healed
|
|
106
|
+
// (2026-05-15 incident). This makes the failure STICKY: after a rate-limit
|
|
107
|
+
// failure, every getOpenOrders short-circuits with the same error WITHOUT
|
|
108
|
+
// touching Binance for the cooldown, so the weight window drains, the
|
|
109
|
+
// pacer clears, and the next post-cooldown call can actually succeed.
|
|
110
|
+
getOpenOrdersUnavailableUntil = 0;
|
|
111
|
+
static OPEN_ORDERS_RL_COOLDOWN_MS = 45_000;
|
|
112
|
+
// Sprint 2 safety modules — all owned by the adapter
|
|
113
|
+
rateLimiter = new BinanceRateLimiter();
|
|
114
|
+
intentJournal;
|
|
115
|
+
orderPoller;
|
|
116
|
+
slippageTracker = new SlippageTracker();
|
|
117
|
+
reconciler;
|
|
118
|
+
enricher;
|
|
119
|
+
depositTracker;
|
|
120
|
+
// Exchange-native bracket orders — null when mode='off' (legacy stop-watcher owns it).
|
|
121
|
+
bracketMode;
|
|
122
|
+
bracketLedger = null;
|
|
123
|
+
bracketManager = null;
|
|
124
|
+
bracketReconciler = null;
|
|
125
|
+
// User-data WebSocket stream — null when mode='off' (REST polling owns reads).
|
|
126
|
+
// In Phase 1 this is dead code on prod (mode='off' is the default); the wiring
|
|
127
|
+
// is in place so Phase 2 onwards only requires a flag flip. Read paths
|
|
128
|
+
// (getPositions/getBalance/getOpenOrders) remain REST-backed until
|
|
129
|
+
// userDataStreamAuthoritative(mode) is true (observe/enforce — Phase 2+).
|
|
130
|
+
userDataStreamMode;
|
|
131
|
+
userDataStream = null;
|
|
132
|
+
// Per-cid last-seen ALGO_UPDATE status — the authoritative WS bracket-leg
|
|
133
|
+
// signal for resolveBracketLegLiveness()'s Tier-1 fast-path. onAlgoUpdate
|
|
134
|
+
// does NOT terminalize the ledger on CANCELED (by design — see its doc
|
|
135
|
+
// block), so the ledger row state is a LOSSY proxy that misses the exact
|
|
136
|
+
// spiral cancel. This map keeps the real per-leg WS truth. Insertion-order
|
|
137
|
+
// bounded (oldest evicted past the cap) — cids are one-shot per bracket.
|
|
138
|
+
wsAlgoStatusByCid = new Map();
|
|
139
|
+
static WS_ALGO_STATUS_CAP = 1000;
|
|
140
|
+
// Freshness window for the symbol-scoped trusted-live sweep
|
|
141
|
+
// (findTrustedLiveBracketCidsForSymbol). The 2026-05-16 INJ spiral's fresh
|
|
142
|
+
// leg is seconds old when the guard needs it, so this only has to be
|
|
143
|
+
// generous enough to never miss a genuinely-live leg while short enough
|
|
144
|
+
// that an ancient record can't suppress a real repair forever. Past this
|
|
145
|
+
// age the sweep ignores the record and the caller falls back to the
|
|
146
|
+
// per-leg REST authority — never to a guess.
|
|
147
|
+
static WS_LIVE_LEG_MAX_AGE_MS = 5 * 60_000;
|
|
148
|
+
// Session-start NAV — captured once at initialization, used for drawdown calculation.
|
|
149
|
+
// If not yet captured, getSessionStartNav() returns null.
|
|
150
|
+
_sessionStartNav = null;
|
|
151
|
+
// Last time the realized-today seed + sessionStartNav were re-anchored from
|
|
152
|
+
// Binance's income endpoint. Throttled to INCOME_REFRESH_MIN_INTERVAL_MS
|
|
153
|
+
// (30 min default) because the anchor is invariant within a UTC day — see
|
|
154
|
+
// that constant's doc for why a re-poll is otherwise pure redundant
|
|
155
|
+
// weight. UTC-midnight rollover forces a refresh regardless of throttle.
|
|
156
|
+
lastIncomeRefreshMs = 0;
|
|
157
|
+
// Day-anchor tracking. utcDayAnchor = the UTC date the current sessionStartNav
|
|
158
|
+
// is computed against. Crossing UTC midnight forces a re-anchor on the next
|
|
159
|
+
// refresh tick regardless of throttle.
|
|
160
|
+
incomeAnchorUtcDay = utcDateString(Date.now());
|
|
161
|
+
// Per-symbol position metadata for live mode. Live positions live on Binance,
|
|
162
|
+
// but the agent-supplied entry context (thesis, setupType, regime, scorecard)
|
|
163
|
+
// and the running MFE state must live somewhere on our side. In-memory only —
|
|
164
|
+
// a plugin restart wipes this. Bracket ledger covers stopPrice persistence
|
|
165
|
+
// through restarts; thesis/setup metadata is recoverable from the create_order
|
|
166
|
+
// log / heartbeat reasoning if needed. Seeded in createOrder, updated on
|
|
167
|
+
// getPositions tick, dropped on closePosition or when a symbol vanishes from
|
|
168
|
+
// the open-positions snapshot.
|
|
169
|
+
liveMetadata = new Map();
|
|
170
|
+
// Readiness state — mutable, starts INIT_PENDING
|
|
171
|
+
_readiness = 'INIT_PENDING';
|
|
172
|
+
get readiness() {
|
|
173
|
+
return this._readiness;
|
|
174
|
+
}
|
|
175
|
+
/** Session-start NAV, captured once at initialization. Used for drawdown calculation. */
|
|
176
|
+
getSessionStartNav() {
|
|
177
|
+
return this._sessionStartNav;
|
|
178
|
+
}
|
|
179
|
+
constructor(config, mode, microLiveConfig, bracketMode = 'off', userDataStreamMode = 'off', userDataStreamTunables = USER_DATA_STREAM_DEFAULT_TUNABLES,
|
|
180
|
+
/** TRADE_AUDIT_TRAIL_PLAN Phase 1 — optional audit-trail wiring. Wired
|
|
181
|
+
* through to UserDataStreamController. Absent today on every existing
|
|
182
|
+
* call site (default off); operator-driven once activation is approved. */
|
|
183
|
+
tradeIngest,
|
|
184
|
+
/** Position-decision auto-capture wiring. When provided alongside
|
|
185
|
+
* tradeIngest, ws-ingest also routes every TRADE event through
|
|
186
|
+
* `onWsFillObserved` to capture limit-order entries + scale-ins
|
|
187
|
+
* (PR1 deferral resolution, 2026-05-03). Optional for back-compat. */
|
|
188
|
+
autoCapture) {
|
|
189
|
+
super();
|
|
190
|
+
this.mode = mode;
|
|
191
|
+
this.bracketMode = bracketMode;
|
|
192
|
+
this.userDataStreamMode = userDataStreamMode;
|
|
193
|
+
this.api = new BinancePrivateApi(config);
|
|
194
|
+
if (mode === 'MICRO_LIVE') {
|
|
195
|
+
// sizeCapMultiplier is 1.0 — the agent already sizes for the real wallet.
|
|
196
|
+
// maxPositionUSDT is the safety cap (e.g., $50 max per position).
|
|
197
|
+
this.sizeCapMultiplier = 1.0;
|
|
198
|
+
this.maxPositionUSDT = microLiveConfig?.maxPositionUSDT ?? 50;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
this.sizeCapMultiplier = 1.0;
|
|
202
|
+
this.maxPositionUSDT = null;
|
|
203
|
+
}
|
|
204
|
+
// Initialize safety modules
|
|
205
|
+
this.intentJournal = new IntentJournal();
|
|
206
|
+
this.orderPoller = new OrderPoller(this);
|
|
207
|
+
this.reconciler = new PositionReconciler(this);
|
|
208
|
+
this.enricher = new LiveBalanceEnricher();
|
|
209
|
+
this.depositTracker = new DepositTracker(this.api);
|
|
210
|
+
// Bracket-orders system — only initialised when the operator has flipped
|
|
211
|
+
// the flag to 'observe' or 'enforce'. In 'off' mode the legacy stop-watcher
|
|
212
|
+
// continues to own protection.
|
|
213
|
+
if (bracketsEnabled(this.bracketMode)) {
|
|
214
|
+
this.bracketLedger = new BracketLedger();
|
|
215
|
+
const liveBracketApi = new LiveBracketApi(this.api);
|
|
216
|
+
this.bracketManager = new BracketManager(liveBracketApi, this.bracketLedger);
|
|
217
|
+
this.bracketManager.on('event', (ev) => this.emit('bracket_event', ev));
|
|
218
|
+
this.bracketReconciler = new BracketReconciler(this, this.bracketLedger, this.bracketManager, liveBracketApi);
|
|
219
|
+
this.bracketReconciler.on('drift', (d) => this.emit('bracket_drift', d));
|
|
220
|
+
logger.info(TAG, `Bracket orders ENABLED in mode=${this.bracketMode}`);
|
|
221
|
+
}
|
|
222
|
+
// Forward poller events
|
|
223
|
+
this.orderPoller.on('order_event', (event) => {
|
|
224
|
+
this.emit('order_event', event);
|
|
225
|
+
// Track slippage on fills
|
|
226
|
+
if (event.type === 'filled' && event.order.average != null) {
|
|
227
|
+
this.slippageTracker.recordFill({
|
|
228
|
+
orderId: event.orderId,
|
|
229
|
+
symbol: event.order.symbol,
|
|
230
|
+
side: event.order.side,
|
|
231
|
+
expectedPrice: event.order.price ?? event.order.average,
|
|
232
|
+
actualPrice: event.order.average,
|
|
233
|
+
amount: event.order.filled,
|
|
234
|
+
cost: event.order.cost,
|
|
235
|
+
fee: event.order.fee?.cost ?? 0,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
// Attach brackets when a pending limit entry fills. For market orders
|
|
239
|
+
// the attach happens inline in createOrder; this branch covers limits.
|
|
240
|
+
if (event.type === 'filled' && this.bracketManager && this.bracketLedger) {
|
|
241
|
+
const pending = this.bracketLedger.getBySymbol(event.order.symbol);
|
|
242
|
+
if (pending && pending.state === 'pending_entry') {
|
|
243
|
+
const filledAmount = event.order.filled;
|
|
244
|
+
if (filledAmount > 0) {
|
|
245
|
+
this.attachBracketsAsync(event.order.symbol, filledAmount);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
// Forward reconciler events
|
|
251
|
+
this.reconciler.on('drift_detected', (result) => {
|
|
252
|
+
this.emit('drift_detected', result);
|
|
253
|
+
});
|
|
254
|
+
// User-data WebSocket stream — constructed eagerly but only started in
|
|
255
|
+
// `initialize()` when mode!='off'. The controller defaults are safe to
|
|
256
|
+
// instantiate without I/O (no network calls until .start()).
|
|
257
|
+
if (userDataStreamEnabled(this.userDataStreamMode)) {
|
|
258
|
+
this.userDataStream = new UserDataStreamController(this.api, {
|
|
259
|
+
mode: this.userDataStreamMode,
|
|
260
|
+
testnet: config.testnet ?? false,
|
|
261
|
+
tunables: userDataStreamTunables,
|
|
262
|
+
tradeIngest,
|
|
263
|
+
autoCapture,
|
|
264
|
+
});
|
|
265
|
+
this.userDataStream.on('snapshotApplied', (snap) => this.emit('user_data_snapshot', snap));
|
|
266
|
+
this.userDataStream.on('drift', (drift) => this.emit('user_data_drift', drift));
|
|
267
|
+
this.userDataStream.on('forcedResync', (cause) => this.emit('user_data_forced_resync', cause));
|
|
268
|
+
this.userDataStream.on('marginCall', (ev) => this.emit('user_data_margin_call', ev));
|
|
269
|
+
this.userDataStream.on('reconnecting', (attempt, delay) => this.emit('user_data_reconnecting', attempt, delay));
|
|
270
|
+
// AUTHORITATIVE bracket-lifecycle signal. Replaces REST-audit guessing
|
|
271
|
+
// (the audit→re-attach spiral that flushed live positions on
|
|
272
|
+
// 2026-05-15). Doc-verified status semantics drive the action.
|
|
273
|
+
this.userDataStream.on('algoUpdate', (ev) => this.onAlgoUpdate(ev));
|
|
274
|
+
logger.info(TAG, `User-data stream ENABLED in mode=${this.userDataStreamMode}`);
|
|
275
|
+
}
|
|
276
|
+
logger.info(TAG, `LiveAdapter created in ${mode} mode (size cap: ${this.sizeCapMultiplier * 100}%, max pos USDT: ${this.maxPositionUSDT ?? 'unlimited'})`);
|
|
277
|
+
}
|
|
278
|
+
/** Get the underlying BinancePrivateApi (for advanced queries like fetchTicker). */
|
|
279
|
+
getApi() {
|
|
280
|
+
return this.api;
|
|
281
|
+
}
|
|
282
|
+
/** Get the exchange info cache (for symbol rules). */
|
|
283
|
+
getExchangeInfo() {
|
|
284
|
+
return this.exchangeInfo;
|
|
285
|
+
}
|
|
286
|
+
/** Whether the account is in hedge mode. */
|
|
287
|
+
getIsHedgeMode() {
|
|
288
|
+
return this.isHedgeMode;
|
|
289
|
+
}
|
|
290
|
+
/** Get safety modules for external access (dashboard, tools, etc.). */
|
|
291
|
+
getRateLimiter() { return this.rateLimiter; }
|
|
292
|
+
getIntentJournal() { return this.intentJournal; }
|
|
293
|
+
getOrderPoller() { return this.orderPoller; }
|
|
294
|
+
getSlippageTracker() { return this.slippageTracker; }
|
|
295
|
+
getReconciler() { return this.reconciler; }
|
|
296
|
+
getEnricher() { return this.enricher; }
|
|
297
|
+
getDepositTracker() { return this.depositTracker; }
|
|
298
|
+
/** Active user-data-stream mode. `'off'` when the feature flag is disabled. */
|
|
299
|
+
getUserDataStreamMode() { return this.userDataStreamMode; }
|
|
300
|
+
/** User-data stream controller, or null if mode='off'. Used by dashboards
|
|
301
|
+
* and diagnostic tools to read the live-state store + health. */
|
|
302
|
+
getUserDataStream() { return this.userDataStream; }
|
|
303
|
+
/**
|
|
304
|
+
* Post-registration async initialization.
|
|
305
|
+
* Must complete before create_order is allowed (readiness gate).
|
|
306
|
+
* Emergency controls (cancel, close) work regardless of readiness.
|
|
307
|
+
*/
|
|
308
|
+
async initialize() {
|
|
309
|
+
try {
|
|
310
|
+
// 1. Validate API permissions
|
|
311
|
+
logger.info(TAG, 'Validating API permissions...');
|
|
312
|
+
const perms = await this.api.validatePermissions();
|
|
313
|
+
if (!perms.canReadBalance || !perms.canReadPositions) {
|
|
314
|
+
logger.error(TAG, `API key lacks required permissions: ${perms.errors.join(', ')}`);
|
|
315
|
+
this._readiness = 'BLOCKED';
|
|
316
|
+
this.emit('readiness_changed', this._readiness, 'API key missing read permissions');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
logger.info(TAG, 'API permissions validated');
|
|
320
|
+
// 2. Fetch real balance and capture session-start NAV (once)
|
|
321
|
+
const balance = await this.api.fetchBalance();
|
|
322
|
+
const quoteBalance = getQuoteWalletBalance(balance);
|
|
323
|
+
if (this._sessionStartNav == null) {
|
|
324
|
+
// Anchor today's KPIs to Binance's income endpoint so the dashboard
|
|
325
|
+
// Realized + Day P&L match the Binance app even across plugin
|
|
326
|
+
// restarts. One call, two uses of the same `netNonTransfer` figure:
|
|
327
|
+
// - Realized KPI = netNonTransfer (REALIZED_PNL + COMMISSION +
|
|
328
|
+
// FUNDING_FEE + rebates). This is what the Binance app shows
|
|
329
|
+
// under "Today's Realized PNL" — verified against the mobile
|
|
330
|
+
// app on 2026-05-14 (Binance -$5.98 vs ours when we were
|
|
331
|
+
// using gross-only -$3.70 = $2.28 fees/funding gap).
|
|
332
|
+
// - sessionStartNav anchor = wallet_now − netNonTransfer = the
|
|
333
|
+
// wallet at UTC midnight. Day P&L = NAV − sessionStartNav.
|
|
334
|
+
// The `realizedPnlGross` field on the breakdown is retained for
|
|
335
|
+
// debug logging only — it does NOT match Binance's UI label.
|
|
336
|
+
// Refreshed periodically inside getBalance() — see refreshIncomeAnchor.
|
|
337
|
+
// forced:true → never-paced context. This boot anchor establishes
|
|
338
|
+
// the wallet_at_midnight baseline the ENTIRE Day-P&L / Realized KPI
|
|
339
|
+
// chain is derived from; it must not be shed by a heartbeat scan
|
|
340
|
+
// burst (2026-05-16 KPI incident).
|
|
341
|
+
const utcMidnight = utcMidnightMs();
|
|
342
|
+
const breakdown = await this.api.fetchTodayIncomeBreakdown(utcMidnight, {
|
|
343
|
+
forced: true,
|
|
344
|
+
});
|
|
345
|
+
if (breakdown != null) {
|
|
346
|
+
this.enricher.seedRealizedPnlToday(breakdown.netNonTransfer);
|
|
347
|
+
const navAtMidnight = quoteBalance - breakdown.netNonTransfer;
|
|
348
|
+
this._sessionStartNav = navAtMidnight;
|
|
349
|
+
this.enricher.setSessionStartNav(navAtMidnight);
|
|
350
|
+
this.lastIncomeRefreshMs = Date.now();
|
|
351
|
+
logger.info(TAG, `Session-start NAV anchored: ${navAtMidnight.toFixed(4)} (wallet ${quoteBalance.toFixed(4)} − netNonTransfer ${breakdown.netNonTransfer.toFixed(4)}); realizedPnlGross ${breakdown.realizedPnlGross.toFixed(4)} (debug only)`);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
// null≠empty: do NOT coerce to {0,0} (that pins sessionStartNav to
|
|
355
|
+
// wallet_now and zeroes Day P&L — 2026-05-16 KPI incident). Leave
|
|
356
|
+
// the anchor uncaptured and lastIncomeRefreshMs at 0 so
|
|
357
|
+
// refreshIncomeAnchor() self-heals on the next getBalance cycle
|
|
358
|
+
// (forced while _sessionStartNav == null → lands as soon as we're
|
|
359
|
+
// not under a real ban).
|
|
360
|
+
logger.warn(TAG, 'Session-start NAV NOT anchored — /fapi/v1/income unavailable at boot (real ban/network). Day P&L anchors on the first successful income refresh; refusing to fabricate a {0,0} anchor.');
|
|
361
|
+
}
|
|
362
|
+
// Arm the deposit tracker from this instant forward; any pre-init
|
|
363
|
+
// transfers are already baked into the starting wallet so they must
|
|
364
|
+
// not be double-counted.
|
|
365
|
+
this.depositTracker.reset(Date.now());
|
|
366
|
+
}
|
|
367
|
+
logger.info(TAG, `Live balance: ${quoteBalance}`);
|
|
368
|
+
// 3. Check for micro-live minimum balance
|
|
369
|
+
if (this.mode === 'MICRO_LIVE' && this.maxPositionUSDT != null && quoteBalance < this.maxPositionUSDT) {
|
|
370
|
+
logger.warn(TAG, `Balance ${quoteBalance} is below micro-live max position ${this.maxPositionUSDT}`);
|
|
371
|
+
// Not blocking — but log the warning
|
|
372
|
+
}
|
|
373
|
+
// 4. Load exchange info + position mode BEFORE position check.
|
|
374
|
+
// Emergency controls (close/cancel) need these even in DEGRADED state.
|
|
375
|
+
logger.info(TAG, 'Loading exchange info...');
|
|
376
|
+
await this.loadExchangeInfo();
|
|
377
|
+
await this.detectPositionMode();
|
|
378
|
+
// 4b. Hedge-mode accounts are not supported in v1 (one-way only — see
|
|
379
|
+
// CLAUDE.md "User-Data WebSocket Stream" + BRACKET_ORDERS_DESIGN.md
|
|
380
|
+
// §2). The entry path only sends `positionSide` when a caller-
|
|
381
|
+
// supplied option is present (the tool path never supplies one) and
|
|
382
|
+
// bracket legs always send `reduceOnly:true` with no `positionSide` —
|
|
383
|
+
// both are rejected by Binance in Hedge Mode (positionSide is
|
|
384
|
+
// mandatory; reduceOnly must not be sent). Going READY here would let
|
|
385
|
+
// the agent place entries that fill while their brackets are rejected,
|
|
386
|
+
// i.e. naked live positions. Refuse trading instead. Emergency
|
|
387
|
+
// controls (close/cancel) are NOT readiness-gated and still work.
|
|
388
|
+
if (this.isHedgeMode) {
|
|
389
|
+
this._readiness = 'BLOCKED';
|
|
390
|
+
const reason = 'Hedge-mode account not supported in v1 — switch the Binance Futures account to One-Way position mode to enable trading';
|
|
391
|
+
this.emit('readiness_changed', this._readiness, reason);
|
|
392
|
+
logger.error(TAG, `Adapter BLOCKED: ${reason}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// 5. Fetch existing positions (reconciliation)
|
|
396
|
+
const positions = await this.api.fetchPositions();
|
|
397
|
+
if (positions && positions.length > 0) {
|
|
398
|
+
logger.warn(TAG, `Found ${positions.length} existing position(s) on exchange at startup`);
|
|
399
|
+
this._readiness = 'DEGRADED';
|
|
400
|
+
this.emit('readiness_changed', this._readiness, 'Existing positions found — auto-acknowledging after lockout');
|
|
401
|
+
this.emit('positions_found_on_startup', { count: positions.length, positions });
|
|
402
|
+
// Auto-acknowledge after 15s lockout — the agent needs to place protective
|
|
403
|
+
// stops on existing positions, which requires READY status. Staying DEGRADED
|
|
404
|
+
// forever leaves live positions unprotected after every deploy/restart.
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
if (this._readiness === 'DEGRADED') {
|
|
407
|
+
logger.info(TAG, 'Auto-acknowledging existing positions after startup lockout');
|
|
408
|
+
void this.acknowledgePositions();
|
|
409
|
+
}
|
|
410
|
+
}, 16_000);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// 6. Compact the intent journal (prevents unbounded file growth)
|
|
414
|
+
this.intentJournal.compact();
|
|
415
|
+
// 7. Resolve any pending order intents from a prior crash
|
|
416
|
+
const unresolved = this.intentJournal.getUnresolved();
|
|
417
|
+
let hasUnresolvedIntents = false;
|
|
418
|
+
if (unresolved.length > 0) {
|
|
419
|
+
logger.warn(TAG, `Resolving ${unresolved.length} unresolved order intents from prior session`);
|
|
420
|
+
for (const intent of unresolved) {
|
|
421
|
+
try {
|
|
422
|
+
const existing = await this.api.fetchOrderByClientId(intent.clientOrderId, intent.symbol);
|
|
423
|
+
if (existing) {
|
|
424
|
+
this.intentJournal.updateIntent(intent.clientOrderId, { status: 'confirmed', exchangeOrderId: existing.id });
|
|
425
|
+
logger.info(TAG, `Resolved intent ${intent.clientOrderId} → order ${existing.id} (${existing.status})`);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
this.intentJournal.updateIntent(intent.clientOrderId, { status: 'failed', error: 'Order not found on exchange after restart' });
|
|
429
|
+
logger.warn(TAG, `Intent ${intent.clientOrderId} not found on exchange — marked failed`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
// Resolution failed — this intent's state is ambiguous
|
|
434
|
+
logger.error(TAG, `Failed to resolve intent ${intent.clientOrderId}: ${formatError(err)}`);
|
|
435
|
+
hasUnresolvedIntents = true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// If any intents couldn't be resolved, stay DEGRADED — operator must acknowledge
|
|
440
|
+
if (hasUnresolvedIntents) {
|
|
441
|
+
const stillUnresolved = this.intentJournal.getUnresolved();
|
|
442
|
+
logger.warn(TAG, `${stillUnresolved.length} intents still unresolved — holding DEGRADED`);
|
|
443
|
+
this._readiness = 'DEGRADED';
|
|
444
|
+
this.emit('readiness_changed', this._readiness, `${stillUnresolved.length} order intents could not be resolved — operator acknowledgment required`);
|
|
445
|
+
this.emit('unresolved_intents', { count: stillUnresolved.length, intents: stillUnresolved });
|
|
446
|
+
// Reconciler still starts — read-only ops are fine in DEGRADED
|
|
447
|
+
this.reconciler.start();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// 7. Start periodic position reconciliation (every 60s)
|
|
451
|
+
this.reconciler.start();
|
|
452
|
+
// 7b. Bracket restart recovery — one pass before accepting orders, then
|
|
453
|
+
// start the periodic reconciler. Any unknown orphans on the exchange
|
|
454
|
+
// (from a prior crash) are cancelled here; stale brackets on
|
|
455
|
+
// already-closed positions are cleaned up.
|
|
456
|
+
if (this.bracketReconciler) {
|
|
457
|
+
try {
|
|
458
|
+
const result = await this.bracketReconciler.runPass();
|
|
459
|
+
const notable = result.drifts.filter(d => d.kind !== 'healthy');
|
|
460
|
+
if (notable.length > 0) {
|
|
461
|
+
logger.warn(TAG, `Bracket restart recovery found ${notable.length} drift(s): ${notable.map(d => `${d.kind}:${d.symbol ?? '?'}`).join(', ')}`);
|
|
462
|
+
}
|
|
463
|
+
this.bracketReconciler.start();
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
logger.error(TAG, `Bracket restart recovery pass failed (non-fatal): ${formatError(err)}`);
|
|
467
|
+
// Still start the periodic reconciler — next pass may succeed.
|
|
468
|
+
this.bracketReconciler.start();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// 8. Start the user-data WebSocket stream (if enabled). Fires after the
|
|
472
|
+
// REST reconciliation is clean so the initial snapshot doesn't race
|
|
473
|
+
// with a DEGRADED-state entry. On hedge-mode accounts we refuse to
|
|
474
|
+
// enable observe/enforce — the store is one-way-only in v1.
|
|
475
|
+
if (this.userDataStream) {
|
|
476
|
+
if (this.isHedgeMode && (this.userDataStreamMode === 'observe' || this.userDataStreamMode === 'enforce')) {
|
|
477
|
+
logger.error(TAG, `User-data stream ${this.userDataStreamMode} refused on hedge-mode account — falling back to REST reads`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
try {
|
|
481
|
+
await this.userDataStream.start();
|
|
482
|
+
logger.info(TAG, `User-data stream started in mode=${this.userDataStreamMode}`);
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
// Fail-open: a broken WS shouldn't block trading on REST.
|
|
486
|
+
logger.error(TAG, `User-data stream start failed (continuing on REST): ${formatError(err)}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// 9. All clear
|
|
491
|
+
this._readiness = 'READY';
|
|
492
|
+
this.emit('readiness_changed', this._readiness, `Adapter ready in ${this.mode} mode`);
|
|
493
|
+
logger.info(TAG, `Adapter READY in ${this.mode} mode (${this.exchangeInfo.size} symbols, ${this.isHedgeMode ? 'hedge' : 'one-way'} mode)`);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
logger.error(TAG, `Init failed: ${formatError(err)}`);
|
|
497
|
+
this._readiness = 'BLOCKED';
|
|
498
|
+
this.emit('readiness_changed', this._readiness, formatError(err));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Acknowledge existing positions on startup.
|
|
503
|
+
* Transitions DEGRADED → READY. Exchange info + position mode are already loaded
|
|
504
|
+
* (they're loaded before the position check in initialize()).
|
|
505
|
+
*/
|
|
506
|
+
async acknowledgePositions() {
|
|
507
|
+
if (this._readiness !== 'DEGRADED') {
|
|
508
|
+
logger.warn(TAG, `acknowledgePositions called in ${this._readiness} state — ignored`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Start position reconciler (was skipped in initialize() when DEGRADED due to existing positions)
|
|
512
|
+
this.reconciler.start();
|
|
513
|
+
// Bracket restart recovery + periodic reconciler — mirrors the block in initialize()
|
|
514
|
+
// that's skipped when startup finds existing positions (early return before step 7b).
|
|
515
|
+
if (this.bracketReconciler) {
|
|
516
|
+
try {
|
|
517
|
+
const result = await this.bracketReconciler.runPass();
|
|
518
|
+
const notable = result.drifts.filter(d => d.kind !== 'healthy');
|
|
519
|
+
if (notable.length > 0) {
|
|
520
|
+
logger.warn(TAG, `Bracket restart recovery found ${notable.length} drift(s): ${notable.map(d => `${d.kind}:${d.symbol ?? '?'}`).join(', ')}`);
|
|
521
|
+
}
|
|
522
|
+
this.bracketReconciler.start();
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
logger.error(TAG, `Bracket restart recovery pass failed (non-fatal): ${formatError(err)}`);
|
|
526
|
+
this.bracketReconciler.start();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// User-data WebSocket stream — mirrors the block in initialize() that's skipped
|
|
530
|
+
// on the existing-positions DEGRADED path. Without this, the stream is constructed
|
|
531
|
+
// but never connects whenever the plugin restarts with an open position.
|
|
532
|
+
if (this.userDataStream) {
|
|
533
|
+
if (this.isHedgeMode && (this.userDataStreamMode === 'observe' || this.userDataStreamMode === 'enforce')) {
|
|
534
|
+
logger.error(TAG, `User-data stream ${this.userDataStreamMode} refused on hedge-mode account — falling back to REST reads`);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
try {
|
|
538
|
+
await this.userDataStream.start();
|
|
539
|
+
logger.info(TAG, `User-data stream started in mode=${this.userDataStreamMode}`);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
logger.error(TAG, `User-data stream start failed (continuing on REST): ${formatError(err)}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
this._readiness = 'READY';
|
|
547
|
+
this.emit('readiness_changed', this._readiness, 'Positions acknowledged — trading enabled');
|
|
548
|
+
logger.info(TAG, 'Positions acknowledged — adapter now READY (reconciler started)');
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Tear down background loops so the adapter can be replaced at runtime
|
|
552
|
+
* (e.g. when the operator changes credentials or trading mode from the
|
|
553
|
+
* dashboard). Must be called before discarding the LiveAdapter reference
|
|
554
|
+
* or the poller + reconciler timers will keep firing against stale state.
|
|
555
|
+
*
|
|
556
|
+
* Idempotent — safe to call multiple times.
|
|
557
|
+
*/
|
|
558
|
+
shutdown() {
|
|
559
|
+
try {
|
|
560
|
+
this.orderPoller.stopAll();
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
logger.warn(TAG, `orderPoller.stopAll failed: ${formatError(err)}`);
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
this.reconciler.stop();
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
logger.warn(TAG, `reconciler.stop failed: ${formatError(err)}`);
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
this.bracketReconciler?.stop();
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
logger.warn(TAG, `bracketReconciler.stop failed: ${formatError(err)}`);
|
|
576
|
+
}
|
|
577
|
+
// User-data stream stop is async; fire-and-forget since shutdown is sync.
|
|
578
|
+
if (this.userDataStream) {
|
|
579
|
+
void this.userDataStream.stop().catch((err) => logger.warn(TAG, `userDataStream.stop failed: ${formatError(err)}`));
|
|
580
|
+
}
|
|
581
|
+
this._readiness = 'BLOCKED';
|
|
582
|
+
this.removeAllListeners();
|
|
583
|
+
logger.info(TAG, 'LiveAdapter shut down');
|
|
584
|
+
}
|
|
585
|
+
// ---- IExchangeAdapter implementation ----
|
|
586
|
+
async createOrder(symbol, side, type, amount, price, metadata, options) {
|
|
587
|
+
// Refuse new entries during shutdown. Risk-reducing orders (emergency
|
|
588
|
+
// flatten, reduceOnly close) bypass this gate so the operator can still
|
|
589
|
+
// size down mid-deploy. In-flight orders that already started continue.
|
|
590
|
+
const isRiskReducing = options?.emergency === true || options?.reduceOnly === true;
|
|
591
|
+
if (!isRiskReducing) {
|
|
592
|
+
assertNotShuttingDown(`createOrder(${symbol} ${side} ${type})`);
|
|
593
|
+
}
|
|
594
|
+
const releaseOp = registerOp(`createOrder(${symbol} ${side} ${type}${isRiskReducing ? ' reduce' : ''})`);
|
|
595
|
+
try {
|
|
596
|
+
// ---- Rate limit check (skipped for emergency close/flatten) ----
|
|
597
|
+
const weight = ENDPOINT_WEIGHTS.createOrder ?? 1;
|
|
598
|
+
if (!options?.emergency) {
|
|
599
|
+
const rateCheck = this.rateLimiter.canSubmitOrder(weight);
|
|
600
|
+
if (!rateCheck.allowed) {
|
|
601
|
+
throw new Error(`Rate limit: ${rateCheck.reason}. Retry after ${rateCheck.retryAfterMs}ms.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// ---- Size caps ----
|
|
605
|
+
let effectiveAmount = amount * this.sizeCapMultiplier;
|
|
606
|
+
// Apply max position USDT cap for micro-live
|
|
607
|
+
if (this.maxPositionUSDT != null) {
|
|
608
|
+
const ticker = await this.api.fetchTicker(symbol);
|
|
609
|
+
this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchTicker ?? 1);
|
|
610
|
+
const tickerPrice = ticker?.last;
|
|
611
|
+
if (tickerPrice && tickerPrice > 0) {
|
|
612
|
+
const maxAmount = this.maxPositionUSDT / tickerPrice;
|
|
613
|
+
if (effectiveAmount > maxAmount) {
|
|
614
|
+
logger.info(TAG, `Micro-live cap: reducing amount from ${effectiveAmount} to ${maxAmount} (max $${this.maxPositionUSDT})`);
|
|
615
|
+
effectiveAmount = maxAmount;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
else if (!isRiskReducing) {
|
|
619
|
+
// The ticker read is SHED_FIRST under weight pressure — silently
|
|
620
|
+
// proceeding uncapped exactly when Binance is pacing us would bypass
|
|
621
|
+
// the micro-live size cap on a NEW exposure. Reject instead. Risk-
|
|
622
|
+
// reducing orders (emergency / reduceOnly) pass: they cannot increase
|
|
623
|
+
// the position, and blocking a close is worse than an unverified cap.
|
|
624
|
+
throw new Error(`Micro-live size cap ($${this.maxPositionUSDT}) cannot be verified — ticker unavailable for ${symbol}; ` +
|
|
625
|
+
'rejecting order rather than proceeding uncapped');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// ---- Exchange filter validation ----
|
|
629
|
+
const refPrice = price ?? (await this.api.fetchTicker(symbol))?.last ?? undefined;
|
|
630
|
+
const validation = this.exchangeInfo.validate(symbol, effectiveAmount, price, refPrice);
|
|
631
|
+
if (!validation.valid) {
|
|
632
|
+
throw new Error(`Order validation failed: ${validation.error}`);
|
|
633
|
+
}
|
|
634
|
+
// ---- Bracket direction sanity-check (PRE-submission) ----
|
|
635
|
+
// A flipped stop/target sign must be caught BEFORE the entry order is
|
|
636
|
+
// submitted. The old code only validated this AFTER createOrder returned,
|
|
637
|
+
// so a sign error on a market entry left the position live + unprotected
|
|
638
|
+
// (the caller saw only the thrown error). Validate against the best
|
|
639
|
+
// pre-trade reference: the limit price, else the ticker ref price. The
|
|
640
|
+
// post-fill block below stays as defense-in-depth.
|
|
641
|
+
if (metadata && (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined)) {
|
|
642
|
+
const entryRef = price ?? refPrice ?? 0;
|
|
643
|
+
if (entryRef > 0) {
|
|
644
|
+
if (metadata.stopPrice !== undefined) {
|
|
645
|
+
const msg = validateStopDirection(side, entryRef, metadata.stopPrice);
|
|
646
|
+
if (msg)
|
|
647
|
+
throw new Error(`Bracket rejected (pre-submit): ${msg}`);
|
|
648
|
+
}
|
|
649
|
+
if (metadata.targetPrice !== undefined) {
|
|
650
|
+
const msg = validateTargetDirection(side, entryRef, metadata.targetPrice);
|
|
651
|
+
if (msg)
|
|
652
|
+
throw new Error(`Bracket rejected (pre-submit): ${msg}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// ---- Idempotency: clientOrderId + intent journal ----
|
|
657
|
+
const clientOrderId = options?.clientOrderId ?? generateClientOrderId();
|
|
658
|
+
// Capture expected price for slippage tracking
|
|
659
|
+
const expectedPrice = type === 'limit' ? (price ?? refPrice ?? 0) : (refPrice ?? 0);
|
|
660
|
+
// Write intent to journal BEFORE submitting to exchange
|
|
661
|
+
this.intentJournal.recordIntent({
|
|
662
|
+
clientOrderId,
|
|
663
|
+
symbol,
|
|
664
|
+
side,
|
|
665
|
+
type,
|
|
666
|
+
amount: validation.roundedAmount,
|
|
667
|
+
price: validation.roundedPrice,
|
|
668
|
+
});
|
|
669
|
+
// Build CCXT params — these are actually sent to Binance
|
|
670
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
671
|
+
const ccxtParams = {
|
|
672
|
+
newClientOrderId: clientOrderId,
|
|
673
|
+
};
|
|
674
|
+
if (this.isHedgeMode && options?.positionSide) {
|
|
675
|
+
ccxtParams.positionSide = options.positionSide;
|
|
676
|
+
}
|
|
677
|
+
else if (!this.isHedgeMode && options?.reduceOnly) {
|
|
678
|
+
ccxtParams.reduceOnly = true;
|
|
679
|
+
}
|
|
680
|
+
// ---- Submit to exchange ----
|
|
681
|
+
logger.info(TAG, `Submitting ${type} ${side} ${validation.roundedAmount} ${symbol} @ ${validation.roundedPrice ?? 'market'} (clientOrderId: ${clientOrderId})`);
|
|
682
|
+
let order;
|
|
683
|
+
try {
|
|
684
|
+
order = await this.api.createOrder(symbol, side, type, validation.roundedAmount, validation.roundedPrice, ccxtParams);
|
|
685
|
+
this.syncRateLimits();
|
|
686
|
+
this.rateLimiter.recordOrder(weight);
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
// ---- Exchange error handling ----
|
|
690
|
+
const parsed = parseExchangeError(err);
|
|
691
|
+
if (parsed.isNetworkError) {
|
|
692
|
+
// Network timeout — check if order went through via clientOrderId
|
|
693
|
+
logger.warn(TAG, `Network error on submit — checking by clientOrderId ${clientOrderId}`);
|
|
694
|
+
const existing = await this.api.fetchOrderByClientId(clientOrderId, symbol);
|
|
695
|
+
if (existing) {
|
|
696
|
+
// The order DID land during the timeout. Returning here (the old
|
|
697
|
+
// behaviour) skipped ALL post-submission bookkeeping — slippage,
|
|
698
|
+
// limit-order polling, live metadata, and critically bracket
|
|
699
|
+
// registration/attach — so a market entry that filled during the
|
|
700
|
+
// timeout came back "recovered" yet permanently NAKED. Resume the
|
|
701
|
+
// normal path instead: assign `order` and fall through so brackets
|
|
702
|
+
// attach exactly as if createOrder had returned this order.
|
|
703
|
+
logger.info(TAG, `Order found after timeout: ${existing.id} status=${existing.status} — resuming full post-submission bookkeeping (brackets/poller/slippage)`);
|
|
704
|
+
this.intentJournal.updateIntent(clientOrderId, { status: 'confirmed', exchangeOrderId: existing.id });
|
|
705
|
+
this.syncRateLimits();
|
|
706
|
+
this.rateLimiter.recordOrder(weight);
|
|
707
|
+
order = existing;
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Not found — mark failed, throw the original error
|
|
711
|
+
this.intentJournal.updateIntent(clientOrderId, { status: 'failed', error: parsed.humanMessage });
|
|
712
|
+
throw new Error(parsed.humanMessage);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
this.intentJournal.updateIntent(clientOrderId, { status: 'failed', error: parsed.humanMessage });
|
|
717
|
+
throw new Error(parsed.humanMessage);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// ---- Post-submission bookkeeping ----
|
|
721
|
+
this.intentJournal.updateIntent(clientOrderId, { status: 'confirmed', exchangeOrderId: order.id });
|
|
722
|
+
// Track slippage for market orders that filled immediately
|
|
723
|
+
if (order.status === 'closed' && order.average != null && expectedPrice > 0) {
|
|
724
|
+
this.slippageTracker.recordFill({
|
|
725
|
+
orderId: order.id,
|
|
726
|
+
clientOrderId,
|
|
727
|
+
symbol,
|
|
728
|
+
side,
|
|
729
|
+
expectedPrice,
|
|
730
|
+
actualPrice: order.average,
|
|
731
|
+
amount: order.filled,
|
|
732
|
+
cost: order.cost,
|
|
733
|
+
fee: order.fee?.cost ?? 0,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
// Start polling for limit orders that are still open
|
|
737
|
+
if (order.status === 'open') {
|
|
738
|
+
this.orderPoller.startPolling(order.id, symbol);
|
|
739
|
+
}
|
|
740
|
+
// ---- Per-symbol metadata store (Component C: MFE tracking) ----
|
|
741
|
+
// Capture entry context so getPositions can decorate Binance's position
|
|
742
|
+
// snapshot with thesis/setup + freeze the at-fill stop into
|
|
743
|
+
// originalStopPrice for R-multiple calculations. Only seed on what looks
|
|
744
|
+
// like a NEW position (no existing entry) — scale-ins preserve the
|
|
745
|
+
// original entry's frozen stop.
|
|
746
|
+
if (metadata && !this.liveMetadata.has(symbol)) {
|
|
747
|
+
const seeded = { ...metadata };
|
|
748
|
+
if (seeded.originalStopPrice === undefined && seeded.stopPrice !== undefined) {
|
|
749
|
+
seeded.originalStopPrice = seeded.stopPrice;
|
|
750
|
+
}
|
|
751
|
+
// mfePeakPrice is left undefined — getPositions seeds it from the first
|
|
752
|
+
// observed markPrice rather than guessing here (createOrder doesn't have
|
|
753
|
+
// a reliable fill price for limit orders).
|
|
754
|
+
this.liveMetadata.set(symbol, seeded);
|
|
755
|
+
}
|
|
756
|
+
// ---- Bracket orders ----
|
|
757
|
+
// If the feature flag is on AND the agent asked for at least one protective
|
|
758
|
+
// leg, register the bracket and (for market orders) attach immediately.
|
|
759
|
+
// Limit orders defer attach to the poller's 'filled' event handler.
|
|
760
|
+
if (this.bracketManager && this.bracketLedger && metadata && (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined)) {
|
|
761
|
+
// Defense-in-depth: the pre-submission check above already rejected sign
|
|
762
|
+
// errors against the ref price, but re-check against the actual fill
|
|
763
|
+
// (order.average) in case the fill landed across the level. Crucially, a
|
|
764
|
+
// failure HERE is post-submission: bare-throwing would abandon a live
|
|
765
|
+
// position (filled market order) or a resting entry that fills naked
|
|
766
|
+
// (open limit). So clean up BEFORE throwing.
|
|
767
|
+
const refPrice = order.average ?? order.price ?? price ?? 0;
|
|
768
|
+
if (refPrice > 0) {
|
|
769
|
+
let dirMsg = null;
|
|
770
|
+
if (metadata.stopPrice !== undefined) {
|
|
771
|
+
dirMsg = validateStopDirection(side, refPrice, metadata.stopPrice);
|
|
772
|
+
}
|
|
773
|
+
if (!dirMsg && metadata.targetPrice !== undefined) {
|
|
774
|
+
dirMsg = validateTargetDirection(side, refPrice, metadata.targetPrice);
|
|
775
|
+
}
|
|
776
|
+
if (dirMsg) {
|
|
777
|
+
if (order.status === 'closed' && order.filled > 0) {
|
|
778
|
+
logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}) — flattening the just-filled position to avoid a naked entry`);
|
|
779
|
+
// We have HARD evidence the entry filled (status=closed, filled>0).
|
|
780
|
+
// closePosition throws "No open position" when its attempt-1
|
|
781
|
+
// fetchPositions returns a *confirmed-empty* snapshot — which, in
|
|
782
|
+
// the moments right after a fill, is almost always Binance
|
|
783
|
+
// position-state lagging the order response, NOT a real flat.
|
|
784
|
+
// Accepting that single failure would fail open and leave the
|
|
785
|
+
// position naked (the exact high-risk branch this cleanup exists
|
|
786
|
+
// for). Retry across the lag window instead of trusting one snapshot.
|
|
787
|
+
let flattened = false;
|
|
788
|
+
for (let a = 1; a <= 4 && !flattened; a++) {
|
|
789
|
+
try {
|
|
790
|
+
await this.closePosition(symbol, 'bracket_attach_failed');
|
|
791
|
+
flattened = true;
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${formatError(err)}`);
|
|
795
|
+
if (a < 4)
|
|
796
|
+
await sleep(1500);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!flattened) {
|
|
800
|
+
logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, immediate operator intervention required`);
|
|
801
|
+
this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: `Naked ${symbol} after invalid bracket — manual close required` });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
else if (order.status === 'open') {
|
|
805
|
+
logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the resting entry order so it can't fill unprotected`);
|
|
806
|
+
try {
|
|
807
|
+
await this.api.cancelOrderByClientId(clientOrderId, symbol);
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
logger.error(TAG, `Cancel of resting entry after invalid bracket failed for ${symbol}: ${formatError(err)}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const bracketId = generateBracketId();
|
|
817
|
+
this.bracketManager.registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, buildBracketCid(bracketId, 'entry'));
|
|
818
|
+
if (order.status === 'closed' && order.filled > 0) {
|
|
819
|
+
// Market order (or limit-that-filled-instantly) — attach now.
|
|
820
|
+
// We do NOT await: attach can take seconds and we don't want to block
|
|
821
|
+
// the agent's createOrder return. Auto-flatten handles failures.
|
|
822
|
+
this.attachBracketsAsync(symbol, order.filled);
|
|
823
|
+
}
|
|
824
|
+
// For status='open' limit orders, the order_event handler attaches
|
|
825
|
+
// brackets on fill.
|
|
826
|
+
}
|
|
827
|
+
logger.info(TAG, `Order confirmed: ${order.id} status=${order.status} filled=${order.filled}`);
|
|
828
|
+
return order;
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
releaseOp();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Attach brackets in the background. Auto-flattens on failure per locked
|
|
836
|
+
* decision #2. Never throws — errors are surfaced via emitted events and
|
|
837
|
+
* the subsequent flatten.
|
|
838
|
+
*/
|
|
839
|
+
async attachBracketsAsync(symbol, entryAmount) {
|
|
840
|
+
if (!this.bracketManager)
|
|
841
|
+
return;
|
|
842
|
+
try {
|
|
843
|
+
const result = await this.bracketManager.attachBrackets(symbol, entryAmount);
|
|
844
|
+
if (!result.ok) {
|
|
845
|
+
logger.error(TAG, `Bracket attach FAILED for ${symbol} after ${result.attempts} attempts: ${result.error}. Auto-flattening position.`);
|
|
846
|
+
// Best-effort emergency close. closePosition has its own retry logic.
|
|
847
|
+
try {
|
|
848
|
+
await this.closePosition(symbol, 'bracket_attach_failed');
|
|
849
|
+
}
|
|
850
|
+
catch (err) {
|
|
851
|
+
logger.error(TAG, `Auto-flatten after bracket failure ALSO failed: ${formatError(err)}`);
|
|
852
|
+
// Operator alert handled by the bracket.attach_failed event listener upstream.
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
logger.error(TAG, `attachBracketsAsync unexpected error for ${symbol}: ${formatError(err)}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* AUTHORITATIVE bracket-lifecycle handler — Binance's ALGO_UPDATE push.
|
|
862
|
+
*
|
|
863
|
+
* Why this exists (2026-05-15 RCA, doc-verified): pre-fix the plugin
|
|
864
|
+
* learned bracket state by REST-polling `audit_bracket_protection` /
|
|
865
|
+
* `fetchOpenOrders` and *guessing* — under a 429 storm that produced the
|
|
866
|
+
* audit→re-attach→cancel spiral that flushed live positions. Binance
|
|
867
|
+
* already PUSHES authoritative conditional-order lifecycle. Doc-exact
|
|
868
|
+
* status semantics (developers.binance.com → Event Algo Order Update):
|
|
869
|
+
*
|
|
870
|
+
* EXPIRED = SYSTEM cancel. The canonical trigger is "GTE_GTC
|
|
871
|
+
* conditional + position closed → Binance auto-cancels the
|
|
872
|
+
* surviving sibling". So EXPIRED on our bracket cid ⇒ the
|
|
873
|
+
* position just closed by a non-close_position path (bracket
|
|
874
|
+
* fill, manual, opposite-side). EXPECTED — not a fault.
|
|
875
|
+
* TRIGGERED / FINISHED = the bracket fired (SL/TP hit) → position closed.
|
|
876
|
+
* CANCELED = MANUAL cancel (an explicit cancelOrder from us). Post the
|
|
877
|
+
* bracket-reconciler fix we should NOT be doing this while a
|
|
878
|
+
* position is open; if we see it, it's the canary that some
|
|
879
|
+
* *other* path still strips protection — WARN loudly.
|
|
880
|
+
*
|
|
881
|
+
* For EXPIRED/TRIGGERED/FINISHED we (a) mark the ledger terminal so the
|
|
882
|
+
* attach-brackets re-attach loop STOPS chasing a bracket Binance already
|
|
883
|
+
* removed, and (b) emit the SAME `drift_detected` shape the position
|
|
884
|
+
* reconciler emits, so the existing, tested close-bypass cleanup
|
|
885
|
+
* (`onReconcilerObservedClose` in index.ts) runs immediately instead of
|
|
886
|
+
* up to 60 s later — which is what clears the stale state-store entry and
|
|
887
|
+
* stops the NEXT entry on the symbol being mis-classified as a scale-in
|
|
888
|
+
* (the bug that left fresh positions naked). No auto-flatten here: that
|
|
889
|
+
* stays deferred until this signal has soaked.
|
|
890
|
+
*/
|
|
891
|
+
onAlgoUpdate(ev) {
|
|
892
|
+
const cid = ev.clientAlgoId ?? '';
|
|
893
|
+
const parsed = parseBracketCid(cid);
|
|
894
|
+
if (!parsed)
|
|
895
|
+
return; // not one of our rc-*-{s,t} bracket legs
|
|
896
|
+
const symbol = normalizeBracketSymbol(ev.symbol);
|
|
897
|
+
const row = this.bracketLedger?.getBySymbol(symbol);
|
|
898
|
+
const status = ev.status.toUpperCase();
|
|
899
|
+
// Record the authoritative per-leg WS status for the Tier-1 fast-path of
|
|
900
|
+
// resolveBracketLegLiveness(). Every parsed rc-* lifecycle event, every
|
|
901
|
+
// branch — including CANCELED, which the ledger deliberately does NOT
|
|
902
|
+
// terminalize. Re-setting an existing cid keeps its insertion position
|
|
903
|
+
// (Map semantics) so the cap evicts genuinely-old legs, not active ones.
|
|
904
|
+
this.wsAlgoStatusByCid.set(cid, { status, ts: Date.now(), symbol });
|
|
905
|
+
if (this.wsAlgoStatusByCid.size > LiveAdapter.WS_ALGO_STATUS_CAP) {
|
|
906
|
+
const oldest = this.wsAlgoStatusByCid.keys().next().value;
|
|
907
|
+
if (oldest !== undefined)
|
|
908
|
+
this.wsAlgoStatusByCid.delete(oldest);
|
|
909
|
+
}
|
|
910
|
+
const emitSyntheticClose = (reasonTag) => {
|
|
911
|
+
// Pre-close size from the reconciler's last poll snapshot — the position
|
|
912
|
+
// only just closed, so its last-known contracts are still cached there.
|
|
913
|
+
// The close-bypass cleanup (onReconcilerObservedClose) needs a non-zero
|
|
914
|
+
// size to post the synthetic close that flips webapp positions.status to
|
|
915
|
+
// 'closed'. A hardcoded 0 here made every bracket-fill close SKIP that DB
|
|
916
|
+
// update (fillSize>0 guard) while still wiping the state-store — leaking
|
|
917
|
+
// the position as status='open' forever. The reduce-only-fill close
|
|
918
|
+
// handler (ws-ingest → onWsFillObserved) is the primary, exact-PnL closer;
|
|
919
|
+
// this stays the backstop for a missed WS fill.
|
|
920
|
+
let lastContracts = 0;
|
|
921
|
+
try {
|
|
922
|
+
const lastKnown = this.reconciler
|
|
923
|
+
.getLastExchangePositions()
|
|
924
|
+
.find((p) => normalizeBracketSymbol(p.symbol) === symbol);
|
|
925
|
+
if (lastKnown && Number.isFinite(lastKnown.contracts)) {
|
|
926
|
+
lastContracts = Math.abs(lastKnown.contracts);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch {
|
|
930
|
+
/* best-effort — fall back to 0 (state-store still gets cleaned) */
|
|
931
|
+
}
|
|
932
|
+
// Mirror PositionReconciler's ReconciliationResult shape so the
|
|
933
|
+
// existing index.ts `drift_detected` → onReconcilerObservedClose
|
|
934
|
+
// wiring fires without new plumbing.
|
|
935
|
+
this.emit('drift_detected', {
|
|
936
|
+
timestamp: new Date().toISOString(),
|
|
937
|
+
hasDrift: true,
|
|
938
|
+
exchangePositions: [],
|
|
939
|
+
exchangeBalance: null,
|
|
940
|
+
exchangeOpenOrders: [],
|
|
941
|
+
drifts: [{
|
|
942
|
+
symbol,
|
|
943
|
+
localSide: row?.entrySide ?? null,
|
|
944
|
+
exchangeSide: null,
|
|
945
|
+
localContracts: lastContracts,
|
|
946
|
+
exchangeContracts: 0,
|
|
947
|
+
type: 'closed',
|
|
948
|
+
}],
|
|
949
|
+
source: `algo_update:${reasonTag}`,
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
if (status === 'EXPIRED' || status === 'TRIGGERED' || status === 'FINISHED') {
|
|
953
|
+
if (row && row.state !== 'cancelled' && row.state !== 'triggered_sl'
|
|
954
|
+
&& row.state !== 'triggered_tp' && row.state !== 'failed') {
|
|
955
|
+
const nextState = status === 'TRIGGERED' || status === 'FINISHED'
|
|
956
|
+
? (parsed.role === 'stop' ? 'triggered_sl' : 'triggered_tp')
|
|
957
|
+
: 'cancelled';
|
|
958
|
+
const closeReason = nextState === 'cancelled'
|
|
959
|
+
? 'cancelled_auto'
|
|
960
|
+
: (nextState === 'triggered_sl' ? 'triggered_sl' : 'triggered_tp');
|
|
961
|
+
try {
|
|
962
|
+
this.bracketLedger?.markState(symbol, nextState, { closeReason });
|
|
963
|
+
logger.info(TAG, `ALGO_UPDATE ${status} on ${symbol} (${cid}, role=${parsed.role}) — ` +
|
|
964
|
+
`ledger→${nextState}. Position closed by exchange/bracket; ` +
|
|
965
|
+
`firing close-bypass cleanup so the next entry isn't mis-classified as scale-in.`);
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
logger.warn(TAG, `ALGO_UPDATE ${status}: ledger markState(${symbol}) failed: ${formatError(err)}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// Always signal the close even if the ledger row was already terminal
|
|
972
|
+
// — the state-store/webapp cleanup is idempotent and we want it prompt.
|
|
973
|
+
emitSyntheticClose(status.toLowerCase());
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (status === 'CANCELED') {
|
|
977
|
+
// Doc: CANCELED = an explicit cancelOrder. Post the bracket-reconciler
|
|
978
|
+
// null-collapse fix WE should not be cancelling a live position's
|
|
979
|
+
// brackets. If the ledger still thinks this bracket is active, some
|
|
980
|
+
// path is still stripping protection — surface it loudly. We do NOT
|
|
981
|
+
// mark terminal (that would block a legit re-attach by the now-safe
|
|
982
|
+
// audit/attach path) and we do NOT auto-flatten (deferred).
|
|
983
|
+
if (row && (row.state === 'active' || row.state === 'partial' || row.state === 'attaching')) {
|
|
984
|
+
logger.warn(TAG, `ALGO_UPDATE CANCELED on ${symbol} (${cid}) while ledger=${row.state} — ` +
|
|
985
|
+
`a bracket leg was explicitly cancelled while we believe the position ` +
|
|
986
|
+
`is still protected. This should not happen post the reconciler fix; ` +
|
|
987
|
+
`investigate the cancel source (reason=${ev.reason ?? 'none'}).`);
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
logger.info(TAG, `ALGO_UPDATE CANCELED on ${symbol} (${cid}) — ledger=${row?.state ?? 'none'} (benign: cancel of an already-terminal/absent bracket).`);
|
|
991
|
+
}
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
// NEW / TRIGGERING / REJECTED — informational. REJECTED is worth a WARN
|
|
995
|
+
// (Binance refused the conditional order — e.g. margin check).
|
|
996
|
+
if (status === 'REJECTED') {
|
|
997
|
+
logger.warn(TAG, `ALGO_UPDATE REJECTED on ${symbol} (${cid}) reason=${ev.reason ?? 'none'} — bracket leg refused by Binance; position may be unprotected.`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/** Expose the bracket manager for agent-facing tools (modify_stop, etc.).
|
|
1001
|
+
* Returns null when bracket mode is 'off'. */
|
|
1002
|
+
getBracketManager() {
|
|
1003
|
+
return this.bracketManager;
|
|
1004
|
+
}
|
|
1005
|
+
/** Expose the bracket ledger for reconciliation + admin queries. */
|
|
1006
|
+
getBracketLedger() {
|
|
1007
|
+
return this.bracketLedger;
|
|
1008
|
+
}
|
|
1009
|
+
/** Is the user-data WS store currently trusted (seeded + fresh + no gap)?
|
|
1010
|
+
* The Tier-1 gate for resolveBracketLegLiveness — when false the WS
|
|
1011
|
+
* per-cid signal is NOT consulted (a stale/gapped stream that missed a
|
|
1012
|
+
* CANCELED would otherwise yield a false 'live' on a naked leg — the exact
|
|
1013
|
+
* flaw the layered design exists to prevent). Null stream (mode=off) ⇒
|
|
1014
|
+
* never trusted ⇒ always the REST authority. */
|
|
1015
|
+
isUserDataStoreTrusted() {
|
|
1016
|
+
try {
|
|
1017
|
+
return this.userDataStream?.isStoreTrusted() ?? false;
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/** Authoritative per-bracket-leg liveness — the Phase 2 disambiguator that
|
|
1024
|
+
* replaces inferring presence/absence from a broad openOrders snapshot
|
|
1025
|
+
* (the SOL/INJ spiral root: a *successful* empty /fapi/v1/openAlgoOrders
|
|
1026
|
+
* is NOT proof a leg is gone).
|
|
1027
|
+
*
|
|
1028
|
+
* Precedence (the agreed layering):
|
|
1029
|
+
* 1. WS fast-path — ONLY behind isUserDataStoreTrusted(): the real
|
|
1030
|
+
* per-cid ALGO_UPDATE status (incl. CANCELED, which the ledger does
|
|
1031
|
+
* NOT record). Zero weight, sub-second. No record for the cid ⇒ no
|
|
1032
|
+
* WS opinion ⇒ fall through (never infer from absence).
|
|
1033
|
+
* 2. REST authority — queryAlgoOrderStatus(cid): a DIRECT weight-1
|
|
1034
|
+
* question about that exact order id. Used when WS is untrusted or
|
|
1035
|
+
* silent on the cid.
|
|
1036
|
+
* 3. 'unknown' — both unavailable. The d59e51b safe floor: callers MUST
|
|
1037
|
+
* refuse destructive action and retry; NEVER treat 'unknown' as
|
|
1038
|
+
* 'terminal'/'stale'.
|
|
1039
|
+
*
|
|
1040
|
+
* 'live' = leg is on the exchange (do NOT reattach/clobber).
|
|
1041
|
+
* 'terminal' = positively confirmed gone (CANCELED/EXPIRED/REJECTED) or
|
|
1042
|
+
* fired (TRIGGERED/FINISHED) — only this licenses repair.
|
|
1043
|
+
* 'unknown' = could not get an answer — safe-wait. */
|
|
1044
|
+
async resolveBracketLegLiveness(cid) {
|
|
1045
|
+
if (!cid)
|
|
1046
|
+
return 'unknown';
|
|
1047
|
+
const classify = (status) => status === 'NEW' || status === 'WORKING' || status === 'TRIGGERING'
|
|
1048
|
+
? 'live'
|
|
1049
|
+
: 'terminal'; // CANCELED | EXPIRED | REJECTED | TRIGGERED | FINISHED
|
|
1050
|
+
// Tier 1 — trusted WS fast-path (zero weight).
|
|
1051
|
+
if (this.isUserDataStoreTrusted()) {
|
|
1052
|
+
const rec = this.wsAlgoStatusByCid.get(cid);
|
|
1053
|
+
if (rec)
|
|
1054
|
+
return classify(rec.status);
|
|
1055
|
+
// Healthy stream but no lifecycle event ever seen for this cid — do
|
|
1056
|
+
// NOT infer from absence; fall to the REST authority.
|
|
1057
|
+
}
|
|
1058
|
+
// Tier 2 — REST authority (weight 1, WS-independent).
|
|
1059
|
+
const restStatus = await this.api.queryAlgoOrderStatus(cid);
|
|
1060
|
+
if (restStatus)
|
|
1061
|
+
return classify(restStatus);
|
|
1062
|
+
// Tier 3 — no answer anywhere.
|
|
1063
|
+
return 'unknown';
|
|
1064
|
+
}
|
|
1065
|
+
/** Symbol-scoped generalization of the trusted-WS fast-path — the
|
|
1066
|
+
* authoritative action-layer guard for the 2026-05-16 INJ bracket spiral.
|
|
1067
|
+
*
|
|
1068
|
+
* `resolveBracketLegLiveness(cid)` answers about ONE cid. The spiral is:
|
|
1069
|
+
* a prior `attach_brackets` reattach placed a FRESH bracket that is live
|
|
1070
|
+
* on the exchange under a NEW cid, while the ledger row still holds the
|
|
1071
|
+
* PREVIOUS, genuinely-terminal cids. Classifying only the ledger's cids
|
|
1072
|
+
* yields a false 'stale' → clear+reattach cancels the live fresh bracket
|
|
1073
|
+
* → naked → audit unprotected → attach → spiral (prod: INJ/USDT cancelled
|
|
1074
|
+
* every ~15-40s for hours). This asks the question the ledger cids can't:
|
|
1075
|
+
* "is ANY rc-* bracket leg for this symbol live RIGHT NOW, under any cid?"
|
|
1076
|
+
*
|
|
1077
|
+
* Trust model (identical to resolveBracketLegLiveness Tier-1): the WS map
|
|
1078
|
+
* is consulted ONLY when `isUserDataStoreTrusted()` (seeded + fresh +
|
|
1079
|
+
* no-gap — the property that guarantees no missed CANCELED, so a record
|
|
1080
|
+
* reading 'live' really is live). When the store is NOT trusted this
|
|
1081
|
+
* returns `{ trusted:false, liveCids:[] }` and the caller must NOT infer
|
|
1082
|
+
* "no live leg" from it — it falls back to the per-leg REST authority and,
|
|
1083
|
+
* failing that, refuses the destructive action (the d59e51b safe floor).
|
|
1084
|
+
* A record older than WS_LIVE_LEG_MAX_AGE_MS is ignored (caller falls back
|
|
1085
|
+
* too) so a stale 'live' can never suppress a genuine repair forever. */
|
|
1086
|
+
findTrustedLiveBracketCidsForSymbol(symbol) {
|
|
1087
|
+
if (!this.isUserDataStoreTrusted())
|
|
1088
|
+
return { trusted: false, liveCids: [] };
|
|
1089
|
+
const norm = normalizeBracketSymbol(symbol);
|
|
1090
|
+
const now = Date.now();
|
|
1091
|
+
const liveCids = [];
|
|
1092
|
+
for (const [cid, rec] of this.wsAlgoStatusByCid) {
|
|
1093
|
+
if (rec.symbol !== norm)
|
|
1094
|
+
continue;
|
|
1095
|
+
if (now - rec.ts > LiveAdapter.WS_LIVE_LEG_MAX_AGE_MS)
|
|
1096
|
+
continue;
|
|
1097
|
+
if (rec.status === 'NEW' || rec.status === 'WORKING' || rec.status === 'TRIGGERING') {
|
|
1098
|
+
liveCids.push(cid);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return { trusted: true, liveCids };
|
|
1102
|
+
}
|
|
1103
|
+
/** Cancel every bracket-tagged (SL or TP) open order for a symbol. Used by
|
|
1104
|
+
* the `attach_brackets` recovery tool to clear stale bracket orders before
|
|
1105
|
+
* re-attaching a fresh pair. Does not touch non-bracket orders. Returns
|
|
1106
|
+
* the number successfully cancelled. Errors are logged + swallowed —
|
|
1107
|
+
* "already gone" is treated as success by the underlying cancel path. */
|
|
1108
|
+
async cancelSymbolBracketOrders(symbol) {
|
|
1109
|
+
const open = await this.api.fetchOpenOrders(symbol);
|
|
1110
|
+
if (open === null) {
|
|
1111
|
+
// null = order state UNKNOWN — returning 0 here would read as
|
|
1112
|
+
// "checked, no orphans" in the attach_brackets tool result. Throw
|
|
1113
|
+
// instead; the caller logs the cleanup failure and still proceeds
|
|
1114
|
+
// with the attach (protection beats orphan hygiene).
|
|
1115
|
+
throw new Error(`cancelSymbolBracketOrders(${symbol}): fetchOpenOrders returned null (order state unknown)`);
|
|
1116
|
+
}
|
|
1117
|
+
const all = open;
|
|
1118
|
+
let cancelled = 0;
|
|
1119
|
+
for (const o of all) {
|
|
1120
|
+
if (!o.clientOrderId)
|
|
1121
|
+
continue;
|
|
1122
|
+
const parsed = parseBracketCid(o.clientOrderId);
|
|
1123
|
+
if (!parsed || (parsed.role !== 'stop' && parsed.role !== 'target'))
|
|
1124
|
+
continue;
|
|
1125
|
+
try {
|
|
1126
|
+
await this.api.cancelOrderByClientId(o.clientOrderId, symbol);
|
|
1127
|
+
cancelled++;
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
logger.warn(TAG, `cancelSymbolBracketOrders: ${o.clientOrderId} failed: ${formatError(err)}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return cancelled;
|
|
1134
|
+
}
|
|
1135
|
+
async cancelOrder(orderId, symbol) {
|
|
1136
|
+
return this.api.cancelOrder(orderId, symbol);
|
|
1137
|
+
}
|
|
1138
|
+
async cancelAllOrders(symbol) {
|
|
1139
|
+
// Emergency path — NO readiness gate, 3x retry, progress emission.
|
|
1140
|
+
const openOrders = await this.api.fetchOpenOrders(symbol);
|
|
1141
|
+
if (openOrders === null) {
|
|
1142
|
+
// null = order state UNKNOWN (read failed / paced / banned) — never
|
|
1143
|
+
// report "nothing to cancel" off a failed read. Proceed into the retry
|
|
1144
|
+
// loop: api.cancelAllOrders re-fetches and throws on null, so a
|
|
1145
|
+
// transient blip recovers on retry and a persistent outage escalates
|
|
1146
|
+
// to the loud MANUAL-INTERVENTION failure instead of a silent [].
|
|
1147
|
+
logger.warn(TAG, `KILL: fetchOpenOrders returned null (order state unknown)${symbol ? ` for ${symbol}` : ''} — proceeding with cancel attempts`);
|
|
1148
|
+
}
|
|
1149
|
+
else if (openOrders.length === 0) {
|
|
1150
|
+
return [];
|
|
1151
|
+
}
|
|
1152
|
+
const countLabel = openOrders === null ? 'unknown #' : String(openOrders.length);
|
|
1153
|
+
logger.warn(TAG, `KILL: cancelling ${countLabel} orders${symbol ? ` for ${symbol}` : ''}`);
|
|
1154
|
+
this.emit('emergency_progress', { action: 'kill', status: 'in_progress', orders: openOrders?.length ?? -1 });
|
|
1155
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1156
|
+
try {
|
|
1157
|
+
await this.api.cancelAllOrders(symbol);
|
|
1158
|
+
logger.info(TAG, `KILL: ${countLabel} orders cancelled (attempt ${attempt})`);
|
|
1159
|
+
this.emit('emergency_progress', { action: 'kill', status: 'completed' });
|
|
1160
|
+
// Return empty — all orders are cancelled. Don't fabricate status on the pre-cancel snapshot
|
|
1161
|
+
// because some may have filled between the fetch and the cancel.
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
logger.warn(TAG, `KILL attempt ${attempt}/3 failed: ${formatError(err)}`);
|
|
1166
|
+
if (attempt < 3)
|
|
1167
|
+
await sleep(1000);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
// All retries exhausted
|
|
1171
|
+
const msg = 'MANUAL INTERVENTION REQUIRED — cancel orders manually on Binance';
|
|
1172
|
+
logger.error(TAG, msg);
|
|
1173
|
+
this.emit('emergency_progress', { action: 'kill', status: 'failed', message: msg });
|
|
1174
|
+
throw new Error(msg);
|
|
1175
|
+
}
|
|
1176
|
+
async closePosition(symbol, closeReason) {
|
|
1177
|
+
// Risk-reducing — allowed during shutdown, but tracked so the drain awaits
|
|
1178
|
+
// it before the process exits. Without tracking, a SIGTERM mid-close could
|
|
1179
|
+
// leave the position open on Binance with the plugin restarting and
|
|
1180
|
+
// assuming it's still tracked.
|
|
1181
|
+
const releaseOp = registerOp(`closePosition(${symbol})`);
|
|
1182
|
+
try {
|
|
1183
|
+
// Emergency path — NO readiness gate, 3x retry, reduceOnly safety.
|
|
1184
|
+
// closeReason flows onto the symbol's metadata so the next getPositions
|
|
1185
|
+
// cycle's eventual cleanup carries the right reason. The metadata entry
|
|
1186
|
+
// itself is dropped after the close completes (see end of this method).
|
|
1187
|
+
const reasonSuffix = closeReason ? ` (reason=${closeReason})` : '';
|
|
1188
|
+
logger.warn(TAG, `FLATTEN: closing position ${symbol}${reasonSuffix}`);
|
|
1189
|
+
if (closeReason) {
|
|
1190
|
+
const existing = this.liveMetadata.get(symbol);
|
|
1191
|
+
if (existing)
|
|
1192
|
+
this.liveMetadata.set(symbol, { ...existing, closeReason });
|
|
1193
|
+
}
|
|
1194
|
+
// ---- Trusted position read FIRST — BEFORE the destructive bracket cancel.
|
|
1195
|
+
// The old order (cancelBrackets, then fetchPositions) opened a naked
|
|
1196
|
+
// window: cancelling protection off a position we then couldn't confirm
|
|
1197
|
+
// (paced/failed read → throw) or that didn't exist left live exposure
|
|
1198
|
+
// with no brackets and no close order. Only a TRUSTED read that confirms
|
|
1199
|
+
// an open position licenses stripping its protection.
|
|
1200
|
+
//
|
|
1201
|
+
// CRITICAL null-vs-empty split (2026-05-15 XRP phantom-close incident).
|
|
1202
|
+
// `BinancePrivateApi.fetchPositions` returns `null` on a FAILED fetch
|
|
1203
|
+
// (429 / rate-limited / transient) and `[]` only when the exchange
|
|
1204
|
+
// CONFIRMS zero positions. Conflating them here is catastrophic: under
|
|
1205
|
+
// a rate-limit storm a still-open naked position 429s → `null` → the
|
|
1206
|
+
// old `!positions` branch either threw "No open position" (the agent
|
|
1207
|
+
// interpreted that as "position gone from exchange truth" and recorded
|
|
1208
|
+
// a phantom close, abandoning a live losing short) or, on a retry,
|
|
1209
|
+
// returned a SYNTHETIC closed-success. "Couldn't read" must NEVER be
|
|
1210
|
+
// reported as "closed". Only a real, non-null empty array means flat.
|
|
1211
|
+
// Mirrors the getOpenOrders / reconciler null-collapse rule. See
|
|
1212
|
+
// `memory/feedback_getopenorders_null_collapse_naked_loop.md`.
|
|
1213
|
+
const preCheck = await this.api.fetchPositions(symbol);
|
|
1214
|
+
if (preCheck === null) {
|
|
1215
|
+
throw new Error(`closePosition: exchange position state UNAVAILABLE for ${symbol} ` +
|
|
1216
|
+
`(fetchPositions failed — 429 / rate-limited / transient). Refusing ` +
|
|
1217
|
+
`to cancel brackets or treat this as "closed": the position may ` +
|
|
1218
|
+
`still be open and protected. Retry once exchange reads recover; ` +
|
|
1219
|
+
`do NOT record a close.`);
|
|
1220
|
+
}
|
|
1221
|
+
if (preCheck.length === 0) {
|
|
1222
|
+
// Confirmed flat (trusted empty read) — nothing to close, and
|
|
1223
|
+
// critically the brackets were NOT touched.
|
|
1224
|
+
throw new Error(`No open position for ${symbol} on exchange`);
|
|
1225
|
+
}
|
|
1226
|
+
let lastKnownContracts = Math.abs(preCheck[0].contracts ?? 0);
|
|
1227
|
+
// Cancel any attached brackets — leaving them on the exchange after
|
|
1228
|
+
// the close would book a phantom reverse trade if the trigger fires on a
|
|
1229
|
+
// re-entry. cancelBrackets is idempotent and swallows not-found errors.
|
|
1230
|
+
if (this.bracketManager) {
|
|
1231
|
+
try {
|
|
1232
|
+
await this.bracketManager.cancelBrackets(symbol, closeReason ?? 'close_position');
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
logger.warn(TAG, `cancelBrackets during closePosition failed: ${formatError(err)}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1239
|
+
// Fresh position snapshot each attempt (position may have partially closed)
|
|
1240
|
+
const positions = await this.api.fetchPositions(symbol);
|
|
1241
|
+
// Same null-vs-empty split as the pre-check above.
|
|
1242
|
+
if (positions === null) {
|
|
1243
|
+
throw new Error(`closePosition: exchange position state UNAVAILABLE for ${symbol} ` +
|
|
1244
|
+
`(fetchPositions failed — 429 / rate-limited / transient). Refusing ` +
|
|
1245
|
+
`to treat this as "closed": the position may still be open. Retry ` +
|
|
1246
|
+
`once exchange reads recover; do NOT record a close.`);
|
|
1247
|
+
}
|
|
1248
|
+
if (positions.length === 0) {
|
|
1249
|
+
// The pre-check CONFIRMED an open position, so a trusted empty read
|
|
1250
|
+
// now means it closed while we were working — a prior attempt's
|
|
1251
|
+
// market order landed, a bracket fired in the race window, or an
|
|
1252
|
+
// external close. Success either way (confirmed empty, not a failed read).
|
|
1253
|
+
logger.info(TAG, `FLATTEN: ${symbol} confirmed flat on attempt ${attempt}`);
|
|
1254
|
+
this.emit('emergency_progress', { action: 'flatten', status: 'completed', symbol });
|
|
1255
|
+
// Return a synthetic closed order
|
|
1256
|
+
return { id: 'flatten-done', symbol, side: 'sell', type: 'market', status: 'closed', amount: 0, filled: 0, remaining: 0, average: null, price: null, cost: 0, fee: { cost: 0, currency: 'USDT' }, timestamp: Date.now(), datetime: new Date().toISOString(), timeInForce: 'GTC' };
|
|
1257
|
+
}
|
|
1258
|
+
const position = positions[0];
|
|
1259
|
+
lastKnownContracts = Math.abs(position.contracts ?? 0);
|
|
1260
|
+
const closeSide = position.side === 'long' ? 'sell' : 'buy';
|
|
1261
|
+
// Build CCXT params with reduceOnly/positionSide for close safety
|
|
1262
|
+
const clientOrderId = generateClientOrderId();
|
|
1263
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1264
|
+
const ccxtParams = { newClientOrderId: clientOrderId };
|
|
1265
|
+
if (this.isHedgeMode) {
|
|
1266
|
+
ccxtParams.positionSide = position.side === 'long' ? 'LONG' : 'SHORT';
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
ccxtParams.reduceOnly = true;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
this.emit('emergency_progress', { action: 'flatten', status: 'in_progress', symbol, attempt });
|
|
1273
|
+
const order = await this.api.createOrder(symbol, closeSide, 'market', position.contracts, undefined, ccxtParams);
|
|
1274
|
+
logger.info(TAG, `FLATTEN: ${symbol} close order ${order.id} status=${order.status}`);
|
|
1275
|
+
this.emit('emergency_progress', { action: 'flatten', status: 'completed', symbol });
|
|
1276
|
+
return order;
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
logger.warn(TAG, `FLATTEN ${symbol} attempt ${attempt}/3 failed: ${formatError(err)}`);
|
|
1280
|
+
if (attempt < 3)
|
|
1281
|
+
await sleep(1000);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
// Every close attempt failed AND the brackets were cancelled above — the
|
|
1285
|
+
// position is live and UNPROTECTED. Best-effort re-protect from the
|
|
1286
|
+
// ledger's preserved levels before escalating (cancelBrackets marks the
|
|
1287
|
+
// row terminal but keeps stopPrice/targetPrice/entrySide).
|
|
1288
|
+
if (this.bracketManager) {
|
|
1289
|
+
await this.reattachBracketsAfterFailedClose(symbol, lastKnownContracts);
|
|
1290
|
+
}
|
|
1291
|
+
const msg = `MANUAL INTERVENTION REQUIRED — close ${symbol} manually on Binance`;
|
|
1292
|
+
logger.error(TAG, msg);
|
|
1293
|
+
this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: msg });
|
|
1294
|
+
throw new Error(msg);
|
|
1295
|
+
}
|
|
1296
|
+
finally {
|
|
1297
|
+
releaseOp();
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/** Re-arm protection on a position whose closePosition flow cancelled the
|
|
1301
|
+
* brackets and then failed every market-close attempt. Reuses the normal
|
|
1302
|
+
* attach machinery: re-register the ledger row (terminal row → fresh
|
|
1303
|
+
* pending_entry with the SAME stop/target levels) and run the standard
|
|
1304
|
+
* 3-retry attach. Never throws — this runs on the already-failing close
|
|
1305
|
+
* path; on any failure it emits the reconciler's `position_unprotected`
|
|
1306
|
+
* drift shape (re-emitted upstream as `bracket_drift`) plus a loud log so
|
|
1307
|
+
* the operator knows re-protection is required. */
|
|
1308
|
+
async reattachBracketsAfterFailedClose(symbol, sizeHint) {
|
|
1309
|
+
const emitUnprotected = (details) => {
|
|
1310
|
+
logger.error(TAG, `UNPROTECTED POSITION after failed close: ${symbol} — brackets were cancelled before the ` +
|
|
1311
|
+
`close attempts and could not be re-attached (${details}). Manual re-protection required ` +
|
|
1312
|
+
`(attach_brackets or close on Binance).`);
|
|
1313
|
+
this.emit('bracket_drift', {
|
|
1314
|
+
kind: 'position_unprotected',
|
|
1315
|
+
symbol: normalizeBracketSymbol(symbol),
|
|
1316
|
+
details: `close failed after bracket cancel; re-attach ${details}`,
|
|
1317
|
+
ts: new Date().toISOString(),
|
|
1318
|
+
});
|
|
1319
|
+
};
|
|
1320
|
+
if (!this.bracketManager || !this.bracketLedger)
|
|
1321
|
+
return;
|
|
1322
|
+
try {
|
|
1323
|
+
const row = this.bracketLedger.getBySymbol(normalizeBracketSymbol(symbol));
|
|
1324
|
+
if (!row || (row.stopPrice === undefined && row.targetPrice === undefined)) {
|
|
1325
|
+
// No preserved levels — either the position never had brackets (no
|
|
1326
|
+
// protection was stripped) or the row is gone. Nothing to restore.
|
|
1327
|
+
if (row)
|
|
1328
|
+
emitUnprotected('skipped: ledger row has no stop/target levels');
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const size = sizeHint > 0 ? sizeHint : row.qty ?? 0;
|
|
1332
|
+
if (!(size > 0)) {
|
|
1333
|
+
emitUnprotected('skipped: no valid position size to attach at');
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const bracketId = generateBracketId();
|
|
1337
|
+
// registerEntry removes the terminal row left by cancelBrackets and
|
|
1338
|
+
// creates a fresh pending_entry carrying the same levels.
|
|
1339
|
+
this.bracketManager.registerEntry({ symbol: row.symbol, side: row.entrySide, stopPrice: row.stopPrice, targetPrice: row.targetPrice }, bracketId, buildBracketCid(bracketId, 'entry'));
|
|
1340
|
+
const result = await this.bracketManager.attachBrackets(row.symbol, size);
|
|
1341
|
+
if (result.ok) {
|
|
1342
|
+
logger.warn(TAG, `closePosition(${symbol}) failed but protection was RE-ATTACHED ` +
|
|
1343
|
+
`(sl=${row.stopPrice ?? '-'} tp=${row.targetPrice ?? '-'} size=${size}) — position is protected, not closed`);
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
emitUnprotected(`failed after ${result.attempts} attempts: ${result.error ?? 'unknown'}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
catch (err) {
|
|
1350
|
+
emitUnprotected(`threw: ${formatError(err)}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async getBalance() {
|
|
1354
|
+
const balance = await this.api.fetchBalance();
|
|
1355
|
+
this.syncRateLimits();
|
|
1356
|
+
this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchBalance ?? 5);
|
|
1357
|
+
if (!balance) {
|
|
1358
|
+
return { free: {}, used: {}, total: {} };
|
|
1359
|
+
}
|
|
1360
|
+
// Binance USDⓈ-M quirk: `availableBalance` is only populated for the primary
|
|
1361
|
+
// margin asset (USDT). Secondary collaterals like USDC come back with
|
|
1362
|
+
// total > 0 but free = 0 and used = 0 — internally inconsistent. If nothing
|
|
1363
|
+
// is used, everything is free by definition, so backfill free = total.
|
|
1364
|
+
repairFreeFromTotal(balance);
|
|
1365
|
+
const bnfcrWallet = (() => {
|
|
1366
|
+
const assets = balance?.info?.assets;
|
|
1367
|
+
if (!Array.isArray(assets))
|
|
1368
|
+
return undefined;
|
|
1369
|
+
const a = assets.find(x => x?.asset === 'BNFCR');
|
|
1370
|
+
return a == null ? undefined : parseFloat(String(a.walletBalance ?? 0));
|
|
1371
|
+
})();
|
|
1372
|
+
logger.info(TAG, `Raw balance: USDT=${JSON.stringify(balance.total.USDT ?? 0)}, USDC=${JSON.stringify(balance.total.USDC ?? 0)}, BNFCR.wallet=${bnfcrWallet ?? 'n/a'}, free.USDT=${balance.free.USDT ?? 0}, free.USDC=${balance.free.USDC ?? 0}`);
|
|
1373
|
+
// Detect capital inflows/outflows (spot↔futures transfers, deposits,
|
|
1374
|
+
// withdrawals) and re-anchor sessionStartNav so they don't masquerade
|
|
1375
|
+
// as trading P&L. Fire-and-forget the first time (delta resolves on the
|
|
1376
|
+
// next cycle) so an income-endpoint hiccup never blocks a balance fetch.
|
|
1377
|
+
const transferDelta = await this.depositTracker.refresh();
|
|
1378
|
+
if (transferDelta !== 0) {
|
|
1379
|
+
const prev = this.enricher.getSessionStartNav();
|
|
1380
|
+
const next = prev + transferDelta;
|
|
1381
|
+
this.enricher.setSessionStartNav(next);
|
|
1382
|
+
this._sessionStartNav = next;
|
|
1383
|
+
logger.info(TAG, `Session NAV re-anchored for capital flow: ${prev.toFixed(4)} → ${next.toFixed(4)} (delta=${transferDelta >= 0 ? '+' : ''}${transferDelta.toFixed(4)})`);
|
|
1384
|
+
}
|
|
1385
|
+
// Re-anchor today's realized seed + sessionStartNav from Binance's
|
|
1386
|
+
// income endpoint. The anchor is invariant within a UTC day (see
|
|
1387
|
+
// INCOME_REFRESH_MIN_INTERVAL_MS doc), so this is heavily throttled and
|
|
1388
|
+
// exists only as a long safety reconcile against BNFCR/transfer types
|
|
1389
|
+
// DepositTracker can't see. UTC-midnight rollover forces a refresh
|
|
1390
|
+
// (throttle bypassed) to re-anchor the new day.
|
|
1391
|
+
await this.refreshIncomeAnchor(getQuoteWalletBalance(balance));
|
|
1392
|
+
// Enrich with session PnL + equity using cached positions (no extra API call)
|
|
1393
|
+
const cachedPositions = this.reconciler.getLastExchangePositions();
|
|
1394
|
+
return this.enricher.enrich(balance, cachedPositions);
|
|
1395
|
+
}
|
|
1396
|
+
/** Re-anchor the realized-today seed + sessionStartNav from /fapi/v1/income.
|
|
1397
|
+
* Throttled to {@link INCOME_REFRESH_MIN_INTERVAL_MS}; UTC date rollover
|
|
1398
|
+
* forces an immediate refresh. Best-effort — failures are logged and
|
|
1399
|
+
* swallowed so a transient income-endpoint blip never blocks a balance
|
|
1400
|
+
* read. Called from getBalance() with the fresh quote wallet so we don't
|
|
1401
|
+
* fetch balance twice.
|
|
1402
|
+
*/
|
|
1403
|
+
async refreshIncomeAnchor(quoteBalance) {
|
|
1404
|
+
const now = Date.now();
|
|
1405
|
+
const today = utcDateString(now);
|
|
1406
|
+
const dayRolled = today !== this.incomeAnchorUtcDay;
|
|
1407
|
+
const noAnchorYet = this._sessionStartNav == null;
|
|
1408
|
+
// Throttle bypassed while no anchor exists yet (boot fetch failed) so we
|
|
1409
|
+
// retry promptly until the baseline is established.
|
|
1410
|
+
if (!dayRolled && !noAnchorYet && now - this.lastIncomeRefreshMs < INCOME_REFRESH_MIN_INTERVAL_MS)
|
|
1411
|
+
return;
|
|
1412
|
+
// forced (never-paced) ONLY for the anchors that actually change the
|
|
1413
|
+
// value: a UTC-day rollover (new wallet_at_midnight) and the bootstrap
|
|
1414
|
+
// case (no anchor yet). Routine same-day refreshes stay sheddable — the
|
|
1415
|
+
// anchor is invariant within a UTC day, so a paced miss is a no-op
|
|
1416
|
+
// (we keep the last good value), which is the whole point.
|
|
1417
|
+
const forced = dayRolled || noAnchorYet;
|
|
1418
|
+
const utcMidnight = utcMidnightMs(now);
|
|
1419
|
+
const breakdown = await this.api.fetchTodayIncomeBreakdown(utcMidnight, { forced });
|
|
1420
|
+
if (breakdown == null) {
|
|
1421
|
+
// null≠empty: paced/failed income fetch. sessionStartNav =
|
|
1422
|
+
// wallet_at_midnight is INVARIANT within a UTC day, so keeping the
|
|
1423
|
+
// last good anchor is exactly correct — NEVER coerce to {0,0} (that
|
|
1424
|
+
// pins it to wallet_now and zeroes Day P&L — 2026-05-16 KPI
|
|
1425
|
+
// incident). Routine same-day miss with an anchor already set: arm
|
|
1426
|
+
// the normal cadence (the value can't have changed). Rollover /
|
|
1427
|
+
// bootstrap miss: leave the throttle unarmed so the next getBalance
|
|
1428
|
+
// cycle retries (forced → lands once not under a real ban).
|
|
1429
|
+
if (!dayRolled && !noAnchorYet)
|
|
1430
|
+
this.lastIncomeRefreshMs = now;
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
this.lastIncomeRefreshMs = now;
|
|
1434
|
+
this.incomeAnchorUtcDay = today;
|
|
1435
|
+
// Realized KPI = netNonTransfer (REALIZED_PNL + fees + funding) — matches
|
|
1436
|
+
// Binance's "Today's Realized PNL" UI label. See seedRealizedPnlToday
|
|
1437
|
+
// comment block above for the 2026-05-14 verification against the
|
|
1438
|
+
// Binance mobile app.
|
|
1439
|
+
this.enricher.seedRealizedPnlToday(breakdown.netNonTransfer, today);
|
|
1440
|
+
// sessionStartNav = wallet_now − netNonTransfer = wallet_at_midnight.
|
|
1441
|
+
// This is invariant under non-transfer activity so it self-corrects on
|
|
1442
|
+
// every refresh, including BNFCR transfers that DepositTracker can't
|
|
1443
|
+
// see (TRANSFER incomeType is excluded from netNonTransfer).
|
|
1444
|
+
const navAtMidnight = quoteBalance - breakdown.netNonTransfer;
|
|
1445
|
+
this.enricher.setSessionStartNav(navAtMidnight);
|
|
1446
|
+
this._sessionStartNav = navAtMidnight;
|
|
1447
|
+
if (dayRolled) {
|
|
1448
|
+
logger.info(TAG, `UTC day rolled to ${today}; income anchor reset (netNonTransfer=${breakdown.netNonTransfer.toFixed(4)}, sessionStartNav=${navAtMidnight.toFixed(4)})`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async getPositions(symbol) {
|
|
1452
|
+
// Display/KPI contract: a failed REST fetch collapses to []. 20+
|
|
1453
|
+
// display/KPI callers depend on this (KPI-must-equal-Binance). Decision
|
|
1454
|
+
// paths must call getPositionsOrNull() instead. Single code path.
|
|
1455
|
+
return (await this.getPositionsOrNull(symbol)) ?? [];
|
|
1456
|
+
}
|
|
1457
|
+
async getPositionsOrNull(symbol) {
|
|
1458
|
+
if (this.userDataStream &&
|
|
1459
|
+
userDataStreamAuthoritative(this.userDataStreamMode) &&
|
|
1460
|
+
this.userDataStream.isStoreTrusted()) {
|
|
1461
|
+
const positions = this.userDataStream.getStore().getPositions(symbol);
|
|
1462
|
+
// Store is trusted (seeded + fresh) — its state is authoritative and
|
|
1463
|
+
// never "unknown", so this branch never returns null.
|
|
1464
|
+
return this.decoratePositionsWithMetadata(positions, symbol);
|
|
1465
|
+
}
|
|
1466
|
+
// Store not trusted: either not yet seeded by a trusted snapshot
|
|
1467
|
+
// (startup / post-reset) OR stale beyond the dead-socket watchdog
|
|
1468
|
+
// window (extended disconnect / failed-resync). Serving its state here
|
|
1469
|
+
// would be the null≠empty / stale-as-truth collapse on the
|
|
1470
|
+
// authoritative read path — fall through to the REST read instead,
|
|
1471
|
+
// which carries real exchange state (or surfaces the failure honestly
|
|
1472
|
+
// as null below). See isStoreTrusted().
|
|
1473
|
+
const positions = await this.api.fetchPositions(symbol);
|
|
1474
|
+
this.syncRateLimits();
|
|
1475
|
+
this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchPositions ?? 5);
|
|
1476
|
+
// null = fetch FAILED (429 / weight-paced / transient) → state UNKNOWN.
|
|
1477
|
+
// Propagate it; callers on decision paths must not treat it as "flat".
|
|
1478
|
+
if (positions === null)
|
|
1479
|
+
return null;
|
|
1480
|
+
return this.decoratePositionsWithMetadata(positions, symbol);
|
|
1481
|
+
}
|
|
1482
|
+
/** Merge agent-supplied entry metadata + ratchet MFE state into the raw
|
|
1483
|
+
* CCXT position list. Side effect: drops metadata for symbols no longer
|
|
1484
|
+
* in the open-positions snapshot (only when called without a symbol filter
|
|
1485
|
+
* — symbol-scoped calls skip cleanup so we don't lose context for other
|
|
1486
|
+
* open positions). */
|
|
1487
|
+
decoratePositionsWithMetadata(positions, symbolFilter) {
|
|
1488
|
+
const out = [];
|
|
1489
|
+
const openSymbols = new Set();
|
|
1490
|
+
// Helper: read live bracket state for a symbol. Only active/partial/attaching
|
|
1491
|
+
// brackets surface — terminal states leave the row in the ledger for the
|
|
1492
|
+
// reconciler but mean "no live protection on exchange right now".
|
|
1493
|
+
const lookupBracket = (symbol) => {
|
|
1494
|
+
if (!this.bracketLedger)
|
|
1495
|
+
return undefined;
|
|
1496
|
+
const row = this.bracketLedger.getBySymbol(symbol);
|
|
1497
|
+
if (!row)
|
|
1498
|
+
return undefined;
|
|
1499
|
+
if (row.state !== 'active' && row.state !== 'partial' && row.state !== 'attaching') {
|
|
1500
|
+
return undefined;
|
|
1501
|
+
}
|
|
1502
|
+
return { slPrice: row.stopPrice, tpPrice: row.targetPrice, state: row.state };
|
|
1503
|
+
};
|
|
1504
|
+
for (const p of positions) {
|
|
1505
|
+
openSymbols.add(p.symbol);
|
|
1506
|
+
const meta = this.liveMetadata.get(p.symbol);
|
|
1507
|
+
if (!meta) {
|
|
1508
|
+
// Position observed without agent metadata (e.g. opened externally via
|
|
1509
|
+
// Binance UI). Still surface the bracket — operator should see live
|
|
1510
|
+
// protection levels regardless of how the position was opened.
|
|
1511
|
+
out.push({ ...p, bracket: lookupBracket(p.symbol) });
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
// Lazy seed of MFE peak — first observation uses entryPrice (matches the
|
|
1515
|
+
// paper-side seedMfeMetadata behaviour). originalStopPrice is set at
|
|
1516
|
+
// createOrder time when stopPrice was known then, OR opportunistically
|
|
1517
|
+
// here from current stopPrice if the agent supplied one post-entry via
|
|
1518
|
+
// modify_stop. Whichever path sets it first wins (frozen-at-first-known).
|
|
1519
|
+
const freshMeta = { ...meta };
|
|
1520
|
+
if (freshMeta.originalStopPrice === undefined && freshMeta.stopPrice !== undefined) {
|
|
1521
|
+
freshMeta.originalStopPrice = freshMeta.stopPrice;
|
|
1522
|
+
}
|
|
1523
|
+
const out2 = updateMfe({
|
|
1524
|
+
side: p.side,
|
|
1525
|
+
entryPrice: p.entryPrice,
|
|
1526
|
+
originalStopPrice: freshMeta.originalStopPrice,
|
|
1527
|
+
markPrice: p.markPrice,
|
|
1528
|
+
priorPeakPrice: freshMeta.mfePeakPrice ?? p.entryPrice,
|
|
1529
|
+
});
|
|
1530
|
+
freshMeta.mfePeakPrice = out2.mfePeakPrice;
|
|
1531
|
+
freshMeta.mfeR = out2.mfeR;
|
|
1532
|
+
freshMeta.giveBackRatio = out2.giveBackRatio;
|
|
1533
|
+
this.liveMetadata.set(p.symbol, freshMeta);
|
|
1534
|
+
out.push({
|
|
1535
|
+
...p,
|
|
1536
|
+
setupType: p.setupType ?? freshMeta.setupType,
|
|
1537
|
+
thesis: p.thesis ?? freshMeta.thesis,
|
|
1538
|
+
stopPrice: p.stopPrice ?? freshMeta.stopPrice,
|
|
1539
|
+
targetPrice: p.targetPrice ?? freshMeta.targetPrice,
|
|
1540
|
+
regime: p.regime ?? freshMeta.regime,
|
|
1541
|
+
regimeConfidence: p.regimeConfidence ?? freshMeta.regimeConfidence,
|
|
1542
|
+
scorecardVerdict: p.scorecardVerdict ?? freshMeta.scorecardVerdict,
|
|
1543
|
+
confluenceScore: p.confluenceScore ?? freshMeta.confluenceScore,
|
|
1544
|
+
originalStopPrice: freshMeta.originalStopPrice,
|
|
1545
|
+
mfePeakPrice: freshMeta.mfePeakPrice,
|
|
1546
|
+
mfeR: freshMeta.mfeR,
|
|
1547
|
+
giveBackRatio: freshMeta.giveBackRatio,
|
|
1548
|
+
invalidationPrice: freshMeta.invalidationPrice,
|
|
1549
|
+
realizationRule: freshMeta.realizationRule,
|
|
1550
|
+
invalidationHit: computeInvalidationHit(p.side, p.markPrice, freshMeta.invalidationPrice),
|
|
1551
|
+
bracket: lookupBracket(p.symbol),
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
// Cleanup: drop metadata for any symbol that no longer has an open
|
|
1555
|
+
// position. Skip when caller supplied a symbol filter — the snapshot is
|
|
1556
|
+
// partial and would falsely evict every other symbol.
|
|
1557
|
+
if (symbolFilter === undefined) {
|
|
1558
|
+
for (const sym of this.liveMetadata.keys()) {
|
|
1559
|
+
if (!openSymbols.has(sym))
|
|
1560
|
+
this.liveMetadata.delete(sym);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return out;
|
|
1564
|
+
}
|
|
1565
|
+
async getOpenOrders(symbol) {
|
|
1566
|
+
if (this.userDataStream &&
|
|
1567
|
+
userDataStreamAuthoritative(this.userDataStreamMode) &&
|
|
1568
|
+
this.userDataStream.isStoreTrusted()) {
|
|
1569
|
+
return this.userDataStream.getStore().getOpenOrders(symbol);
|
|
1570
|
+
}
|
|
1571
|
+
// Store not trusted (unseeded OR stale beyond the watchdog window) —
|
|
1572
|
+
// do NOT serve its order set as authoritative (a phantom-naked or
|
|
1573
|
+
// stale verdict would re-attach / cancel against real protective
|
|
1574
|
+
// orders). See isStoreTrusted(). Fall through to the REST path
|
|
1575
|
+
// below, which throws on a failed/rate-limited fetch rather than
|
|
1576
|
+
// collapsing "unknown" to "no orders".
|
|
1577
|
+
// Sticky rate-limit cooldown: while armed, fail fast WITHOUT a Binance
|
|
1578
|
+
// call so a tight agent retry loop can't pin the IP weight budget and
|
|
1579
|
+
// keep the whole agent blind. Same null/throw contract as below — every
|
|
1580
|
+
// existing caller already handles it safely.
|
|
1581
|
+
const nowMs = Date.now();
|
|
1582
|
+
if (nowMs < this.getOpenOrdersUnavailableUntil) {
|
|
1583
|
+
const waitS = Math.ceil((this.getOpenOrdersUnavailableUntil - nowMs) / 1000);
|
|
1584
|
+
throw new Error(`getOpenOrders: exchange rate-limited — sticky cooldown active (~${waitS}s left, ` +
|
|
1585
|
+
'NO Binance call made so the weight window can drain). Caller must NOT proceed ' +
|
|
1586
|
+
'as if the exchange has zero orders.');
|
|
1587
|
+
}
|
|
1588
|
+
const orders = await this.api.fetchOpenOrders(symbol);
|
|
1589
|
+
this.syncRateLimits();
|
|
1590
|
+
this.rateLimiter.recordQuery(symbol ? (ENDPOINT_WEIGHTS.fetchOpenOrders ?? 1) : (ENDPOINT_WEIGHTS.fetchOpenOrdersAll ?? 40));
|
|
1591
|
+
// Critical: `BinancePrivateApi.fetchOpenOrders` returns `null` on fetch
|
|
1592
|
+
// error (429 / transient network). Collapsing that to `[]` here destroys
|
|
1593
|
+
// the "unknown" signal — downstream consumers (audit-bracket-protection,
|
|
1594
|
+
// attach-brackets verifyLedgerStale, bracket reconciler) then treat
|
|
1595
|
+
// "fetch failed" as "exchange truly has zero orders" and trigger
|
|
1596
|
+
// re-attach / cancel paths against real protective orders that ARE on
|
|
1597
|
+
// the exchange. This caused the 2026-05-14 ATOM naked-bracket loop:
|
|
1598
|
+
// ~17 cycles of spurious re-attach while the agent's bracket-cid
|
|
1599
|
+
// protective orders kept getting clobbered. Throwing here turns the
|
|
1600
|
+
// unknown into an error the existing try/catches in callers handle
|
|
1601
|
+
// safely (audit returns error to agent, verifyLedgerStale returns
|
|
1602
|
+
// `false` = "not stale, can't tell", bracket-reconciler aborts the
|
|
1603
|
+
// cycle). See `memory/feedback_getopenorders_null_collapse_naked_loop.md`.
|
|
1604
|
+
if (orders === null) {
|
|
1605
|
+
// Arm the sticky cooldown so the next ~45 s of retries cost zero
|
|
1606
|
+
// Binance weight and the budget can actually drain.
|
|
1607
|
+
this.getOpenOrdersUnavailableUntil =
|
|
1608
|
+
Date.now() + LiveAdapter.OPEN_ORDERS_RL_COOLDOWN_MS;
|
|
1609
|
+
throw new Error('getOpenOrders: fetchOpenOrders returned null (likely 429 / transient network). ' +
|
|
1610
|
+
'Caller must NOT proceed as if the exchange has zero orders — that path causes ' +
|
|
1611
|
+
'phantom-naked re-attach loops that clobber real protective orders. ' +
|
|
1612
|
+
`Sticky cooldown armed ${LiveAdapter.OPEN_ORDERS_RL_COOLDOWN_MS / 1000}s.`);
|
|
1613
|
+
}
|
|
1614
|
+
// Clean read — clear any cooldown so recovery resumes immediately.
|
|
1615
|
+
this.getOpenOrdersUnavailableUntil = 0;
|
|
1616
|
+
return orders;
|
|
1617
|
+
}
|
|
1618
|
+
async fetchOrder(orderId, symbol) {
|
|
1619
|
+
return this.api.fetchOrder(orderId, symbol);
|
|
1620
|
+
}
|
|
1621
|
+
async getLastPrice(symbol) {
|
|
1622
|
+
const ticker = await this.api.fetchTicker(symbol);
|
|
1623
|
+
return ticker?.last ?? null;
|
|
1624
|
+
}
|
|
1625
|
+
// ---- Internal helpers ----
|
|
1626
|
+
/** Sync rate limiter from exchange response headers after every private API call. */
|
|
1627
|
+
syncRateLimits() {
|
|
1628
|
+
try {
|
|
1629
|
+
const headers = this.api.getLastResponseHeaders();
|
|
1630
|
+
if (Object.keys(headers).length > 0) {
|
|
1631
|
+
this.rateLimiter.syncFromHeaders(headers);
|
|
1632
|
+
// Reconcile the process-wide weight pacer against Binance truth.
|
|
1633
|
+
noteResponseWeight(headers);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
// Best-effort — don't crash on header sync failure
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
async loadExchangeInfo() {
|
|
1641
|
+
const exchange = this.api.getExchange();
|
|
1642
|
+
await this.exchangeInfo.load(exchange);
|
|
1643
|
+
}
|
|
1644
|
+
async detectPositionMode() {
|
|
1645
|
+
try {
|
|
1646
|
+
// GET /fapi/v1/positionSide/dual is weight 30 (doc-verified). Route it
|
|
1647
|
+
// through the shared gate like every other Binance read so it can't
|
|
1648
|
+
// pile onto a startup storm or extend an active ban.
|
|
1649
|
+
assertNotBanned('fetchPositionMode');
|
|
1650
|
+
const exchange = this.api.getExchange();
|
|
1651
|
+
const result = await exchange.fapiPrivateGetPositionSideDual();
|
|
1652
|
+
noteSuccess();
|
|
1653
|
+
this.isHedgeMode = result?.dualSidePosition === true;
|
|
1654
|
+
logger.info(TAG, `Account position mode: ${this.isHedgeMode ? 'HEDGE' : 'ONE-WAY'}`);
|
|
1655
|
+
}
|
|
1656
|
+
catch (err) {
|
|
1657
|
+
noteBinanceError(err);
|
|
1658
|
+
logger.warn(TAG, `Position mode detection failed (assuming one-way): ${formatError(err)}`);
|
|
1659
|
+
this.isHedgeMode = false;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
function sleep(ms) {
|
|
1664
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1665
|
+
}
|