@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,450 @@
|
|
|
1
|
+
// LiveStateStore — in-memory account state fed by the user-data WebSocket.
|
|
2
|
+
//
|
|
3
|
+
// Design notes:
|
|
4
|
+
// - Pure state machine. No IO. No timers. No network calls. All testable
|
|
5
|
+
// with synchronous unit tests + snapshot assertions.
|
|
6
|
+
// - Writes come from two sources:
|
|
7
|
+
// 1. `applySnapshot(snap)` — authoritative REST snapshot taken on
|
|
8
|
+
// connect + reconnect + forced resync. Replaces state wholesale.
|
|
9
|
+
// 2. `applyAccountUpdate(ev)` / `applyOrderUpdate(ev)` — WS deltas that
|
|
10
|
+
// mutate state per-symbol / per-asset / per-orderId.
|
|
11
|
+
// - Reads (`getPositions` / `getBalance` / `getOpenOrders`) return
|
|
12
|
+
// STRUCTURAL CLONES. Consumers can't mutate the store by accident.
|
|
13
|
+
// - `diffAgainstSnapshot(rest)` compares local state vs a REST truth-check.
|
|
14
|
+
// Drifts are classified minor/major; parent decides whether to force resync.
|
|
15
|
+
// - Idempotent snapshot apply — a snapshot replaces rather than appends, so
|
|
16
|
+
// a duplicate snapshot doesn't double-count.
|
|
17
|
+
// - No hedge-mode support — v1 is one-way only. If a position event with
|
|
18
|
+
// positionSide=LONG/SHORT (not BOTH) arrives, we log a warning and apply
|
|
19
|
+
// by symbol (first-write-wins). Parent refuses to enable observe/enforce
|
|
20
|
+
// on hedge-mode accounts at a higher layer.
|
|
21
|
+
//
|
|
22
|
+
// Binance event shapes (user-data stream, Futures):
|
|
23
|
+
// ACCOUNT_UPDATE.a.P[i] = { s: symbol, ps: 'LONG'|'SHORT'|'BOTH',
|
|
24
|
+
// pa: positionAmt, ep: entryPrice,
|
|
25
|
+
// up: unrealizedPnl, mt: 'cross'|'isolated',
|
|
26
|
+
// cr: cumulativeRealized (optional) }
|
|
27
|
+
// ACCOUNT_UPDATE.a.B[i] = { a: asset, wb: walletBalance, cw: crossWallet,
|
|
28
|
+
// bc: balanceChange }
|
|
29
|
+
// ORDER_TRADE_UPDATE.o = { s: symbol, i: orderId, c: clientOrderId,
|
|
30
|
+
// S: 'BUY'|'SELL', o: orderType, f: timeInForce,
|
|
31
|
+
// q: origQty, p: origPrice, ap: avgPrice,
|
|
32
|
+
// x: execType ('NEW'|'TRADE'|'CANCELED'|'EXPIRED'|'REJECTED'|...),
|
|
33
|
+
// X: orderStatus ('NEW'|'PARTIALLY_FILLED'|'FILLED'|...),
|
|
34
|
+
// l: lastFilledQty, z: cumFilledQty, L: lastFillPrice,
|
|
35
|
+
// n: commission, N: commissionAsset, T: transactTime }
|
|
36
|
+
//
|
|
37
|
+
// We translate into our narrower domain types (CcxtPosition, CcxtBalance,
|
|
38
|
+
// CcxtOrder) at apply time so the rest of the plugin sees one shape only.
|
|
39
|
+
import { EventEmitter } from 'node:events';
|
|
40
|
+
import { parseBracketCid } from './bracket-id.js';
|
|
41
|
+
import { logger } from '../logger.js';
|
|
42
|
+
const TAG = 'live-state-store';
|
|
43
|
+
const DEFAULT_THRESHOLDS = {
|
|
44
|
+
balanceMinorBps: 10, // 0.1%
|
|
45
|
+
positionMinorContracts: 1e-6, // essentially any nonzero is major
|
|
46
|
+
};
|
|
47
|
+
export class LiveStateStore extends EventEmitter {
|
|
48
|
+
// Positions keyed by symbol (one-way mode: one position per symbol).
|
|
49
|
+
positions = new Map();
|
|
50
|
+
// Balance keyed by currency.
|
|
51
|
+
balances = new Map();
|
|
52
|
+
// Open orders keyed by exchange orderId.
|
|
53
|
+
orders = new Map();
|
|
54
|
+
// Last-apply timestamps for observability.
|
|
55
|
+
lastSnapshotAt = null;
|
|
56
|
+
lastAccountUpdateAt = null;
|
|
57
|
+
lastOrderUpdateAt = null;
|
|
58
|
+
thresholds;
|
|
59
|
+
constructor(thresholds = {}) {
|
|
60
|
+
super();
|
|
61
|
+
this.thresholds = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
62
|
+
}
|
|
63
|
+
// ---- Reads (synchronous, safe) ----
|
|
64
|
+
/** Get positions, optionally filtered by symbol. Returns clones. */
|
|
65
|
+
getPositions(symbol) {
|
|
66
|
+
if (symbol) {
|
|
67
|
+
const p = this.positions.get(symbol);
|
|
68
|
+
return p ? [clonePosition(p)] : [];
|
|
69
|
+
}
|
|
70
|
+
return Array.from(this.positions.values()).map(clonePosition);
|
|
71
|
+
}
|
|
72
|
+
/** Get the current balance view. Matches the CCXT shape. */
|
|
73
|
+
getBalance() {
|
|
74
|
+
const free = {};
|
|
75
|
+
const used = {};
|
|
76
|
+
const total = {};
|
|
77
|
+
const result = { free, used, total };
|
|
78
|
+
for (const [cur, b] of this.balances) {
|
|
79
|
+
free[cur] = b.free;
|
|
80
|
+
used[cur] = b.used;
|
|
81
|
+
total[cur] = b.total;
|
|
82
|
+
result[cur] = { ...b };
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
/** Get open orders, optionally filtered by symbol. Returns clones. */
|
|
87
|
+
getOpenOrders(symbol) {
|
|
88
|
+
const all = Array.from(this.orders.values());
|
|
89
|
+
const filtered = symbol ? all.filter(o => o.symbol === symbol) : all;
|
|
90
|
+
return filtered.map(cloneOrder);
|
|
91
|
+
}
|
|
92
|
+
/** Observability — last-apply timestamps for each write path. */
|
|
93
|
+
getHealth() {
|
|
94
|
+
return {
|
|
95
|
+
lastSnapshotAt: this.lastSnapshotAt,
|
|
96
|
+
lastAccountUpdateAt: this.lastAccountUpdateAt,
|
|
97
|
+
lastOrderUpdateAt: this.lastOrderUpdateAt,
|
|
98
|
+
positionCount: this.positions.size,
|
|
99
|
+
orderCount: this.orders.size,
|
|
100
|
+
balanceAssetCount: this.balances.size,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// ---- Writes ----
|
|
104
|
+
/** Replace state with a REST snapshot. Called on connect + reconnect + forced resync.
|
|
105
|
+
* Idempotent: a duplicate snapshot produces identical state. */
|
|
106
|
+
applySnapshot(snap) {
|
|
107
|
+
this.positions.clear();
|
|
108
|
+
for (const p of snap.positions) {
|
|
109
|
+
if (p.contracts > 0) {
|
|
110
|
+
this.positions.set(p.symbol, clonePosition(p));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.balances.clear();
|
|
114
|
+
if (snap.balance) {
|
|
115
|
+
for (const cur of Object.keys(snap.balance.total ?? {})) {
|
|
116
|
+
const total = snap.balance.total[cur] ?? 0;
|
|
117
|
+
const free = snap.balance.free[cur] ?? 0;
|
|
118
|
+
const used = snap.balance.used[cur] ?? 0;
|
|
119
|
+
if (total > 0 || free > 0 || used > 0) {
|
|
120
|
+
this.balances.set(cur, { free, used, total });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.orders.clear();
|
|
125
|
+
for (const o of snap.openOrders) {
|
|
126
|
+
if (o.status === 'open' && o.id) {
|
|
127
|
+
this.orders.set(o.id, cloneOrder(o));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.lastSnapshotAt = snap.timestamp;
|
|
131
|
+
this.emit('stateChanged', 'snapshot');
|
|
132
|
+
}
|
|
133
|
+
/** Replace the algo-order subset of the store with a fresh REST list.
|
|
134
|
+
*
|
|
135
|
+
* Binance's user-data WS does NOT emit ORDER_TRADE_UPDATE for algo orders
|
|
136
|
+
* (STOP_MARKET / TAKE_PROFIT_MARKET) until they trigger — only the resulting
|
|
137
|
+
* regular order fires the event. As a result, the store cannot observe algo
|
|
138
|
+
* add/cancel/modify events via WS alone and diverges from REST whenever
|
|
139
|
+
* brackets are touched.
|
|
140
|
+
*
|
|
141
|
+
* This method closes that gap: callers (the controller's refresh loop) hand
|
|
142
|
+
* it the current REST view of algo orders, and we swap them in wholesale
|
|
143
|
+
* while leaving regular orders (WS-tracked) untouched. An order is classed
|
|
144
|
+
* as "algo" iff its clientOrderId parses as a reefclaw bracket cid with
|
|
145
|
+
* role stop or target. External algo orders (manual STOP_MARKET placed via
|
|
146
|
+
* Binance UI with no rc- prefix) are NOT tracked by this path — the 60s
|
|
147
|
+
* reconciler snapshot still covers them. */
|
|
148
|
+
replaceAlgoOrders(freshAlgoOrders) {
|
|
149
|
+
let removedStale = 0;
|
|
150
|
+
for (const [id, order] of this.orders) {
|
|
151
|
+
if (isAlgoBracketOrder(order)) {
|
|
152
|
+
this.orders.delete(id);
|
|
153
|
+
removedStale++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let addedFresh = 0;
|
|
157
|
+
for (const o of freshAlgoOrders) {
|
|
158
|
+
if (!o.id || o.status !== 'open')
|
|
159
|
+
continue;
|
|
160
|
+
if (!isAlgoBracketOrder(o))
|
|
161
|
+
continue;
|
|
162
|
+
this.orders.set(o.id, cloneOrder(o));
|
|
163
|
+
addedFresh++;
|
|
164
|
+
}
|
|
165
|
+
if (removedStale !== addedFresh) {
|
|
166
|
+
logger.info('live-state-store', `replaceAlgoOrders: ${removedStale} removed, ${addedFresh} added`);
|
|
167
|
+
}
|
|
168
|
+
this.emit('stateChanged', 'algo_refresh');
|
|
169
|
+
}
|
|
170
|
+
/** Refresh ONLY the mark-derived soft fields (markPrice + unrealizedPnl) of
|
|
171
|
+
* positions that already exist in the store, from a fresh REST snapshot.
|
|
172
|
+
*
|
|
173
|
+
* Why this exists: Binance's user-data `ACCOUNT_UPDATE` is event-driven
|
|
174
|
+
* (fills / funding / transfers) and carries NO per-tick mark price, so a
|
|
175
|
+
* quiet open position's `unrealizedPnl` in this store is FROZEN at the last
|
|
176
|
+
* event's value while the Binance app recomputes uPnl every mark tick. In
|
|
177
|
+
* enforce/observe mode this store is the authoritative read path, so the
|
|
178
|
+
* dashboard's Unrealized cell froze between events ("close but lags/frozen"
|
|
179
|
+
* — 2026-05-16). The controller's 60s truth-check already fetches a fresh
|
|
180
|
+
* REST snapshot (with live uPnl + mark) but DISCARDS it unless position
|
|
181
|
+
* structure drifts (diffPositions ignores uPnl entirely). This method lets
|
|
182
|
+
* the truth-check spend that already-paid REST read on a uPnl refresh
|
|
183
|
+
* instead of throwing it away — bounding uPnl staleness to the 60s interval
|
|
184
|
+
* rather than "until the next trade/funding event".
|
|
185
|
+
*
|
|
186
|
+
* Strictly bounded — this is NOT applySnapshot:
|
|
187
|
+
* - only mutates positions present in BOTH the store and the fresh REST
|
|
188
|
+
* list with the SAME symbol AND side (structural changes are the
|
|
189
|
+
* truth-check's job, not this method's);
|
|
190
|
+
* - never adds, never removes, never touches balance or orders;
|
|
191
|
+
* - so it cannot reintroduce the null≠empty / stale-as-truth collapse
|
|
192
|
+
* class (callers only reach here with a non-null REST snapshot anyway —
|
|
193
|
+
* buildSnapshot throws UntrustedSnapshotError on a failed fetch). */
|
|
194
|
+
refreshDerivedMarkFields(restPositions) {
|
|
195
|
+
let refreshed = 0;
|
|
196
|
+
for (const rp of restPositions) {
|
|
197
|
+
if (rp.contracts <= 0)
|
|
198
|
+
continue;
|
|
199
|
+
const lp = this.positions.get(rp.symbol);
|
|
200
|
+
if (!lp || lp.side !== rp.side)
|
|
201
|
+
continue; // structural change → leave for truth-check
|
|
202
|
+
if (lp.markPrice === rp.markPrice && lp.unrealizedPnl === rp.unrealizedPnl)
|
|
203
|
+
continue;
|
|
204
|
+
this.positions.set(rp.symbol, {
|
|
205
|
+
...lp,
|
|
206
|
+
markPrice: rp.markPrice,
|
|
207
|
+
unrealizedPnl: rp.unrealizedPnl,
|
|
208
|
+
});
|
|
209
|
+
refreshed++;
|
|
210
|
+
}
|
|
211
|
+
if (refreshed > 0) {
|
|
212
|
+
this.emit('stateChanged', 'mark_refresh');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Apply an ACCOUNT_UPDATE event (positions + balance deltas). */
|
|
216
|
+
applyAccountUpdate(ev) {
|
|
217
|
+
for (const pos of ev.positions) {
|
|
218
|
+
// Refuse hedge-mode position events — v1 is one-way. `BOTH` is one-way;
|
|
219
|
+
// `LONG`/`SHORT` on a one-way account shouldn't happen but if it does
|
|
220
|
+
// we warn and still apply by symbol.
|
|
221
|
+
if (pos.positionSide === 'LONG' || pos.positionSide === 'SHORT') {
|
|
222
|
+
logger.warn(TAG, `Hedge-mode position event received for ${pos.symbol} (positionSide=${pos.positionSide}) — v1 is one-way only`);
|
|
223
|
+
this.emit('applyError', 'hedge_mode_position', `${pos.symbol} positionSide=${pos.positionSide}`);
|
|
224
|
+
// Fall through: still apply per symbol. Reconciler will catch drift.
|
|
225
|
+
}
|
|
226
|
+
if (pos.side === 'flat' || pos.quantity <= 0) {
|
|
227
|
+
this.positions.delete(pos.symbol);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const existing = this.positions.get(pos.symbol);
|
|
231
|
+
// Preserve metadata fields we don't receive via WS (setupType, thesis,
|
|
232
|
+
// stopPrice, etc). They're set at open time by the plugin itself, not
|
|
233
|
+
// by Binance. WS updates carry the numeric state; metadata carries over.
|
|
234
|
+
const merged = {
|
|
235
|
+
symbol: pos.symbol,
|
|
236
|
+
side: pos.side,
|
|
237
|
+
contracts: pos.quantity,
|
|
238
|
+
contractSize: existing?.contractSize ?? 1,
|
|
239
|
+
entryPrice: pos.entryPrice,
|
|
240
|
+
markPrice: existing?.markPrice ?? pos.entryPrice,
|
|
241
|
+
notional: pos.quantity * pos.entryPrice,
|
|
242
|
+
unrealizedPnl: pos.unrealizedPnl,
|
|
243
|
+
percentage: existing?.percentage ?? 0,
|
|
244
|
+
timestamp: ev.eventTime,
|
|
245
|
+
datetime: new Date(ev.eventTime).toISOString(),
|
|
246
|
+
// Carry-over metadata
|
|
247
|
+
setupType: existing?.setupType,
|
|
248
|
+
thesis: existing?.thesis,
|
|
249
|
+
stopPrice: existing?.stopPrice,
|
|
250
|
+
targetPrice: existing?.targetPrice,
|
|
251
|
+
regime: existing?.regime,
|
|
252
|
+
regimeConfidence: existing?.regimeConfidence,
|
|
253
|
+
scorecardVerdict: existing?.scorecardVerdict,
|
|
254
|
+
confluenceScore: existing?.confluenceScore,
|
|
255
|
+
};
|
|
256
|
+
this.positions.set(pos.symbol, merged);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
for (const bal of ev.balances) {
|
|
260
|
+
if (bal.walletBalance <= 0 && (this.balances.get(bal.asset)?.used ?? 0) <= 0) {
|
|
261
|
+
// Zero wallet and no reserved margin: drop the asset so getBalance()
|
|
262
|
+
// doesn't return a forest of empty entries.
|
|
263
|
+
this.balances.delete(bal.asset);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const prev = this.balances.get(bal.asset);
|
|
267
|
+
const used = prev?.used ?? 0;
|
|
268
|
+
// `free` is derived — Binance's WS balance-change doesn't include
|
|
269
|
+
// free/used split. We keep whatever `used` we already had (margin
|
|
270
|
+
// lockup changes through position events, not balance events) and
|
|
271
|
+
// compute `free = total - used`.
|
|
272
|
+
const total = bal.walletBalance;
|
|
273
|
+
const free = Math.max(0, total - used);
|
|
274
|
+
this.balances.set(bal.asset, { free, used, total });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this.lastAccountUpdateAt = ev.eventTime;
|
|
278
|
+
this.emit('stateChanged', 'account_update');
|
|
279
|
+
}
|
|
280
|
+
/** Apply an ORDER_TRADE_UPDATE event (single order state transition). */
|
|
281
|
+
applyOrderUpdate(ev) {
|
|
282
|
+
const key = ev.orderId;
|
|
283
|
+
if (ev.status === 'closed' || ev.status === 'canceled') {
|
|
284
|
+
// Terminal — remove from open-orders view.
|
|
285
|
+
this.orders.delete(key);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// open (NEW or PARTIALLY_FILLED)
|
|
289
|
+
const existing = this.orders.get(key);
|
|
290
|
+
const cctxType = ev.type === 'limit' ? 'limit' : 'market';
|
|
291
|
+
const merged = {
|
|
292
|
+
id: ev.orderId,
|
|
293
|
+
symbol: ev.symbol,
|
|
294
|
+
side: ev.side,
|
|
295
|
+
type: cctxType,
|
|
296
|
+
status: 'open',
|
|
297
|
+
amount: ev.origQuantity,
|
|
298
|
+
filled: ev.cumulativeFilled,
|
|
299
|
+
remaining: Math.max(0, ev.origQuantity - ev.cumulativeFilled),
|
|
300
|
+
average: ev.avgPrice,
|
|
301
|
+
price: ev.origPrice,
|
|
302
|
+
cost: ev.cumulativeFilled * (ev.avgPrice ?? 0),
|
|
303
|
+
fee: existing?.fee ?? { cost: ev.commission, currency: ev.commissionAsset },
|
|
304
|
+
timestamp: ev.transactionTime,
|
|
305
|
+
datetime: new Date(ev.transactionTime).toISOString(),
|
|
306
|
+
timeInForce: ev.timeInForce,
|
|
307
|
+
clientOrderId: ev.clientOrderId,
|
|
308
|
+
};
|
|
309
|
+
this.orders.set(key, merged);
|
|
310
|
+
}
|
|
311
|
+
this.lastOrderUpdateAt = ev.eventTime;
|
|
312
|
+
this.emit('stateChanged', 'order_update');
|
|
313
|
+
}
|
|
314
|
+
/** Clear all state. Used on shutdown/reset. */
|
|
315
|
+
reset() {
|
|
316
|
+
this.positions.clear();
|
|
317
|
+
this.balances.clear();
|
|
318
|
+
this.orders.clear();
|
|
319
|
+
this.lastSnapshotAt = null;
|
|
320
|
+
this.lastAccountUpdateAt = null;
|
|
321
|
+
this.lastOrderUpdateAt = null;
|
|
322
|
+
}
|
|
323
|
+
// ---- Drift detection (60s truth-check) ----
|
|
324
|
+
/** Diff local state vs a fresh REST snapshot. Classifies drifts by severity
|
|
325
|
+
* so the parent reconciler can decide: log-only (minor) vs forced resync (major). */
|
|
326
|
+
diffAgainstSnapshot(rest) {
|
|
327
|
+
const positions = this.diffPositions(rest.positions);
|
|
328
|
+
const balance = this.diffBalance(rest.balance);
|
|
329
|
+
const orders = this.diffOrders(rest.openOrders);
|
|
330
|
+
// Severity rollup
|
|
331
|
+
const hasMajorPosition = positions.some(p => p.kind === 'new' || p.kind === 'closed' || p.kind === 'side_changed' ||
|
|
332
|
+
Math.abs(p.localContracts - p.restContracts) > this.thresholds.positionMinorContracts);
|
|
333
|
+
const hasMajorBalance = balance.some(b => Math.abs(b.deltaBps) >= this.thresholds.balanceMinorBps);
|
|
334
|
+
const hasMajorOrder = orders.length > 0; // any order mismatch is major — small-N
|
|
335
|
+
let severity = 'none';
|
|
336
|
+
if (hasMajorPosition || hasMajorBalance || hasMajorOrder) {
|
|
337
|
+
severity = 'major';
|
|
338
|
+
}
|
|
339
|
+
else if (positions.length > 0 || balance.length > 0) {
|
|
340
|
+
severity = 'minor';
|
|
341
|
+
}
|
|
342
|
+
const summary = severity === 'none'
|
|
343
|
+
? 'no drift'
|
|
344
|
+
: `${positions.length} position / ${balance.length} balance / ${orders.length} order diffs`;
|
|
345
|
+
return { severity, positions, balance, orders, summary };
|
|
346
|
+
}
|
|
347
|
+
diffPositions(rest) {
|
|
348
|
+
const drifts = [];
|
|
349
|
+
const restMap = new Map(rest.filter(p => p.contracts > 0).map(p => [p.symbol, p]));
|
|
350
|
+
for (const [symbol, rp] of restMap) {
|
|
351
|
+
const lp = this.positions.get(symbol);
|
|
352
|
+
if (!lp) {
|
|
353
|
+
drifts.push({
|
|
354
|
+
symbol,
|
|
355
|
+
localContracts: 0, restContracts: rp.contracts,
|
|
356
|
+
localSide: null, restSide: rp.side,
|
|
357
|
+
kind: 'new',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
else if (lp.side !== rp.side) {
|
|
361
|
+
drifts.push({
|
|
362
|
+
symbol,
|
|
363
|
+
localContracts: lp.contracts, restContracts: rp.contracts,
|
|
364
|
+
localSide: lp.side, restSide: rp.side,
|
|
365
|
+
kind: 'side_changed',
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else if (Math.abs(lp.contracts - rp.contracts) > this.thresholds.positionMinorContracts) {
|
|
369
|
+
drifts.push({
|
|
370
|
+
symbol,
|
|
371
|
+
localContracts: lp.contracts, restContracts: rp.contracts,
|
|
372
|
+
localSide: lp.side, restSide: rp.side,
|
|
373
|
+
kind: 'size_changed',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const [symbol, lp] of this.positions) {
|
|
378
|
+
if (!restMap.has(symbol)) {
|
|
379
|
+
drifts.push({
|
|
380
|
+
symbol,
|
|
381
|
+
localContracts: lp.contracts, restContracts: 0,
|
|
382
|
+
localSide: lp.side, restSide: null,
|
|
383
|
+
kind: 'closed',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return drifts;
|
|
388
|
+
}
|
|
389
|
+
diffBalance(rest) {
|
|
390
|
+
const drifts = [];
|
|
391
|
+
if (!rest)
|
|
392
|
+
return drifts;
|
|
393
|
+
const restTotals = rest.total ?? {};
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
for (const asset of Object.keys(restTotals)) {
|
|
396
|
+
seen.add(asset);
|
|
397
|
+
const restTotal = restTotals[asset] ?? 0;
|
|
398
|
+
const local = this.balances.get(asset);
|
|
399
|
+
const localTotal = local?.total ?? 0;
|
|
400
|
+
if (Math.abs(localTotal - restTotal) < 1e-10)
|
|
401
|
+
continue;
|
|
402
|
+
const denom = Math.abs(restTotal) > 1e-10 ? Math.abs(restTotal) : 1;
|
|
403
|
+
const deltaBps = ((localTotal - restTotal) / denom) * 10_000;
|
|
404
|
+
drifts.push({ asset, localTotal, restTotal, deltaBps });
|
|
405
|
+
}
|
|
406
|
+
for (const [asset, b] of this.balances) {
|
|
407
|
+
if (seen.has(asset))
|
|
408
|
+
continue;
|
|
409
|
+
// Asset present locally but absent from REST
|
|
410
|
+
if (b.total > 1e-10) {
|
|
411
|
+
drifts.push({
|
|
412
|
+
asset, localTotal: b.total, restTotal: 0,
|
|
413
|
+
deltaBps: 10_000, // effectively infinite divergence
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return drifts;
|
|
418
|
+
}
|
|
419
|
+
diffOrders(rest) {
|
|
420
|
+
const drifts = [];
|
|
421
|
+
const restMap = new Map(rest.filter(o => o.status === 'open' && o.id).map(o => [o.id, o]));
|
|
422
|
+
for (const [id, ro] of restMap) {
|
|
423
|
+
if (!this.orders.has(id)) {
|
|
424
|
+
drifts.push({ orderId: id, symbol: ro.symbol, kind: 'missing_locally' });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const [id, lo] of this.orders) {
|
|
428
|
+
if (!restMap.has(id)) {
|
|
429
|
+
drifts.push({ orderId: id, symbol: lo.symbol, kind: 'missing_on_rest' });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return drifts;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// ---- Clone helpers ----
|
|
436
|
+
function clonePosition(p) {
|
|
437
|
+
return { ...p };
|
|
438
|
+
}
|
|
439
|
+
function cloneOrder(o) {
|
|
440
|
+
return { ...o, fee: { ...o.fee } };
|
|
441
|
+
}
|
|
442
|
+
/** True iff an order is a reefclaw-managed bracket SL/TP (i.e. an algo order
|
|
443
|
+
* we track). Regular entries with bracket-cid role 'entry' are NOT algo;
|
|
444
|
+
* they fire normal ORDER_TRADE_UPDATE events and stay in the store via WS. */
|
|
445
|
+
function isAlgoBracketOrder(o) {
|
|
446
|
+
if (!o.clientOrderId)
|
|
447
|
+
return false;
|
|
448
|
+
const parsed = parseBracketCid(o.clientOrderId);
|
|
449
|
+
return parsed !== null && (parsed.role === 'stop' || parsed.role === 'target');
|
|
450
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { FactSubscriber } from './fact-subscriber.js';
|
|
2
|
+
import { type EvaluatorParams } from './local-strategy-evaluator.js';
|
|
3
|
+
import type { StrategyConfig, SignalSnapshot } from '@reefclaw/shared';
|
|
4
|
+
export interface LocalSignalServiceOptions {
|
|
5
|
+
intelligenceUrl: string;
|
|
6
|
+
connectionToken: string;
|
|
7
|
+
/** Symbols to stream + evaluate locally. */
|
|
8
|
+
symbols: string[];
|
|
9
|
+
params?: EvaluatorParams;
|
|
10
|
+
/** Strategy mirror refresh cadence (default 10 min). */
|
|
11
|
+
strategyRefreshMs?: number;
|
|
12
|
+
/** Local evaluation cadence (default 5s — matches the central engine). */
|
|
13
|
+
evalIntervalMs?: number;
|
|
14
|
+
/** Injected for tests. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
/** Injected for tests. */
|
|
17
|
+
now?: () => number;
|
|
18
|
+
/** Max age of a symbol's streamed fact before getSnapshot refuses to serve
|
|
19
|
+
* it (default 60s = 12× the broadcaster's 5s cadence). A stale fact means
|
|
20
|
+
* the stream is stalled/down — enforce-mode get_signals must fall back to
|
|
21
|
+
* central rather than serve signals computed from old market data. */
|
|
22
|
+
factStalenessMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class LocalSignalService {
|
|
25
|
+
private readonly opts;
|
|
26
|
+
private readonly subscriber;
|
|
27
|
+
private readonly evaluator;
|
|
28
|
+
private readonly params;
|
|
29
|
+
private readonly doFetch;
|
|
30
|
+
private readonly now;
|
|
31
|
+
private strategies;
|
|
32
|
+
private readonly statesCache;
|
|
33
|
+
private readonly recent;
|
|
34
|
+
private strategyTimer;
|
|
35
|
+
private evalTimer;
|
|
36
|
+
constructor(opts: LocalSignalServiceOptions);
|
|
37
|
+
private factIsFresh;
|
|
38
|
+
start(): Promise<void>;
|
|
39
|
+
stop(): void;
|
|
40
|
+
/** Latest locally-computed snapshot for a symbol, or null if no fact +
|
|
41
|
+
* evaluation has happened yet OR the symbol's streamed fact has gone
|
|
42
|
+
* stale (caller should fall back to central in both cases). */
|
|
43
|
+
getSnapshot(symbol: string): SignalSnapshot | null;
|
|
44
|
+
hasSnapshot(symbol: string): boolean;
|
|
45
|
+
getStats(): {
|
|
46
|
+
strategies: number;
|
|
47
|
+
symbolsEvaluated: number;
|
|
48
|
+
subscriber: ReturnType<FactSubscriber['getStats']>;
|
|
49
|
+
};
|
|
50
|
+
/** One evaluation cycle across all streamed symbols (mirrors the engine tick). */
|
|
51
|
+
evalAll(): Promise<void>;
|
|
52
|
+
private refreshStrategies;
|
|
53
|
+
/** Test seam — set strategies without an HTTP fetch. */
|
|
54
|
+
__setStrategiesForTest(s: StrategyConfig[]): void;
|
|
55
|
+
/** Test seam — feed a raw SSE chunk to the subscriber. */
|
|
56
|
+
__ingestForTest(chunk: string): void;
|
|
57
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Local signal service — facts-out (docs/FACTS_OUT_ARCHITECTURE.md) step 5.
|
|
2
|
+
//
|
|
3
|
+
// Runs the central signal engine's job LOCALLY in the plugin: subscribes to the
|
|
4
|
+
// intel fact stream, mirrors the user's strategy configs (GET /api/strategies),
|
|
5
|
+
// and evaluates them every ~5s with the shared LocalStrategyEvaluator — caching
|
|
6
|
+
// per-symbol states + a recent-signals ring exactly like the central engine.
|
|
7
|
+
// getSnapshot(symbol) returns the same SignalSnapshot shape get_signals serves.
|
|
8
|
+
//
|
|
9
|
+
// Gated by plugin-config `signals.evaluator` (central | shadow | enforce):
|
|
10
|
+
// central (default) — service never created; get_signals calls intel as today.
|
|
11
|
+
// shadow — service runs; get_signals still returns central but logs
|
|
12
|
+
// the local snapshot for comparison. Zero behaviour change.
|
|
13
|
+
// enforce — get_signals returns the local snapshot (falling back to
|
|
14
|
+
// central when no fact has arrived for that symbol yet).
|
|
15
|
+
import { FactSubscriber } from './fact-subscriber.js';
|
|
16
|
+
import { LocalStrategyEvaluator, DEFAULT_EVALUATOR_PARAMS } from './local-strategy-evaluator.js';
|
|
17
|
+
const RECENT_RING = 50;
|
|
18
|
+
const DEFAULT_FACT_STALENESS_MS = 60_000;
|
|
19
|
+
export class LocalSignalService {
|
|
20
|
+
opts;
|
|
21
|
+
subscriber;
|
|
22
|
+
evaluator = new LocalStrategyEvaluator();
|
|
23
|
+
params;
|
|
24
|
+
doFetch;
|
|
25
|
+
now;
|
|
26
|
+
strategies = [];
|
|
27
|
+
statesCache = new Map();
|
|
28
|
+
recent = new Map();
|
|
29
|
+
strategyTimer = null;
|
|
30
|
+
evalTimer = null;
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
this.params = opts.params ?? DEFAULT_EVALUATOR_PARAMS;
|
|
34
|
+
this.doFetch = opts.fetchImpl ?? fetch;
|
|
35
|
+
this.now = opts.now ?? Date.now;
|
|
36
|
+
this.subscriber = new FactSubscriber({
|
|
37
|
+
baseUrl: opts.intelligenceUrl,
|
|
38
|
+
token: opts.connectionToken,
|
|
39
|
+
symbols: opts.symbols,
|
|
40
|
+
fetchImpl: opts.fetchImpl,
|
|
41
|
+
now: opts.now,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
factIsFresh(symbol) {
|
|
45
|
+
const at = this.subscriber.getLatestFactAt(symbol);
|
|
46
|
+
if (at === null)
|
|
47
|
+
return false;
|
|
48
|
+
return this.now() - at <= (this.opts.factStalenessMs ?? DEFAULT_FACT_STALENESS_MS);
|
|
49
|
+
}
|
|
50
|
+
async start() {
|
|
51
|
+
await this.refreshStrategies();
|
|
52
|
+
this.strategyTimer = setInterval(() => void this.refreshStrategies(), this.opts.strategyRefreshMs ?? 600_000);
|
|
53
|
+
this.evalTimer = setInterval(() => void this.evalAll(), this.opts.evalIntervalMs ?? 5_000);
|
|
54
|
+
// Don't let the background timers hold the process open during shutdown.
|
|
55
|
+
this.strategyTimer.unref?.();
|
|
56
|
+
this.evalTimer.unref?.();
|
|
57
|
+
this.subscriber.start();
|
|
58
|
+
}
|
|
59
|
+
stop() {
|
|
60
|
+
if (this.strategyTimer) {
|
|
61
|
+
clearInterval(this.strategyTimer);
|
|
62
|
+
this.strategyTimer = null;
|
|
63
|
+
}
|
|
64
|
+
if (this.evalTimer) {
|
|
65
|
+
clearInterval(this.evalTimer);
|
|
66
|
+
this.evalTimer = null;
|
|
67
|
+
}
|
|
68
|
+
this.subscriber.stop();
|
|
69
|
+
}
|
|
70
|
+
/** Latest locally-computed snapshot for a symbol, or null if no fact +
|
|
71
|
+
* evaluation has happened yet OR the symbol's streamed fact has gone
|
|
72
|
+
* stale (caller should fall back to central in both cases). */
|
|
73
|
+
getSnapshot(symbol) {
|
|
74
|
+
const states = this.statesCache.get(symbol);
|
|
75
|
+
if (!states)
|
|
76
|
+
return null;
|
|
77
|
+
if (!this.factIsFresh(symbol))
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
activeStrategies: states,
|
|
81
|
+
recentSignals: this.recent.get(symbol) ?? [],
|
|
82
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
83
|
+
scope: 'local',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
hasSnapshot(symbol) {
|
|
87
|
+
return this.statesCache.has(symbol);
|
|
88
|
+
}
|
|
89
|
+
getStats() {
|
|
90
|
+
return {
|
|
91
|
+
strategies: this.strategies.length,
|
|
92
|
+
symbolsEvaluated: this.statesCache.size,
|
|
93
|
+
subscriber: this.subscriber.getStats(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** One evaluation cycle across all streamed symbols (mirrors the engine tick). */
|
|
97
|
+
async evalAll() {
|
|
98
|
+
if (this.strategies.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
const now = this.now();
|
|
101
|
+
for (const symbol of this.opts.symbols) {
|
|
102
|
+
const ctx = this.subscriber.getLatest(symbol);
|
|
103
|
+
if (!ctx)
|
|
104
|
+
continue;
|
|
105
|
+
// Don't re-evaluate stale contexts — getSnapshot refuses them anyway.
|
|
106
|
+
if (!this.factIsFresh(symbol))
|
|
107
|
+
continue;
|
|
108
|
+
const { states, signals } = await this.evaluator.evaluateSnapshot(ctx, this.strategies, now, this.params);
|
|
109
|
+
this.statesCache.set(symbol, states);
|
|
110
|
+
if (signals.length > 0) {
|
|
111
|
+
const ring = this.recent.get(symbol) ?? [];
|
|
112
|
+
ring.unshift(...signals);
|
|
113
|
+
if (ring.length > RECENT_RING)
|
|
114
|
+
ring.length = RECENT_RING;
|
|
115
|
+
this.recent.set(symbol, ring);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async refreshStrategies() {
|
|
120
|
+
try {
|
|
121
|
+
const resp = await this.doFetch(`${this.opts.intelligenceUrl}/api/strategies?active=true`, {
|
|
122
|
+
headers: { Authorization: `Bearer ${this.opts.connectionToken}` },
|
|
123
|
+
});
|
|
124
|
+
if (!resp.ok)
|
|
125
|
+
return;
|
|
126
|
+
const body = (await resp.json());
|
|
127
|
+
const configs = (body.strategies ?? [])
|
|
128
|
+
.map((r) => r.config)
|
|
129
|
+
.filter((c) => !!c);
|
|
130
|
+
// Keep last-known strategies if the fetch returned an empty/garbage set.
|
|
131
|
+
if (configs.length > 0)
|
|
132
|
+
this.strategies = configs;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* keep last-known strategies — fail-open */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Test seam — set strategies without an HTTP fetch. */
|
|
139
|
+
__setStrategiesForTest(s) {
|
|
140
|
+
this.strategies = s;
|
|
141
|
+
}
|
|
142
|
+
/** Test seam — feed a raw SSE chunk to the subscriber. */
|
|
143
|
+
__ingestForTest(chunk) {
|
|
144
|
+
this.subscriber.ingest(chunk);
|
|
145
|
+
}
|
|
146
|
+
}
|