@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,579 @@
|
|
|
1
|
+
// UserDataStreamController — owns the WS + store + snapshot lifecycle
|
|
2
|
+
// behind the `userDataStream.mode` feature flag.
|
|
3
|
+
//
|
|
4
|
+
// Responsibility separation (Phase 1/2):
|
|
5
|
+
// - `start()` mints a listenKey, opens the WS, takes a REST snapshot, seeds
|
|
6
|
+
// the store, and routes WS events into the store.
|
|
7
|
+
// - In `shadow` mode, a periodic 60s REST truth-check runs; diffs are logged
|
|
8
|
+
// but REST remains authoritative for `LiveAdapter` reads.
|
|
9
|
+
// - In `observe` / `enforce` (Phase 2–3, not yet activated on prod), the
|
|
10
|
+
// store becomes authoritative — but the wiring in LiveAdapter is what
|
|
11
|
+
// flips the read path. The controller just keeps the store fresh.
|
|
12
|
+
//
|
|
13
|
+
// The controller deliberately keeps NO business logic — it orchestrates the
|
|
14
|
+
// store + stream + REST client. Policy (when to auto-pause on repeated drift,
|
|
15
|
+
// when to alert) lives one level up in LiveAdapter + dashboards.
|
|
16
|
+
import { EventEmitter } from 'node:events';
|
|
17
|
+
import { UserDataStream } from './user-data-stream.js';
|
|
18
|
+
import { LiveStateStore } from './live-state-store.js';
|
|
19
|
+
import { defaultWebSocketFactory, userDataWsBaseUrl } from './user-data-stream-ws.js';
|
|
20
|
+
import { logger, formatError } from '../logger.js';
|
|
21
|
+
import { WsIngest } from '../ingest/ws-ingest.js';
|
|
22
|
+
import { RestGapFiller } from '../ingest/rest-gap-filler.js';
|
|
23
|
+
import { UserDataActiveProbe } from './user-data-active-probe.js';
|
|
24
|
+
/**
|
|
25
|
+
* Rewrite `balance.total[asset]` from CCXT's marginBalance-equivalent (wallet
|
|
26
|
+
* + unrealizedProfit for Binance Futures) to raw walletBalance only, so the
|
|
27
|
+
* REST snapshot aligns with what the WS ACCOUNT_UPDATE frames deliver. The
|
|
28
|
+
* raw walletBalance field lives under `balance.info.assets[i].walletBalance`
|
|
29
|
+
* — an array of per-asset records Binance Futures always returns alongside
|
|
30
|
+
* the top-level totals. Exported for unit testing.
|
|
31
|
+
*
|
|
32
|
+
* Falls back to the unmodified balance if `info.assets` is missing or
|
|
33
|
+
* malformed (e.g. mock responses in unit tests without the info envelope).
|
|
34
|
+
* Only `total` is rewritten; `free` and `used` are left untouched because
|
|
35
|
+
* the WS store computes them separately from position-lock state.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeBinanceFuturesBalance(balance) {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
const assets = balance?.info?.assets;
|
|
40
|
+
if (!Array.isArray(assets))
|
|
41
|
+
return balance;
|
|
42
|
+
const walletByAsset = {};
|
|
43
|
+
for (const a of assets) {
|
|
44
|
+
if (!a || typeof a.asset !== 'string')
|
|
45
|
+
continue;
|
|
46
|
+
const wb = parseFloat(String(a.walletBalance));
|
|
47
|
+
if (Number.isFinite(wb))
|
|
48
|
+
walletByAsset[a.asset] = wb;
|
|
49
|
+
}
|
|
50
|
+
if (Object.keys(walletByAsset).length === 0)
|
|
51
|
+
return balance;
|
|
52
|
+
const newTotal = { ...(balance.total ?? {}) };
|
|
53
|
+
for (const [asset, wb] of Object.entries(walletByAsset)) {
|
|
54
|
+
newTotal[asset] = wb;
|
|
55
|
+
}
|
|
56
|
+
return { ...balance, total: newTotal };
|
|
57
|
+
}
|
|
58
|
+
const TAG = 'user-data-stream-ctl';
|
|
59
|
+
/**
|
|
60
|
+
* Thrown by `buildSnapshot` when ANY of the three REST fetches returned
|
|
61
|
+
* `null` — i.e. the fetch FAILED (Binance 429 / weight-paced / transient
|
|
62
|
+
* network), state is UNKNOWN. It does NOT mean "the account is flat / has no
|
|
63
|
+
* orders". `LiveStateStore.applySnapshot` replaces positions + orders +
|
|
64
|
+
* balances WHOLESALE, so applying a snapshot built from a failed fetch would
|
|
65
|
+
* wipe still-live positions and bracket SL/TP from the store — which is the
|
|
66
|
+
* AUTHORITATIVE read path in observe/enforce mode — blinding the agent into
|
|
67
|
+
* the documented phantom-naked spiral.
|
|
68
|
+
*
|
|
69
|
+
* Subclass of `Error` so the existing `.catch()` sites absorb it unchanged;
|
|
70
|
+
* callers detect the type to skip the apply, leave the store intact, and let
|
|
71
|
+
* WS deltas + the next reconnect/resync repair state. Same null≠empty rule as
|
|
72
|
+
* `getOpenOrders` / `getPositions` / `closePosition` / the bracket reconciler.
|
|
73
|
+
*/
|
|
74
|
+
export class UntrustedSnapshotError extends Error {
|
|
75
|
+
constructor(message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'UntrustedSnapshotError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const REAL_CTL_TIMERS = {
|
|
81
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
82
|
+
clearInterval: (h) => clearInterval(h),
|
|
83
|
+
now: () => Date.now(),
|
|
84
|
+
};
|
|
85
|
+
export class UserDataStreamController extends EventEmitter {
|
|
86
|
+
mode;
|
|
87
|
+
api;
|
|
88
|
+
stream;
|
|
89
|
+
store;
|
|
90
|
+
truthCheckIntervalMs;
|
|
91
|
+
staleStoreThresholdMs;
|
|
92
|
+
timers;
|
|
93
|
+
truthCheckTimer = null;
|
|
94
|
+
algoRefreshTimer = null;
|
|
95
|
+
reconnectRateTimer = null;
|
|
96
|
+
algoRefreshMs;
|
|
97
|
+
wsIngest;
|
|
98
|
+
restGapFiller;
|
|
99
|
+
activeProbe;
|
|
100
|
+
/** Count of stale-forced + other reconnects in the current hourly window.
|
|
101
|
+
* Checked every hour; a spike above RECONNECT_RATE_ALERT_THRESHOLD logs
|
|
102
|
+
* a WARN so operator can spot Binance-side trouble or network flaps. */
|
|
103
|
+
reconnectCountThisHour = 0;
|
|
104
|
+
running = false;
|
|
105
|
+
snapshotInFlight = false;
|
|
106
|
+
/** Serialises snapshot applies so WS events don't race with a snapshot
|
|
107
|
+
* we're in the middle of fetching. WS events that arrive during a
|
|
108
|
+
* snapshot are buffered here and drained after apply. */
|
|
109
|
+
bufferedDuringSnapshot = [];
|
|
110
|
+
consecutiveMajorDrifts = 0;
|
|
111
|
+
constructor(api, options) {
|
|
112
|
+
super();
|
|
113
|
+
this.api = api;
|
|
114
|
+
this.mode = options.mode;
|
|
115
|
+
this.store = options.store ?? new LiveStateStore();
|
|
116
|
+
this.truthCheckIntervalMs = options.truthCheckIntervalMs ?? 60_000;
|
|
117
|
+
this.algoRefreshMs = options.tunables.algoRefreshMs;
|
|
118
|
+
// Reuse the dead-socket watchdog's calibrated threshold as the
|
|
119
|
+
// store-staleness bound for isStoreTrusted() — see that method.
|
|
120
|
+
this.staleStoreThresholdMs = options.tunables.staleEventWatchdogMs;
|
|
121
|
+
this.timers = {
|
|
122
|
+
setInterval: options.timers?.setInterval ?? REAL_CTL_TIMERS.setInterval,
|
|
123
|
+
clearInterval: options.timers?.clearInterval ?? REAL_CTL_TIMERS.clearInterval,
|
|
124
|
+
now: options.timers?.now ?? REAL_CTL_TIMERS.now,
|
|
125
|
+
};
|
|
126
|
+
this.stream = options.stream ?? new UserDataStream(api, this.buildStreamOptions(options));
|
|
127
|
+
this.attachStreamHandlers();
|
|
128
|
+
// Active probe — proactive REST check that catches "WS silently stopped
|
|
129
|
+
// delivering" before the staleness watchdog. Disabled when intervalMs=0.
|
|
130
|
+
// Construction is side-effect-free; subscription happens in start().
|
|
131
|
+
const probeIntervalMs = options.tunables.activeProbeIntervalMs;
|
|
132
|
+
this.activeProbe = probeIntervalMs > 0
|
|
133
|
+
? new UserDataActiveProbe({
|
|
134
|
+
api: this.api,
|
|
135
|
+
stream: this.stream,
|
|
136
|
+
store: this.store,
|
|
137
|
+
intervalMs: probeIntervalMs,
|
|
138
|
+
})
|
|
139
|
+
: null;
|
|
140
|
+
// WsIngest is constructed eagerly when a tradeIngest config is supplied,
|
|
141
|
+
// but only start()-ed inside the controller's start() — and only when
|
|
142
|
+
// mode≠'off'. Construction is side-effect-free; subscription happens on
|
|
143
|
+
// start(). See TRADE_AUDIT_TRAIL_PLAN.md Phase 1.
|
|
144
|
+
this.wsIngest = options.tradeIngest
|
|
145
|
+
? new WsIngest({
|
|
146
|
+
stream: this.stream,
|
|
147
|
+
client: options.tradeIngest.client,
|
|
148
|
+
userId: options.tradeIngest.userId,
|
|
149
|
+
exchange: options.tradeIngest.exchange,
|
|
150
|
+
autoCapture: options.autoCapture,
|
|
151
|
+
})
|
|
152
|
+
: null;
|
|
153
|
+
// REST gap-filler — Phase 2 of TRADE_AUDIT_TRAIL_PLAN. Runs alongside
|
|
154
|
+
// WsIngest whenever dbWrite is active. On every reconnect (stale-forced
|
|
155
|
+
// or otherwise), queries /fapi/v1/userTrades for every known symbol
|
|
156
|
+
// and POSTs any missing fills with source='rest_reconcile'. The webapp
|
|
157
|
+
// ingest endpoint dedupes on (exchange, exchangeTradeId), so WS-already-
|
|
158
|
+
// delivered fills stay recorded as source='ws' (ON CONFLICT DO NOTHING,
|
|
159
|
+
// not last-writer-wins — see shared/src/fills.ts). Required for mode
|
|
160
|
+
// advancement to observe/enforce: in those modes WS is authoritative
|
|
161
|
+
// for the audit trail, and fills during reconnect blind windows (5-20s)
|
|
162
|
+
// would otherwise be silently missing from the trades table.
|
|
163
|
+
this.restGapFiller = options.tradeIngest
|
|
164
|
+
? new RestGapFiller({
|
|
165
|
+
api: this.api,
|
|
166
|
+
stream: this.stream,
|
|
167
|
+
store: this.store,
|
|
168
|
+
client: options.tradeIngest.client,
|
|
169
|
+
userId: options.tradeIngest.userId,
|
|
170
|
+
exchange: options.tradeIngest.exchange,
|
|
171
|
+
touchedSymbolsStore: options.tradeIngest.touchedSymbolsStore,
|
|
172
|
+
now: () => this.timers.now(),
|
|
173
|
+
})
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
176
|
+
getStore() {
|
|
177
|
+
return this.store;
|
|
178
|
+
}
|
|
179
|
+
getStream() {
|
|
180
|
+
return this.stream;
|
|
181
|
+
}
|
|
182
|
+
/** True when the in-memory store is safe to serve as the authoritative
|
|
183
|
+
* source for positions/orders reads. Two independent conditions, BOTH
|
|
184
|
+
* required — each guards a distinct failure in the null≠empty class
|
|
185
|
+
* (same class as the getOpenOrders / getPositions / closePosition /
|
|
186
|
+
* bracket-reconciler / UntrustedSnapshotError rules):
|
|
187
|
+
*
|
|
188
|
+
* (1) **Seeded.** A trusted REST snapshot has run at least once
|
|
189
|
+
* (`lastSnapshotAt !== null`). False during startup before the
|
|
190
|
+
* `connected` handler's initial snapshot lands and the gap after
|
|
191
|
+
* `reset()`. An unseeded `[]` is indistinguishable from "no
|
|
192
|
+
* positions/orders" and would trip naked-bracket / orphan-cancel
|
|
193
|
+
* paths against real exchange state.
|
|
194
|
+
*
|
|
195
|
+
* (2) **Fresh.** We've received SOME incoming frame within
|
|
196
|
+
* `staleStoreThresholdMs` — the SAME calibrated threshold
|
|
197
|
+
* (`staleEventWatchdogMs`, default 6 min) and the SAME signal
|
|
198
|
+
* (`lastFrameAt`, any frame incl. Binance's 3-min server pings) the
|
|
199
|
+
* dead-socket watchdog itself uses to declare the socket dead.
|
|
200
|
+
* Beyond it the watchdog is already forcing a reconnect; serving a
|
|
201
|
+
* seeded-but-stale store as authoritative there would present stale
|
|
202
|
+
* state as truth on a destructive-action-gating read — the residual
|
|
203
|
+
* a reviewer flagged. Deliberately NOT a bare `isConnected()` check:
|
|
204
|
+
* a brief reconnect (listenKey rotation ~25 min, 1 s→60 s backoff)
|
|
205
|
+
* keeps `lastFrameAt` recent, so the common case is NOT pushed onto
|
|
206
|
+
* the weight-heavy / rate-limit-poisoned REST path — that would just
|
|
207
|
+
* trade one failure in this class for another. When the reconnect +
|
|
208
|
+
* resync completes, `lastFrameAt` refreshes and the store is trusted
|
|
209
|
+
* again. `lastFrameAt === null` (seeded but never a frame — an odd
|
|
210
|
+
* state) is conservatively treated as untrusted. */
|
|
211
|
+
isStoreTrusted() {
|
|
212
|
+
if (this.store.getHealth().lastSnapshotAt === null)
|
|
213
|
+
return false;
|
|
214
|
+
const { lastFrameAt } = this.stream.getHealth();
|
|
215
|
+
if (lastFrameAt === null)
|
|
216
|
+
return false;
|
|
217
|
+
return this.timers.now() - lastFrameAt <= this.staleStoreThresholdMs;
|
|
218
|
+
}
|
|
219
|
+
/** Start the stream + run the initial REST snapshot + seed the store.
|
|
220
|
+
* No-op if mode is 'off'. Throws if already running. */
|
|
221
|
+
async start() {
|
|
222
|
+
if (this.mode === 'off') {
|
|
223
|
+
logger.info(TAG, 'mode=off — controller not started');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (this.running) {
|
|
227
|
+
logger.warn(TAG, 'start() called while already running — ignored');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.running = true;
|
|
231
|
+
logger.info(TAG, `starting in mode=${this.mode}${this.wsIngest ? ' + dbWrite=on' : ''}`);
|
|
232
|
+
await this.stream.start();
|
|
233
|
+
// 'connected' handler will run the initial snapshot + seed.
|
|
234
|
+
if (this.mode === 'shadow' || this.mode === 'observe' || this.mode === 'enforce') {
|
|
235
|
+
this.startTruthCheckLoop();
|
|
236
|
+
this.startAlgoRefreshLoop();
|
|
237
|
+
this.startReconnectRateMonitor();
|
|
238
|
+
}
|
|
239
|
+
// Audit-trail WS ingest — opt-in via tradeIngest config. Subscribes to
|
|
240
|
+
// the stream's orderUpdate event, which is already wired for store apply.
|
|
241
|
+
// Both listeners coexist; the ingest one is filter+forward only, no
|
|
242
|
+
// mutation of the store.
|
|
243
|
+
if (this.wsIngest) {
|
|
244
|
+
this.wsIngest.start();
|
|
245
|
+
}
|
|
246
|
+
// REST gap-filler — closes the audit-trail hole opened by every WS
|
|
247
|
+
// reconnect blind window. Also opt-in via tradeIngest (same gate as
|
|
248
|
+
// WsIngest — either both run or neither, since they write to the same
|
|
249
|
+
// audit table with complementary provenance).
|
|
250
|
+
if (this.restGapFiller) {
|
|
251
|
+
this.restGapFiller.start();
|
|
252
|
+
}
|
|
253
|
+
// Active probe — proactive REST detector for silent-stream events. Runs in
|
|
254
|
+
// every mode that the stream itself runs in (shadow, observe, enforce);
|
|
255
|
+
// there is no audit-trail dependency. Independent of dbWrite.
|
|
256
|
+
if (this.activeProbe) {
|
|
257
|
+
this.activeProbe.start();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Graceful shutdown. */
|
|
261
|
+
async stop() {
|
|
262
|
+
if (!this.running)
|
|
263
|
+
return;
|
|
264
|
+
this.running = false;
|
|
265
|
+
this.stopTruthCheckLoop();
|
|
266
|
+
this.stopAlgoRefreshLoop();
|
|
267
|
+
this.stopReconnectRateMonitor();
|
|
268
|
+
if (this.wsIngest)
|
|
269
|
+
this.wsIngest.stop();
|
|
270
|
+
if (this.restGapFiller)
|
|
271
|
+
this.restGapFiller.stop();
|
|
272
|
+
if (this.activeProbe)
|
|
273
|
+
this.activeProbe.stop();
|
|
274
|
+
try {
|
|
275
|
+
await this.stream.stop();
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
logger.warn(TAG, `stream.stop failed: ${formatError(err)}`);
|
|
279
|
+
}
|
|
280
|
+
logger.info(TAG, 'stopped');
|
|
281
|
+
}
|
|
282
|
+
/** Force a full snapshot + re-seed. Used by operator tools for manual resync. */
|
|
283
|
+
async forceResync(cause) {
|
|
284
|
+
logger.warn(TAG, `forced resync: ${cause}`);
|
|
285
|
+
await this.takeAndApplySnapshot();
|
|
286
|
+
this.emit('forcedResync', cause);
|
|
287
|
+
}
|
|
288
|
+
// ---- Internal ----
|
|
289
|
+
buildStreamOptions(options) {
|
|
290
|
+
return {
|
|
291
|
+
wsBaseUrl: userDataWsBaseUrl(options.testnet),
|
|
292
|
+
webSocketFactory: defaultWebSocketFactory,
|
|
293
|
+
listenKey: {
|
|
294
|
+
refreshIntervalMs: options.tunables.listenKeyRefreshMs,
|
|
295
|
+
},
|
|
296
|
+
reconnectBaseMs: options.tunables.reconnectBaseMs,
|
|
297
|
+
reconnectCapMs: options.tunables.reconnectCapMs,
|
|
298
|
+
// Single watchdog threshold — the per-position "active" specialisation
|
|
299
|
+
// was removed in 2026-04-25's ping-aware refactor. Empirical false-positive
|
|
300
|
+
// rate was 99% (203 fires/day vs 2 true positives) because the 90s active
|
|
301
|
+
// threshold was below Binance's 180s server-ping interval, and the
|
|
302
|
+
// watchdog only counted message frames as liveness. The active REST probe
|
|
303
|
+
// (60s `fetchMyTrades` per open-position symbol) is the canonical
|
|
304
|
+
// "events stopped arriving on a position" detector — it fires within ~60s
|
|
305
|
+
// of any missed fill and was already running alongside the broken watchdog.
|
|
306
|
+
staleEventWatchdogMs: options.tunables.staleEventWatchdogMs,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
attachStreamHandlers() {
|
|
310
|
+
this.stream.on('connected', () => {
|
|
311
|
+
void this.takeAndApplySnapshot().catch((err) => {
|
|
312
|
+
if (err instanceof UntrustedSnapshotError) {
|
|
313
|
+
logger.warn(TAG, `initial snapshot skipped (store intact): ${err.message}`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
logger.error(TAG, `initial snapshot failed: ${formatError(err)}`);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
this.stream.on('resyncRequired', (cause) => {
|
|
321
|
+
void this.takeAndApplySnapshot().then(() => {
|
|
322
|
+
this.emit('forcedResync', cause);
|
|
323
|
+
}).catch((err) => {
|
|
324
|
+
if (err instanceof UntrustedSnapshotError) {
|
|
325
|
+
// Did NOT resync — do not emit forcedResync. WS deltas keep the
|
|
326
|
+
// store fresh; the next reconnect/resync retries.
|
|
327
|
+
logger.warn(TAG, `resync snapshot skipped (store intact, will retry): ${err.message}`);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
logger.error(TAG, `resync snapshot failed: ${formatError(err)}`);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
this.stream.on('accountUpdate', (ev) => {
|
|
335
|
+
if (this.snapshotInFlight) {
|
|
336
|
+
this.bufferedDuringSnapshot.push(() => this.store.applyAccountUpdate(ev));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.store.applyAccountUpdate(ev);
|
|
340
|
+
});
|
|
341
|
+
this.stream.on('orderUpdate', (ev) => {
|
|
342
|
+
if (this.snapshotInFlight) {
|
|
343
|
+
this.bufferedDuringSnapshot.push(() => this.store.applyOrderUpdate(ev));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.store.applyOrderUpdate(ev);
|
|
347
|
+
});
|
|
348
|
+
this.stream.on('marginCall', (ev) => this.emit('marginCall', ev));
|
|
349
|
+
// ALGO_UPDATE pass-through. NOT buffered through snapshot apply: it is a
|
|
350
|
+
// terminal/lifecycle signal for conditional orders, not store state the
|
|
351
|
+
// snapshot would clobber, and acting on EXPIRED/TRIGGERED promptly is the
|
|
352
|
+
// whole point (it's how we learn a position closed without REST polling).
|
|
353
|
+
this.stream.on('algoUpdate', (ev) => this.emit('algoUpdate', ev));
|
|
354
|
+
this.stream.on('reconnecting', (attempt, delay) => this.emit('reconnecting', attempt, delay));
|
|
355
|
+
this.stream.on('disconnected', (reason, willReconnect) => {
|
|
356
|
+
logger.warn(TAG, `stream disconnected: ${reason} (willReconnect=${willReconnect})`);
|
|
357
|
+
});
|
|
358
|
+
this.stream.on('staleForcedReconnect', (idleMs) => {
|
|
359
|
+
logger.warn(TAG, `stream stale watchdog fired after ${idleMs}ms idle`);
|
|
360
|
+
});
|
|
361
|
+
// Every reconnect attempt bumps the hourly counter; the monitor below
|
|
362
|
+
// checks the counter each hour and WARNs if it exceeds the alert
|
|
363
|
+
// threshold. Expected baseline on a quiet account is ~3-4/hour
|
|
364
|
+
// (worst case listenKey rotation every 25 min + a few stale
|
|
365
|
+
// reconnects). Sustained 10+/hour means Binance-side trouble,
|
|
366
|
+
// network flap, or a regression — worth an operator look.
|
|
367
|
+
this.stream.on('reconnecting', () => {
|
|
368
|
+
this.reconnectCountThisHour++;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/** Fetch REST positions/balance/orders and normalise symbols so the store
|
|
372
|
+
* keys match WS events regardless of which write path landed first.
|
|
373
|
+
* CCXT returns `BTC/USDT:USDT`; our WS parser produces `BTC/USDT`. */
|
|
374
|
+
async buildSnapshot() {
|
|
375
|
+
const [positions, balance, openOrders] = await Promise.all([
|
|
376
|
+
this.api.fetchPositions(),
|
|
377
|
+
this.api.fetchBalance(),
|
|
378
|
+
this.api.fetchOpenOrders(),
|
|
379
|
+
]);
|
|
380
|
+
// null = the underlying fetch FAILED (429 / weight-paced / transient),
|
|
381
|
+
// NOT "flat / no orders". applySnapshot() wipes positions+orders+balances
|
|
382
|
+
// wholesale, so a snapshot built from any failed fetch would strip live
|
|
383
|
+
// positions + bracket SL/TP from the (authoritative in observe/enforce)
|
|
384
|
+
// store. A WS reconnect storm correlates with rate-limit pressure, so this
|
|
385
|
+
// path is hot, not theoretical. Refuse to build an untrusted snapshot; the
|
|
386
|
+
// caller skips the apply and the store survives for WS deltas + the next
|
|
387
|
+
// reconnect/resync to repair. All three are treated as one trust unit
|
|
388
|
+
// because applySnapshot clears all three sections (a null balance would
|
|
389
|
+
// zero NAV/equity in enforce — KPI-must-equal-Binance).
|
|
390
|
+
if (positions === null || openOrders === null || balance === null) {
|
|
391
|
+
throw new UntrustedSnapshotError(`REST snapshot fetch failed (positions=${positions === null ? 'null' : 'ok'} ` +
|
|
392
|
+
`balance=${balance === null ? 'null' : 'ok'} ` +
|
|
393
|
+
`openOrders=${openOrders === null ? 'null' : 'ok'}) — refusing to apply a ` +
|
|
394
|
+
`store-wiping snapshot; store left intact, will retry next cycle`);
|
|
395
|
+
}
|
|
396
|
+
const normSymbol = (s) => s.replace(/:[A-Z]+$/, '');
|
|
397
|
+
return {
|
|
398
|
+
timestamp: this.timers.now(),
|
|
399
|
+
positions: positions.map(p => ({ ...p, symbol: normSymbol(p.symbol) })),
|
|
400
|
+
// Normalize REST balance.total from CCXT's marginBalance (= wallet + uPnl)
|
|
401
|
+
// to walletBalance-only, matching what WS ACCOUNT_UPDATE.B[].wb stores.
|
|
402
|
+
// Without this, any open position with accruing unrealized PnL causes
|
|
403
|
+
// persistent drift on the margin asset (BNFCR on prod as of 2026-04-21)
|
|
404
|
+
// — just above the 10 bps minor threshold, forcing resync every 3 cycles
|
|
405
|
+
// in shadow mode. See live-state-store.ts:336 for the WS-side wallet
|
|
406
|
+
// store that this normalisation aligns against.
|
|
407
|
+
balance: normalizeBinanceFuturesBalance(balance),
|
|
408
|
+
openOrders: openOrders.map(o => ({ ...o, symbol: normSymbol(o.symbol) })),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async takeAndApplySnapshot() {
|
|
412
|
+
if (this.snapshotInFlight) {
|
|
413
|
+
logger.info(TAG, 'snapshot already in flight — skipping');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.snapshotInFlight = true;
|
|
417
|
+
try {
|
|
418
|
+
const snap = await this.buildSnapshot();
|
|
419
|
+
this.store.applySnapshot(snap);
|
|
420
|
+
logger.info(TAG, `snapshot applied: ${snap.positions.length} positions, ${snap.openOrders.length} orders, balance=${snap.balance ? 'ok' : 'null'}`);
|
|
421
|
+
this.emit('snapshotApplied', snap);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
this.snapshotInFlight = false;
|
|
425
|
+
// Drain any WS events that arrived during snapshot apply.
|
|
426
|
+
const buffered = this.bufferedDuringSnapshot;
|
|
427
|
+
this.bufferedDuringSnapshot = [];
|
|
428
|
+
for (const fn of buffered) {
|
|
429
|
+
try {
|
|
430
|
+
fn();
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
logger.warn(TAG, `buffered event apply failed: ${formatError(err)}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
startTruthCheckLoop() {
|
|
439
|
+
this.stopTruthCheckLoop();
|
|
440
|
+
this.truthCheckTimer = this.timers.setInterval(() => {
|
|
441
|
+
if (!this.running)
|
|
442
|
+
return;
|
|
443
|
+
void this.runTruthCheck().catch((err) => {
|
|
444
|
+
logger.warn(TAG, `truth check failed (will retry): ${formatError(err)}`);
|
|
445
|
+
});
|
|
446
|
+
}, this.truthCheckIntervalMs);
|
|
447
|
+
logger.info(TAG, `truth-check loop started (every ${this.truthCheckIntervalMs / 1000}s)`);
|
|
448
|
+
}
|
|
449
|
+
stopTruthCheckLoop() {
|
|
450
|
+
if (this.truthCheckTimer) {
|
|
451
|
+
this.timers.clearInterval(this.truthCheckTimer);
|
|
452
|
+
this.truthCheckTimer = null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** Start the algo-order refresh loop — REST-polls open orders and replaces
|
|
456
|
+
* the algo (bracket SL/TP) subset in the store. Fills the gap left by
|
|
457
|
+
* Binance's WS not emitting ORDER_TRADE_UPDATE for algo orders until they
|
|
458
|
+
* trigger. Disabled when algoRefreshMs === 0. */
|
|
459
|
+
startAlgoRefreshLoop() {
|
|
460
|
+
this.stopAlgoRefreshLoop();
|
|
461
|
+
if (this.algoRefreshMs <= 0) {
|
|
462
|
+
logger.info(TAG, 'algo-refresh loop disabled (algoRefreshMs=0)');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.algoRefreshTimer = this.timers.setInterval(() => {
|
|
466
|
+
if (!this.running)
|
|
467
|
+
return;
|
|
468
|
+
void this.runAlgoRefresh().catch((err) => {
|
|
469
|
+
logger.warn(TAG, `algo-refresh failed (will retry): ${formatError(err)}`);
|
|
470
|
+
});
|
|
471
|
+
}, this.algoRefreshMs);
|
|
472
|
+
logger.info(TAG, `algo-refresh loop started (every ${this.algoRefreshMs / 1000}s)`);
|
|
473
|
+
}
|
|
474
|
+
stopAlgoRefreshLoop() {
|
|
475
|
+
if (this.algoRefreshTimer) {
|
|
476
|
+
this.timers.clearInterval(this.algoRefreshTimer);
|
|
477
|
+
this.algoRefreshTimer = null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/** Alert threshold — hourly reconnect count that logs a WARN. Set at
|
|
481
|
+
* 10/hour because the natural baseline on a quiet account (listenKey
|
|
482
|
+
* rotation every 25 min + occasional stale-watchdog firing) is
|
|
483
|
+
* 3-4/hour; anything consistently above that is unexpected. Does NOT
|
|
484
|
+
* auto-pause or take action — operator investigates when they see
|
|
485
|
+
* the log line land. */
|
|
486
|
+
static RECONNECT_RATE_ALERT_THRESHOLD = 10;
|
|
487
|
+
static RECONNECT_RATE_WINDOW_MS = 60 * 60_000;
|
|
488
|
+
/** Start the reconnect-rate monitor. Runs hourly; resets the counter
|
|
489
|
+
* after each check whether or not it alerted. */
|
|
490
|
+
startReconnectRateMonitor() {
|
|
491
|
+
this.stopReconnectRateMonitor();
|
|
492
|
+
this.reconnectRateTimer = this.timers.setInterval(() => {
|
|
493
|
+
if (!this.running)
|
|
494
|
+
return;
|
|
495
|
+
const count = this.reconnectCountThisHour;
|
|
496
|
+
this.reconnectCountThisHour = 0;
|
|
497
|
+
if (count >= UserDataStreamController.RECONNECT_RATE_ALERT_THRESHOLD) {
|
|
498
|
+
logger.warn(TAG, `HIGH reconnect rate: ${count} reconnects in the last hour ` +
|
|
499
|
+
`(threshold=${UserDataStreamController.RECONNECT_RATE_ALERT_THRESHOLD}). ` +
|
|
500
|
+
`Check Binance API status + network health; listenKey rotation is 25-min, ` +
|
|
501
|
+
`stale-watchdog is 15-min — baseline should be 3-4/hr.`);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
logger.info(TAG, `reconnect-rate OK: ${count}/hr`);
|
|
505
|
+
}
|
|
506
|
+
}, UserDataStreamController.RECONNECT_RATE_WINDOW_MS);
|
|
507
|
+
logger.info(TAG, 'reconnect-rate monitor started (hourly window)');
|
|
508
|
+
}
|
|
509
|
+
stopReconnectRateMonitor() {
|
|
510
|
+
if (this.reconnectRateTimer) {
|
|
511
|
+
this.timers.clearInterval(this.reconnectRateTimer);
|
|
512
|
+
this.reconnectRateTimer = null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** One pass of algo-order refresh: fetch open orders (merges regular + algo
|
|
516
|
+
* via BinancePrivateApi), filter to algo-bracket cids, and hand them to the
|
|
517
|
+
* store's replaceAlgoOrders. No mutation if WS isn't connected (we'd be
|
|
518
|
+
* polluting state the reconnect snapshot is about to replace anyway). */
|
|
519
|
+
async runAlgoRefresh() {
|
|
520
|
+
if (!this.stream.isConnected())
|
|
521
|
+
return;
|
|
522
|
+
if (this.snapshotInFlight)
|
|
523
|
+
return;
|
|
524
|
+
const open = await this.api.fetchOpenOrders();
|
|
525
|
+
if (open === null) {
|
|
526
|
+
// Fetch FAILED (429 / weight-paced / transient) — NOT "no open orders".
|
|
527
|
+
// replaceAlgoOrders([]) would strip EVERY live bracket SL/TP from the
|
|
528
|
+
// authoritative store until the next successful poll (≤algoRefreshMs
|
|
529
|
+
// naked window). Keep the existing algo orders; a trustworthy refresh
|
|
530
|
+
// replaces them. Same null≠empty rule as buildSnapshot above.
|
|
531
|
+
logger.warn(TAG, 'algo-refresh: fetchOpenOrders returned null (REST unavailable) — keeping existing algo orders, will retry');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.store.replaceAlgoOrders(open);
|
|
535
|
+
}
|
|
536
|
+
async runTruthCheck() {
|
|
537
|
+
if (!this.stream.isConnected()) {
|
|
538
|
+
// WS isn't up — skip; snapshot will happen on reconnect anyway.
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const snap = await this.buildSnapshot();
|
|
542
|
+
const drift = this.store.diffAgainstSnapshot(snap);
|
|
543
|
+
if (drift.severity === 'none') {
|
|
544
|
+
this.consecutiveMajorDrifts = 0;
|
|
545
|
+
// Spend the REST read we already paid for on a uPnl/mark refresh rather
|
|
546
|
+
// than discarding it. ACCOUNT_UPDATE is event-driven and carries no
|
|
547
|
+
// per-tick mark price, so without this the store's unrealizedPnl is
|
|
548
|
+
// frozen between fills/funding while the Binance app moves every tick
|
|
549
|
+
// (2026-05-16 "Unrealized lags/frozen"). Bounded, non-structural — see
|
|
550
|
+
// LiveStateStore.refreshDerivedMarkFields. Only on severity 'none':
|
|
551
|
+
// 'minor'/'major' mean structure is in question, so leave those to the
|
|
552
|
+
// existing paths (major already applies the full fresh snapshot).
|
|
553
|
+
this.store.refreshDerivedMarkFields(snap.positions);
|
|
554
|
+
logger.info(TAG, `truth-check OK (mode=${this.mode})`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.emit('drift', drift);
|
|
558
|
+
if (drift.severity === 'minor') {
|
|
559
|
+
this.consecutiveMajorDrifts = 0;
|
|
560
|
+
logger.warn(TAG, `minor drift: ${drift.summary}`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// major
|
|
564
|
+
this.consecutiveMajorDrifts += 1;
|
|
565
|
+
logger.error(TAG, `MAJOR drift (#${this.consecutiveMajorDrifts}): ${drift.summary}`);
|
|
566
|
+
// In shadow mode: never mutate store from truth-check — REST is authoritative
|
|
567
|
+
// for reads anyway, so a drift is a signal that the WS pipeline is wrong.
|
|
568
|
+
// In observe/enforce: apply the snapshot so reads are correct. On 3rd
|
|
569
|
+
// consecutive major drift in any mode, force resync and emit the event.
|
|
570
|
+
if (this.mode !== 'shadow') {
|
|
571
|
+
this.store.applySnapshot(snap);
|
|
572
|
+
}
|
|
573
|
+
if (this.consecutiveMajorDrifts >= 3) {
|
|
574
|
+
logger.error(TAG, `${this.consecutiveMajorDrifts} consecutive major drifts — emitting forcedResync`);
|
|
575
|
+
this.emit('forcedResync', 'consecutive_major_drifts');
|
|
576
|
+
this.consecutiveMajorDrifts = 0;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import type { StreamWebSocket, WebSocketFactory } from './user-data-stream.js';
|
|
3
|
+
/** Default mainnet user-data WS base URL. Append `/<listenKey>` to connect.
|
|
4
|
+
* Binance decommissioned the legacy `wss://fstream.binance.com/ws` URL on
|
|
5
|
+
* 2026-04-23 (per developers.binance.com changelog 2026-04-02). The legacy
|
|
6
|
+
* URL still accepts WS connections and even sends server pings, but no
|
|
7
|
+
* longer routes ACCOUNT_UPDATE / ORDER_TRADE_UPDATE / ACCOUNT_CONFIG_UPDATE
|
|
8
|
+
* events. Migration was silent: events stopped arriving for this account on
|
|
9
|
+
* 2026-04-27 10:35 UTC. New URL adds the `/private` path segment per the
|
|
10
|
+
* current user-data-streams docs page. Verified via A/B test — set leverage
|
|
11
|
+
* fires ACCOUNT_CONFIG_UPDATE on /private/ws but NOT on /ws. */
|
|
12
|
+
export declare const MAINNET_USER_DATA_WS = "wss://fstream.binance.com/private/ws";
|
|
13
|
+
/** Testnet user-data WS base URL. Matches CCXT's testnet futures endpoint. */
|
|
14
|
+
export declare const TESTNET_USER_DATA_WS = "wss://stream.binancefuture.com/ws";
|
|
15
|
+
/** Build the user-data WS base URL for a given environment. */
|
|
16
|
+
export declare function userDataWsBaseUrl(testnet: boolean): string;
|
|
17
|
+
/** Adapter from the node `ws` library to our minimal StreamWebSocket shape. */
|
|
18
|
+
export declare function wrapWsClient(ws: WebSocket): StreamWebSocket;
|
|
19
|
+
/** Default factory. Opens a real WS using the `ws` library.
|
|
20
|
+
* perMessageDeflate=false matches Binance's recommendation for user-data streams —
|
|
21
|
+
* frames are tiny and JSON, compression adds latency without meaningful savings. */
|
|
22
|
+
export declare const defaultWebSocketFactory: WebSocketFactory;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Default WebSocketFactory wrapping the `ws` library.
|
|
2
|
+
//
|
|
3
|
+
// Split out from user-data-stream.ts so that:
|
|
4
|
+
// 1. Unit tests import UserDataStream without loading the `ws` native addon.
|
|
5
|
+
// 2. The `ws`-specific glue (buffer handling, ping/pong) stays in one place.
|
|
6
|
+
// 3. Alternative factories (native WebSocket, debug proxies) can swap in.
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
/** Default mainnet user-data WS base URL. Append `/<listenKey>` to connect.
|
|
9
|
+
* Binance decommissioned the legacy `wss://fstream.binance.com/ws` URL on
|
|
10
|
+
* 2026-04-23 (per developers.binance.com changelog 2026-04-02). The legacy
|
|
11
|
+
* URL still accepts WS connections and even sends server pings, but no
|
|
12
|
+
* longer routes ACCOUNT_UPDATE / ORDER_TRADE_UPDATE / ACCOUNT_CONFIG_UPDATE
|
|
13
|
+
* events. Migration was silent: events stopped arriving for this account on
|
|
14
|
+
* 2026-04-27 10:35 UTC. New URL adds the `/private` path segment per the
|
|
15
|
+
* current user-data-streams docs page. Verified via A/B test — set leverage
|
|
16
|
+
* fires ACCOUNT_CONFIG_UPDATE on /private/ws but NOT on /ws. */
|
|
17
|
+
export const MAINNET_USER_DATA_WS = 'wss://fstream.binance.com/private/ws';
|
|
18
|
+
/** Testnet user-data WS base URL. Matches CCXT's testnet futures endpoint. */
|
|
19
|
+
export const TESTNET_USER_DATA_WS = 'wss://stream.binancefuture.com/ws';
|
|
20
|
+
/** Build the user-data WS base URL for a given environment. */
|
|
21
|
+
export function userDataWsBaseUrl(testnet) {
|
|
22
|
+
return testnet ? TESTNET_USER_DATA_WS : MAINNET_USER_DATA_WS;
|
|
23
|
+
}
|
|
24
|
+
/** Adapter from the node `ws` library to our minimal StreamWebSocket shape. */
|
|
25
|
+
export function wrapWsClient(ws) {
|
|
26
|
+
const adapter = {
|
|
27
|
+
on(event, cb) {
|
|
28
|
+
// `ws` and StreamWebSocket agree on event names; just forward.
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
ws.on(event, cb);
|
|
31
|
+
},
|
|
32
|
+
close(code, reason) {
|
|
33
|
+
try {
|
|
34
|
+
ws.close(code, reason);
|
|
35
|
+
}
|
|
36
|
+
catch { /* already closed */ }
|
|
37
|
+
},
|
|
38
|
+
pong(data) {
|
|
39
|
+
try {
|
|
40
|
+
ws.pong(data);
|
|
41
|
+
}
|
|
42
|
+
catch { /* not connected */ }
|
|
43
|
+
},
|
|
44
|
+
terminate() {
|
|
45
|
+
try {
|
|
46
|
+
ws.terminate();
|
|
47
|
+
}
|
|
48
|
+
catch { /* already closed */ }
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return adapter;
|
|
52
|
+
}
|
|
53
|
+
/** Default factory. Opens a real WS using the `ws` library.
|
|
54
|
+
* perMessageDeflate=false matches Binance's recommendation for user-data streams —
|
|
55
|
+
* frames are tiny and JSON, compression adds latency without meaningful savings. */
|
|
56
|
+
export const defaultWebSocketFactory = (url) => {
|
|
57
|
+
const ws = new WebSocket(url, {
|
|
58
|
+
perMessageDeflate: false,
|
|
59
|
+
// Binance is strict about the Origin header; wss clients default to empty.
|
|
60
|
+
handshakeTimeout: 15_000,
|
|
61
|
+
});
|
|
62
|
+
return wrapWsClient(ws);
|
|
63
|
+
};
|