@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,35 @@
|
|
|
1
|
+
export interface SymbolRules {
|
|
2
|
+
minNotional: number;
|
|
3
|
+
minQty: number;
|
|
4
|
+
stepSize: number;
|
|
5
|
+
tickSize: number;
|
|
6
|
+
maxQty: number;
|
|
7
|
+
pricePrecision: number;
|
|
8
|
+
amountPrecision: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ValidationResult {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
roundedAmount: number;
|
|
14
|
+
roundedPrice?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Normalize symbol format for consistent lookups.
|
|
18
|
+
* Tools use "BTC/USDT", CCXT futures markets are "BTC/USDT:USDT".
|
|
19
|
+
* This function tries both formats to find a match.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeSymbol(symbol: string, knownSymbols: Set<string>): string;
|
|
22
|
+
export declare class ExchangeInfoCache {
|
|
23
|
+
private rules;
|
|
24
|
+
/** Set of all known symbols for normalization lookups. */
|
|
25
|
+
private knownSymbols;
|
|
26
|
+
/** Load market info from CCXT exchange instance. */
|
|
27
|
+
load(exchange: any): Promise<void>;
|
|
28
|
+
/** Get rules for a symbol (tries both BTC/USDT and BTC/USDT:USDT formats). */
|
|
29
|
+
getRules(symbol: string): SymbolRules | undefined;
|
|
30
|
+
/** Validate and round order parameters before submission. */
|
|
31
|
+
validate(symbol: string, amount: number, price?: number, lastPrice?: number): ValidationResult;
|
|
32
|
+
/** Round value to step size and precision. */
|
|
33
|
+
private roundToStep;
|
|
34
|
+
get size(): number;
|
|
35
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Exchange info cache — caches per-symbol trading rules (min notional, lot sizes, tick sizes).
|
|
2
|
+
// Loaded once on startup from CCXT loadMarkets(), used to validate and round orders before submission.
|
|
3
|
+
import { logger } from '../logger.js';
|
|
4
|
+
const TAG = 'exchange-info';
|
|
5
|
+
/**
|
|
6
|
+
* Normalize symbol format for consistent lookups.
|
|
7
|
+
* Tools use "BTC/USDT", CCXT futures markets are "BTC/USDT:USDT".
|
|
8
|
+
* This function tries both formats to find a match.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeSymbol(symbol, knownSymbols) {
|
|
11
|
+
// Exact match first
|
|
12
|
+
if (knownSymbols.has(symbol))
|
|
13
|
+
return symbol;
|
|
14
|
+
// Try adding :USDT suffix (most common futures pattern)
|
|
15
|
+
const withSuffix = `${symbol}:USDT`;
|
|
16
|
+
if (knownSymbols.has(withSuffix))
|
|
17
|
+
return withSuffix;
|
|
18
|
+
// Try removing :USDT suffix
|
|
19
|
+
const withoutSuffix = symbol.replace(/:USDT$/, '');
|
|
20
|
+
if (knownSymbols.has(withoutSuffix))
|
|
21
|
+
return withoutSuffix;
|
|
22
|
+
// No match — return as-is (will fail validation with "Unknown symbol")
|
|
23
|
+
return symbol;
|
|
24
|
+
}
|
|
25
|
+
export class ExchangeInfoCache {
|
|
26
|
+
rules = new Map();
|
|
27
|
+
/** Set of all known symbols for normalization lookups. */
|
|
28
|
+
knownSymbols = new Set();
|
|
29
|
+
/** Load market info from CCXT exchange instance. */
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
async load(exchange) {
|
|
32
|
+
const info = await exchange.loadMarkets(true); // force refresh
|
|
33
|
+
for (const [symbol, market] of Object.entries(info)) {
|
|
34
|
+
const m = market;
|
|
35
|
+
// Only cache futures (swap) markets
|
|
36
|
+
if (m.type !== 'swap')
|
|
37
|
+
continue;
|
|
38
|
+
const limits = m.limits;
|
|
39
|
+
const precision = m.precision;
|
|
40
|
+
const rules = {
|
|
41
|
+
minNotional: limits?.cost?.min ?? 5,
|
|
42
|
+
minQty: limits?.amount?.min ?? 0.001,
|
|
43
|
+
stepSize: precision?.amount != null ? Math.pow(10, -precision.amount) : 0.001,
|
|
44
|
+
tickSize: precision?.price != null ? Math.pow(10, -precision.price) : 0.01,
|
|
45
|
+
maxQty: limits?.amount?.max ?? Infinity,
|
|
46
|
+
pricePrecision: precision?.price ?? 2,
|
|
47
|
+
amountPrecision: precision?.amount ?? 3,
|
|
48
|
+
};
|
|
49
|
+
// Store under both "BTC/USDT:USDT" and "BTC/USDT" keys for lookup flexibility
|
|
50
|
+
this.rules.set(symbol, rules);
|
|
51
|
+
this.knownSymbols.add(symbol);
|
|
52
|
+
const shortSymbol = symbol.replace(/:USDT$/, '');
|
|
53
|
+
if (shortSymbol !== symbol && !this.rules.has(shortSymbol)) {
|
|
54
|
+
this.rules.set(shortSymbol, rules);
|
|
55
|
+
this.knownSymbols.add(shortSymbol);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
logger.info(TAG, `Loaded exchange rules for ${this.knownSymbols.size} symbols`);
|
|
59
|
+
}
|
|
60
|
+
/** Get rules for a symbol (tries both BTC/USDT and BTC/USDT:USDT formats). */
|
|
61
|
+
getRules(symbol) {
|
|
62
|
+
return this.rules.get(symbol) ?? this.rules.get(normalizeSymbol(symbol, this.knownSymbols));
|
|
63
|
+
}
|
|
64
|
+
/** Validate and round order parameters before submission. */
|
|
65
|
+
validate(symbol, amount, price, lastPrice) {
|
|
66
|
+
const normalized = normalizeSymbol(symbol, this.knownSymbols);
|
|
67
|
+
const rules = this.rules.get(normalized);
|
|
68
|
+
if (!rules)
|
|
69
|
+
return { valid: false, error: `Unknown symbol: ${symbol}`, roundedAmount: amount };
|
|
70
|
+
const roundedAmount = this.roundToStep(amount, rules.stepSize, rules.amountPrecision);
|
|
71
|
+
const roundedPrice = price != null
|
|
72
|
+
? this.roundToStep(price, rules.tickSize, rules.pricePrecision)
|
|
73
|
+
: undefined;
|
|
74
|
+
if (roundedAmount < rules.minQty) {
|
|
75
|
+
// Include the requested amount + the rule context. Without this, sub-step
|
|
76
|
+
// requests floor to 0 and the agent reads "Amount 0 below min 0.001"
|
|
77
|
+
// as a validator bug rather than as "your size is below the exchange
|
|
78
|
+
// minimum". Surface the dollar minimum too when we know the price —
|
|
79
|
+
// tells the agent exactly how much to size up.
|
|
80
|
+
const refPrice = roundedPrice ?? price ?? lastPrice;
|
|
81
|
+
const minNotionalAtPrice = refPrice != null && refPrice > 0
|
|
82
|
+
? ` (~$${(rules.minQty * refPrice).toFixed(2)})`
|
|
83
|
+
: '';
|
|
84
|
+
const requestedDesc = roundedAmount === amount
|
|
85
|
+
? `${roundedAmount}`
|
|
86
|
+
: `${amount} (rounded to ${roundedAmount} by step ${rules.stepSize})`;
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
error: `Amount ${requestedDesc} below min ${rules.minQty}${minNotionalAtPrice} for ${symbol}`,
|
|
90
|
+
roundedAmount,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (roundedAmount > rules.maxQty) {
|
|
94
|
+
return { valid: false, error: `Amount ${roundedAmount} above max ${rules.maxQty} for ${symbol}`, roundedAmount };
|
|
95
|
+
}
|
|
96
|
+
// Check min notional (order value)
|
|
97
|
+
const refPrice = roundedPrice ?? lastPrice;
|
|
98
|
+
if (refPrice != null && refPrice > 0) {
|
|
99
|
+
const notional = roundedAmount * refPrice;
|
|
100
|
+
if (notional < rules.minNotional) {
|
|
101
|
+
return {
|
|
102
|
+
valid: false,
|
|
103
|
+
error: `Order notional $${notional.toFixed(2)} below minimum $${rules.minNotional} for ${symbol}`,
|
|
104
|
+
roundedAmount,
|
|
105
|
+
roundedPrice,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { valid: true, roundedAmount, roundedPrice };
|
|
110
|
+
}
|
|
111
|
+
/** Round value to step size and precision. */
|
|
112
|
+
roundToStep(value, stepSize, precision) {
|
|
113
|
+
const stepped = Math.floor(value / stepSize) * stepSize;
|
|
114
|
+
return Number(stepped.toFixed(precision));
|
|
115
|
+
}
|
|
116
|
+
get size() {
|
|
117
|
+
return this.rules.size;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MarketContext } from '@reefclaw/shared';
|
|
2
|
+
export interface ParsedEvent {
|
|
3
|
+
id?: number;
|
|
4
|
+
event: string;
|
|
5
|
+
data: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Incremental SSE parser. Feed raw text; returns the complete events plus the
|
|
9
|
+
* unparsed remainder (a partial event still arriving at the buffer tail). Pure.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseSse(buffer: string): {
|
|
12
|
+
events: ParsedEvent[];
|
|
13
|
+
remainder: string;
|
|
14
|
+
};
|
|
15
|
+
export interface FactSubscriberOptions {
|
|
16
|
+
/** Intel base URL, e.g. https://intel.reefclaw.com */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** rc_ connection token. */
|
|
19
|
+
token: string;
|
|
20
|
+
symbols: string[];
|
|
21
|
+
/** Injected for tests; defaults to global fetch. */
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
/** Base reconnect delay (ms). Doubles per consecutive failure with ±20%
|
|
24
|
+
* jitter up to reconnectCapMs — a fixed delay would hard-loop a 401/404
|
|
25
|
+
* (token misconfig / stream disabled) at max rate forever. */
|
|
26
|
+
reconnectDelayMs?: number;
|
|
27
|
+
/** Reconnect backoff cap (ms, default 60s). */
|
|
28
|
+
reconnectCapMs?: number;
|
|
29
|
+
/** Abort + reconnect when a LIVE connection delivers no bytes for this
|
|
30
|
+
* long (default 30s = 6× the broadcaster's 5s cadence). Without this, a
|
|
31
|
+
* silently stalled stream (no close, no RST) leaves `reader.read()`
|
|
32
|
+
* pending forever while the evaluator keeps serving ever-staler facts —
|
|
33
|
+
* same class as the user-data-stream / routed-WS staleness watchdogs. */
|
|
34
|
+
stallThresholdMs?: number;
|
|
35
|
+
/** Injected for tests. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
export declare class FactSubscriber {
|
|
39
|
+
private readonly opts;
|
|
40
|
+
private readonly latest;
|
|
41
|
+
/** Per-symbol wall-clock receipt time of the latest fact (now()-based). */
|
|
42
|
+
private readonly latestAt;
|
|
43
|
+
private buffer;
|
|
44
|
+
private lastEventId;
|
|
45
|
+
private running;
|
|
46
|
+
private controller;
|
|
47
|
+
private applied;
|
|
48
|
+
private errors;
|
|
49
|
+
private stalls;
|
|
50
|
+
private consecutiveFailures;
|
|
51
|
+
/** 0 = no live connection; otherwise last byte-activity time. */
|
|
52
|
+
private lastChunkAt;
|
|
53
|
+
private watchdog;
|
|
54
|
+
private readonly now;
|
|
55
|
+
constructor(opts: FactSubscriberOptions);
|
|
56
|
+
/** Latest streamed context for a symbol — what the evaluator reads. */
|
|
57
|
+
getLatest(symbol: string): MarketContext | null;
|
|
58
|
+
/** When the latest fact for a symbol ARRIVED (receipt clock, not the
|
|
59
|
+
* context's own timestamp). Null when no fact has ever arrived. Callers
|
|
60
|
+
* use this to refuse stale facts (LocalSignalService.getSnapshot). */
|
|
61
|
+
getLatestFactAt(symbol: string): number | null;
|
|
62
|
+
getStats(): {
|
|
63
|
+
applied: number;
|
|
64
|
+
errors: number;
|
|
65
|
+
stalls: number;
|
|
66
|
+
lastEventId: number | undefined;
|
|
67
|
+
symbols: number;
|
|
68
|
+
};
|
|
69
|
+
/** Apply one parsed event: deserialize a `fact` and store it as the latest
|
|
70
|
+
* context for its symbol. No I/O — exposed for tests. */
|
|
71
|
+
applyEvent(ev: ParsedEvent): void;
|
|
72
|
+
/** Feed a raw text chunk; handles events split across chunk boundaries. */
|
|
73
|
+
ingest(chunk: string): void;
|
|
74
|
+
start(): void;
|
|
75
|
+
stop(): void;
|
|
76
|
+
private backoffMs;
|
|
77
|
+
private loop;
|
|
78
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Plugin-side fact stream subscriber — facts-out (docs/FACTS_OUT_ARCHITECTURE.md)
|
|
2
|
+
// step 4. Connects to the intel broadcaster's SSE endpoint
|
|
3
|
+
// (/api/facts/subscribe), maintains the latest MarketContext per symbol, and
|
|
4
|
+
// feeds it to the LocalStrategyEvaluator (PR #45). Reconnects with Last-Event-ID.
|
|
5
|
+
//
|
|
6
|
+
// Uses the SAME matched (de)serializer the broadcaster uses (shared via the sync
|
|
7
|
+
// manifest), so the wire round-trip is byte-identical — the Date fields are
|
|
8
|
+
// rehydrated rather than left as strings.
|
|
9
|
+
//
|
|
10
|
+
// NOT wired into the heartbeat yet — built + tested standalone. The parse /
|
|
11
|
+
// apply core is pure and unit-tested; the fetch loop is thin plumbing.
|
|
12
|
+
import { deserializeContext } from '../signals/serialize-context.js';
|
|
13
|
+
/**
|
|
14
|
+
* Incremental SSE parser. Feed raw text; returns the complete events plus the
|
|
15
|
+
* unparsed remainder (a partial event still arriving at the buffer tail). Pure.
|
|
16
|
+
*/
|
|
17
|
+
export function parseSse(buffer) {
|
|
18
|
+
const events = [];
|
|
19
|
+
let sep;
|
|
20
|
+
// Events are terminated by a blank line ("\n\n").
|
|
21
|
+
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
|
22
|
+
const raw = buffer.slice(0, sep);
|
|
23
|
+
buffer = buffer.slice(sep + 2);
|
|
24
|
+
let id;
|
|
25
|
+
let event = 'message';
|
|
26
|
+
const dataLines = [];
|
|
27
|
+
for (const line of raw.split('\n')) {
|
|
28
|
+
if (line.startsWith('id:')) {
|
|
29
|
+
const v = Number(line.slice(3).trim());
|
|
30
|
+
if (Number.isFinite(v))
|
|
31
|
+
id = v;
|
|
32
|
+
}
|
|
33
|
+
else if (line.startsWith('event:')) {
|
|
34
|
+
event = line.slice(6).trim();
|
|
35
|
+
}
|
|
36
|
+
else if (line.startsWith('data:')) {
|
|
37
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
38
|
+
}
|
|
39
|
+
// ":" comment lines and unknown fields are ignored, per the SSE spec.
|
|
40
|
+
}
|
|
41
|
+
events.push({ id, event, data: dataLines.join('\n') });
|
|
42
|
+
}
|
|
43
|
+
return { events, remainder: buffer };
|
|
44
|
+
}
|
|
45
|
+
export class FactSubscriber {
|
|
46
|
+
opts;
|
|
47
|
+
latest = new Map();
|
|
48
|
+
/** Per-symbol wall-clock receipt time of the latest fact (now()-based). */
|
|
49
|
+
latestAt = new Map();
|
|
50
|
+
buffer = '';
|
|
51
|
+
lastEventId;
|
|
52
|
+
running = false;
|
|
53
|
+
controller = null;
|
|
54
|
+
applied = 0;
|
|
55
|
+
errors = 0;
|
|
56
|
+
stalls = 0;
|
|
57
|
+
consecutiveFailures = 0;
|
|
58
|
+
/** 0 = no live connection; otherwise last byte-activity time. */
|
|
59
|
+
lastChunkAt = 0;
|
|
60
|
+
watchdog = null;
|
|
61
|
+
now;
|
|
62
|
+
constructor(opts) {
|
|
63
|
+
this.opts = opts;
|
|
64
|
+
this.now = opts.now ?? Date.now;
|
|
65
|
+
}
|
|
66
|
+
/** Latest streamed context for a symbol — what the evaluator reads. */
|
|
67
|
+
getLatest(symbol) {
|
|
68
|
+
return this.latest.get(symbol) ?? null;
|
|
69
|
+
}
|
|
70
|
+
/** When the latest fact for a symbol ARRIVED (receipt clock, not the
|
|
71
|
+
* context's own timestamp). Null when no fact has ever arrived. Callers
|
|
72
|
+
* use this to refuse stale facts (LocalSignalService.getSnapshot). */
|
|
73
|
+
getLatestFactAt(symbol) {
|
|
74
|
+
return this.latestAt.get(symbol) ?? null;
|
|
75
|
+
}
|
|
76
|
+
getStats() {
|
|
77
|
+
return {
|
|
78
|
+
applied: this.applied,
|
|
79
|
+
errors: this.errors,
|
|
80
|
+
stalls: this.stalls,
|
|
81
|
+
lastEventId: this.lastEventId,
|
|
82
|
+
symbols: this.latest.size,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Apply one parsed event: deserialize a `fact` and store it as the latest
|
|
86
|
+
* context for its symbol. No I/O — exposed for tests. */
|
|
87
|
+
applyEvent(ev) {
|
|
88
|
+
if (ev.id !== undefined)
|
|
89
|
+
this.lastEventId = ev.id;
|
|
90
|
+
if (ev.event !== 'fact')
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
const ctx = deserializeContext(ev.data);
|
|
94
|
+
this.latest.set(ctx.symbol, ctx);
|
|
95
|
+
this.latestAt.set(ctx.symbol, this.now());
|
|
96
|
+
this.applied++;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
this.errors++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Feed a raw text chunk; handles events split across chunk boundaries. */
|
|
103
|
+
ingest(chunk) {
|
|
104
|
+
this.buffer += chunk;
|
|
105
|
+
const { events, remainder } = parseSse(this.buffer);
|
|
106
|
+
this.buffer = remainder;
|
|
107
|
+
for (const ev of events)
|
|
108
|
+
this.applyEvent(ev);
|
|
109
|
+
}
|
|
110
|
+
start() {
|
|
111
|
+
if (this.running)
|
|
112
|
+
return;
|
|
113
|
+
this.running = true;
|
|
114
|
+
const stallMs = this.opts.stallThresholdMs ?? 30_000;
|
|
115
|
+
this.watchdog = setInterval(() => {
|
|
116
|
+
if (!this.running || this.lastChunkAt === 0)
|
|
117
|
+
return;
|
|
118
|
+
if (this.now() - this.lastChunkAt > stallMs) {
|
|
119
|
+
this.stalls++;
|
|
120
|
+
this.lastChunkAt = 0; // one abort per stall — reset until reconnect
|
|
121
|
+
this.controller?.abort();
|
|
122
|
+
}
|
|
123
|
+
}, Math.max(1_000, Math.floor(stallMs / 3)));
|
|
124
|
+
this.watchdog.unref?.();
|
|
125
|
+
void this.loop();
|
|
126
|
+
}
|
|
127
|
+
stop() {
|
|
128
|
+
this.running = false;
|
|
129
|
+
if (this.watchdog) {
|
|
130
|
+
clearInterval(this.watchdog);
|
|
131
|
+
this.watchdog = null;
|
|
132
|
+
}
|
|
133
|
+
this.controller?.abort();
|
|
134
|
+
}
|
|
135
|
+
backoffMs() {
|
|
136
|
+
const base = this.opts.reconnectDelayMs ?? 3_000;
|
|
137
|
+
const cap = this.opts.reconnectCapMs ?? 60_000;
|
|
138
|
+
const exp = Math.min(base * 2 ** Math.min(this.consecutiveFailures, 10), cap);
|
|
139
|
+
return Math.round(exp * (0.8 + Math.random() * 0.4)); // ±20% jitter
|
|
140
|
+
}
|
|
141
|
+
async loop() {
|
|
142
|
+
const doFetch = this.opts.fetchImpl ?? fetch;
|
|
143
|
+
const url = `${this.opts.baseUrl}/api/facts/subscribe?symbols=${encodeURIComponent(this.opts.symbols.join(','))}`;
|
|
144
|
+
while (this.running) {
|
|
145
|
+
this.controller = new AbortController();
|
|
146
|
+
let gotData = false;
|
|
147
|
+
try {
|
|
148
|
+
const headers = {
|
|
149
|
+
Authorization: `Bearer ${this.opts.token}`,
|
|
150
|
+
Accept: 'text/event-stream',
|
|
151
|
+
};
|
|
152
|
+
if (this.lastEventId !== undefined)
|
|
153
|
+
headers['Last-Event-ID'] = String(this.lastEventId);
|
|
154
|
+
const resp = await doFetch(url, { headers, signal: this.controller.signal });
|
|
155
|
+
if (!resp.ok || !resp.body)
|
|
156
|
+
throw new Error(`fact stream HTTP ${resp.status}`);
|
|
157
|
+
this.buffer = '';
|
|
158
|
+
this.lastChunkAt = this.now(); // arm the stall watchdog
|
|
159
|
+
const reader = resp.body.getReader();
|
|
160
|
+
const decoder = new TextDecoder();
|
|
161
|
+
while (this.running) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done)
|
|
164
|
+
break;
|
|
165
|
+
this.lastChunkAt = this.now();
|
|
166
|
+
gotData = true;
|
|
167
|
+
this.ingest(decoder.decode(value, { stream: true }));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
if (!this.running)
|
|
172
|
+
return;
|
|
173
|
+
this.errors++;
|
|
174
|
+
}
|
|
175
|
+
this.lastChunkAt = 0; // no live connection — watchdog idle
|
|
176
|
+
if (!this.running)
|
|
177
|
+
return;
|
|
178
|
+
this.consecutiveFailures = gotData ? 0 : this.consecutiveFailures + 1;
|
|
179
|
+
await new Promise((r) => setTimeout(r, this.backoffMs()));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface OrderIntent {
|
|
2
|
+
clientOrderId: string;
|
|
3
|
+
symbol: string;
|
|
4
|
+
side: 'buy' | 'sell';
|
|
5
|
+
type: 'market' | 'limit';
|
|
6
|
+
amount: number;
|
|
7
|
+
price?: number;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
status: 'pending' | 'submitted' | 'confirmed' | 'failed';
|
|
10
|
+
exchangeOrderId?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
resolvedAt?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class IntentJournal {
|
|
15
|
+
private readonly filePath;
|
|
16
|
+
private intents;
|
|
17
|
+
/** `basePath` overrides the storage root (default `~/.openclaw/plugins`).
|
|
18
|
+
* Tests pass a temp dir to keep runs hermetic; production omits it.
|
|
19
|
+
* `OPENCLAW_HOME` env var, when set, also reroutes the default base. */
|
|
20
|
+
constructor(pluginId?: string, opts?: {
|
|
21
|
+
basePath?: string;
|
|
22
|
+
});
|
|
23
|
+
/** Record a new order intent BEFORE submitting to exchange. */
|
|
24
|
+
recordIntent(intent: Omit<OrderIntent, 'createdAt' | 'status'>): OrderIntent;
|
|
25
|
+
/** Update intent status after exchange response. */
|
|
26
|
+
updateIntent(clientOrderId: string, update: Partial<Pick<OrderIntent, 'status' | 'exchangeOrderId' | 'error'>>): void;
|
|
27
|
+
/** Get all unresolved intents (pending or submitted). */
|
|
28
|
+
getUnresolved(): OrderIntent[];
|
|
29
|
+
/** Get intent by clientOrderId. */
|
|
30
|
+
getIntent(clientOrderId: string): OrderIntent | undefined;
|
|
31
|
+
/** Get the journal file path. */
|
|
32
|
+
getFilePath(): string;
|
|
33
|
+
/** Load existing intents from JSONL file. Later entries override earlier ones (last-write-wins). */
|
|
34
|
+
private loadExisting;
|
|
35
|
+
/** Append a line to the JSONL file. */
|
|
36
|
+
private appendLine;
|
|
37
|
+
/**
|
|
38
|
+
* Compact the journal — rewrite with only the latest state of each intent.
|
|
39
|
+
* Call periodically (e.g., daily) to prevent unbounded file growth.
|
|
40
|
+
*/
|
|
41
|
+
compact(): void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Order intent journal — append-only JSONL file for crash recovery and idempotency.
|
|
2
|
+
// Every live order intent is written BEFORE submission. On network timeout,
|
|
3
|
+
// check exchange by clientOrderId before retry. On restart, resolve all pending intents.
|
|
4
|
+
import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { logger, formatError } from '../logger.js';
|
|
7
|
+
import { resolvePluginsBaseDir } from '../util/plugin-paths.js';
|
|
8
|
+
const TAG = 'intent-journal';
|
|
9
|
+
export class IntentJournal {
|
|
10
|
+
filePath;
|
|
11
|
+
intents = new Map();
|
|
12
|
+
/** `basePath` overrides the storage root (default `~/.openclaw/plugins`).
|
|
13
|
+
* Tests pass a temp dir to keep runs hermetic; production omits it.
|
|
14
|
+
* `OPENCLAW_HOME` env var, when set, also reroutes the default base. */
|
|
15
|
+
constructor(pluginId, opts) {
|
|
16
|
+
const base = resolvePluginsBaseDir(opts?.basePath);
|
|
17
|
+
const dir = join(base, pluginId ?? 'reefclaw-paper-trading');
|
|
18
|
+
if (!existsSync(dir)) {
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
this.filePath = join(dir, 'order-intents.jsonl');
|
|
22
|
+
this.loadExisting();
|
|
23
|
+
}
|
|
24
|
+
/** Record a new order intent BEFORE submitting to exchange. */
|
|
25
|
+
recordIntent(intent) {
|
|
26
|
+
const full = {
|
|
27
|
+
...intent,
|
|
28
|
+
createdAt: new Date().toISOString(),
|
|
29
|
+
status: 'pending',
|
|
30
|
+
};
|
|
31
|
+
this.intents.set(full.clientOrderId, full);
|
|
32
|
+
this.appendLine(full);
|
|
33
|
+
logger.info(TAG, `Intent recorded: ${full.clientOrderId} ${full.side} ${full.amount} ${full.symbol}`);
|
|
34
|
+
return full;
|
|
35
|
+
}
|
|
36
|
+
/** Update intent status after exchange response. */
|
|
37
|
+
updateIntent(clientOrderId, update) {
|
|
38
|
+
const intent = this.intents.get(clientOrderId);
|
|
39
|
+
if (!intent) {
|
|
40
|
+
logger.warn(TAG, `Intent not found for update: ${clientOrderId}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (update.status)
|
|
44
|
+
intent.status = update.status;
|
|
45
|
+
if (update.exchangeOrderId)
|
|
46
|
+
intent.exchangeOrderId = update.exchangeOrderId;
|
|
47
|
+
if (update.error)
|
|
48
|
+
intent.error = update.error;
|
|
49
|
+
if (update.status === 'confirmed' || update.status === 'failed') {
|
|
50
|
+
intent.resolvedAt = new Date().toISOString();
|
|
51
|
+
}
|
|
52
|
+
this.appendLine(intent);
|
|
53
|
+
}
|
|
54
|
+
/** Get all unresolved intents (pending or submitted). */
|
|
55
|
+
getUnresolved() {
|
|
56
|
+
return Array.from(this.intents.values()).filter(i => i.status === 'pending' || i.status === 'submitted');
|
|
57
|
+
}
|
|
58
|
+
/** Get intent by clientOrderId. */
|
|
59
|
+
getIntent(clientOrderId) {
|
|
60
|
+
return this.intents.get(clientOrderId);
|
|
61
|
+
}
|
|
62
|
+
/** Get the journal file path. */
|
|
63
|
+
getFilePath() {
|
|
64
|
+
return this.filePath;
|
|
65
|
+
}
|
|
66
|
+
// ---- Internal ----
|
|
67
|
+
/** Load existing intents from JSONL file. Later entries override earlier ones (last-write-wins). */
|
|
68
|
+
loadExisting() {
|
|
69
|
+
if (!existsSync(this.filePath))
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(this.filePath, 'utf-8');
|
|
73
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
try {
|
|
76
|
+
const intent = JSON.parse(line);
|
|
77
|
+
if (intent.clientOrderId) {
|
|
78
|
+
this.intents.set(intent.clientOrderId, intent);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Skip malformed lines
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const unresolved = this.getUnresolved();
|
|
86
|
+
if (unresolved.length > 0) {
|
|
87
|
+
logger.warn(TAG, `Loaded ${this.intents.size} intents, ${unresolved.length} unresolved`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
logger.info(TAG, `Loaded ${this.intents.size} intents, all resolved`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
logger.error(TAG, `Failed to load intent journal: ${formatError(err)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Append a line to the JSONL file. */
|
|
98
|
+
appendLine(intent) {
|
|
99
|
+
try {
|
|
100
|
+
appendFileSync(this.filePath, JSON.stringify(intent) + '\n');
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
logger.error(TAG, `Failed to write intent: ${formatError(err)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Compact the journal — rewrite with only the latest state of each intent.
|
|
108
|
+
* Call periodically (e.g., daily) to prevent unbounded file growth.
|
|
109
|
+
*/
|
|
110
|
+
compact() {
|
|
111
|
+
try {
|
|
112
|
+
const lines = Array.from(this.intents.values())
|
|
113
|
+
.map(i => JSON.stringify(i))
|
|
114
|
+
.join('\n') + '\n';
|
|
115
|
+
writeFileSync(this.filePath, lines);
|
|
116
|
+
logger.info(TAG, `Compacted journal: ${this.intents.size} intents`);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
logger.error(TAG, `Failed to compact journal: ${formatError(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { BinancePrivateApi } from '../ccxt/binance-private.js';
|
|
3
|
+
/** Injectable timer/clock deps — makes fake-timer tests trivial. */
|
|
4
|
+
export interface ListenKeyTimerDeps {
|
|
5
|
+
setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
6
|
+
clearTimeout: (handle: ReturnType<typeof setTimeout>) => void;
|
|
7
|
+
now: () => number;
|
|
8
|
+
}
|
|
9
|
+
export interface ListenKeyManagerOptions {
|
|
10
|
+
/** Defaults to 25 * 60_000 (25 min). */
|
|
11
|
+
refreshIntervalMs?: number;
|
|
12
|
+
/** How many consecutive keep-alive failures before emitting 'keyKeepAliveFailed'.
|
|
13
|
+
* Defaults to 3 — transient network failures self-recover; a sustained
|
|
14
|
+
* failure probably means the key is gone and the parent must reconnect. */
|
|
15
|
+
maxConsecutiveFailures?: number;
|
|
16
|
+
/** Timer injection for tests. Defaults to real setTimeout/Date. */
|
|
17
|
+
timers?: Partial<ListenKeyTimerDeps>;
|
|
18
|
+
}
|
|
19
|
+
/** Emitted events. Callers should treat 'keyKeepAliveFailed' as "drop the WS
|
|
20
|
+
* and restart from scratch" — the current key is presumed dead. */
|
|
21
|
+
export type ListenKeyEvents = {
|
|
22
|
+
/** Fresh key minted via POST /fapi/v1/listenKey. Fires on start() and
|
|
23
|
+
* after every successful re-mint. */
|
|
24
|
+
keyMinted: (listenKey: string) => void;
|
|
25
|
+
/** A keep-alive (PUT) succeeded. Informational — mostly for observability. */
|
|
26
|
+
keyRefreshed: () => void;
|
|
27
|
+
/** Consecutive keep-alive failures reached `maxConsecutiveFailures`. The
|
|
28
|
+
* parent should treat the stream as dead and reconnect from scratch. */
|
|
29
|
+
keyKeepAliveFailed: (reason: string) => void;
|
|
30
|
+
};
|
|
31
|
+
/** Lightweight typed emitter — EventEmitter with a phantom type. */
|
|
32
|
+
export declare class ListenKeyManager extends EventEmitter {
|
|
33
|
+
private readonly api;
|
|
34
|
+
private readonly refreshIntervalMs;
|
|
35
|
+
private readonly maxConsecutiveFailures;
|
|
36
|
+
private readonly timers;
|
|
37
|
+
private currentKey;
|
|
38
|
+
private keyMintedAt;
|
|
39
|
+
private refreshTimer;
|
|
40
|
+
private consecutiveFailures;
|
|
41
|
+
private running;
|
|
42
|
+
constructor(api: BinancePrivateApi, options?: ListenKeyManagerOptions);
|
|
43
|
+
/** Mint a listenKey and start the refresh loop. Returns the minted key.
|
|
44
|
+
* Throws if the first POST fails — caller decides how to back off. */
|
|
45
|
+
start(): Promise<string>;
|
|
46
|
+
/** Stop the refresh loop and best-effort close the key. Idempotent.
|
|
47
|
+
* For GRACEFUL shutdown only — a reconnect-driven teardown must use
|
|
48
|
+
* `stopKeepAlive()` instead (see below). */
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
/** Stop the refresh loop WITHOUT the DELETE — for reconnect-driven closes.
|
|
51
|
+
* Binance returns the SAME active listenKey on a re-POST and merely
|
|
52
|
+
* extends its validity (doc-verified 2026-07-02: "Doing a POST on an
|
|
53
|
+
* account with an active listenKey will return the currently active
|
|
54
|
+
* listenKey and extend its validity for 60 minutes"), so an in-flight
|
|
55
|
+
* DELETE racing the reconnect's POST can land AFTER it and invalidate the
|
|
56
|
+
* key the fresh socket just authenticated with — a silent dead stream.
|
|
57
|
+
* Skipping the DELETE is safe: an unused key expires on its own after
|
|
58
|
+
* 60 min of no keep-alive. Synchronous + idempotent. */
|
|
59
|
+
stopKeepAlive(): void;
|
|
60
|
+
/** Current listenKey, or null if stopped. */
|
|
61
|
+
getKey(): string | null;
|
|
62
|
+
/** Age in ms since the key was last minted. Null if stopped. Useful for
|
|
63
|
+
* observability dashboards — a key far beyond refreshIntervalMs but still
|
|
64
|
+
* alive means Binance is tolerating us past the nominal window. */
|
|
65
|
+
getKeyAge(): number | null;
|
|
66
|
+
/** Number of consecutive failed keep-alive probes. Exposed for diagnostics. */
|
|
67
|
+
getConsecutiveFailures(): number;
|
|
68
|
+
private scheduleRefresh;
|
|
69
|
+
private refresh;
|
|
70
|
+
}
|