@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,573 @@
|
|
|
1
|
+
// BracketReconciler — periodic drift check between the local bracket ledger
|
|
2
|
+
// and the exchange state. Runs every 60s by default.
|
|
3
|
+
//
|
|
4
|
+
// Reconciliation matrix (key = {position exists, SL on exchange, TP on exchange}):
|
|
5
|
+
//
|
|
6
|
+
// pos:Y sl:Y tp:Y → healthy, no-op
|
|
7
|
+
// pos:Y sl:Y tp:N → partial (TP missing) — ALERT (manual cancel or trigger race)
|
|
8
|
+
// pos:Y sl:N tp:Y → partial (SL missing) — ALERT (position unprotected!)
|
|
9
|
+
// pos:Y sl:N tp:N → brackets gone, position alive → ALERT (unprotected)
|
|
10
|
+
// pos:N sl:Y tp:Y → position closed, brackets orphaned → AUTO-CANCEL, mark ledger
|
|
11
|
+
// pos:N sl:Y tp:N → position closed, one orphan → AUTO-CANCEL, mark ledger
|
|
12
|
+
// pos:N sl:N tp:Y → position closed, one orphan → AUTO-CANCEL, mark ledger
|
|
13
|
+
// pos:N sl:N tp:N → position closed cleanly → mark ledger (triggered or cancelled)
|
|
14
|
+
//
|
|
15
|
+
// Unknown orphans (bracket-tagged clientOrderIds on exchange with no ledger
|
|
16
|
+
// entry) are auto-cancelled — they represent either a crash before ledger
|
|
17
|
+
// persist or a leftover from a previous plugin install.
|
|
18
|
+
import { EventEmitter } from 'node:events';
|
|
19
|
+
import { normalizeBracketSymbol } from './bracket-ledger.js';
|
|
20
|
+
import { parseBracketCid } from './bracket-id.js';
|
|
21
|
+
import { logger, formatError } from '../logger.js';
|
|
22
|
+
const TAG = 'bracket-reconciler';
|
|
23
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
24
|
+
/** Grace window for the unknown-bracket orphan sweep (2026-05-16 SOL
|
|
25
|
+
* incident). A bracket-tagged `rc-*` order with no active ledger row is
|
|
26
|
+
* NOT necessarily junk: in the seconds right after an entry fill the
|
|
27
|
+
* post-entry auto-attach places SL/TP on the exchange while (a) the ledger
|
|
28
|
+
* row is still being persisted and (b) the reconciler's positions snapshot
|
|
29
|
+
* has not yet propagated the brand-new position — so BOTH the "active
|
|
30
|
+
* ledger entry" check AND the "live position exists" guard miss it, and the
|
|
31
|
+
* sweep cancels the freshly-placed protection (prod: SOL bracket cancelled
|
|
32
|
+
* ~8 s after entry, naked spiral). A bracket the reconciler is seeing for
|
|
33
|
+
* the first time, or whose exchange order is younger than this window, is
|
|
34
|
+
* too young to be a crash/old-plugin leftover (those are minutes-to-hours
|
|
35
|
+
* old) — delaying their cleanup by a minute is harmless; cancelling a
|
|
36
|
+
* live position's only protection is not. Env-overridable; clamp 10s–10m. */
|
|
37
|
+
const ORPHAN_GRACE_MS = (() => {
|
|
38
|
+
const raw = Number(process.env.RC_BRACKET_ORPHAN_GRACE_MS);
|
|
39
|
+
return Number.isFinite(raw) && raw >= 10_000 && raw <= 600_000 ? raw : 60_000;
|
|
40
|
+
})();
|
|
41
|
+
/** Max multiplier applied to interval under sustained back-pressure (e.g.
|
|
42
|
+
* binance 429 storm where `getOpenOrders` keeps throwing). 5× → 5 min cap.
|
|
43
|
+
* Matches the upper end of Binance's documented per-IP rate-limit window. */
|
|
44
|
+
const MAX_BACKOFF_MULTIPLIER = 5;
|
|
45
|
+
/** Error-message substrings that indicate transient exchange unavailability
|
|
46
|
+
* (rate-limit or null-collapse from `LiveAdapter.getOpenOrders`). When
|
|
47
|
+
* matched, the reconciler doubles its tick interval (up to the cap) so it
|
|
48
|
+
* stops piling onto an already-throttled IP. The match is intentionally
|
|
49
|
+
* loose — better to over-back-off on a misclassified network error than
|
|
50
|
+
* to keep hammering during a real 429. */
|
|
51
|
+
const TRANSIENT_UNAVAILABLE_MARKERS = [
|
|
52
|
+
'429',
|
|
53
|
+
'Too Many Requests',
|
|
54
|
+
'fetchOpenOrders returned null',
|
|
55
|
+
'ETIMEDOUT',
|
|
56
|
+
'ECONNRESET',
|
|
57
|
+
'getaddrinfo',
|
|
58
|
+
];
|
|
59
|
+
export class BracketReconciler extends EventEmitter {
|
|
60
|
+
adapter;
|
|
61
|
+
ledger;
|
|
62
|
+
manager;
|
|
63
|
+
api;
|
|
64
|
+
baseIntervalMs;
|
|
65
|
+
now;
|
|
66
|
+
handle = null;
|
|
67
|
+
/** Distinct from `handle` because we need to know "did stop() get called
|
|
68
|
+
* while a pass was in flight" — at that moment `handle` is still the
|
|
69
|
+
* in-flight setTimeout token but `started` has flipped to false. The
|
|
70
|
+
* finally-block consults `started` (not `handle`) to decide whether to
|
|
71
|
+
* schedule another tick. */
|
|
72
|
+
started = false;
|
|
73
|
+
running = false;
|
|
74
|
+
/** Current multiplier applied to baseIntervalMs. Doubles on each
|
|
75
|
+
* transient-unavailable error, capped at MAX_BACKOFF_MULTIPLIER. Resets to
|
|
76
|
+
* 1 on a successful pass. Persisted across pass invocations as the
|
|
77
|
+
* observed back-pressure state. */
|
|
78
|
+
backoffMultiplier = 1;
|
|
79
|
+
/** bracketId → epoch-ms the orphan sweep first observed it as an
|
|
80
|
+
* unmatched (no-active-ledger-row) bracket. Drives the ORPHAN_GRACE_MS
|
|
81
|
+
* youth guard: a bracket seen for the first time is, by construction,
|
|
82
|
+
* ≤ one pass old from our side — never cancel it that pass. Pruned each
|
|
83
|
+
* pass to bracketIds still unmatched on the exchange (bounded). */
|
|
84
|
+
firstSeenOrphanAt = new Map();
|
|
85
|
+
constructor(adapter, ledger, manager, api, opts = {}) {
|
|
86
|
+
super();
|
|
87
|
+
this.adapter = adapter;
|
|
88
|
+
this.ledger = ledger;
|
|
89
|
+
this.manager = manager;
|
|
90
|
+
this.api = api;
|
|
91
|
+
this.baseIntervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
92
|
+
this.now = opts.now ?? Date.now;
|
|
93
|
+
}
|
|
94
|
+
/** Begin periodic reconciliation. Safe to call if already started. */
|
|
95
|
+
start() {
|
|
96
|
+
if (this.started)
|
|
97
|
+
return;
|
|
98
|
+
this.started = true;
|
|
99
|
+
this.scheduleNextPass();
|
|
100
|
+
logger.info(TAG, `Started (interval=${this.baseIntervalMs}ms)`);
|
|
101
|
+
}
|
|
102
|
+
stop() {
|
|
103
|
+
if (!this.started)
|
|
104
|
+
return;
|
|
105
|
+
this.started = false;
|
|
106
|
+
if (this.handle) {
|
|
107
|
+
clearTimeout(this.handle);
|
|
108
|
+
this.handle = null;
|
|
109
|
+
}
|
|
110
|
+
logger.info(TAG, 'Stopped');
|
|
111
|
+
}
|
|
112
|
+
/** Self-rescheduling tick. Replaces the old `setInterval` because the
|
|
113
|
+
* next interval is dynamic — we slow down under back-pressure, speed back
|
|
114
|
+
* up after a clean pass. The old approach kept hitting Binance at full
|
|
115
|
+
* 60 s cadence during a 429 storm, amplifying it. */
|
|
116
|
+
scheduleNextPass() {
|
|
117
|
+
if (!this.started)
|
|
118
|
+
return;
|
|
119
|
+
const delay = this.baseIntervalMs * this.backoffMultiplier;
|
|
120
|
+
this.handle = setTimeout(() => {
|
|
121
|
+
this.handle = null;
|
|
122
|
+
void this.runPass()
|
|
123
|
+
.then(() => {
|
|
124
|
+
if (this.backoffMultiplier > 1) {
|
|
125
|
+
logger.info(TAG, `clean pass — backoff multiplier reset (was ${this.backoffMultiplier}×)`);
|
|
126
|
+
this.backoffMultiplier = 1;
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
const msg = formatError(err);
|
|
131
|
+
logger.error(TAG, `reconcile pass failed: ${msg}`);
|
|
132
|
+
const transient = TRANSIENT_UNAVAILABLE_MARKERS.some((m) => msg.includes(m));
|
|
133
|
+
if (transient) {
|
|
134
|
+
const prev = this.backoffMultiplier;
|
|
135
|
+
this.backoffMultiplier = Math.min(prev * 2, MAX_BACKOFF_MULTIPLIER);
|
|
136
|
+
if (this.backoffMultiplier !== prev) {
|
|
137
|
+
logger.warn(TAG, `transient exchange unavailability — backing off ` +
|
|
138
|
+
`${prev}× → ${this.backoffMultiplier}× (next pass in ` +
|
|
139
|
+
`${(this.baseIntervalMs * this.backoffMultiplier) / 1000}s)`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
// Schedule next tick unless stop() was called while this pass was
|
|
145
|
+
// in flight. `started` is the authoritative signal — `handle` was
|
|
146
|
+
// cleared at top of this callback above.
|
|
147
|
+
if (this.started)
|
|
148
|
+
this.scheduleNextPass();
|
|
149
|
+
});
|
|
150
|
+
}, delay);
|
|
151
|
+
}
|
|
152
|
+
/** One reconciliation pass. Exposed for unit tests + startup recovery. */
|
|
153
|
+
async runPass() {
|
|
154
|
+
if (this.running) {
|
|
155
|
+
// Skip re-entrant pass — last one still in flight
|
|
156
|
+
return { startedAt: new Date(this.now()).toISOString(), drifts: [], positionsChecked: 0, ordersChecked: 0 };
|
|
157
|
+
}
|
|
158
|
+
this.running = true;
|
|
159
|
+
const startedAt = new Date(this.now()).toISOString();
|
|
160
|
+
const drifts = [];
|
|
161
|
+
try {
|
|
162
|
+
const [positionsRaw, openOrders] = await Promise.all([
|
|
163
|
+
this.adapter.getPositionsOrNull
|
|
164
|
+
? this.adapter.getPositionsOrNull()
|
|
165
|
+
: this.adapter.getPositions(),
|
|
166
|
+
this.adapter.getOpenOrders(),
|
|
167
|
+
]);
|
|
168
|
+
// `null` = positions fetch FAILED (429 / weight-paced / transient) —
|
|
169
|
+
// state UNKNOWN, NOT "flat". Under this snapshot we must take ZERO
|
|
170
|
+
// destructive action: an empty/failed positions list must never read as
|
|
171
|
+
// "no live position → safe to cancel these orphan brackets". 2026-05-15
|
|
172
|
+
// XRP strip loop: positions kept collapsing, the Section-2 orphan sweep
|
|
173
|
+
// cancelled the live position's bracket legs every pass.
|
|
174
|
+
const positionsUntrusted = positionsRaw === null;
|
|
175
|
+
const positions = positionsRaw ?? [];
|
|
176
|
+
// Key by normalized symbol so `entry.symbol` (canonical un-suffixed
|
|
177
|
+
// form stored by BracketLedger) matches CCXT position.symbol (which
|
|
178
|
+
// arrives suffixed as `BTC/USDT:USDT`). Without this, every active
|
|
179
|
+
// bracket entry would look "position gone" and the reconciler would
|
|
180
|
+
// auto-cancel live protection. See bracket-ledger.ts
|
|
181
|
+
// `normalizeBracketSymbol` for the rationale.
|
|
182
|
+
const positionBySymbol = new Map();
|
|
183
|
+
for (const p of positions)
|
|
184
|
+
positionBySymbol.set(normalizeBracketSymbol(p.symbol), p);
|
|
185
|
+
// ROOT-CAUSE FIX 2026-05-16 (belt-and-suspenders to the attach-time
|
|
186
|
+
// disable): proactively neutralise Binance's per-symbol
|
|
187
|
+
// countdownCancelAll dead-man's-switch on every symbol that currently
|
|
188
|
+
// has a live position. An externally-armed countdown (nothing in our
|
|
189
|
+
// stack arms it — verified) purges ALL orders for the symbol
|
|
190
|
+
// server-side ~tens of seconds after they're placed; that was the
|
|
191
|
+
// months-long naked-position spiral. Disabling it each pass means a
|
|
192
|
+
// re-armed switch is cleared within one reconciler cycle instead of
|
|
193
|
+
// silently stripping protection. Best-effort: never let it break the
|
|
194
|
+
// pass; the method itself is idempotent + swallows its own errors.
|
|
195
|
+
if (this.api.disableCountdownCancelAll) {
|
|
196
|
+
for (const sym of new Set(positions.map(p => p.symbol))) {
|
|
197
|
+
try {
|
|
198
|
+
await this.api.disableCountdownCancelAll(sym);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
logger.warn(TAG, `periodic disableCountdownCancelAll(${sym}) ignored: ${formatError(err)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Bucket open orders by bracketId + role
|
|
206
|
+
const ordersByBracket = new Map();
|
|
207
|
+
for (const o of openOrders) {
|
|
208
|
+
const cid = o.clientOrderId;
|
|
209
|
+
if (!cid)
|
|
210
|
+
continue;
|
|
211
|
+
const parsed = parseBracketCid(cid);
|
|
212
|
+
if (!parsed)
|
|
213
|
+
continue;
|
|
214
|
+
const bucket = ordersByBracket.get(parsed.bracketId) ?? {};
|
|
215
|
+
bucket[parsed.role] = o;
|
|
216
|
+
ordersByBracket.set(parsed.bracketId, bucket);
|
|
217
|
+
}
|
|
218
|
+
// ---- 1. Walk active ledger entries ----
|
|
219
|
+
for (const entry of this.ledger.getActive()) {
|
|
220
|
+
const onExchange = ordersByBracket.get(entry.bracketId) ?? {};
|
|
221
|
+
const position = positionBySymbol.get(entry.symbol);
|
|
222
|
+
const slExpected = entry.stopPrice !== undefined;
|
|
223
|
+
const tpExpected = entry.targetPrice !== undefined;
|
|
224
|
+
const slLive = !!onExchange.stop;
|
|
225
|
+
const tpLive = !!onExchange.target;
|
|
226
|
+
if (position) {
|
|
227
|
+
// Position alive — brackets SHOULD be present as expected
|
|
228
|
+
const missingSl = slExpected && !slLive;
|
|
229
|
+
const missingTp = tpExpected && !tpLive;
|
|
230
|
+
if (missingSl || missingTp) {
|
|
231
|
+
drifts.push({
|
|
232
|
+
kind: 'position_unprotected',
|
|
233
|
+
symbol: entry.symbol,
|
|
234
|
+
bracketId: entry.bracketId,
|
|
235
|
+
details: `missing: ${[missingSl && 'stop', missingTp && 'target'].filter(Boolean).join('+')}`,
|
|
236
|
+
ts: new Date(this.now()).toISOString(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
drifts.push({
|
|
241
|
+
kind: 'healthy',
|
|
242
|
+
symbol: entry.symbol,
|
|
243
|
+
bracketId: entry.bracketId,
|
|
244
|
+
ts: new Date(this.now()).toISOString(),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (positionsUntrusted) {
|
|
249
|
+
// Positions fetch FAILED — "position gone" is unverifiable. Never
|
|
250
|
+
// mark the ledger terminal or cancel on an untrusted snapshot;
|
|
251
|
+
// brackets (if any) stay, ledger stays, re-evaluate next pass.
|
|
252
|
+
drifts.push({
|
|
253
|
+
kind: 'untrusted_snapshot_skip',
|
|
254
|
+
symbol: entry.symbol,
|
|
255
|
+
bracketId: entry.bracketId,
|
|
256
|
+
details: `positions fetch returned null (429 / weight-paced / transient) — ` +
|
|
257
|
+
`cannot verify ${entry.symbol} is gone. NO ledger mutation, NO cancel. ` +
|
|
258
|
+
`Re-evaluating next pass.`,
|
|
259
|
+
ts: new Date(this.now()).toISOString(),
|
|
260
|
+
});
|
|
261
|
+
logger.warn(TAG, `Untrusted positions snapshot — skipping "position gone" handling for ` +
|
|
262
|
+
`${entry.symbol} (bracketId=${entry.bracketId}); NOT mutating ledger or cancelling.`);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// Position gone (per getPositions) — but is that trustworthy?
|
|
266
|
+
//
|
|
267
|
+
// 2026-05-15 bracket-spiral root cause: `getPositions()` collapses a
|
|
268
|
+
// 429 / transient `fetchPositions` failure to `[]` (live-adapter.ts
|
|
269
|
+
// `positions ?? []`), unlike `getOpenOrders()` which throws on null
|
|
270
|
+
// since the 2026-05-14 fix. `fetchPositions` and `fetchOpenOrders`
|
|
271
|
+
// are independent HTTP calls with independent rate-limit windows —
|
|
272
|
+
// under a 429 storm one succeeds while the other fails. When
|
|
273
|
+
// positions 429-collapses to empty but openOrders SUCCEEDS and shows
|
|
274
|
+
// the bracket legs live, this branch fired `cancelBrackets` and
|
|
275
|
+
// stripped protection off a still-open live position. The position
|
|
276
|
+
// then "reappeared" next snapshot, now naked. Confirmed via prod
|
|
277
|
+
// ALGO_UPDATE sniffer: SL+TP placed, then CANCELED ~46-60s later
|
|
278
|
+
// (= one reconciler cycle) with no Binance reason — i.e. an explicit
|
|
279
|
+
// cancelOrder from us, not an exchange-side action.
|
|
280
|
+
//
|
|
281
|
+
// The discriminator: Binance auto-cancels reduce-only STOP/TP orders
|
|
282
|
+
// within ~10s of a position reaching zero. So if `getOpenOrders`
|
|
283
|
+
// (trustworthy — it throws rather than lying on 429) STILL shows the
|
|
284
|
+
// bracket legs live, the position almost certainly still exists and
|
|
285
|
+
// the empty positions list is a failed fetch. Live brackets + zero
|
|
286
|
+
// positions is a logical contradiction, not a closed position.
|
|
287
|
+
// Refuse the destructive cancel; let the next pass (Patch C backs
|
|
288
|
+
// the cadence off under this exact pressure) re-evaluate with good
|
|
289
|
+
// data. Cost of waiting: ~nothing (Binance auto-cancels genuine
|
|
290
|
+
// reduce-only orphans anyway). Cost of acting on the bad snapshot:
|
|
291
|
+
// a naked live position. See
|
|
292
|
+
// `memory/feedback_getopenorders_null_collapse_naked_loop.md`.
|
|
293
|
+
if (slLive || tpLive) {
|
|
294
|
+
drifts.push({
|
|
295
|
+
kind: 'untrusted_snapshot_skip',
|
|
296
|
+
symbol: entry.symbol,
|
|
297
|
+
bracketId: entry.bracketId,
|
|
298
|
+
details: `getPositions() returned no position for ${entry.symbol} but ` +
|
|
299
|
+
`getOpenOrders() shows live ${[slLive && 'stop', tpLive && 'target'].filter(Boolean).join('+')} ` +
|
|
300
|
+
`(bracketId=${entry.bracketId}). Live reduce-only brackets cannot ` +
|
|
301
|
+
`coexist with a closed position on Binance — treating the empty ` +
|
|
302
|
+
`positions snapshot as a failed fetch and SKIPPING cancel. ` +
|
|
303
|
+
`Re-evaluating next pass.`,
|
|
304
|
+
ts: new Date(this.now()).toISOString(),
|
|
305
|
+
});
|
|
306
|
+
logger.warn(TAG, `REFUSING destructive cancel for ${entry.symbol}: positions=empty but ` +
|
|
307
|
+
`brackets live on exchange (bracketId=${entry.bracketId}). ` +
|
|
308
|
+
`Bad positions snapshot — NOT cancelling live protection.`);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
// Position gone, no brackets open — clean terminal state.
|
|
312
|
+
// We don't know which side triggered (SL vs TP) without fill history,
|
|
313
|
+
// so log as 'cancelled' unless ledger already resolved via the
|
|
314
|
+
// trigger-handler path (future wiring).
|
|
315
|
+
drifts.push({
|
|
316
|
+
kind: 'terminal_clean',
|
|
317
|
+
symbol: entry.symbol,
|
|
318
|
+
bracketId: entry.bracketId,
|
|
319
|
+
ts: new Date(this.now()).toISOString(),
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
this.ledger.markState(entry.symbol, 'cancelled', { closeReason: 'cancelled_auto' });
|
|
323
|
+
}
|
|
324
|
+
catch { /* already terminal */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ---- 2. Walk exchange orders for unknown-bracket orphans ----
|
|
329
|
+
const activeBracketIds = new Set(this.ledger.getActive().map(e => e.bracketId));
|
|
330
|
+
// Prune the first-seen map to bracketIds still present + unmatched on
|
|
331
|
+
// the exchange this pass (resolved/gone ones can't spiral). Keeps it
|
|
332
|
+
// bounded and means a bracketId that disappears then reappears is
|
|
333
|
+
// correctly treated as young again.
|
|
334
|
+
for (const id of [...this.firstSeenOrphanAt.keys()]) {
|
|
335
|
+
if (!ordersByBracket.has(id) || activeBracketIds.has(id)) {
|
|
336
|
+
this.firstSeenOrphanAt.delete(id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [bracketId, bucket] of ordersByBracket) {
|
|
340
|
+
if (activeBracketIds.has(bracketId))
|
|
341
|
+
continue; // already handled
|
|
342
|
+
// Bracket-tagged cids on exchange with no ACTIVE ledger entry.
|
|
343
|
+
// Possible causes: crashed before ledger persist; leftover from old
|
|
344
|
+
// plugin; OR — the 2026-05-16 SOL incident — a bracket just placed
|
|
345
|
+
// by the post-entry auto-attach whose ledger row / position snapshot
|
|
346
|
+
// hasn't propagated yet. NOT always safe to cancel; gated below.
|
|
347
|
+
const samples = [bucket.stop, bucket.target, bucket.entry].filter(Boolean);
|
|
348
|
+
if (samples.length === 0)
|
|
349
|
+
continue;
|
|
350
|
+
const symbol = samples[0].symbol;
|
|
351
|
+
// Record the first pass that ever saw this bracketId unmatched, so
|
|
352
|
+
// the grace clock (below) starts at true first sighting regardless
|
|
353
|
+
// of which guard fires this pass. Pruned above once it resolves.
|
|
354
|
+
const nowMs = this.now();
|
|
355
|
+
if (!this.firstSeenOrphanAt.has(bracketId)) {
|
|
356
|
+
this.firstSeenOrphanAt.set(bracketId, nowMs);
|
|
357
|
+
}
|
|
358
|
+
// ---- PRIMARY POSITIVE-CONFIRMATION GATE (2026-05-16 SOL) --------
|
|
359
|
+
// The authoritative, zero-weight source of per-leg liveness is the
|
|
360
|
+
// trusted user-data WS ALGO_UPDATE map — the SAME signal
|
|
361
|
+
// audit_bracket_protection used to (correctly) report SOL's stop
|
|
362
|
+
// leg LIVE at 08:36:10.167, 271 ms before this sweep cancelled it
|
|
363
|
+
// off a stale REST positions snapshot. Ask it FIRST. If any of the
|
|
364
|
+
// exact orders we are about to cancel is positively WS-confirmed
|
|
365
|
+
// live, it is reduce-only protection on a real position (Binance
|
|
366
|
+
// auto-cancels true reduce-only orphans within ~10 s, so a still-
|
|
367
|
+
// live leg ⇒ a position exists) — NEVER cancel it, regardless of
|
|
368
|
+
// ledger/snapshot state. Absence from `liveCids` is NOT proof a leg
|
|
369
|
+
// is dead (never-recorded ≠ cancelled — same contract as
|
|
370
|
+
// resolveBracketLegLiveness): the WS can only POSITIVELY protect
|
|
371
|
+
// here; it never licenses a cancel. When the WS has no usable
|
|
372
|
+
// opinion (untrusted/silent) we fall through to the snapshot guards
|
|
373
|
+
// and the grace backstop unchanged.
|
|
374
|
+
const wsLive = this.adapter.findTrustedLiveBracketCidsForSymbol?.(symbol);
|
|
375
|
+
if (wsLive?.trusted && wsLive.liveCids.length > 0) {
|
|
376
|
+
const liveSet = new Set(wsLive.liveCids);
|
|
377
|
+
const liveLegCids = samples
|
|
378
|
+
.map(o => o.clientOrderId)
|
|
379
|
+
.filter((c) => !!c && liveSet.has(c));
|
|
380
|
+
if (liveLegCids.length > 0) {
|
|
381
|
+
drifts.push({
|
|
382
|
+
kind: 'unknown_orphan_skip_ws_live',
|
|
383
|
+
symbol,
|
|
384
|
+
bracketId,
|
|
385
|
+
details: `no active ledger row for bracketId=${bracketId} but the trusted ` +
|
|
386
|
+
`user-data WS POSITIVELY confirms leg(s) [${liveLegCids.join(', ')}] ` +
|
|
387
|
+
`LIVE for ${symbol} — authoritative reduce-only protection on a ` +
|
|
388
|
+
`real position. REFUSING to cancel (zero-weight positive proof, ` +
|
|
389
|
+
`independent of the unreliable ledger row / stale positions ` +
|
|
390
|
+
`snapshot). Investigate why the ledger row is missing.`,
|
|
391
|
+
ts: new Date(nowMs).toISOString(),
|
|
392
|
+
});
|
|
393
|
+
logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
|
|
394
|
+
`trusted WS confirms leg(s) [${liveLegCids.join(', ')}] LIVE — ` +
|
|
395
|
+
`these are protecting a live position. Keeping them. Ledger row ` +
|
|
396
|
+
`missing/non-active for an open position — root cause to fix.`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// CRITICAL money-safety guard (2026-05-15 XRP strip loop). A
|
|
401
|
+
// bracket-tagged order with no ACTIVE ledger entry is NOT
|
|
402
|
+
// automatically a safe orphan: if a live position exists for this
|
|
403
|
+
// symbol, these legs are the ONLY exchange protection on a real open
|
|
404
|
+
// position whose ledger row we lost (stale/terminal/restart/the
|
|
405
|
+
// ledger-goes-non-active root cause under investigation). Cancelling
|
|
406
|
+
// them strips a live position naked; attach re-adds, the ledger goes
|
|
407
|
+
// stale again, next pass strips again → infinite naked loop. Losing
|
|
408
|
+
// the ledger row must NEVER strip exchange protection off a live
|
|
409
|
+
// position. Only a TRUE orphan — positions snapshot TRUSTED *and* no
|
|
410
|
+
// live position for the symbol — is safe to cancel.
|
|
411
|
+
if (positionsUntrusted) {
|
|
412
|
+
drifts.push({
|
|
413
|
+
kind: 'untrusted_snapshot_skip',
|
|
414
|
+
symbol,
|
|
415
|
+
bracketId,
|
|
416
|
+
details: `unknown-bracket cids on exchange for ${symbol} but positions ` +
|
|
417
|
+
`snapshot is untrusted (null fetch) — cannot prove these are true ` +
|
|
418
|
+
`orphans vs protection on a live position. SKIPPING cancel.`,
|
|
419
|
+
ts: new Date(this.now()).toISOString(),
|
|
420
|
+
});
|
|
421
|
+
logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
|
|
422
|
+
`positions snapshot untrusted — NOT cancelling, could be live protection.`);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (positionBySymbol.has(normalizeBracketSymbol(symbol))) {
|
|
426
|
+
drifts.push({
|
|
427
|
+
kind: 'unknown_orphan_skip_live_position',
|
|
428
|
+
symbol,
|
|
429
|
+
bracketId,
|
|
430
|
+
details: `no active ledger entry for bracketId=${bracketId} but a LIVE ` +
|
|
431
|
+
`position exists for ${symbol} — these bracket legs are protecting ` +
|
|
432
|
+
`it (lost/stale ledger row). REFUSING to cancel; brackets stay. ` +
|
|
433
|
+
`Investigate why the ledger row went non-active.`,
|
|
434
|
+
ts: new Date(this.now()).toISOString(),
|
|
435
|
+
});
|
|
436
|
+
logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
|
|
437
|
+
`live position present — these legs are its protection. Keeping them. ` +
|
|
438
|
+
`Ledger row lost/non-active for an open position — root cause to investigate.`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// ---- GRACE GUARD (TERTIARY backstop, 2026-05-16 SOL incident) ----
|
|
442
|
+
// Reached only when: the trusted-WS positive gate above had NO
|
|
443
|
+
// usable opinion (WS untrusted/silent, or no live record for these
|
|
444
|
+
// cids) AND trusted snapshot AND no live position for the symbol AND
|
|
445
|
+
// no active ledger row. The authoritative fix is the WS gate above;
|
|
446
|
+
// this remains purely as a safety net for the WS-blind path. Same
|
|
447
|
+
// false-positive the SOL spiral exploited — a bracket placed seconds
|
|
448
|
+
// ago by the post-entry auto-attach, before the ledger row persisted
|
|
449
|
+
// and before the brand-new position propagated into the positions
|
|
450
|
+
// snapshot. A genuine crash/old-plugin orphan is minutes-to-hours
|
|
451
|
+
// old; a real leftover is never seconds old. Two independent youth
|
|
452
|
+
// signals; if EITHER says "young", REFUSE the cancel (positive-
|
|
453
|
+
// safety: only act when positively proven stale, never on a maybe):
|
|
454
|
+
// - reconciler first-seen: first unmatched sighting ⇒ ≤ one pass
|
|
455
|
+
// old from our side ⇒ young by construction.
|
|
456
|
+
// - exchange order age: youngest plausible sample timestamp (algo
|
|
457
|
+
// orders may omit it — then unused, never assumed old).
|
|
458
|
+
const seenAgeMs = nowMs - (this.firstSeenOrphanAt.get(bracketId) ?? nowMs);
|
|
459
|
+
let exchangeAgeMs = Number.POSITIVE_INFINITY;
|
|
460
|
+
for (const o of samples) {
|
|
461
|
+
const ts = o.timestamp;
|
|
462
|
+
if (typeof ts === 'number' && ts > 0 && ts <= nowMs + 5_000) {
|
|
463
|
+
exchangeAgeMs = Math.min(exchangeAgeMs, nowMs - ts);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const apparentAgeMs = Math.min(seenAgeMs, exchangeAgeMs);
|
|
467
|
+
if (apparentAgeMs < ORPHAN_GRACE_MS) {
|
|
468
|
+
drifts.push({
|
|
469
|
+
kind: 'unknown_orphan_grace_skip',
|
|
470
|
+
symbol,
|
|
471
|
+
bracketId,
|
|
472
|
+
details: `bracket-tagged cids for ${symbol} have no active ledger row and ` +
|
|
473
|
+
`no live position in the snapshot, but are only ~${Math.round(apparentAgeMs / 1000)}s old (grace ${Math.round(ORPHAN_GRACE_MS / 1000)}s) — too ` +
|
|
474
|
+
`young to be a crash/old-plugin leftover; treating as a freshly-` +
|
|
475
|
+
`attached bracket racing ledger-persist + position propagation. ` +
|
|
476
|
+
`NOT cancelling this pass.`,
|
|
477
|
+
ts: new Date(nowMs).toISOString(),
|
|
478
|
+
});
|
|
479
|
+
logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
|
|
480
|
+
`only ~${Math.round(apparentAgeMs / 1000)}s old (< ${Math.round(ORPHAN_GRACE_MS / 1000)}s grace) — freshly-placed bracket, ledger/position not yet ` +
|
|
481
|
+
`propagated. Keeping it; re-evaluate next pass.`);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
// ---- AUTHORITATIVE POSITIVE-TERMINAL GATE — THE Section-2 root fix
|
|
485
|
+
// (2026-05-16 SOL recurring naked-position spiral) ------------------
|
|
486
|
+
// Reaching here means: WS positive-live gate had no opinion, the
|
|
487
|
+
// positions snapshot is non-null and does NOT contain this symbol,
|
|
488
|
+
// and the orphan is past the grace window. PRE-FIX that conjunction
|
|
489
|
+
// alone licensed the cancel — Section-2 inferred "orphan" purely
|
|
490
|
+
// from the symbol's ABSENCE in the positions snapshot. That
|
|
491
|
+
// inference is structurally unsound for rc-* legs: every rc-*-{s,t}
|
|
492
|
+
// is a reduce-only STOP/TP, and Binance auto-cancels a reduce-only
|
|
493
|
+
// order within ~10 s of the position reaching zero. A reduce-only
|
|
494
|
+
// rc-* leg still LIVE on the exchange well past the grace window
|
|
495
|
+
// (60 s ≫ 10 s) is therefore POSITIVE PROOF the position is still
|
|
496
|
+
// OPEN and the empty / weight-paced / eventually-consistent
|
|
497
|
+
// positions snapshot is a bad read — the exact null≠empty hole
|
|
498
|
+
// Section-1 already refuses (the "live brackets + empty positions =
|
|
499
|
+
// logical contradiction" branch above) and the precise mechanism
|
|
500
|
+
// every prior guard (WS / live-position / grace) was routed around
|
|
501
|
+
// because each only narrowed, never removed, the
|
|
502
|
+
// positions-absence→cancel path.
|
|
503
|
+
//
|
|
504
|
+
// The invariant, applied symmetrically here: ONLY positive per-leg
|
|
505
|
+
// terminal proof — NEVER positions-absence — may license a
|
|
506
|
+
// destructive bracket cancel. resolveBracketLegLiveness is the same
|
|
507
|
+
// authoritative signal classifyLedgerVsExchange uses (trusted-WS →
|
|
508
|
+
// weight-1 REST → 'unknown' floor). ALL legs positively 'terminal'
|
|
509
|
+
// ⇒ the legs really are dead ⇒ the cancel is a harmless idempotent
|
|
510
|
+
// cleanup (Binance had likely auto-cancelled them already). ANY leg
|
|
511
|
+
// 'live' or 'unknown' ⇒ REFUSE and re-evaluate next pass; a genuine
|
|
512
|
+
// crash orphan stays caught (Binance reduce-only auto-cancel, or a
|
|
513
|
+
// later pass once a leg goes positively terminal / positions read
|
|
514
|
+
// trustworthy). The resolver is optional only for narrow unit mocks;
|
|
515
|
+
// the real LiveAdapter always implements it, so the production path
|
|
516
|
+
// is unconditionally gated and the spiral is structurally impossible.
|
|
517
|
+
if (this.adapter.resolveBracketLegLiveness) {
|
|
518
|
+
const cids = samples
|
|
519
|
+
.map(o => o.clientOrderId)
|
|
520
|
+
.filter((c) => !!c);
|
|
521
|
+
const verdicts = await Promise.all(cids.map(c => this.adapter.resolveBracketLegLiveness(c)));
|
|
522
|
+
const allTerminal = verdicts.length > 0 && verdicts.every(v => v === 'terminal');
|
|
523
|
+
if (!allTerminal) {
|
|
524
|
+
drifts.push({
|
|
525
|
+
kind: 'unknown_orphan_skip_legs_not_terminal',
|
|
526
|
+
symbol,
|
|
527
|
+
bracketId,
|
|
528
|
+
details: `no ledger row + positions snapshot reports ${symbol} flat, but ` +
|
|
529
|
+
`the authoritative per-leg resolver did NOT positively confirm ` +
|
|
530
|
+
`ALL legs terminal (verdicts=${JSON.stringify(verdicts)}). A ` +
|
|
531
|
+
`reduce-only rc-* leg still live past the ${Math.round(ORPHAN_GRACE_MS / 1000)}s grace implies an OPEN position (Binance auto-cancels ` +
|
|
532
|
+
`reduce-only orphans in ~10s) — the empty positions snapshot ` +
|
|
533
|
+
`is a bad read. REFUSING the cancel; only positive terminal ` +
|
|
534
|
+
`proof licenses it. Re-evaluating next pass.`,
|
|
535
|
+
ts: new Date(this.now()).toISOString(),
|
|
536
|
+
});
|
|
537
|
+
logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
|
|
538
|
+
`per-leg resolver verdicts=${JSON.stringify(verdicts)} (not all ` +
|
|
539
|
+
`'terminal'). A live reduce-only rc-* leg past grace means the ` +
|
|
540
|
+
`position is OPEN — positions-absence is NOT proof of ` +
|
|
541
|
+
`orphan-hood. Keeping protection. (Section-2 positive-terminal ` +
|
|
542
|
+
`gate — the 2026-05-16 SOL spiral root fix.)`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
drifts.push({
|
|
547
|
+
kind: 'unknown_orphan',
|
|
548
|
+
symbol,
|
|
549
|
+
bracketId,
|
|
550
|
+
details: `no ledger entry for bracketId=${bracketId}, no live position for ${symbol}, all legs POSITIVELY terminal, ~${Math.round((nowMs - (this.firstSeenOrphanAt.get(bracketId) ?? nowMs)) / 1000)}s observed (> grace); cancelling ${samples.length} orphan order(s)`,
|
|
551
|
+
ts: new Date(this.now()).toISOString(),
|
|
552
|
+
});
|
|
553
|
+
for (const o of samples) {
|
|
554
|
+
if (!o.clientOrderId)
|
|
555
|
+
continue;
|
|
556
|
+
try {
|
|
557
|
+
await this.api.cancelOrderByClientId(o.clientOrderId, o.symbol);
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
logger.warn(TAG, `orphan cancel failed for cid ${o.clientOrderId}: ${formatError(err)}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Emit every drift so listeners (telemetry, admin dashboard) see them all.
|
|
565
|
+
for (const d of drifts)
|
|
566
|
+
this.emit('drift', d);
|
|
567
|
+
return { startedAt, drifts, positionsChecked: positions.length, ordersChecked: openOrders.length };
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
this.running = false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|