@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,565 @@
|
|
|
1
|
+
// REST gap-filler — Phase 2 of TRADE_AUDIT_TRAIL_PLAN.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// The user-data WebSocket is event-driven: Binance only pushes frames when
|
|
6
|
+
// the account moves. A quiet account sits silent for minutes at a time.
|
|
7
|
+
// When a fill lands during a WS reconnect window (stale-watchdog or network
|
|
8
|
+
// blip), the ORDER_TRADE_UPDATE event for that fill is lost forever — it's
|
|
9
|
+
// a pushed stream, not a queryable log. The positions/balance snapshot
|
|
10
|
+
// taken on reconnect reflects the POST-fill state, so truth-check returns
|
|
11
|
+
// OK, but the individual fill row never lands in the `trades` table.
|
|
12
|
+
//
|
|
13
|
+
// In shadow mode this is benign (REST is authoritative for reads). In
|
|
14
|
+
// observe/enforce mode, WS is the sole audit-trail source and missed fills
|
|
15
|
+
// silently degrade correctness of query_trades, daily P&L reconciliation,
|
|
16
|
+
// and downstream analytics. This class closes that gap.
|
|
17
|
+
//
|
|
18
|
+
// WHAT IT DOES
|
|
19
|
+
// ------------
|
|
20
|
+
// On every WS 'connected' event AFTER the initial connect, query
|
|
21
|
+
// `/fapi/v1/userTrades` for every symbol this session has touched, using
|
|
22
|
+
// `since = lastDisconnectTime - safetyBufferMs` so the window overlaps
|
|
23
|
+
// what the WS may already have delivered. Convert each trade to a
|
|
24
|
+
// FillEvent with source='rest_reconcile' and POST via TradeStoreClient.
|
|
25
|
+
//
|
|
26
|
+
// The webapp's ingest endpoint upserts ON CONFLICT (exchange,
|
|
27
|
+
// exchange_trade_id) DO NOTHING — see webapp/src/app/api/internal/trades.
|
|
28
|
+
// Any trade the WS already delivered via source='ws' will lose the race
|
|
29
|
+
// and stay recorded as 'ws' (correct). New trades land with
|
|
30
|
+
// source='rest_reconcile'.
|
|
31
|
+
//
|
|
32
|
+
// WHAT IT DOES NOT DO
|
|
33
|
+
// -------------------
|
|
34
|
+
// - It does NOT touch the LiveStateStore. Positions/balance seeding is
|
|
35
|
+
// the snapshot's job; this class is audit-trail only.
|
|
36
|
+
// - It does NOT gap-fill on the initial connect. The plugin just started —
|
|
37
|
+
// there's no blind window to backfill. Initial snapshot seeds state from
|
|
38
|
+
// REST, which is already authoritative for any fills before startup.
|
|
39
|
+
// - It does NOT retry failed fetches. If fetchMyTrades returns [] because
|
|
40
|
+
// of an error, we log and move on. The next reconnect will retry over
|
|
41
|
+
// a superset window. (Reconnect path only — the startup deep-fill uses
|
|
42
|
+
// fetchMyTradesOrNull and DISTINGUISHES a failed read (null) from a
|
|
43
|
+
// genuinely empty window, aborting the symbol instead of silently
|
|
44
|
+
// truncating the backfill.)
|
|
45
|
+
//
|
|
46
|
+
// SYMBOL TRACKING
|
|
47
|
+
// ---------------
|
|
48
|
+
// Binance requires a symbol per fetchMyTrades call; there's no
|
|
49
|
+
// portfolio-wide trades endpoint. We track every symbol we've seen via
|
|
50
|
+
// WS orderUpdate events + position snapshots. Set never shrinks during
|
|
51
|
+
// the session — a closed position's symbol is still queried on reconnect
|
|
52
|
+
// because late fills (closing trade) may have been missed.
|
|
53
|
+
//
|
|
54
|
+
// Spec:
|
|
55
|
+
// https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/Account-Trade-List
|
|
56
|
+
import { logger, formatError } from '../logger.js';
|
|
57
|
+
import { TouchedSymbolsStore } from './touched-symbols-store.js';
|
|
58
|
+
const TAG = 'rest-gap-filler';
|
|
59
|
+
/** Binance caps a single userTrades window (startTime..endTime) at 7 days, so
|
|
60
|
+
* the startup deep-fill chunks its lookback into windows no larger than this. */
|
|
61
|
+
const DEEP_FILL_WINDOW_MS = 7 * 24 * 60 * 60_000;
|
|
62
|
+
/** CCXT unified symbol 'BTC/USDT:USDT' → canonical 'BTC/USDT'. Matches the
|
|
63
|
+
* WS-side normalizer so gap-fill rows land under the same symbol key
|
|
64
|
+
* every downstream consumer uses. */
|
|
65
|
+
function normSymbol(s) {
|
|
66
|
+
return (s ?? '').replace(/:[A-Z]+$/, '');
|
|
67
|
+
}
|
|
68
|
+
/** Parse a positive-integer env override, falling back to `def` on missing /
|
|
69
|
+
* non-numeric / non-positive input. */
|
|
70
|
+
function parseEnvInt(raw, def) {
|
|
71
|
+
const n = Number(raw);
|
|
72
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : def;
|
|
73
|
+
}
|
|
74
|
+
export class RestGapFiller {
|
|
75
|
+
api;
|
|
76
|
+
stream;
|
|
77
|
+
store;
|
|
78
|
+
client;
|
|
79
|
+
userId;
|
|
80
|
+
exchange;
|
|
81
|
+
safetyBufferMs;
|
|
82
|
+
maxWindowMs;
|
|
83
|
+
perSymbolLimit;
|
|
84
|
+
startupDeepFill;
|
|
85
|
+
startupLookbackMs;
|
|
86
|
+
deepFillPaceMs;
|
|
87
|
+
maxPagesPerWindow;
|
|
88
|
+
now;
|
|
89
|
+
/** Every symbol we've seen via WS orderUpdate, a position snapshot, or
|
|
90
|
+
* the persistent disk store. Never shrinks — a symbol's closing trade
|
|
91
|
+
* may still be pending delivery when its position hits zero, and a
|
|
92
|
+
* symbol traded in a previous session is still a candidate for fills
|
|
93
|
+
* the WS missed during a plugin restart. */
|
|
94
|
+
knownSymbols = new Set();
|
|
95
|
+
/** Optional file-backed persistence so the symbol universe survives
|
|
96
|
+
* plugin restarts. Null in tests that want pure in-memory behaviour. */
|
|
97
|
+
touchedStore;
|
|
98
|
+
/** Timestamp of the last observed disconnect. Bootstrapped to null so
|
|
99
|
+
* the initial window (before the first disconnect) contributes no
|
|
100
|
+
* backfill — no blind window existed before the stream was ever up. */
|
|
101
|
+
lastDisconnectAt = null;
|
|
102
|
+
started = false;
|
|
103
|
+
onResyncRequired;
|
|
104
|
+
onDisconnected;
|
|
105
|
+
onOrderUpdate;
|
|
106
|
+
// Metrics — exposed via getHealth() for operator dashboards.
|
|
107
|
+
reconnectEvents = 0;
|
|
108
|
+
gapFillRuns = 0;
|
|
109
|
+
symbolsQueried = 0;
|
|
110
|
+
tradesBackfilled = 0;
|
|
111
|
+
errorCount = 0;
|
|
112
|
+
constructor(opts) {
|
|
113
|
+
this.api = opts.api;
|
|
114
|
+
this.stream = opts.stream;
|
|
115
|
+
this.store = opts.store;
|
|
116
|
+
this.client = opts.client;
|
|
117
|
+
this.userId = opts.userId;
|
|
118
|
+
this.exchange = opts.exchange ?? 'binance_futures';
|
|
119
|
+
this.safetyBufferMs = opts.safetyBufferMs ?? 10_000;
|
|
120
|
+
this.maxWindowMs = opts.maxWindowMs ?? 60 * 60_000;
|
|
121
|
+
this.perSymbolLimit = opts.perSymbolLimit ?? 500;
|
|
122
|
+
// Startup deep-fill: opt-in via env RC_GAPFILL_STARTUP=on (gated-flag
|
|
123
|
+
// convention — every restart makes paced Binance calls + idempotent webapp
|
|
124
|
+
// POSTs, so it's activated explicitly on deploy, not silently on by default).
|
|
125
|
+
this.startupDeepFill =
|
|
126
|
+
opts.startupDeepFill ?? (process.env.RC_GAPFILL_STARTUP === 'on');
|
|
127
|
+
this.startupLookbackMs =
|
|
128
|
+
opts.startupLookbackMs ??
|
|
129
|
+
parseEnvInt(process.env.RC_GAPFILL_STARTUP_LOOKBACK_MS, DEEP_FILL_WINDOW_MS);
|
|
130
|
+
this.deepFillPaceMs =
|
|
131
|
+
opts.deepFillPaceMs ?? parseEnvInt(process.env.RC_GAPFILL_PACE_MS, 250);
|
|
132
|
+
this.maxPagesPerWindow = opts.maxPagesPerWindow ?? 25;
|
|
133
|
+
this.now = opts.now ?? (() => Date.now());
|
|
134
|
+
// touchedSymbolsStore: explicit null disables persistence; undefined
|
|
135
|
+
// takes the production default (file-backed under ~/.reefclaw).
|
|
136
|
+
this.touchedStore =
|
|
137
|
+
opts.touchedSymbolsStore === null
|
|
138
|
+
? null
|
|
139
|
+
: (opts.touchedSymbolsStore ?? new TouchedSymbolsStore({ userId: this.userId }));
|
|
140
|
+
this.onResyncRequired = (cause) => this.handleResyncRequired(cause);
|
|
141
|
+
this.onDisconnected = () => this.handleDisconnected();
|
|
142
|
+
this.onOrderUpdate = (ev) => {
|
|
143
|
+
if (!ev?.symbol)
|
|
144
|
+
return;
|
|
145
|
+
const norm = normSymbol(ev.symbol);
|
|
146
|
+
if (this.knownSymbols.has(norm))
|
|
147
|
+
return;
|
|
148
|
+
this.knownSymbols.add(norm);
|
|
149
|
+
// Persist on first sighting only — same-symbol updates are a no-op.
|
|
150
|
+
this.touchedStore?.save(this.knownSymbols);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
start() {
|
|
154
|
+
if (this.started)
|
|
155
|
+
return;
|
|
156
|
+
// `resyncRequired` fires on every successful (re)connect AFTER the
|
|
157
|
+
// initial connect — exactly the hook we need. Initial connect has no
|
|
158
|
+
// blind window so we skip it by design (UserDataStream only emits
|
|
159
|
+
// resyncRequired on the 2nd, 3rd, ... connects).
|
|
160
|
+
this.stream.on('resyncRequired', this.onResyncRequired);
|
|
161
|
+
this.stream.on('disconnected', this.onDisconnected);
|
|
162
|
+
this.stream.on('orderUpdate', this.onOrderUpdate);
|
|
163
|
+
this.started = true;
|
|
164
|
+
// Hydrate first so any open-position seed merges in without erasing
|
|
165
|
+
// the persisted history. Then persist if the merge added anything.
|
|
166
|
+
this.hydrateKnownSymbolsFromDisk();
|
|
167
|
+
this.seedKnownSymbolsFromStore();
|
|
168
|
+
// Background income discovery — recovers symbols that were traded
|
|
169
|
+
// during plugin downtime even when they aren't in the persisted
|
|
170
|
+
// touched-symbols cache (e.g. a brand-new symbol that opened AND
|
|
171
|
+
// closed entirely while the plugin was offline). Discovery resolves
|
|
172
|
+
// BEFORE the startup deep-fill so a symbol traded entirely during the
|
|
173
|
+
// outage is in knownSymbols when its trades are queried. Fire-and-forget
|
|
174
|
+
// so start() never blocks the boot path.
|
|
175
|
+
void (async () => {
|
|
176
|
+
try {
|
|
177
|
+
const lookbackSince = this.now() - this.startupLookbackMs;
|
|
178
|
+
// Match the discovery window to the deep-fill lookback so a
|
|
179
|
+
// multi-day-old symbol is found; falls back to the 1h default
|
|
180
|
+
// window when the deep-fill is disabled.
|
|
181
|
+
await this.discoverSymbolsFromIncome(this.startupDeepFill ? lookbackSince : undefined);
|
|
182
|
+
if (this.startupDeepFill) {
|
|
183
|
+
await this.runStartupDeepFill(lookbackSince);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
this.errorCount++;
|
|
188
|
+
logger.error(TAG, `startup discovery/deep-fill chain rejected: ${formatError(err)}`);
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
logger.info(TAG, `Started — userId=${this.userId} safetyBuffer=${this.safetyBufferMs}ms maxWindow=${this.maxWindowMs}ms ` +
|
|
192
|
+
`startupDeepFill=${this.startupDeepFill} startupLookback=${this.startupLookbackMs}ms knownSymbols=${this.knownSymbols.size}`);
|
|
193
|
+
}
|
|
194
|
+
stop() {
|
|
195
|
+
if (!this.started)
|
|
196
|
+
return;
|
|
197
|
+
this.stream.off('resyncRequired', this.onResyncRequired);
|
|
198
|
+
this.stream.off('disconnected', this.onDisconnected);
|
|
199
|
+
this.stream.off('orderUpdate', this.onOrderUpdate);
|
|
200
|
+
this.started = false;
|
|
201
|
+
logger.info(TAG, 'Stopped');
|
|
202
|
+
}
|
|
203
|
+
getHealth() {
|
|
204
|
+
return {
|
|
205
|
+
reconnectEvents: this.reconnectEvents,
|
|
206
|
+
gapFillRuns: this.gapFillRuns,
|
|
207
|
+
symbolsQueried: this.symbolsQueried,
|
|
208
|
+
tradesBackfilled: this.tradesBackfilled,
|
|
209
|
+
errorCount: this.errorCount,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Testing hook — lets unit tests trigger a gap-fill pass with a known
|
|
213
|
+
* time window instead of replaying stream events. */
|
|
214
|
+
async runGapFill(since) {
|
|
215
|
+
this.gapFillRuns++;
|
|
216
|
+
this.seedKnownSymbolsFromStore();
|
|
217
|
+
const symbols = Array.from(this.knownSymbols);
|
|
218
|
+
if (symbols.length === 0) {
|
|
219
|
+
logger.info(TAG, `gap-fill skipped: no known symbols (since=${new Date(since).toISOString()})`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
logger.info(TAG, `gap-fill starting: ${symbols.length} symbol(s), since=${new Date(since).toISOString()}`);
|
|
223
|
+
let totalThisRun = 0;
|
|
224
|
+
for (const symbol of symbols) {
|
|
225
|
+
this.symbolsQueried++;
|
|
226
|
+
try {
|
|
227
|
+
// Binance userTrades takes the raw symbol (e.g. 'BTC/USDT:USDT' —
|
|
228
|
+
// CCXT's unified form). We stored normalized keys, add the suffix
|
|
229
|
+
// back for the API call. CCXT handles either form.
|
|
230
|
+
const apiSymbol = symbol.includes(':') ? symbol : `${symbol}:${symbol.split('/')[1]}`;
|
|
231
|
+
const trades = await this.api.fetchMyTrades(apiSymbol, since, this.perSymbolLimit);
|
|
232
|
+
const posted = this.postTrades(symbol, trades);
|
|
233
|
+
totalThisRun += posted;
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
this.errorCount++;
|
|
237
|
+
logger.warn(TAG, `gap-fill ${symbol} threw: ${formatError(err)} — continuing other symbols`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
logger.info(TAG, `gap-fill complete: ${totalThisRun} fills backfilled across ${symbols.length} symbol(s)`);
|
|
241
|
+
}
|
|
242
|
+
/** One-shot startup deep-fill — the recurrence fix.
|
|
243
|
+
*
|
|
244
|
+
* The reconnect gap-fill (handleResyncRequired) only covers blind windows
|
|
245
|
+
* while the plugin is RUNNING and caps its lookback at maxWindowMs (1h). A
|
|
246
|
+
* fill that lands while the plugin is DOWN — most damagingly a position's
|
|
247
|
+
* closing trade — is never re-pushed by the event-driven WS and was never
|
|
248
|
+
* gap-filled, so it silently never reaches the `trades` ledger. Over many
|
|
249
|
+
* restarts these missing closes accumulate and make the journal's net-PnL
|
|
250
|
+
* (derived from the ledger) wrong. This pass queries userTrades for every
|
|
251
|
+
* known symbol over a configurable lookback so downtime closes land on the
|
|
252
|
+
* next start. Paginated + 7-day-window-chunked (Binance caps userTrades at
|
|
253
|
+
* 1000 rows / 7-day span per call) and paced to stay clear of the per-IP
|
|
254
|
+
* weight ceiling. Idempotent at the webapp (ON CONFLICT DO NOTHING), so
|
|
255
|
+
* re-querying already-recorded fills is a harmless no-op. */
|
|
256
|
+
async runStartupDeepFill(sinceFloor) {
|
|
257
|
+
this.seedKnownSymbolsFromStore();
|
|
258
|
+
const symbols = Array.from(this.knownSymbols);
|
|
259
|
+
if (symbols.length === 0) {
|
|
260
|
+
logger.info(TAG, 'startup deep-fill skipped: no known symbols');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.gapFillRuns++;
|
|
264
|
+
logger.info(TAG, `startup deep-fill starting: ${symbols.length} symbol(s), since=${new Date(sinceFloor).toISOString()} ` +
|
|
265
|
+
`(lookback=${this.startupLookbackMs}ms, pace=${this.deepFillPaceMs}ms)`);
|
|
266
|
+
let total = 0;
|
|
267
|
+
for (const symbol of symbols) {
|
|
268
|
+
try {
|
|
269
|
+
total += await this.deepFillSymbol(symbol, sinceFloor);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
this.errorCount++;
|
|
273
|
+
logger.warn(TAG, `startup deep-fill ${symbol} threw: ${formatError(err)} — continuing other symbols`);
|
|
274
|
+
}
|
|
275
|
+
await this.sleep(this.deepFillPaceMs);
|
|
276
|
+
}
|
|
277
|
+
logger.info(TAG, `startup deep-fill complete: ${total} fill(s) backfilled across ${symbols.length} symbol(s)`);
|
|
278
|
+
}
|
|
279
|
+
/** Deep-fill a single symbol over [sinceFloor, now], chunked into ≤7-day
|
|
280
|
+
* windows (Binance's per-call span cap). Within a window the FIRST page is
|
|
281
|
+
* time-bounded (startTime/endTime); FULL pages continue via Binance's
|
|
282
|
+
* `fromId = lastTradeId + 1` (doc-verified: fromId pages ascending by
|
|
283
|
+
* trade id and CANNOT be combined with startTime/endTime), stopping when
|
|
284
|
+
* a page runs past the window end, comes back short, or hits the page
|
|
285
|
+
* cap. Paging by id — not by `lastTime + 1` — is what keeps
|
|
286
|
+
* same-millisecond trades on a full-page boundary from being skipped.
|
|
287
|
+
*
|
|
288
|
+
* Every fetch goes through the ban-gated `api.fetchMyTradesOrNull`
|
|
289
|
+
* wrapper — we deliberately do NOT use CCXT's built-in `{ paginate: true }`,
|
|
290
|
+
* which re-enters CCXT directly and would bypass the process-wide IP-ban
|
|
291
|
+
* gate. A `null` read (paced / banned / garbled) is a FAILED read, not an
|
|
292
|
+
* empty window: we abort THIS symbol by throwing (caller logs + counts the
|
|
293
|
+
* error and continues other symbols) instead of advancing the cursor —
|
|
294
|
+
* error-as-empty silently truncated the backfill exactly when Binance was
|
|
295
|
+
* shedding us. Returns the number of fills posted. */
|
|
296
|
+
async deepFillSymbol(symbol, sinceFloor) {
|
|
297
|
+
this.symbolsQueried++;
|
|
298
|
+
// Binance userTrades wants CCXT's unified suffixed form ('BTC/USDT:USDT');
|
|
299
|
+
// knownSymbols holds normalized keys, so re-append the quote suffix.
|
|
300
|
+
const apiSymbol = symbol.includes(':') ? symbol : `${symbol}:${symbol.split('/')[1]}`;
|
|
301
|
+
const nowMs = this.now();
|
|
302
|
+
let windowStart = sinceFloor;
|
|
303
|
+
let posted = 0;
|
|
304
|
+
while (windowStart < nowMs) {
|
|
305
|
+
const windowEnd = Math.min(windowStart + DEEP_FILL_WINDOW_MS, nowMs);
|
|
306
|
+
let fromId = null;
|
|
307
|
+
for (let page = 0; page < this.maxPagesPerWindow; page++) {
|
|
308
|
+
const trades = fromId == null
|
|
309
|
+
? await this.api.fetchMyTradesOrNull(apiSymbol, windowStart, this.perSymbolLimit, { endTime: windowEnd })
|
|
310
|
+
: await this.api.fetchMyTradesOrNull(apiSymbol, undefined, this.perSymbolLimit, { fromId });
|
|
311
|
+
if (trades === null) {
|
|
312
|
+
throw new Error(`deep-fill ${symbol}: fetchMyTrades returned null (read failed — paced/banned/garbled). ` +
|
|
313
|
+
'Aborting this symbol WITHOUT marking any window complete; the next startup deep-fill retries (posting is idempotent).');
|
|
314
|
+
}
|
|
315
|
+
if (trades.length === 0)
|
|
316
|
+
break;
|
|
317
|
+
// A fromId continuation page walks forward in id order with NO upper
|
|
318
|
+
// time bound — drop anything past this window (the next window's
|
|
319
|
+
// time-bounded first page re-fetches it; posting is idempotent, the
|
|
320
|
+
// filter just keeps the metrics honest).
|
|
321
|
+
const inWindow = trades.filter((t) => this.tradeTime(t) <= windowEnd);
|
|
322
|
+
posted += this.postTrades(symbol, inWindow);
|
|
323
|
+
if (inWindow.length < trades.length)
|
|
324
|
+
break; // paged past the window end
|
|
325
|
+
// A short page means this window is exhausted.
|
|
326
|
+
if (trades.length < this.perSymbolLimit)
|
|
327
|
+
break;
|
|
328
|
+
const lastId = this.tradeId(trades[trades.length - 1]);
|
|
329
|
+
if (lastId == null) {
|
|
330
|
+
// Cannot continue by id (malformed row) — stop rather than loop.
|
|
331
|
+
logger.warn(TAG, `deep-fill ${symbol}: full page with no parseable trade id — stopping pagination for this window`);
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
fromId = lastId + 1; // fromId is inclusive — skip the last posted trade
|
|
335
|
+
await this.sleep(this.deepFillPaceMs);
|
|
336
|
+
}
|
|
337
|
+
windowStart = windowEnd;
|
|
338
|
+
}
|
|
339
|
+
return posted;
|
|
340
|
+
}
|
|
341
|
+
/** Epoch-ms timestamp of a CCXT trade — unified `timestamp` first, raw
|
|
342
|
+
* Binance `info.time` as fallback. 0 when neither is present. */
|
|
343
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
344
|
+
tradeTime(t) {
|
|
345
|
+
const ts = t?.timestamp != null ? Number(t.timestamp) : Number(t?.info?.time);
|
|
346
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
347
|
+
}
|
|
348
|
+
/** Numeric Binance trade id of a CCXT trade — unified `id` first, raw
|
|
349
|
+
* `info.id` fallback. Null when not a finite number (`fromId` is a LONG
|
|
350
|
+
* on the Binance side, so a non-numeric id cannot seed pagination). */
|
|
351
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
352
|
+
tradeId(t) {
|
|
353
|
+
const raw = t?.id ?? t?.info?.id;
|
|
354
|
+
const n = Number(raw);
|
|
355
|
+
return Number.isFinite(n) ? n : null;
|
|
356
|
+
}
|
|
357
|
+
/** Cooperative delay used to pace deep-fill Binance calls. Resolves
|
|
358
|
+
* immediately for a non-positive duration (e.g. tests pass 0). */
|
|
359
|
+
sleep(ms) {
|
|
360
|
+
if (!(ms > 0))
|
|
361
|
+
return Promise.resolve();
|
|
362
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
363
|
+
}
|
|
364
|
+
// ---- Internals ----
|
|
365
|
+
handleResyncRequired(cause) {
|
|
366
|
+
this.reconnectEvents++;
|
|
367
|
+
const nowMs = this.now();
|
|
368
|
+
// The gap-fill window must cover everything the WS may have missed. Two
|
|
369
|
+
// independent triggers can drive a reconnect:
|
|
370
|
+
// 1. Stale-watchdog / listenKey rotation — happens when WS goes briefly
|
|
371
|
+
// silent but didn't necessarily miss a fill. lastDisconnectAt is
|
|
372
|
+
// ~now and lastEventAt is also recent.
|
|
373
|
+
// 2. Active-probe-detected silent stream — REST sees a fill the WS
|
|
374
|
+
// never delivered. lastDisconnectAt becomes ~now (probe forces
|
|
375
|
+
// reconnect), but lastEventAt may be tens of minutes ago. Using
|
|
376
|
+
// disconnectAt as `since` makes the gap-fill window start AFTER
|
|
377
|
+
// the missed fill, so fetchMyTrades returns 0 every time.
|
|
378
|
+
// Fix: use the OLDER of (lastDisconnectAt, lastEventAt) so the window
|
|
379
|
+
// always covers the actual gap. maxWindow still caps the lookback so
|
|
380
|
+
// we don't backfill multi-hour outages in one shot.
|
|
381
|
+
const disconnectAt = this.lastDisconnectAt ?? nowMs;
|
|
382
|
+
const lastEventAt = this.stream.getHealth().lastEventAt ?? disconnectAt;
|
|
383
|
+
const baseSince = Math.min(disconnectAt, lastEventAt);
|
|
384
|
+
const rawSince = baseSince - this.safetyBufferMs;
|
|
385
|
+
const floorSince = nowMs - this.maxWindowMs;
|
|
386
|
+
const since = Math.max(rawSince, floorSince);
|
|
387
|
+
logger.info(TAG, `resync (cause=${cause}) — scheduling gap-fill since=${new Date(since).toISOString()} ` +
|
|
388
|
+
`(disconnectAt=${new Date(disconnectAt).toISOString()}, lastEventAt=${new Date(lastEventAt).toISOString()})`);
|
|
389
|
+
// Run async; don't block the stream handler. Discovery MUST resolve
|
|
390
|
+
// before runGapFill so a brand-new symbol traded entirely during this
|
|
391
|
+
// outage is in knownSymbols when fetchMyTrades runs. Discovery uses the
|
|
392
|
+
// exact same `since` window as the gap-fill so a single REST call
|
|
393
|
+
// covers everything that may have been missed.
|
|
394
|
+
void (async () => {
|
|
395
|
+
try {
|
|
396
|
+
await this.discoverSymbolsFromIncome(since);
|
|
397
|
+
await this.runGapFill(since);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
this.errorCount++;
|
|
401
|
+
logger.error(TAG, `resync gap-fill chain rejected: ${formatError(err)}`);
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
}
|
|
405
|
+
handleDisconnected() {
|
|
406
|
+
this.lastDisconnectAt = this.now();
|
|
407
|
+
}
|
|
408
|
+
seedKnownSymbolsFromStore() {
|
|
409
|
+
const sizeBefore = this.knownSymbols.size;
|
|
410
|
+
try {
|
|
411
|
+
const positions = this.store.getPositions();
|
|
412
|
+
for (const p of positions) {
|
|
413
|
+
if (p?.symbol)
|
|
414
|
+
this.knownSymbols.add(normSymbol(p.symbol));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// Store may not be initialised yet on the first start() call; no-op.
|
|
419
|
+
}
|
|
420
|
+
if (this.knownSymbols.size > sizeBefore) {
|
|
421
|
+
this.touchedStore?.save(this.knownSymbols);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Merge the on-disk symbol history into the in-memory set. Best-effort —
|
|
425
|
+
* load() returns an empty Set on any error so a corrupt cache file is a
|
|
426
|
+
* silent regression to "session-only" behaviour, never a startup blocker. */
|
|
427
|
+
hydrateKnownSymbolsFromDisk() {
|
|
428
|
+
if (!this.touchedStore)
|
|
429
|
+
return;
|
|
430
|
+
const persisted = this.touchedStore.load();
|
|
431
|
+
if (persisted.size === 0)
|
|
432
|
+
return;
|
|
433
|
+
for (const s of persisted)
|
|
434
|
+
this.knownSymbols.add(s);
|
|
435
|
+
logger.info(TAG, `hydrated ${persisted.size} touched symbol(s) from disk`);
|
|
436
|
+
}
|
|
437
|
+
/** Discovery via /fapi/v1/income. Catches symbols traded during a blind
|
|
438
|
+
* window (plugin downtime, mid-session WS outage) that aren't represented
|
|
439
|
+
* in the persisted touched cache or the current open-position seed —
|
|
440
|
+
* i.e. brand-new symbols that opened AND closed entirely during the gap.
|
|
441
|
+
*
|
|
442
|
+
* `since` defaults to the maxWindow lookback (used by start()'s one-shot
|
|
443
|
+
* pass). On every resync, handleResyncRequired passes the reconnect's
|
|
444
|
+
* computed `since` so the discovery window precisely matches the
|
|
445
|
+
* fetchMyTrades window — a single REST call recovers every symbol that
|
|
446
|
+
* could have had activity in the gap.
|
|
447
|
+
*
|
|
448
|
+
* Best-effort: any error logs and returns; gap-fill still runs against
|
|
449
|
+
* whatever knownSymbols already contains. */
|
|
450
|
+
async discoverSymbolsFromIncome(since) {
|
|
451
|
+
if (typeof this.api.fetchRecentTradedSymbols !== 'function') {
|
|
452
|
+
// Older API surfaces / mocks that don't implement the discovery path.
|
|
453
|
+
// Silent skip preserves existing behaviour for unit tests that don't
|
|
454
|
+
// care about cross-restart symbol recovery.
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const sinceMs = since ?? this.now() - this.maxWindowMs;
|
|
458
|
+
try {
|
|
459
|
+
const symbols = await this.api.fetchRecentTradedSymbols(sinceMs);
|
|
460
|
+
if (!Array.isArray(symbols) || symbols.length === 0)
|
|
461
|
+
return;
|
|
462
|
+
let added = 0;
|
|
463
|
+
for (const s of symbols) {
|
|
464
|
+
const norm = normSymbol(s);
|
|
465
|
+
if (!norm)
|
|
466
|
+
continue;
|
|
467
|
+
if (this.knownSymbols.has(norm))
|
|
468
|
+
continue;
|
|
469
|
+
this.knownSymbols.add(norm);
|
|
470
|
+
added++;
|
|
471
|
+
}
|
|
472
|
+
if (added > 0) {
|
|
473
|
+
this.touchedStore?.save(this.knownSymbols);
|
|
474
|
+
logger.info(TAG, `income discovery added ${added} symbol(s) (since=${new Date(sinceMs).toISOString()}, total=${this.knownSymbols.size})`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
logger.warn(TAG, `income discovery failed: ${formatError(err)} — gap-fill will use existing knownSymbols`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** Convert raw CCXT trade objects to FillEvents and post each to the
|
|
482
|
+
* webapp ingest endpoint. Returns the count successfully posted
|
|
483
|
+
* (posted means handed to the HTTP client; upsert dedup happens in
|
|
484
|
+
* the webapp). Silent on empty input. */
|
|
485
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
486
|
+
postTrades(symbol, raw) {
|
|
487
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
488
|
+
return 0;
|
|
489
|
+
let posted = 0;
|
|
490
|
+
for (const t of raw) {
|
|
491
|
+
const fill = this.tradeToFillEvent(symbol, t);
|
|
492
|
+
if (!fill)
|
|
493
|
+
continue;
|
|
494
|
+
this.client.post(this.userId, fill);
|
|
495
|
+
this.tradesBackfilled++;
|
|
496
|
+
posted++;
|
|
497
|
+
}
|
|
498
|
+
return posted;
|
|
499
|
+
}
|
|
500
|
+
/** Map a CCXT trade (Binance /fapi/v1/userTrades) to our FillEvent shape.
|
|
501
|
+
* Returns null on malformed input — tradeId + orderId + price + qty are
|
|
502
|
+
* all required columns on the trades table.
|
|
503
|
+
*
|
|
504
|
+
* Binance raw field names (NOT CCXT-unified) per the docs — we read these
|
|
505
|
+
* via `t.info.*` as the second-choice source after CCXT's unified fields:
|
|
506
|
+
* id, orderId, symbol, side, positionSide, price, qty, quoteQty,
|
|
507
|
+
* commission, commissionAsset, time, buyer, maker, realizedPnl.
|
|
508
|
+
* Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/Account-Trade-List
|
|
509
|
+
*
|
|
510
|
+
* Fields NOT returned by userTrades (document-verified):
|
|
511
|
+
* - `reduceOnly` — lives on order endpoints, not trades. Gap-filled rows
|
|
512
|
+
* get reduceOnly=undefined; the primary WS path (source='ws') always
|
|
513
|
+
* has it. Since dedup preserves the first-writer row, trades delivered
|
|
514
|
+
* by WS retain reduceOnly; only genuinely-missed-by-WS trades that the
|
|
515
|
+
* gap-filler rescues will have this field blank. Acceptable gap.
|
|
516
|
+
* - `clientOrderId` — same: lives on the order, not the trade. We infer
|
|
517
|
+
* from the ledger by crosswalking `orderId` if consumers need it. */
|
|
518
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
519
|
+
tradeToFillEvent(symbol, t) {
|
|
520
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
521
|
+
const info = (t?.info ?? {});
|
|
522
|
+
// Binance raw field is `id` (not `tradeId`). CCXT maps raw `id` onto the
|
|
523
|
+
// unified `id` field — primary path is `t.id`; fallback to `info.id` for
|
|
524
|
+
// raw responses that haven't been through CCXT's unifier.
|
|
525
|
+
const tradeId = t?.id != null ? String(t.id) : (info.id != null ? String(info.id) : null);
|
|
526
|
+
const orderId = t?.order != null ? String(t.order) : (info.orderId != null ? String(info.orderId) : null);
|
|
527
|
+
const priceRaw = t?.price != null ? Number(t.price) : Number(info.price);
|
|
528
|
+
const qtyRaw = t?.amount != null ? Number(t.amount) : Number(info.qty);
|
|
529
|
+
const timeRaw = t?.timestamp != null ? Number(t.timestamp) : Number(info.time);
|
|
530
|
+
if (!tradeId || !orderId || !(priceRaw > 0) || !(qtyRaw > 0) || !(timeRaw > 0)) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
const sideRaw = String(t?.side ?? info.side ?? 'buy').toLowerCase();
|
|
534
|
+
const side = sideRaw === 'sell' ? 'SELL' : 'BUY';
|
|
535
|
+
// CCXT's unified `takerOrMaker` is the authoritative source; raw
|
|
536
|
+
// Binance exposes `maker: boolean` under info as the second-choice.
|
|
537
|
+
const maker = typeof t?.takerOrMaker === 'string'
|
|
538
|
+
? t.takerOrMaker === 'maker'
|
|
539
|
+
: (info.maker != null ? Boolean(info.maker) : undefined);
|
|
540
|
+
const commissionRaw = t?.fee?.cost != null ? Number(t.fee.cost) : Number(info.commission);
|
|
541
|
+
const commissionAsset = typeof t?.fee?.currency === 'string'
|
|
542
|
+
? t.fee.currency
|
|
543
|
+
: (typeof info.commissionAsset === 'string' ? info.commissionAsset : undefined);
|
|
544
|
+
const realizedPnlRaw = info.realizedPnl != null ? Number(info.realizedPnl) : undefined;
|
|
545
|
+
return {
|
|
546
|
+
exchange: this.exchange,
|
|
547
|
+
exchangeTradeId: tradeId,
|
|
548
|
+
exchangeOrderId: orderId,
|
|
549
|
+
// clientOrderId + reduceOnly are NOT in the Binance userTrades schema.
|
|
550
|
+
// See docstring on this method for why we don't attempt to source them.
|
|
551
|
+
source: 'rest_reconcile',
|
|
552
|
+
userId: this.userId,
|
|
553
|
+
symbol: normSymbol(symbol),
|
|
554
|
+
side,
|
|
555
|
+
quantity: qtyRaw,
|
|
556
|
+
price: priceRaw,
|
|
557
|
+
maker,
|
|
558
|
+
commission: Number.isFinite(commissionRaw) ? commissionRaw : undefined,
|
|
559
|
+
commissionAsset,
|
|
560
|
+
realizedPnl: Number.isFinite(realizedPnlRaw) ? realizedPnlRaw : undefined,
|
|
561
|
+
exchangeTime: timeRaw,
|
|
562
|
+
rawPayload: t,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface TouchedSymbolsStorageOptions {
|
|
2
|
+
/** Override path — primarily for tests. Default is per-user under ~/.reefclaw. */
|
|
3
|
+
path?: string;
|
|
4
|
+
/** Identifies the file owner; written into the JSON for forensic clarity. */
|
|
5
|
+
userId: string;
|
|
6
|
+
/** Test-only injected clock for `updatedAt`. */
|
|
7
|
+
now?: () => number;
|
|
8
|
+
}
|
|
9
|
+
/** Default per-user path. The userId scoping prevents two parallel plugin
|
|
10
|
+
* instances on the same machine (rare, but possible during ops) from
|
|
11
|
+
* clobbering each other's history. */
|
|
12
|
+
export declare function defaultTouchedSymbolsPath(userId: string): string;
|
|
13
|
+
export declare class TouchedSymbolsStore {
|
|
14
|
+
private readonly path;
|
|
15
|
+
private readonly userId;
|
|
16
|
+
private readonly now;
|
|
17
|
+
constructor(opts: TouchedSymbolsStorageOptions);
|
|
18
|
+
/** Read the persisted set. Returns an empty Set on any error so a corrupt
|
|
19
|
+
* file can't block ingest startup. The corrupt file isn't deleted —
|
|
20
|
+
* the next save() naturally overwrites it via tmp+rename. */
|
|
21
|
+
load(): Set<string>;
|
|
22
|
+
/** Atomically persist the set to disk. Best-effort — failures are logged
|
|
23
|
+
* but never thrown; persistence is a recovery helper, not on the hot path. */
|
|
24
|
+
save(symbols: Set<string>): void;
|
|
25
|
+
}
|