@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,2302 @@
|
|
|
1
|
+
// GatewayProvider — real OpenClaw gateway integration.
|
|
2
|
+
// Connects to the OpenClaw gateway via HTTP and WebSocket,
|
|
3
|
+
// streams live trading data, and emits ProviderEvents.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { logger, formatError } from '../logger.js';
|
|
8
|
+
import { isTradingMode } from '../types.js';
|
|
9
|
+
import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
|
|
10
|
+
import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
|
|
11
|
+
import { discoverTools } from '../gateway/tool-discovery.js';
|
|
12
|
+
import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, } from '../gateway/event-parser.js';
|
|
13
|
+
import { Poller } from '../gateway/poller.js';
|
|
14
|
+
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
|
|
15
|
+
import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
|
|
16
|
+
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
|
|
17
|
+
const TAG = 'gateway';
|
|
18
|
+
// ---- Session NAV persistence ----
|
|
19
|
+
// Persists sessionStartNav per date so Day P&L survives skill restarts.
|
|
20
|
+
const SESSION_NAV_FILENAME = 'session-nav.json';
|
|
21
|
+
const TRADING_MODE_FILENAME = 'trading-mode.json';
|
|
22
|
+
function getSessionNavPath() {
|
|
23
|
+
return join(homedir(), '.openclaw', 'workspace', SESSION_NAV_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
function getTradingModePath() {
|
|
26
|
+
return join(homedir(), '.openclaw', 'workspace', TRADING_MODE_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
function todayDateStr() {
|
|
29
|
+
return new Date().toISOString().slice(0, 10);
|
|
30
|
+
}
|
|
31
|
+
/** Decide whether the session-start-NAV anchor needs to be (re)set. Pure so it
|
|
32
|
+
* can be unit-tested without the full provider. Returns true when the anchor
|
|
33
|
+
* has never been set, OR — for PAPER only — when it belongs to a previous UTC
|
|
34
|
+
* day (so Day P&L re-anchors at midnight). Live/shadow anchor once and then
|
|
35
|
+
* sync to the plugin's Binance income-endpoint value, so they never
|
|
36
|
+
* self-re-anchor here. */
|
|
37
|
+
export function shouldAnchorSessionNav(args) {
|
|
38
|
+
if (args.sessionStartNav < 0)
|
|
39
|
+
return true; // never anchored
|
|
40
|
+
return args.mode === 'PAPER' && args.sessionDate !== args.today; // paper midnight rollover
|
|
41
|
+
}
|
|
42
|
+
function loadSessionStartNav() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(getSessionNavPath(), 'utf-8');
|
|
45
|
+
const data = JSON.parse(raw);
|
|
46
|
+
if (data.date === todayDateStr() && typeof data.sessionStartNav === 'number' && data.sessionStartNav > 0) {
|
|
47
|
+
return data.sessionStartNav;
|
|
48
|
+
}
|
|
49
|
+
return null; // Different day or invalid
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null; // File doesn't exist or is corrupt
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Persisted across skill restarts so the FIRST snapshot the gateway emits
|
|
57
|
+
* after boot reflects the last-known trading mode instead of the hardcoded
|
|
58
|
+
* 'PAPER' default. Otherwise every skill deploy clobbers the dashboard's
|
|
59
|
+
* localStorage cache with PAPER and the badge sticks until a hard refresh.
|
|
60
|
+
* The fresh fetchBalance round-trip will still correct any drift via the
|
|
61
|
+
* trading_mode event in fetchFreshBalance() — but until then, this prevents
|
|
62
|
+
* the visible PAPER flash.
|
|
63
|
+
*/
|
|
64
|
+
function loadLastTradingMode() {
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(getTradingModePath(), 'utf-8');
|
|
67
|
+
const data = JSON.parse(raw);
|
|
68
|
+
if (isTradingMode(data.mode)) {
|
|
69
|
+
return data.mode;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function saveLastTradingMode(mode) {
|
|
78
|
+
try {
|
|
79
|
+
const dir = join(homedir(), '.openclaw', 'workspace');
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
const data = { mode, savedAt: new Date().toISOString() };
|
|
82
|
+
writeFileSync(getTradingModePath(), JSON.stringify(data, null, 2), 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* non-fatal — next save will retry */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function saveSessionStartNav(nav) {
|
|
89
|
+
try {
|
|
90
|
+
const dir = join(homedir(), '.openclaw', 'workspace');
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
const data = { date: todayDateStr(), sessionStartNav: nav };
|
|
93
|
+
writeFileSync(getSessionNavPath(), JSON.stringify(data, null, 2), 'utf-8');
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
logger.warn(TAG, `Failed to save session NAV: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ---- Agent state emission interval ----
|
|
100
|
+
const AGENT_STATE_INTERVAL_MS = 5_000;
|
|
101
|
+
// ---- GatewayProvider ----
|
|
102
|
+
export class GatewayProvider {
|
|
103
|
+
config;
|
|
104
|
+
// ---- Sub-components (created during initialization) ----
|
|
105
|
+
http = null;
|
|
106
|
+
wsClient = null;
|
|
107
|
+
poller = null;
|
|
108
|
+
eventParser = null;
|
|
109
|
+
toolMap = {};
|
|
110
|
+
// ---- Internal state ----
|
|
111
|
+
agentMode = 'ACTIVE';
|
|
112
|
+
// 'agent_discretionary' is the honest default for trades the agent opens
|
|
113
|
+
// via create_order without a named strategy-engine signal firing. The
|
|
114
|
+
// legacy 'Unknown' value polluted every trade_results row on intel — see
|
|
115
|
+
// [[feedback_skill_bridge_version_compare_pitfall]] adjacent bug. Agents
|
|
116
|
+
// should classify by setup_type (validated NOT NULL on position_entries),
|
|
117
|
+
// not by this skill-side hint field.
|
|
118
|
+
lastKnownStrategy = { name: 'agent_discretionary', version: '0.0.0' };
|
|
119
|
+
/** Agent display name + live model id, resolved from the gateway (best-effort).
|
|
120
|
+
* Surfaced in agent_state so the dashboard header can show the real agent +
|
|
121
|
+
* model instead of a hardcoded placeholder. Empty until the gateway answers. */
|
|
122
|
+
agentIdentity = {};
|
|
123
|
+
/** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
|
|
124
|
+
loggedAgentIdentityProbe = false;
|
|
125
|
+
/** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
|
|
126
|
+
heartbeatSeconds;
|
|
127
|
+
heartbeatReadAtMs = 0;
|
|
128
|
+
loggedHeartbeat = false;
|
|
129
|
+
lastTicker = null;
|
|
130
|
+
positions = [];
|
|
131
|
+
balance = { currency: 'USDT', total: 0, available: 0, locked: 0 };
|
|
132
|
+
/** Today's realized PnL (filtered by closedAt) from plugin trade history. */
|
|
133
|
+
realizedPnlToday = null;
|
|
134
|
+
/** Cumulative all-time realized PnL — used for the webapp KPI strip. */
|
|
135
|
+
realizedPnlAllTime = null;
|
|
136
|
+
/** Paper session execution costs (since plugin boot), forwarded to the
|
|
137
|
+
* dashboard cost cells so "Fees"/"Avg Slip" reflect the drag already in NAV
|
|
138
|
+
* instead of reading $0 (M1). Null in live (LiveAdapter omits them). */
|
|
139
|
+
paperSessionFees = null;
|
|
140
|
+
paperAvgSlippageBps = null;
|
|
141
|
+
/** Mark-to-market equity from plugin (wallet + collateral + unrealized). */
|
|
142
|
+
equity = null;
|
|
143
|
+
openOrders = [];
|
|
144
|
+
startTime = Date.now();
|
|
145
|
+
/** Session start NAV — -1 means "not yet set", 0 is valid (fresh account). */
|
|
146
|
+
sessionStartNav = -1;
|
|
147
|
+
/** UTC date (YYYY-MM-DD) the current sessionStartNav anchor belongs to.
|
|
148
|
+
* Null until first anchored. Used to re-anchor Day P&L at UTC midnight —
|
|
149
|
+
* without it a long-running PAPER session kept yesterday's anchor and Day
|
|
150
|
+
* P&L / Realized accumulated across days. */
|
|
151
|
+
sessionDate = null;
|
|
152
|
+
/** Track whether we've received at least one positions poll (needed for equity calculation). */
|
|
153
|
+
hasReceivedPositions = false;
|
|
154
|
+
/** Track whether we've received at least one balance poll (needed for equity calculation). */
|
|
155
|
+
hasReceivedBalance = false;
|
|
156
|
+
quoteCurrency;
|
|
157
|
+
// ---- Rate tracking ----
|
|
158
|
+
orderTimestamps = [];
|
|
159
|
+
cancelTimestamps = [];
|
|
160
|
+
// ---- Timers ----
|
|
161
|
+
agentStateInterval = null;
|
|
162
|
+
toolRetryInterval = null;
|
|
163
|
+
regimeInterval = null;
|
|
164
|
+
signalInterval = null;
|
|
165
|
+
missionInterval = null;
|
|
166
|
+
analyticsInterval = null;
|
|
167
|
+
decisionTraceInterval = null;
|
|
168
|
+
pendingStartTimers = [];
|
|
169
|
+
started = false;
|
|
170
|
+
startedAt = 0;
|
|
171
|
+
// ---- Mission tracking ----
|
|
172
|
+
lastSeenMissionIds = new Set();
|
|
173
|
+
// ---- Intelligence poller error tracking (circuit-breaker) ----
|
|
174
|
+
intelErrors = {};
|
|
175
|
+
// ---- Event listeners ----
|
|
176
|
+
// ---- Cached data for trade journaling (Feature C) ----
|
|
177
|
+
lastCryptoMetrics = null;
|
|
178
|
+
lastVolumeAnalysis = null;
|
|
179
|
+
// ---- ATR data for volatility-adaptive risk limits ----
|
|
180
|
+
atrData = null;
|
|
181
|
+
atrSampleCount = 0;
|
|
182
|
+
// ---- Current regime (Phase 9e: regime-conditional risk) ----
|
|
183
|
+
currentRegime = 'UNKNOWN';
|
|
184
|
+
// ---- Trade tracking ----
|
|
185
|
+
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
186
|
+
lastTradeTs = null;
|
|
187
|
+
/** Tracks open position entries for computing trade results on close.
|
|
188
|
+
* Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
|
|
189
|
+
openTradeEntries = new Map();
|
|
190
|
+
/** Potential closes awaiting 2-poll confirmation. Binance's fetchPositions
|
|
191
|
+
* occasionally returns a held position as missing or zero-contract for a
|
|
192
|
+
* single poll; those transient drops were the root cause of ~350 phantom
|
|
193
|
+
* trade_results rows/day. We only treat a position as closed after it's
|
|
194
|
+
* been absent on TWO consecutive polls. Key: `symbol:side`. */
|
|
195
|
+
pendingCloses = new Map();
|
|
196
|
+
/** Tracks whether the previous poll returned an empty / all-flat positions
|
|
197
|
+
* snapshot while we were holding non-zero positions. A single empty poll is
|
|
198
|
+
* treated as tentative (most often a Binance 429 / partial response). We
|
|
199
|
+
* skip the diff detector and do NOT overwrite this.positions until the
|
|
200
|
+
* next poll confirms — without this guard, the next non-empty poll re-emits
|
|
201
|
+
* synthetic OPEN fills for positions that were there all along (the
|
|
202
|
+
* open-side mirror of the close-side phantom that pendingCloses guards
|
|
203
|
+
* against). See Track 1 verification 2026-04-21 +
|
|
204
|
+
* docs/TRADE_AUDIT_TRAIL_PLAN.md Phase 0. */
|
|
205
|
+
pendingEmptyPoll = false;
|
|
206
|
+
/** In-flight trade result reports — drained on stop() to prevent data loss during kill/flatten. */
|
|
207
|
+
inFlightReports = [];
|
|
208
|
+
/** File-backed retry queue for failed trade reports */
|
|
209
|
+
retryQueuePath;
|
|
210
|
+
retryTimer = null;
|
|
211
|
+
// ---- Trading mode (Phase 9b) ----
|
|
212
|
+
// Initial value is loaded from the persisted file written on every prior
|
|
213
|
+
// mode change. Falls back to 'PAPER' only on first-ever boot (or after
|
|
214
|
+
// file is corrupt/deleted). Prevents the "dashboard flashes PAPER on every
|
|
215
|
+
// skill restart" UX bug — see saveLastTradingMode() comment above.
|
|
216
|
+
tradingMode = loadLastTradingMode() ?? 'PAPER';
|
|
217
|
+
listeners = {
|
|
218
|
+
ticker: new Set(),
|
|
219
|
+
candle: new Set(),
|
|
220
|
+
orderUpdate: new Set(),
|
|
221
|
+
agentState: new Set(),
|
|
222
|
+
riskUpdate: new Set(),
|
|
223
|
+
chatMessage: new Set(),
|
|
224
|
+
chatMessageStart: new Set(),
|
|
225
|
+
chatMessageChunk: new Set(),
|
|
226
|
+
chatMessageEnd: new Set(),
|
|
227
|
+
chatStreamingKeepAlive: new Set(),
|
|
228
|
+
marketStructure: new Set(),
|
|
229
|
+
cryptoMetrics: new Set(),
|
|
230
|
+
volumeAnalysis: new Set(),
|
|
231
|
+
tradeJournal: new Set(),
|
|
232
|
+
regimeUpdate: new Set(),
|
|
233
|
+
signalUpdate: new Set(),
|
|
234
|
+
missionUpdate: new Set(),
|
|
235
|
+
analyticsUpdate: new Set(),
|
|
236
|
+
fillError: new Set(),
|
|
237
|
+
shadowComparison: new Set(),
|
|
238
|
+
tradingModeUpdate: new Set(),
|
|
239
|
+
decisionTraceUpdate: new Set(),
|
|
240
|
+
};
|
|
241
|
+
constructor(config) {
|
|
242
|
+
this.config = config;
|
|
243
|
+
// Extract quote currency from symbol (e.g. "BTC/USDT" -> "USDT")
|
|
244
|
+
const parts = config.symbol.split('/');
|
|
245
|
+
this.quoteCurrency = parts.length > 1 ? parts[1] : 'USDT';
|
|
246
|
+
// Retry queue for failed trade reports
|
|
247
|
+
const retryDir = join(homedir(), '.reefclaw');
|
|
248
|
+
try {
|
|
249
|
+
mkdirSync(retryDir, { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
catch { /* exists */ }
|
|
252
|
+
this.retryQueuePath = join(retryDir, 'trade-report-queue.json');
|
|
253
|
+
logger.debug(TAG, `Config: url=${config.gatewayUrl}, symbol=${config.symbol}, quote=${this.quoteCurrency}`);
|
|
254
|
+
}
|
|
255
|
+
// ---- Provider interface ----
|
|
256
|
+
start() {
|
|
257
|
+
if (this.started) {
|
|
258
|
+
logger.warn(TAG, 'GatewayProvider already started');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.started = true;
|
|
262
|
+
this.startedAt = Date.now();
|
|
263
|
+
this.startTime = Date.now();
|
|
264
|
+
logger.info(TAG, 'Starting GatewayProvider');
|
|
265
|
+
this.startRetryTimer();
|
|
266
|
+
// Async initialization — errors are logged, not thrown
|
|
267
|
+
void this.initialize().catch((err) => {
|
|
268
|
+
logger.error(TAG, `Initialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
269
|
+
this.agentMode = 'ERROR';
|
|
270
|
+
this.emitAgentState();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
stop() {
|
|
274
|
+
logger.info(TAG, 'Stopping GatewayProvider');
|
|
275
|
+
this.stopRetryTimer();
|
|
276
|
+
this.started = false;
|
|
277
|
+
if (this.toolRetryInterval) {
|
|
278
|
+
clearInterval(this.toolRetryInterval);
|
|
279
|
+
this.toolRetryInterval = null;
|
|
280
|
+
}
|
|
281
|
+
if (this.agentStateInterval) {
|
|
282
|
+
clearInterval(this.agentStateInterval);
|
|
283
|
+
this.agentStateInterval = null;
|
|
284
|
+
}
|
|
285
|
+
if (this.regimeInterval) {
|
|
286
|
+
clearInterval(this.regimeInterval);
|
|
287
|
+
this.regimeInterval = null;
|
|
288
|
+
}
|
|
289
|
+
if (this.signalInterval) {
|
|
290
|
+
clearInterval(this.signalInterval);
|
|
291
|
+
this.signalInterval = null;
|
|
292
|
+
}
|
|
293
|
+
if (this.missionInterval) {
|
|
294
|
+
clearInterval(this.missionInterval);
|
|
295
|
+
this.missionInterval = null;
|
|
296
|
+
}
|
|
297
|
+
if (this.analyticsInterval) {
|
|
298
|
+
clearInterval(this.analyticsInterval);
|
|
299
|
+
this.analyticsInterval = null;
|
|
300
|
+
}
|
|
301
|
+
if (this.decisionTraceInterval) {
|
|
302
|
+
clearInterval(this.decisionTraceInterval);
|
|
303
|
+
this.decisionTraceInterval = null;
|
|
304
|
+
}
|
|
305
|
+
// Clear any pending delayed-start timers (prevents orphaned setInterval after stop)
|
|
306
|
+
for (const timer of this.pendingStartTimers) {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
}
|
|
309
|
+
this.pendingStartTimers = [];
|
|
310
|
+
if (this.poller) {
|
|
311
|
+
this.poller.stop();
|
|
312
|
+
this.poller = null;
|
|
313
|
+
}
|
|
314
|
+
if (this.wsClient) {
|
|
315
|
+
this.wsClient.destroy();
|
|
316
|
+
this.wsClient = null;
|
|
317
|
+
}
|
|
318
|
+
// Reset trade tracking state to avoid stale data on restart
|
|
319
|
+
this.hasReceivedPositions = false;
|
|
320
|
+
this.hasReceivedBalance = false;
|
|
321
|
+
this.openTradeEntries.clear();
|
|
322
|
+
this.pendingCloses.clear();
|
|
323
|
+
this.pendingEmptyPoll = false;
|
|
324
|
+
this.lastTradeTs = null;
|
|
325
|
+
this.realizedPnlToday = null;
|
|
326
|
+
this.realizedPnlAllTime = null;
|
|
327
|
+
this.paperSessionFees = null;
|
|
328
|
+
this.paperAvgSlippageBps = null;
|
|
329
|
+
this.equity = null;
|
|
330
|
+
this.http = null;
|
|
331
|
+
this.eventParser = null;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Drain in-flight trade result reports. Call during shutdown to prevent
|
|
335
|
+
* data loss during kill/flatten sequences. Returns when all pending
|
|
336
|
+
* reports have completed (or timed out).
|
|
337
|
+
*/
|
|
338
|
+
async drainReports() {
|
|
339
|
+
if (this.inFlightReports.length === 0)
|
|
340
|
+
return;
|
|
341
|
+
logger.info(TAG, `Draining ${this.inFlightReports.length} in-flight trade report(s)...`);
|
|
342
|
+
await Promise.allSettled(this.inFlightReports);
|
|
343
|
+
logger.info(TAG, 'All trade reports drained');
|
|
344
|
+
}
|
|
345
|
+
// ---- Trade report retry queue ----
|
|
346
|
+
loadRetryQueue() {
|
|
347
|
+
try {
|
|
348
|
+
const raw = readFileSync(this.retryQueuePath, 'utf-8');
|
|
349
|
+
const parsed = JSON.parse(raw);
|
|
350
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
saveRetryQueue(queue) {
|
|
357
|
+
try {
|
|
358
|
+
writeFileSync(this.retryQueuePath, JSON.stringify(queue));
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
logger.warn(TAG, `Failed to save retry queue: ${formatError(err)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Guards processRetryQueue against overlapping runs — the retry timer fires
|
|
365
|
+
* every 60s and startRetryTimer also kicks an immediate run, so a slow
|
|
366
|
+
* (100-item × 10s-timeout) drain could still be in flight when the next
|
|
367
|
+
* timer ticks. Two concurrent drains would double-POST every queued report
|
|
368
|
+
* AND race their reconcile-saves against each other. */
|
|
369
|
+
retryInProgress = false;
|
|
370
|
+
/** Monotonic tag stamped on every queued report so processRetryQueue can
|
|
371
|
+
* remove EXACTLY the reports it delivered when it reconciles the file — a
|
|
372
|
+
* blind `save(remaining)` would clobber any report enqueued DURING the
|
|
373
|
+
* network phase (read-modify-write race with enqueueFailedReport). */
|
|
374
|
+
nextRetryQid = 0;
|
|
375
|
+
enqueueFailedReport(tradeResult) {
|
|
376
|
+
// Synchronous read-modify-write: no `await` between load and save, so a
|
|
377
|
+
// concurrent processRetryQueue (which only touches the file synchronously
|
|
378
|
+
// outside its network phase) can never interleave and clobber this push.
|
|
379
|
+
const queue = this.loadRetryQueue();
|
|
380
|
+
queue.push({ ...tradeResult, _qid: `${Date.now()}-${this.nextRetryQid++}` });
|
|
381
|
+
// Cap at 100 to prevent unbounded growth
|
|
382
|
+
if (queue.length > 100)
|
|
383
|
+
queue.splice(0, queue.length - 100);
|
|
384
|
+
this.saveRetryQueue(queue);
|
|
385
|
+
logger.info(TAG, `Trade report queued for retry (${queue.length} pending)`);
|
|
386
|
+
}
|
|
387
|
+
async processRetryQueue() {
|
|
388
|
+
// Timer-overlap / in-flight guard — single-drainer at a time.
|
|
389
|
+
if (this.retryInProgress)
|
|
390
|
+
return;
|
|
391
|
+
let snapshot = this.loadRetryQueue();
|
|
392
|
+
if (snapshot.length === 0)
|
|
393
|
+
return;
|
|
394
|
+
// One-time migration: reports queued by a pre-_qid binary carry no tag, so
|
|
395
|
+
// the reconcile-by-qid below couldn't remove them and they'd retry forever
|
|
396
|
+
// (harmless idempotent dup POSTs, but never clearing). Backfill a _qid for
|
|
397
|
+
// any legacy entry. Synchronous read-modify-write — atomic vs enqueue.
|
|
398
|
+
if (snapshot.some((it) => typeof it._qid !== 'string')) {
|
|
399
|
+
snapshot = snapshot.map((it) => typeof it._qid === 'string' ? it : { ...it, _qid: `${Date.now()}-${this.nextRetryQid++}` });
|
|
400
|
+
this.saveRetryQueue(snapshot);
|
|
401
|
+
}
|
|
402
|
+
const url = this.config.intelligenceUrl;
|
|
403
|
+
const token = this.config.connectionToken;
|
|
404
|
+
if (!url || !token)
|
|
405
|
+
return;
|
|
406
|
+
this.retryInProgress = true;
|
|
407
|
+
try {
|
|
408
|
+
const fullUrl = `${url}/api/trades`;
|
|
409
|
+
const deliveredQids = new Set();
|
|
410
|
+
for (const item of snapshot) {
|
|
411
|
+
const qid = typeof item._qid === 'string' ? item._qid : undefined;
|
|
412
|
+
// Strip the internal tag so the POST body is byte-identical to the
|
|
413
|
+
// original report (the intel /api/trades endpoint never sees _qid).
|
|
414
|
+
const tradeResult = { ...item };
|
|
415
|
+
delete tradeResult._qid;
|
|
416
|
+
try {
|
|
417
|
+
const res = await fetch(fullUrl, {
|
|
418
|
+
method: 'POST',
|
|
419
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
420
|
+
body: JSON.stringify(tradeResult),
|
|
421
|
+
signal: AbortSignal.timeout(10_000),
|
|
422
|
+
});
|
|
423
|
+
if (res.ok) {
|
|
424
|
+
if (qid)
|
|
425
|
+
deliveredQids.add(qid);
|
|
426
|
+
logger.info(TAG, `Retry succeeded: ${tradeResult.symbol} ${tradeResult.direction}`);
|
|
427
|
+
}
|
|
428
|
+
// else: still failing — leave it in the queue (not marked delivered)
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Network error — stop retrying; leave this + the rest in the queue.
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Reconcile synchronously (no await between load and save): re-read the
|
|
436
|
+
// CURRENT file — it may now hold reports enqueued while we were POSTing —
|
|
437
|
+
// and drop ONLY the ones we actually delivered. Everything else (still-
|
|
438
|
+
// failing + newly-enqueued) is preserved. A blind save(remaining) here
|
|
439
|
+
// would silently lose any report enqueued during the network phase.
|
|
440
|
+
const current = this.loadRetryQueue();
|
|
441
|
+
const remaining = deliveredQids.size > 0
|
|
442
|
+
? current.filter((item) => !(typeof item._qid === 'string' && deliveredQids.has(item._qid)))
|
|
443
|
+
: current;
|
|
444
|
+
if (remaining.length !== current.length) {
|
|
445
|
+
this.saveRetryQueue(remaining);
|
|
446
|
+
logger.info(TAG, `Retry queue: ${deliveredQids.size} delivered, ${remaining.length} remaining`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
this.retryInProgress = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
startRetryTimer() {
|
|
454
|
+
// Retry every 60 seconds
|
|
455
|
+
this.retryTimer = setInterval(() => void this.processRetryQueue(), 60_000);
|
|
456
|
+
// Also process immediately on startup (recover from prior crash)
|
|
457
|
+
void this.processRetryQueue();
|
|
458
|
+
}
|
|
459
|
+
stopRetryTimer() {
|
|
460
|
+
if (this.retryTimer) {
|
|
461
|
+
clearInterval(this.retryTimer);
|
|
462
|
+
this.retryTimer = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** Dual-fire dedup. Every critical command fires on BOTH WebSocket and
|
|
466
|
+
* HTTP simultaneously (CLAUDE.md: "Agent handles idempotency; first to
|
|
467
|
+
* arrive wins") — so each emergency action normally arrives here TWICE,
|
|
468
|
+
* ~0-200ms apart. Without dedup a second `flatten` re-fetches positions
|
|
469
|
+
* before the first close settles on-exchange and submits a second market
|
|
470
|
+
* close — a position-flip risk. Duplicates of the same action while one
|
|
471
|
+
* is in flight (or within a short post-completion window) share the
|
|
472
|
+
* first invocation's promise/result instead of executing again. */
|
|
473
|
+
static EMERGENCY_DEDUP_MS = 3_000;
|
|
474
|
+
emergencyInFlight = new Map();
|
|
475
|
+
async executeEmergency(action) {
|
|
476
|
+
const existing = this.emergencyInFlight.get(action);
|
|
477
|
+
if (existing &&
|
|
478
|
+
(existing.settledAt === null ||
|
|
479
|
+
Date.now() - existing.settledAt < GatewayProvider.EMERGENCY_DEDUP_MS)) {
|
|
480
|
+
logger.warn(TAG, `Emergency ${action}: duplicate (dual-fire) — returning first invocation's result`);
|
|
481
|
+
return existing.promise;
|
|
482
|
+
}
|
|
483
|
+
const entry = { promise: Promise.resolve({ action, executed: false, timestamp: '' }), settledAt: null };
|
|
484
|
+
entry.promise = this.runEmergency(action).finally(() => {
|
|
485
|
+
entry.settledAt = Date.now();
|
|
486
|
+
});
|
|
487
|
+
this.emergencyInFlight.set(action, entry);
|
|
488
|
+
return entry.promise;
|
|
489
|
+
}
|
|
490
|
+
async runEmergency(action) {
|
|
491
|
+
logger.info(TAG, `Emergency command: ${action}`);
|
|
492
|
+
switch (action) {
|
|
493
|
+
case 'kill':
|
|
494
|
+
return this.executeKill();
|
|
495
|
+
case 'flatten':
|
|
496
|
+
return this.executeFlatten();
|
|
497
|
+
case 'pause':
|
|
498
|
+
return this.executePause();
|
|
499
|
+
case 'resume':
|
|
500
|
+
return this.executeResume();
|
|
501
|
+
default:
|
|
502
|
+
return { action, executed: false, timestamp: new Date().toISOString(), details: `Unknown action: ${action}` };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// ---- Emergency command implementations (delegated to emergency-commands.ts) ----
|
|
506
|
+
async executeKill() {
|
|
507
|
+
const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
|
|
508
|
+
// Pass a FRESH, all-symbols order snapshot so Kill cancels every working
|
|
509
|
+
// order portfolio-wide and correctly identifies (and preserves) protective
|
|
510
|
+
// reduceOnly brackets. fetchFreshOpenOrders falls back to this.openOrders on
|
|
511
|
+
// any fetch error, so this never blocks the kill.
|
|
512
|
+
const freshOrders = await this.fetchFreshOpenOrders();
|
|
513
|
+
const result = await _executeKill(ctx, freshOrders);
|
|
514
|
+
this.agentMode = 'STOPPED';
|
|
515
|
+
this.emitAgentState();
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
async executeFlatten() {
|
|
519
|
+
const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
|
|
520
|
+
const result = await _executeFlatten(ctx, this.positions);
|
|
521
|
+
this.agentMode = 'STOPPED';
|
|
522
|
+
this.emitAgentState();
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
executePause() {
|
|
526
|
+
const { result, newMode } = _executePause(this.agentMode);
|
|
527
|
+
if (newMode !== this.agentMode) {
|
|
528
|
+
this.agentMode = newMode;
|
|
529
|
+
this.emitAgentState();
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
executeResume() {
|
|
534
|
+
const { result, newMode } = _executeResume(this.agentMode);
|
|
535
|
+
if (newMode !== this.agentMode) {
|
|
536
|
+
this.agentMode = newMode;
|
|
537
|
+
this.emitAgentState();
|
|
538
|
+
}
|
|
539
|
+
return result;
|
|
540
|
+
}
|
|
541
|
+
async handleChat(content) {
|
|
542
|
+
// Capture reference before await — defensive against stop() mid-execution
|
|
543
|
+
const ws = this.wsClient;
|
|
544
|
+
if (!ws) {
|
|
545
|
+
const reason = 'Gateway WebSocket not initialized';
|
|
546
|
+
logger.error(TAG, `Chat send failed: ${reason}`);
|
|
547
|
+
return { received: false, error: reason };
|
|
548
|
+
}
|
|
549
|
+
if (!this.started) {
|
|
550
|
+
const reason = 'Provider not started';
|
|
551
|
+
logger.error(TAG, `Chat send failed: ${reason}`);
|
|
552
|
+
return { received: false, error: reason };
|
|
553
|
+
}
|
|
554
|
+
const wsState = ws.getState();
|
|
555
|
+
if (wsState !== 'connected') {
|
|
556
|
+
const reason = `Gateway WebSocket is ${wsState} (not connected)`;
|
|
557
|
+
logger.error(TAG, `Chat send failed: ${reason}`);
|
|
558
|
+
return { received: false, error: reason };
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const idempotencyKey = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
562
|
+
await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: 'main' });
|
|
563
|
+
return { received: true };
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
567
|
+
logger.error(TAG, `Chat send failed: ${reason}`);
|
|
568
|
+
return { received: false, error: reason };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async closePosition(symbol) {
|
|
572
|
+
const tool = this.toolMap.close_position;
|
|
573
|
+
if (!tool || !this.http) {
|
|
574
|
+
return { closed: false, symbol, error: 'close_position tool not available' };
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
// reason='operator_command' is MANDATORY: close_position's schema gate
|
|
578
|
+
// (validateClosePosition) rejects a reason-less close, and only
|
|
579
|
+
// operator_command bypasses the assessment requirement. Without it the
|
|
580
|
+
// operator's per-symbol Close is silently rejected and never executes.
|
|
581
|
+
// See plugin/src/tools/close-position.ts.
|
|
582
|
+
const result = await this.http.invoke(tool, { symbol, reason: 'operator_command' });
|
|
583
|
+
if (result.data && typeof result.data === 'object' && 'error' in result.data) {
|
|
584
|
+
return { closed: false, symbol, error: String(result.data.error) };
|
|
585
|
+
}
|
|
586
|
+
return { closed: true, symbol };
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
590
|
+
logger.error(TAG, `Close position failed for ${symbol}: ${reason}`);
|
|
591
|
+
return { closed: false, symbol, error: reason };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ---- Operator onboarding (PR2) ----
|
|
595
|
+
// Thin wrappers — real logic lives in providers/onboarding-commands.ts
|
|
596
|
+
// so unit tests can drive pure functions with a mocked HTTP client.
|
|
597
|
+
async setTradingMode(mode, acknowledged) {
|
|
598
|
+
return executeSetTradingMode({ http: this.http, toolMap: this.toolMap }, mode, acknowledged);
|
|
599
|
+
}
|
|
600
|
+
async setExchangeCredentials(apiKey, secret, testnet) {
|
|
601
|
+
return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
|
|
602
|
+
}
|
|
603
|
+
async testExchangeCredentials(apiKey, secret, testnet) {
|
|
604
|
+
return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
|
|
605
|
+
}
|
|
606
|
+
async clearExchangeCredentials() {
|
|
607
|
+
return executeClearExchangeCredentials({ http: this.http, toolMap: this.toolMap });
|
|
608
|
+
}
|
|
609
|
+
/** Operator-only. Read the current bracket-orders config from the plugin. */
|
|
610
|
+
async getBracketConfig() {
|
|
611
|
+
return executeGetBracketConfig({ http: this.http, toolMap: this.toolMap });
|
|
612
|
+
}
|
|
613
|
+
/** Operator-only. Flip requireStopLoss or requireTakeProfit. */
|
|
614
|
+
async setBracketRequirement(flag, value) {
|
|
615
|
+
return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap }, flag, value);
|
|
616
|
+
}
|
|
617
|
+
async getSnapshot() {
|
|
618
|
+
logger.info(TAG, 'Reconciliation snapshot requested');
|
|
619
|
+
// Parallel fetch — fresh data with cached fallback
|
|
620
|
+
const [posResult, balResult, ordersResult, healthResult] = await Promise.allSettled([
|
|
621
|
+
this.fetchFreshPositions(),
|
|
622
|
+
this.fetchFreshBalance(),
|
|
623
|
+
this.fetchFreshOpenOrders(),
|
|
624
|
+
this.fetchAgentHealth(),
|
|
625
|
+
]);
|
|
626
|
+
// Guard: stop() may have been called during the fetches
|
|
627
|
+
if (!this.started) {
|
|
628
|
+
logger.info(TAG, 'Snapshot aborted — provider stopped during fetch');
|
|
629
|
+
return {
|
|
630
|
+
positions: [],
|
|
631
|
+
balance: this.balance,
|
|
632
|
+
openOrders: [],
|
|
633
|
+
agentState: this.buildAgentState(),
|
|
634
|
+
riskMetrics: this.computeRiskMetrics(),
|
|
635
|
+
gapFill: [],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
// Extract results (all have fallbacks, so fulfilled is guaranteed by the helper methods)
|
|
639
|
+
const positions = posResult.status === 'fulfilled' ? posResult.value : this.positions;
|
|
640
|
+
const balance = balResult.status === 'fulfilled' ? balResult.value : this.balance;
|
|
641
|
+
const openOrders = ordersResult.status === 'fulfilled' ? ordersResult.value : this.openOrders;
|
|
642
|
+
const agentState = healthResult.status === 'fulfilled' ? healthResult.value : this.buildAgentState();
|
|
643
|
+
// Log if all fetches used cached data (degraded snapshot)
|
|
644
|
+
const freshCount = [posResult, balResult, ordersResult, healthResult]
|
|
645
|
+
.filter((r) => r.status === 'fulfilled').length;
|
|
646
|
+
if (freshCount === 0) {
|
|
647
|
+
logger.error(TAG, 'Snapshot DEGRADED — all fetches failed, returning cached data');
|
|
648
|
+
}
|
|
649
|
+
// Canonicalize symbols at the snapshot ingress too — same single-form
|
|
650
|
+
// invariant as onPollerPositions. The reconnect fetch hits the same
|
|
651
|
+
// plugin getPositions WS-store(unsuffixed)↔REST(suffixed) flip, so
|
|
652
|
+
// without this the first poll AFTER a snapshot diffs a suffixed prev
|
|
653
|
+
// against an unsuffixed curr (or vice-versa) and re-triggers the
|
|
654
|
+
// phantom-fill / NAV-thrash flip (2026-05-16 incident).
|
|
655
|
+
for (const p of positions) {
|
|
656
|
+
if (typeof p.symbol === 'string')
|
|
657
|
+
p.symbol = this.canonicalSymbol(p.symbol);
|
|
658
|
+
}
|
|
659
|
+
// Update internal state with fresh data (poller may be paused during reconnect)
|
|
660
|
+
this.positions = positions;
|
|
661
|
+
this.balance = balance;
|
|
662
|
+
this.openOrders = openOrders;
|
|
663
|
+
// Compute current NAV for portfolio percent calculation (consistent with computeRiskMetrics)
|
|
664
|
+
const equity = this.computeEquity();
|
|
665
|
+
const nav = this.sessionStartNav > 0 ? this.sessionStartNav : Math.max(equity, 1);
|
|
666
|
+
// Map CCXT positions to snapshot format
|
|
667
|
+
const snapshotPositions = [];
|
|
668
|
+
for (const pos of positions) {
|
|
669
|
+
const signedNotional = this.computePositionNotional(pos);
|
|
670
|
+
if (signedNotional === 0) {
|
|
671
|
+
logger.warn(TAG, `Skipping zero-notional position: ${pos.symbol} ${pos.side} contracts=${pos.contracts} notional=${pos.notional} markPrice=${pos.markPrice} entryPrice=${pos.entryPrice}`);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const contractSize = pos.contractSize ?? 1;
|
|
675
|
+
const quantity = Math.abs(pos.contracts ?? 0) * contractSize;
|
|
676
|
+
const entryPrice = pos.entryPrice ?? 0;
|
|
677
|
+
const currentPrice = pos.markPrice ?? pos.entryPrice ?? this.lastTicker?.lastPrice ?? 0;
|
|
678
|
+
// Side mapping — guard against non-standard CCXT values (e.g. 'both' in hedge mode)
|
|
679
|
+
let side;
|
|
680
|
+
if (pos.side === 'short') {
|
|
681
|
+
side = 'SHORT';
|
|
682
|
+
}
|
|
683
|
+
else if (pos.side === 'long') {
|
|
684
|
+
side = 'LONG';
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
logger.warn(TAG, `Unknown position side "${pos.side}" for ${pos.symbol}, defaulting to LONG`);
|
|
688
|
+
side = 'LONG';
|
|
689
|
+
}
|
|
690
|
+
// Unrealized PnL — prefer exchange-provided value, compute as fallback
|
|
691
|
+
let unrealizedPnl = pos.unrealizedPnl;
|
|
692
|
+
if (unrealizedPnl == null && entryPrice > 0 && currentPrice > 0) {
|
|
693
|
+
unrealizedPnl = pos.side === 'long'
|
|
694
|
+
? (currentPrice - entryPrice) * quantity
|
|
695
|
+
: (entryPrice - currentPrice) * quantity;
|
|
696
|
+
}
|
|
697
|
+
unrealizedPnl ??= 0;
|
|
698
|
+
const costBasis = entryPrice * quantity;
|
|
699
|
+
const unrealizedPnlPercent = costBasis > 0 ? (unrealizedPnl / costBasis) * 100 : 0;
|
|
700
|
+
const portfolioPercent = (Math.abs(signedNotional) / nav) * 100;
|
|
701
|
+
const liqFields = extractLiquidationFields(pos);
|
|
702
|
+
const bracketField = extractBracketField(pos);
|
|
703
|
+
snapshotPositions.push({
|
|
704
|
+
symbol: pos.symbol,
|
|
705
|
+
side,
|
|
706
|
+
quantity,
|
|
707
|
+
entryPrice,
|
|
708
|
+
currentPrice,
|
|
709
|
+
unrealizedPnl: +unrealizedPnl.toFixed(4),
|
|
710
|
+
unrealizedPnlPercent: +unrealizedPnlPercent.toFixed(2),
|
|
711
|
+
portfolioPercent: +portfolioPercent.toFixed(2),
|
|
712
|
+
// Component C: MFE / give-back surfacing. The plugin computes these on
|
|
713
|
+
// every mark-price tick (paper) or every getPositions call (live);
|
|
714
|
+
// we just pass them through unchanged. Undefined when the position
|
|
715
|
+
// opened without a stop (R-multiple denominator missing). Index
|
|
716
|
+
// signature on CcxtPosition is `unknown`, so narrow at the boundary.
|
|
717
|
+
mfeR: typeof pos.mfeR === 'number' ? pos.mfeR : undefined,
|
|
718
|
+
mfePeakPrice: typeof pos.mfePeakPrice === 'number' ? pos.mfePeakPrice : undefined,
|
|
719
|
+
giveBackRatio: typeof pos.giveBackRatio === 'number' ? pos.giveBackRatio : undefined,
|
|
720
|
+
originalStopPrice: typeof pos.originalStopPrice === 'number' ? pos.originalStopPrice : undefined,
|
|
721
|
+
...(bracketField !== undefined ? { bracket: bracketField } : {}),
|
|
722
|
+
...liqFields,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
logger.info(TAG, `Snapshot: ${positions.length} raw positions → ${snapshotPositions.length} in snapshot (equity=${equity.toFixed(2)}, wallet=${balance.total?.toFixed(2)})`);
|
|
726
|
+
// Compute risk metrics (reuse existing logic)
|
|
727
|
+
const riskMetrics = this.computeRiskMetrics();
|
|
728
|
+
// NAV must equal Binance's "Margin Balance" = wallet + unrealized.
|
|
729
|
+
// Compute directly from the fresh wallet (balance.total — BNFCR-aware,
|
|
730
|
+
// uPnl-excluded) + unrealized already summed across snapshotPositions.
|
|
731
|
+
// We deliberately do NOT use this.equity: it is only assigned in
|
|
732
|
+
// fetchFreshBalance() (dormant under userDataStream.mode=enforce), so
|
|
733
|
+
// the old `this.equity ?? balance.total` collapsed live NAV to wallet-
|
|
734
|
+
// only on reconnect (2026-05-16 KPI incident). Paper keeps computeEquity
|
|
735
|
+
// (adds back entry notional — correct, paper deducts full notional at
|
|
736
|
+
// open).
|
|
737
|
+
const snapshotUnrealizedTotal = snapshotPositions.reduce((sum, p) => sum + p.unrealizedPnl, 0);
|
|
738
|
+
const snapshotTotal = this.tradingMode !== 'PAPER'
|
|
739
|
+
? balance.total + snapshotUnrealizedTotal // live: wallet + unrealized
|
|
740
|
+
: equity; // paper: wallet + notional
|
|
741
|
+
const adjustedBalance = {
|
|
742
|
+
...balance,
|
|
743
|
+
total: snapshotTotal,
|
|
744
|
+
available: balance.available,
|
|
745
|
+
locked: snapshotTotal - balance.available,
|
|
746
|
+
};
|
|
747
|
+
return {
|
|
748
|
+
positions: snapshotPositions,
|
|
749
|
+
balance: adjustedBalance,
|
|
750
|
+
openOrders,
|
|
751
|
+
agentState,
|
|
752
|
+
riskMetrics,
|
|
753
|
+
gapFill: [],
|
|
754
|
+
sessionStartNav: this.sessionStartNav > 0 ? this.sessionStartNav : undefined,
|
|
755
|
+
realizedPnlToday: this.realizedPnlToday ?? undefined,
|
|
756
|
+
realizedPnlAllTime: this.realizedPnlAllTime ?? undefined,
|
|
757
|
+
sessionFeesPaid: this.paperSessionFees ?? undefined,
|
|
758
|
+
sessionAvgSlippageBps: this.paperAvgSlippageBps ?? undefined,
|
|
759
|
+
equity: this.equity ?? undefined,
|
|
760
|
+
tradingMode: this.tradingMode,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
on(event, listener) {
|
|
764
|
+
this.listeners[event].add(listener);
|
|
765
|
+
}
|
|
766
|
+
off(event, listener) {
|
|
767
|
+
this.listeners[event].delete(listener);
|
|
768
|
+
}
|
|
769
|
+
// ---- Async initialization ----
|
|
770
|
+
async initialize() {
|
|
771
|
+
// Step 1: Create HTTP client (initially with raw gateway token)
|
|
772
|
+
this.http = new GatewayHttpClient(this.config);
|
|
773
|
+
logger.info(TAG, 'HTTP client created');
|
|
774
|
+
// Step 2: Create event parser with async event callback for inactivity timeouts.
|
|
775
|
+
// When the parser's inactivity timer fires (no new chunks for 5s), it flushes
|
|
776
|
+
// the buffer and emits chatMessageEnd outside the normal parseAgentEvent flow.
|
|
777
|
+
this.eventParser = new EventParser(this.quoteCurrency, (events) => {
|
|
778
|
+
for (const evt of events) {
|
|
779
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
780
|
+
this.fire(evt.event, evt.data);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
// Step 3: Create WS client and connect FIRST to obtain device token.
|
|
784
|
+
// OpenClaw's REST API requires a device token from the WS handshake,
|
|
785
|
+
// not the raw gateway auth token. Tool discovery must wait for this.
|
|
786
|
+
this.wsClient = new GatewayWsClient(this.config);
|
|
787
|
+
this.wireWsEvents();
|
|
788
|
+
// Wait for WS handshake to complete (or fail)
|
|
789
|
+
const deviceToken = await this.waitForDeviceToken();
|
|
790
|
+
if (!this.started) {
|
|
791
|
+
logger.info(TAG, 'Stopped during WS handshake — aborting initialization');
|
|
792
|
+
this.http = null;
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// Update HTTP client with device token for REST API calls
|
|
796
|
+
if (deviceToken) {
|
|
797
|
+
this.http.setToken(deviceToken);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
logger.warn(TAG, 'No device token from WS handshake — REST calls may fail (using raw gateway token)');
|
|
801
|
+
}
|
|
802
|
+
// Step 4: Discover available tools (async — check for stop() after)
|
|
803
|
+
const discovery = await discoverTools(this.http);
|
|
804
|
+
if (!this.started) {
|
|
805
|
+
logger.info(TAG, 'Stopped during tool discovery — aborting initialization');
|
|
806
|
+
this.http = null;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
this.toolMap = discovery.toolMap;
|
|
810
|
+
const noToolsFound = discovery.found.length === 0;
|
|
811
|
+
if (noToolsFound) {
|
|
812
|
+
// No trading tools found — enter PAUSED mode and start retry loop.
|
|
813
|
+
// Still connect WS + relay so the dashboard shows the agent is alive.
|
|
814
|
+
logger.warn(TAG, 'No trading tools found. Agent will connect but cannot trade.');
|
|
815
|
+
logger.info(TAG, 'Install a trading plugin: openclaw skills install reefclaw-paper-trading');
|
|
816
|
+
this.agentMode = 'PAUSED';
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
if (discovery.missingRequired.length > 0) {
|
|
820
|
+
logger.error(TAG, `Missing required tools: ${discovery.missingRequired.join(', ')}`);
|
|
821
|
+
}
|
|
822
|
+
logger.info(TAG, `Tools discovered: ${discovery.found.join(', ')}`);
|
|
823
|
+
// Warn if emergency tools are missing
|
|
824
|
+
if (!this.toolMap.cancel_all_orders && !this.toolMap.cancel_order) {
|
|
825
|
+
logger.warn(TAG, 'Kill switch degraded: no cancel tools discovered (cancel_all_orders, cancel_order)');
|
|
826
|
+
}
|
|
827
|
+
if (!this.toolMap.close_position && !this.toolMap.create_order) {
|
|
828
|
+
logger.warn(TAG, 'Flatten degraded: no position close tools discovered (close_position, create_order)');
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// Start retry loop if not all essential tools are discovered.
|
|
832
|
+
// This handles both "no tools at all" and "partial discovery during gateway startup".
|
|
833
|
+
if (!this.hasAllEssentialTools()) {
|
|
834
|
+
const missing = GatewayProvider.ESSENTIAL_OPTIONAL_TOOLS.filter(t => !this.toolMap[t]);
|
|
835
|
+
const missingRequired = ['fetch_ticker', 'fetch_balance'].filter(t => !this.toolMap[t]);
|
|
836
|
+
const allMissing = [...missingRequired, ...missing];
|
|
837
|
+
logger.info(TAG, `Retrying tool discovery every 30s for missing tools: ${allMissing.join(', ')}`);
|
|
838
|
+
this.startToolRetry();
|
|
839
|
+
}
|
|
840
|
+
// Step 5: Start poller if tools available (previously started from onWsConnected)
|
|
841
|
+
const hasTools = Object.keys(this.toolMap).length > 0;
|
|
842
|
+
if (hasTools && this.http && !this.poller) {
|
|
843
|
+
this.poller = this.createPoller();
|
|
844
|
+
this.poller.start();
|
|
845
|
+
}
|
|
846
|
+
// Step 6: Start regime poller if intelligence service is configured
|
|
847
|
+
this.startRegimePoller();
|
|
848
|
+
// Step 7: Start signal poller if intelligence service is configured
|
|
849
|
+
this.startSignalPoller();
|
|
850
|
+
// Step 8: Start mission poller if intelligence service is configured
|
|
851
|
+
this.startMissionPoller();
|
|
852
|
+
// Step 9: Start analytics poller if intelligence service is configured
|
|
853
|
+
this.startAnalyticsPoller();
|
|
854
|
+
// Step 10: Start decision trace poller if intelligence service is configured
|
|
855
|
+
this.startDecisionTracePoller();
|
|
856
|
+
// Step 11: Seed lastTradeTs from Intelligence API (one-shot, best-effort)
|
|
857
|
+
// Without this, "Last Trade" shows "Never" after every restart until a new trade fires.
|
|
858
|
+
void this.seedLastTradeTs();
|
|
859
|
+
// Emit initial agent state
|
|
860
|
+
this.emitAgentState();
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Wait for the WS handshake to complete and return the device token.
|
|
864
|
+
* Returns null if handshake fails or times out.
|
|
865
|
+
*/
|
|
866
|
+
waitForDeviceToken() {
|
|
867
|
+
return new Promise((resolve) => {
|
|
868
|
+
if (!this.wsClient) {
|
|
869
|
+
resolve(null);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
let settled = false;
|
|
873
|
+
const cleanup = () => {
|
|
874
|
+
if (this.wsClient) {
|
|
875
|
+
this.wsClient.off('connected', onConnected);
|
|
876
|
+
this.wsClient.off('failed', onFailed);
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
const settle = (value) => {
|
|
880
|
+
if (settled)
|
|
881
|
+
return;
|
|
882
|
+
settled = true;
|
|
883
|
+
clearTimeout(timeout);
|
|
884
|
+
cleanup();
|
|
885
|
+
resolve(value);
|
|
886
|
+
};
|
|
887
|
+
const timeout = setTimeout(() => {
|
|
888
|
+
logger.warn(TAG, 'WS handshake timed out after 15s — proceeding without device token');
|
|
889
|
+
settle(null);
|
|
890
|
+
}, 15_000);
|
|
891
|
+
const onConnected = (helloOk) => {
|
|
892
|
+
const token = helloOk.auth?.deviceToken ?? null;
|
|
893
|
+
if (token) {
|
|
894
|
+
logger.info(TAG, 'Got device token from WS handshake');
|
|
895
|
+
}
|
|
896
|
+
settle(token);
|
|
897
|
+
};
|
|
898
|
+
const onFailed = () => {
|
|
899
|
+
logger.warn(TAG, 'WS handshake failed — proceeding without device token');
|
|
900
|
+
settle(null);
|
|
901
|
+
};
|
|
902
|
+
this.wsClient.on('connected', onConnected);
|
|
903
|
+
this.wsClient.on('failed', onFailed);
|
|
904
|
+
// Initiate the connection
|
|
905
|
+
this.wsClient.connect();
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Essential optional tools — retry loop keeps running until these are discovered.
|
|
910
|
+
* These are needed for position tracking, emergency controls, and trade display.
|
|
911
|
+
*/
|
|
912
|
+
static ESSENTIAL_OPTIONAL_TOOLS = [
|
|
913
|
+
'fetch_positions',
|
|
914
|
+
'create_order',
|
|
915
|
+
'close_position',
|
|
916
|
+
'cancel_all_orders',
|
|
917
|
+
];
|
|
918
|
+
/** Check if all essential tools are discovered. */
|
|
919
|
+
hasAllEssentialTools() {
|
|
920
|
+
for (const tool of GatewayProvider.ESSENTIAL_OPTIONAL_TOOLS) {
|
|
921
|
+
if (!this.toolMap[tool])
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
// Also need the required tools
|
|
925
|
+
if (!this.toolMap.fetch_ticker || !this.toolMap.fetch_balance)
|
|
926
|
+
return false;
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
/** Retry tool discovery every 30s until all essential tools are found. */
|
|
930
|
+
startToolRetry() {
|
|
931
|
+
if (this.toolRetryInterval)
|
|
932
|
+
return;
|
|
933
|
+
const RETRY_MS = 30_000;
|
|
934
|
+
this.toolRetryInterval = setInterval(async () => {
|
|
935
|
+
if (!this.started || !this.http) {
|
|
936
|
+
if (this.toolRetryInterval) {
|
|
937
|
+
clearInterval(this.toolRetryInterval);
|
|
938
|
+
this.toolRetryInterval = null;
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
logger.info(TAG, 'Retrying tool discovery...');
|
|
943
|
+
try {
|
|
944
|
+
// Capture http ref before await — stop() can nullify it
|
|
945
|
+
const http = this.http;
|
|
946
|
+
const discovery = await discoverTools(http);
|
|
947
|
+
// Guard: stop() may have been called during async discovery
|
|
948
|
+
if (!this.started || !this.http)
|
|
949
|
+
return;
|
|
950
|
+
if (discovery.found.length > 0) {
|
|
951
|
+
// Merge newly discovered tools into existing toolMap
|
|
952
|
+
const prevCount = Object.keys(this.toolMap).length;
|
|
953
|
+
for (const [key, val] of Object.entries(discovery.toolMap)) {
|
|
954
|
+
this.toolMap[key] = val;
|
|
955
|
+
}
|
|
956
|
+
const newCount = Object.keys(this.toolMap).length;
|
|
957
|
+
logger.info(TAG, `Tools discovered on retry: ${discovery.found.join(', ')} (total: ${newCount})`);
|
|
958
|
+
// Transition from PAUSED to ACTIVE once we have required tools
|
|
959
|
+
if (this.agentMode === 'PAUSED' && this.toolMap.fetch_ticker && this.toolMap.fetch_balance) {
|
|
960
|
+
this.agentMode = 'ACTIVE';
|
|
961
|
+
this.emitAgentState();
|
|
962
|
+
}
|
|
963
|
+
// If new tools were added, restart the poller with the updated toolMap
|
|
964
|
+
if (newCount > prevCount && this.started && this.http) {
|
|
965
|
+
if (this.poller) {
|
|
966
|
+
logger.info(TAG, 'Stopping poller to restart with updated tools');
|
|
967
|
+
this.poller.stop();
|
|
968
|
+
this.poller = null;
|
|
969
|
+
}
|
|
970
|
+
this.poller = this.createPoller();
|
|
971
|
+
this.poller.start();
|
|
972
|
+
logger.info(TAG, `Poller (re)started with ${newCount} tools`);
|
|
973
|
+
}
|
|
974
|
+
else if (!this.poller && this.started && this.http) {
|
|
975
|
+
// First time finding tools — create poller
|
|
976
|
+
this.poller = this.createPoller();
|
|
977
|
+
this.poller.start();
|
|
978
|
+
logger.info(TAG, `Poller started with ${newCount} tools`);
|
|
979
|
+
}
|
|
980
|
+
// Stop retry loop only when ALL essential tools are discovered
|
|
981
|
+
if (this.hasAllEssentialTools()) {
|
|
982
|
+
logger.info(TAG, 'All essential tools discovered — stopping retry loop');
|
|
983
|
+
if (this.toolRetryInterval) {
|
|
984
|
+
clearInterval(this.toolRetryInterval);
|
|
985
|
+
this.toolRetryInterval = null;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
const missing = GatewayProvider.ESSENTIAL_OPTIONAL_TOOLS
|
|
990
|
+
.filter(t => !this.toolMap[t]);
|
|
991
|
+
logger.info(TAG, `Still waiting for essential tools: ${missing.join(', ')}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
logger.debug(TAG, 'Tool retry: still no trading tools found');
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
logger.warn(TAG, `Tool retry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1000
|
+
}
|
|
1001
|
+
}, RETRY_MS);
|
|
1002
|
+
}
|
|
1003
|
+
// ---- Poller factory (DRY helper for all Poller creation sites) ----
|
|
1004
|
+
createPoller() {
|
|
1005
|
+
return new Poller(this.http, this.toolMap, this.config.symbol, {
|
|
1006
|
+
onTicker: (data) => this.onPollerTicker(data),
|
|
1007
|
+
onCandle: (data) => this.fire('candle', data),
|
|
1008
|
+
onPositions: (positions) => this.onPollerPositions(positions),
|
|
1009
|
+
onBalance: (balance) => this.onPollerBalance(balance),
|
|
1010
|
+
onOpenOrders: (orders) => this.onPollerOpenOrders(orders),
|
|
1011
|
+
onMarketStructure: (data) => this.onPollerMarketStructure(data),
|
|
1012
|
+
onCryptoMetrics: (data) => this.onPollerCryptoMetrics(data),
|
|
1013
|
+
onVolumeAnalysis: (data) => this.onPollerVolumeAnalysis(data),
|
|
1014
|
+
}, this.quoteCurrency);
|
|
1015
|
+
}
|
|
1016
|
+
// ---- WS event wiring ----
|
|
1017
|
+
wireWsEvents() {
|
|
1018
|
+
if (!this.wsClient)
|
|
1019
|
+
return;
|
|
1020
|
+
this.wsClient.on('connected', (helloOk) => this.onWsConnected(helloOk));
|
|
1021
|
+
this.wsClient.on('disconnected', ({ code, reason }) => {
|
|
1022
|
+
logger.warn(TAG, `WS disconnected: ${code} ${reason}`);
|
|
1023
|
+
this.poller?.pause();
|
|
1024
|
+
});
|
|
1025
|
+
this.wsClient.on('reconnecting', ({ attempt, delayMs }) => {
|
|
1026
|
+
logger.info(TAG, `WS reconnecting: attempt ${attempt}, delay ${Math.round(delayMs)}ms`);
|
|
1027
|
+
});
|
|
1028
|
+
this.wsClient.on('failed', ({ reason }) => {
|
|
1029
|
+
logger.error(TAG, `WS connection failed permanently: ${reason}`);
|
|
1030
|
+
this.agentMode = 'ERROR';
|
|
1031
|
+
this.emitAgentState();
|
|
1032
|
+
});
|
|
1033
|
+
this.wsClient.on('agent', (payload) => this.onAgentEvent(payload));
|
|
1034
|
+
}
|
|
1035
|
+
onWsConnected(helloOk) {
|
|
1036
|
+
if (!this.started)
|
|
1037
|
+
return; // Guard: stop() called before WS connected
|
|
1038
|
+
logger.info(TAG, 'WS reconnected — resuming poller');
|
|
1039
|
+
// Log snapshot info from hello-ok
|
|
1040
|
+
const snap = helloOk.snapshot;
|
|
1041
|
+
if (snap) {
|
|
1042
|
+
logger.info(TAG, `Gateway uptime: ${Math.round((snap.uptimeMs ?? 0) / 1000)}s, stateVersion: ${JSON.stringify(snap.stateVersion)}`);
|
|
1043
|
+
}
|
|
1044
|
+
// Update device token on reconnect
|
|
1045
|
+
if (helloOk.auth?.deviceToken && this.http) {
|
|
1046
|
+
this.http.setToken(helloOk.auth.deviceToken);
|
|
1047
|
+
}
|
|
1048
|
+
// Resume poller on reconnect (initial poller creation is in initialize())
|
|
1049
|
+
if (this.poller) {
|
|
1050
|
+
this.poller.resume();
|
|
1051
|
+
}
|
|
1052
|
+
// Emit agent state
|
|
1053
|
+
this.emitAgentState();
|
|
1054
|
+
this.emitRiskUpdate();
|
|
1055
|
+
// Resolve the agent's real name + model from the gateway (best-effort,
|
|
1056
|
+
// fire-and-forget; re-emits agent_state if it finds anything). Refreshed on
|
|
1057
|
+
// every (re)connect so a model swap — which restarts the gateway and drops
|
|
1058
|
+
// this socket — is reflected on the next handshake.
|
|
1059
|
+
void this.refreshAgentIdentity();
|
|
1060
|
+
// Start periodic agent state emission (every 5s)
|
|
1061
|
+
if (!this.agentStateInterval) {
|
|
1062
|
+
this.agentStateInterval = setInterval(() => {
|
|
1063
|
+
this.emitAgentState();
|
|
1064
|
+
}, AGENT_STATE_INTERVAL_MS);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// ---- Agent event handling (from WS) ----
|
|
1068
|
+
onAgentEvent(payload) {
|
|
1069
|
+
if (!this.started || !this.eventParser)
|
|
1070
|
+
return;
|
|
1071
|
+
const parsed = this.eventParser.parseAgentEvent(payload);
|
|
1072
|
+
for (const evt of parsed) {
|
|
1073
|
+
// Track order/cancel rates for risk computation
|
|
1074
|
+
if (evt.event === 'orderUpdate') {
|
|
1075
|
+
const data = evt.data;
|
|
1076
|
+
if (data.status === 'CANCELLED') {
|
|
1077
|
+
this.cancelTimestamps.push(Date.now());
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
this.orderTimestamps.push(Date.now());
|
|
1081
|
+
}
|
|
1082
|
+
// Track last trade timestamp for agent state
|
|
1083
|
+
if (data.status === 'FILLED') {
|
|
1084
|
+
this.lastTradeTs = data.timestamp ?? new Date().toISOString();
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1088
|
+
this.fire(evt.event, evt.data);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
// ---- Poller callbacks ----
|
|
1092
|
+
onPollerTicker(data) {
|
|
1093
|
+
this.lastTicker = data;
|
|
1094
|
+
this.fire('ticker', data);
|
|
1095
|
+
}
|
|
1096
|
+
/** Strip the live-Binance settle suffix (`SOL/USDT:USDT` → `SOL/USDT`)
|
|
1097
|
+
* to ONE canonical form, matching the webapp/plugin normalizeSymbol
|
|
1098
|
+
* convention (chart/DB are unsuffixed). The plugin's getPositions
|
|
1099
|
+
* returns the suffixed CCXT form via REST but the unsuffixed form via
|
|
1100
|
+
* the WS store; under weight pressure isStoreTrusted() flickers between
|
|
1101
|
+
* the two, so the raw symbol alternates poll-to-poll. */
|
|
1102
|
+
canonicalSymbol(s) {
|
|
1103
|
+
return typeof s === 'string' ? s.replace(/:[A-Z]+$/, '') : s;
|
|
1104
|
+
}
|
|
1105
|
+
async onPollerPositions(positions) {
|
|
1106
|
+
// Canonicalize symbols at this single position-ingress chokepoint so
|
|
1107
|
+
// the ENTIRE skill (position-diff, openTradeEntries keys, snapshots,
|
|
1108
|
+
// NAV/unrealized, trade journal) sees ONE stable symbol form. Without
|
|
1109
|
+
// this, the plugin's WS-store(unsuffixed)↔REST(suffixed) flip — driven
|
|
1110
|
+
// by isStoreTrusted() flickering under weight pressure — made
|
|
1111
|
+
// detectPositionChanges read every flip as close+open: phantom
|
|
1112
|
+
// round-trip fills into the journal/analytics AND a thrashing position
|
|
1113
|
+
// set behind NAV/unrealized. Realized still matched the exchange
|
|
1114
|
+
// because the unrealized term cancels in (NAV−anchor)−unrealized;
|
|
1115
|
+
// NAV/Day P&L/Unrealized did not (2026-05-16 incident). Unsuffixed is
|
|
1116
|
+
// already a live-exercised form through every skill path (it was half
|
|
1117
|
+
// the flip), so this removes the other form, it does not add a new one.
|
|
1118
|
+
for (const p of positions) {
|
|
1119
|
+
if (typeof p.symbol === 'string')
|
|
1120
|
+
p.symbol = this.canonicalSymbol(p.symbol);
|
|
1121
|
+
}
|
|
1122
|
+
// Empty-poll guard: a snapshot showing zero positions while we were
|
|
1123
|
+
// holding open positions is most often a Binance flake (429, partial
|
|
1124
|
+
// response, listenKey rotation). A single empty poll is treated as
|
|
1125
|
+
// tentative — we skip the entire detector path and preserve this.positions
|
|
1126
|
+
// as-is. If the next poll is also empty we fall through and let the
|
|
1127
|
+
// existing 2-poll close-confirmation logic emit confirmed closes. If
|
|
1128
|
+
// positions reappear we silently recover with no synthetic fills emitted.
|
|
1129
|
+
//
|
|
1130
|
+
// Without this guard, an empty-poll flake clears prevMap and the very next
|
|
1131
|
+
// non-empty poll re-emits OPEN fills for positions that were there all
|
|
1132
|
+
// along — the open-side mirror of the close-side phantom. Track 1
|
|
1133
|
+
// (2026-04-21) confirmed 56 phantom rows on 2026-04-20 19:50-19:56 UTC
|
|
1134
|
+
// were caused by this exact failure mode.
|
|
1135
|
+
const prevHadPositions = this.positions.some(p => (p.contracts ?? 0) !== 0);
|
|
1136
|
+
const currIsEmpty = positions.length === 0 || positions.every(p => (p.contracts ?? 0) === 0);
|
|
1137
|
+
if (this.hasReceivedPositions && prevHadPositions && currIsEmpty) {
|
|
1138
|
+
if (!this.pendingEmptyPoll) {
|
|
1139
|
+
this.pendingEmptyPoll = true;
|
|
1140
|
+
logger.warn(TAG, `Position poll returned empty but prev had ${this.positions.length} non-zero positions — treating as tentative (likely Binance flake), awaiting next-poll confirmation`);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
// Second consecutive empty poll — fall through; existing 2-poll close
|
|
1144
|
+
// confirmation in detectPositionChanges queues + confirms.
|
|
1145
|
+
}
|
|
1146
|
+
this.pendingEmptyPoll = false;
|
|
1147
|
+
// Skip synthetic fill detection on the very first poll after startup —
|
|
1148
|
+
// the skill has no prior state, so every position looks "new". Emitting
|
|
1149
|
+
// synthetic opens here causes phantom trades (and phantom closes on the
|
|
1150
|
+
// next poll cycle). The first poll is just the skill learning what exists.
|
|
1151
|
+
if (this.hasReceivedPositions) {
|
|
1152
|
+
this.detectPositionChanges(this.positions, positions);
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
// Seed openTradeEntries on first poll so we can track closes later
|
|
1156
|
+
for (const pos of positions) {
|
|
1157
|
+
const contracts = pos.contracts ?? 0;
|
|
1158
|
+
if (contracts !== 0 && pos.entryPrice) {
|
|
1159
|
+
const contractSize = pos.contractSize ?? 1;
|
|
1160
|
+
const side = pos.side === 'short' ? 'short' : 'long';
|
|
1161
|
+
const key = `${pos.symbol}:${side}`;
|
|
1162
|
+
this.openTradeEntries.set(key, {
|
|
1163
|
+
entryPrice: pos.entryPrice,
|
|
1164
|
+
quantity: Math.abs(contracts) * contractSize,
|
|
1165
|
+
side,
|
|
1166
|
+
entryTime: new Date().toISOString(), // Approximate — true entry time unknown on restart
|
|
1167
|
+
setupType: pos.setupType,
|
|
1168
|
+
regime: pos.regime,
|
|
1169
|
+
confluenceScore: pos.confluenceScore,
|
|
1170
|
+
});
|
|
1171
|
+
logger.info(TAG, `Seeded trade entry for ${pos.symbol}: ${side} ${Math.abs(contracts)} @ ${pos.entryPrice}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
// Paper mark-to-market: update all positions with fresh prices BEFORE
|
|
1176
|
+
// emitting risk update. Live modes preserve Binance's exchange-provided
|
|
1177
|
+
// unrealizedPnl, which is mark-price based and matches the app.
|
|
1178
|
+
if (this.tradingMode === 'PAPER') {
|
|
1179
|
+
const updatePromises = [];
|
|
1180
|
+
for (const pos of positions) {
|
|
1181
|
+
if (this.lastTicker && pos.symbol === this.lastTicker.symbol) {
|
|
1182
|
+
this.applyMarkPrice(pos, this.lastTicker.lastPrice);
|
|
1183
|
+
}
|
|
1184
|
+
else if (pos.symbol && pos.symbol !== this.config.symbol && this.http && this.toolMap.fetch_ticker) {
|
|
1185
|
+
const sym = pos.symbol;
|
|
1186
|
+
const p = this.http.invoke(this.toolMap.fetch_ticker, { symbol: sym }).then(result => {
|
|
1187
|
+
const raw = result.data;
|
|
1188
|
+
if (!raw || typeof raw !== 'object')
|
|
1189
|
+
return;
|
|
1190
|
+
const lastPrice = Number(raw.last ?? raw.lastPrice ?? 0);
|
|
1191
|
+
if (lastPrice <= 0)
|
|
1192
|
+
return;
|
|
1193
|
+
this.applyMarkPrice(pos, lastPrice);
|
|
1194
|
+
}).catch(() => { });
|
|
1195
|
+
updatePromises.push(p);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Wait for all ticker fetches (typically <100ms) so risk_update has fresh marks.
|
|
1199
|
+
if (updatePromises.length > 0) {
|
|
1200
|
+
await Promise.all(updatePromises);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
this.positions = positions;
|
|
1204
|
+
this.hasReceivedPositions = true;
|
|
1205
|
+
this.trySetSessionStartNav();
|
|
1206
|
+
this.emitRiskUpdate();
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Compare previous and current positions from polling.
|
|
1210
|
+
* When a position appears, changes size, or disappears,
|
|
1211
|
+
* emit synthetic orderUpdate events so chart markers show trades.
|
|
1212
|
+
*/
|
|
1213
|
+
/** Apply a fresh mark price to a position (mutates in place). */
|
|
1214
|
+
applyMarkPrice(pos, markPrice) {
|
|
1215
|
+
pos.markPrice = markPrice;
|
|
1216
|
+
const contracts = Math.abs(pos.contracts ?? 0);
|
|
1217
|
+
const contractSize = pos.contractSize ?? 1;
|
|
1218
|
+
const quantity = contracts * contractSize;
|
|
1219
|
+
const entryPrice = pos.entryPrice ?? 0;
|
|
1220
|
+
const pnlMultiplier = pos.side === 'short' ? -1 : 1;
|
|
1221
|
+
pos.unrealizedPnl = (markPrice - entryPrice) * quantity * pnlMultiplier;
|
|
1222
|
+
pos.notional = quantity * markPrice;
|
|
1223
|
+
pos.percentage = entryPrice > 0
|
|
1224
|
+
? ((markPrice - entryPrice) / entryPrice) * 100 * pnlMultiplier
|
|
1225
|
+
: 0;
|
|
1226
|
+
}
|
|
1227
|
+
detectPositionChanges(prev, curr) {
|
|
1228
|
+
// Build lookup by symbol:side to handle hedge mode (both long + short on same symbol)
|
|
1229
|
+
const posKey = (p) => `${p.symbol}:${p.side}`;
|
|
1230
|
+
const prevMap = new Map();
|
|
1231
|
+
for (const p of prev) {
|
|
1232
|
+
if ((p.contracts ?? 0) !== 0)
|
|
1233
|
+
prevMap.set(posKey(p), p);
|
|
1234
|
+
}
|
|
1235
|
+
const currMap = new Map();
|
|
1236
|
+
for (const p of curr) {
|
|
1237
|
+
if ((p.contracts ?? 0) !== 0)
|
|
1238
|
+
currMap.set(posKey(p), p);
|
|
1239
|
+
}
|
|
1240
|
+
// Detect new or changed positions
|
|
1241
|
+
for (const [, pos] of currMap) {
|
|
1242
|
+
const prevPos = prevMap.get(posKey(pos));
|
|
1243
|
+
const prevContracts = prevPos?.contracts ?? 0;
|
|
1244
|
+
const currContracts = pos.contracts ?? 0;
|
|
1245
|
+
if (prevContracts === 0) {
|
|
1246
|
+
// New position opened
|
|
1247
|
+
this.emitSyntheticFill(pos, Math.abs(currContracts));
|
|
1248
|
+
}
|
|
1249
|
+
else if (prevPos && prevPos.side !== pos.side) {
|
|
1250
|
+
// Side flipped (long→short or short→long) — close old + open new
|
|
1251
|
+
this.emitSyntheticFill(prevPos, Math.abs(prevContracts), true);
|
|
1252
|
+
this.emitSyntheticFill(pos, Math.abs(currContracts));
|
|
1253
|
+
}
|
|
1254
|
+
else if (Math.abs(currContracts) !== Math.abs(prevContracts)) {
|
|
1255
|
+
const delta = Math.abs(currContracts) - Math.abs(prevContracts);
|
|
1256
|
+
if (delta > 0) {
|
|
1257
|
+
// Position increased
|
|
1258
|
+
this.emitSyntheticFill(pos, delta);
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
// Position partially closed
|
|
1262
|
+
this.emitSyntheticFill(pos, Math.abs(delta), true);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// Detect fully closed positions — require 2-poll confirmation to reject
|
|
1267
|
+
// Binance fetchPositions flakes. A position gone for one poll is treated
|
|
1268
|
+
// as tentative; we emit the synthetic close (which writes to
|
|
1269
|
+
// trade_results) only if it's still gone on the next poll. Positions
|
|
1270
|
+
// that reappear after one missed poll were API artifacts, not real closes.
|
|
1271
|
+
//
|
|
1272
|
+
// Two-phase:
|
|
1273
|
+
// 1. Resolve every existing pending-close: if it reappeared, clear
|
|
1274
|
+
// (phantom avoided); if it's still missing, emit the real close.
|
|
1275
|
+
// 2. Queue any new missing-from-curr positions as pending for the
|
|
1276
|
+
// NEXT poll to confirm.
|
|
1277
|
+
//
|
|
1278
|
+
// Note: we process pending FIRST, then queue new, so a pending entry
|
|
1279
|
+
// never races with the same-poll new-queue for the same key. We also
|
|
1280
|
+
// pull pending keys from this.pendingCloses (not prevMap) because a
|
|
1281
|
+
// pending close may survive across polls where prevMap has already
|
|
1282
|
+
// moved on (e.g. side-flip: long → short, after poll 1 prev is [short]).
|
|
1283
|
+
for (const [key, pendingPrevPos] of this.pendingCloses) {
|
|
1284
|
+
if (currMap.has(key)) {
|
|
1285
|
+
this.pendingCloses.delete(key);
|
|
1286
|
+
logger.info(TAG, `Position ${key} reappeared — phantom-close avoided`);
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
this.emitSyntheticFill(pendingPrevPos, Math.abs(pendingPrevPos.contracts ?? 0), true);
|
|
1290
|
+
this.pendingCloses.delete(key);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
for (const [key, prevPos] of prevMap) {
|
|
1294
|
+
if (!currMap.has(key) && !this.pendingCloses.has(key)) {
|
|
1295
|
+
this.pendingCloses.set(key, prevPos);
|
|
1296
|
+
logger.debug(TAG, `Pending close for ${key} — awaiting next-poll confirmation`);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Emit a synthetic orderUpdate event derived from position polling data.
|
|
1302
|
+
* Used when WS agent events are unavailable (gateway event filtering).
|
|
1303
|
+
*/
|
|
1304
|
+
emitSyntheticFill(pos, quantity, isClose = false) {
|
|
1305
|
+
const now = new Date().toISOString();
|
|
1306
|
+
const id = `poll-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1307
|
+
// Determine order side from position
|
|
1308
|
+
let side;
|
|
1309
|
+
if (isClose) {
|
|
1310
|
+
side = pos.side === 'long' ? 'SELL' : 'BUY';
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
side = pos.side === 'long' ? 'BUY' : 'SELL';
|
|
1314
|
+
}
|
|
1315
|
+
// For opens, use entryPrice. For closes, use markPrice / lastTicker (exit price).
|
|
1316
|
+
const fillPrice = isClose
|
|
1317
|
+
? (pos.markPrice ?? this.lastTicker?.lastPrice ?? pos.entryPrice ?? 0)
|
|
1318
|
+
: (pos.entryPrice ?? pos.markPrice ?? this.lastTicker?.lastPrice ?? 0);
|
|
1319
|
+
const order = {
|
|
1320
|
+
id,
|
|
1321
|
+
symbol: pos.symbol,
|
|
1322
|
+
side,
|
|
1323
|
+
type: 'MARKET',
|
|
1324
|
+
status: 'FILLED',
|
|
1325
|
+
quantity,
|
|
1326
|
+
filledQuantity: quantity,
|
|
1327
|
+
averageFillPrice: fillPrice,
|
|
1328
|
+
timeInForce: 'GTC',
|
|
1329
|
+
createdAt: now,
|
|
1330
|
+
updatedAt: now,
|
|
1331
|
+
};
|
|
1332
|
+
const payload = {
|
|
1333
|
+
orderId: id,
|
|
1334
|
+
status: 'FILLED',
|
|
1335
|
+
order,
|
|
1336
|
+
fill: {
|
|
1337
|
+
id: `fill-${id}`,
|
|
1338
|
+
orderId: id,
|
|
1339
|
+
symbol: pos.symbol,
|
|
1340
|
+
side,
|
|
1341
|
+
quantity,
|
|
1342
|
+
price: fillPrice,
|
|
1343
|
+
expectedPrice: fillPrice,
|
|
1344
|
+
slippage: 0,
|
|
1345
|
+
slippageBps: 0,
|
|
1346
|
+
fee: 0,
|
|
1347
|
+
feeCurrency: this.quoteCurrency,
|
|
1348
|
+
timestamp: now,
|
|
1349
|
+
reduceOnly: isClose,
|
|
1350
|
+
},
|
|
1351
|
+
timestamp: now,
|
|
1352
|
+
};
|
|
1353
|
+
logger.info(TAG, `Synthetic fill: ${side} ${quantity} ${pos.symbol} @ ${fillPrice} (from position poll)`);
|
|
1354
|
+
this.lastTradeTs = now;
|
|
1355
|
+
this.fire('orderUpdate', payload);
|
|
1356
|
+
// Track open entries for trade result reporting
|
|
1357
|
+
const tradeSide = pos.side === 'short' ? 'short' : 'long';
|
|
1358
|
+
const tradeKey = `${pos.symbol}:${tradeSide}`;
|
|
1359
|
+
if (!isClose) {
|
|
1360
|
+
this.openTradeEntries.set(tradeKey, {
|
|
1361
|
+
entryPrice: fillPrice,
|
|
1362
|
+
quantity,
|
|
1363
|
+
side: tradeSide,
|
|
1364
|
+
entryTime: now,
|
|
1365
|
+
setupType: pos.setupType,
|
|
1366
|
+
regime: pos.regime,
|
|
1367
|
+
confluenceScore: pos.confluenceScore,
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
// Report completed trade to Intelligence API (with actual closed quantity)
|
|
1372
|
+
this.reportTradeResult(tradeKey, pos.symbol, fillPrice, quantity);
|
|
1373
|
+
}
|
|
1374
|
+
// Feature C: emit trade journal entry with market/risk context
|
|
1375
|
+
const action = isClose ? 'CLOSE' : 'OPEN';
|
|
1376
|
+
this.emitTradeJournal(pos.symbol, side, action, quantity, fillPrice);
|
|
1377
|
+
}
|
|
1378
|
+
onPollerBalance(balance) {
|
|
1379
|
+
// Defensive guard: never downgrade a known-good wallet balance to zero.
|
|
1380
|
+
// Binance Futures 429 rate-limits were causing fetchBalance responses
|
|
1381
|
+
// to come back with an empty currency map; mapCcxtBalance then returned
|
|
1382
|
+
// { total: 0, available: 0, locked: 0 }, overwriting the real wallet.
|
|
1383
|
+
// A genuine liquidation would be reflected in positions + drawdown
|
|
1384
|
+
// before the wallet total reaches exactly 0, so a delta from a real
|
|
1385
|
+
// non-zero total to exactly 0 in one poll is almost always an API
|
|
1386
|
+
// artifact. Keep the previous value and wait for a valid poll.
|
|
1387
|
+
if (balance.total === 0 && this.hasReceivedBalance && this.balance.total > 0) {
|
|
1388
|
+
logger.warn(TAG, `Balance poll returned total=0 but last known was ${this.balance.total.toFixed(2)} ${this.balance.currency} — ignoring (likely Binance rate limit or empty response)`);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
this.balance = balance;
|
|
1392
|
+
this.hasReceivedBalance = true;
|
|
1393
|
+
this.trySetSessionStartNav();
|
|
1394
|
+
this.emitRiskUpdate();
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Set session start NAV once both balance AND positions have been received.
|
|
1398
|
+
* Checks persisted file first — if today's NAV was already captured in a
|
|
1399
|
+
* previous skill run, reuse it so Day P&L is stable across restarts.
|
|
1400
|
+
*/
|
|
1401
|
+
trySetSessionStartNav() {
|
|
1402
|
+
// Re-anchor Day P&L at UTC midnight — PAPER only. Without it a long-running
|
|
1403
|
+
// paper session held yesterday's anchor and Day P&L / Realized accumulated
|
|
1404
|
+
// across days (the plugin re-anchors every UTC day; the skill must too).
|
|
1405
|
+
// Called every poll from onPollerBalance/onPollerPositions, so paper
|
|
1406
|
+
// re-anchors within one poll of midnight. Live/shadow are intentionally
|
|
1407
|
+
// excluded: they anchor to the plugin's Binance income-endpoint value
|
|
1408
|
+
// (synced in fetchFreshBalance), and a self-recomputed midnight anchor
|
|
1409
|
+
// would risk the KPI-must-equal-Binance invariant on real money.
|
|
1410
|
+
const today = todayDateStr();
|
|
1411
|
+
if (!shouldAnchorSessionNav({
|
|
1412
|
+
sessionStartNav: this.sessionStartNav,
|
|
1413
|
+
sessionDate: this.sessionDate,
|
|
1414
|
+
today,
|
|
1415
|
+
mode: this.tradingMode,
|
|
1416
|
+
}))
|
|
1417
|
+
return; // Already anchored for today (or live/shadow already set)
|
|
1418
|
+
if (!this.hasReceivedPositions)
|
|
1419
|
+
return; // Wait for positions
|
|
1420
|
+
if (!this.hasReceivedBalance)
|
|
1421
|
+
return; // Wait for balance
|
|
1422
|
+
if (this.balance.total === 0 && this.positions.length === 0)
|
|
1423
|
+
return; // No data yet
|
|
1424
|
+
// Try loading from file first (persisted across restarts within same day).
|
|
1425
|
+
// loadSessionStartNav() itself returns null on a different day, so on a
|
|
1426
|
+
// fresh UTC day this falls through to recompute.
|
|
1427
|
+
const saved = loadSessionStartNav();
|
|
1428
|
+
if (saved !== null) {
|
|
1429
|
+
this.sessionStartNav = saved;
|
|
1430
|
+
this.sessionDate = today;
|
|
1431
|
+
logger.info(TAG, `Session start NAV restored from file: ${saved} ${this.quoteCurrency}`);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
// First anchor of the (UTC) day — compute and persist.
|
|
1435
|
+
this.sessionStartNav = this.computeEquity();
|
|
1436
|
+
this.sessionDate = today;
|
|
1437
|
+
saveSessionStartNav(this.sessionStartNav);
|
|
1438
|
+
logger.info(TAG, `Session start NAV: ${this.sessionStartNav} ${this.quoteCurrency} (wallet=${this.balance.total}, positions=${this.positions.length})`);
|
|
1439
|
+
}
|
|
1440
|
+
onPollerOpenOrders(orders) {
|
|
1441
|
+
this.openOrders = orders;
|
|
1442
|
+
// Open orders stored internally for reconciliation (Phase 7j)
|
|
1443
|
+
// and emergency commands (Phase 7h). No event emission here —
|
|
1444
|
+
// real-time order updates come from the WS agent event stream.
|
|
1445
|
+
}
|
|
1446
|
+
onPollerMarketStructure(data) {
|
|
1447
|
+
this.lastMarketStructure = data;
|
|
1448
|
+
this.fire('marketStructure', data);
|
|
1449
|
+
// Cache ATR data for volatility-adaptive risk limits
|
|
1450
|
+
if (data.atr && data.atr.atr14 > 0) {
|
|
1451
|
+
const currentAtr = data.atr.atr14;
|
|
1452
|
+
this.atrSampleCount++;
|
|
1453
|
+
if (!this.atrData) {
|
|
1454
|
+
// First sample — baseline equals current
|
|
1455
|
+
this.atrData = { currentAtr, baselineAtr: currentAtr };
|
|
1456
|
+
}
|
|
1457
|
+
else {
|
|
1458
|
+
// Update baseline with EMA (capped at 20 samples)
|
|
1459
|
+
const n = Math.min(this.atrSampleCount, 20);
|
|
1460
|
+
const alpha = 2 / (n + 1);
|
|
1461
|
+
const newBaseline = alpha * currentAtr + (1 - alpha) * this.atrData.baselineAtr;
|
|
1462
|
+
this.atrData = { currentAtr, baselineAtr: newBaseline };
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
onPollerCryptoMetrics(data) {
|
|
1467
|
+
this.lastCryptoMetrics = data;
|
|
1468
|
+
this.fire('cryptoMetrics', data);
|
|
1469
|
+
}
|
|
1470
|
+
onPollerVolumeAnalysis(data) {
|
|
1471
|
+
this.lastVolumeAnalysis = data;
|
|
1472
|
+
this.fire('volumeAnalysis', data);
|
|
1473
|
+
}
|
|
1474
|
+
// ---- Trade journal emission (Feature C) ----
|
|
1475
|
+
/**
|
|
1476
|
+
* Build and emit a trade journal entry whenever a synthetic fill is detected.
|
|
1477
|
+
* Called from emitSyntheticFill — captures market + risk context at trade time.
|
|
1478
|
+
*/
|
|
1479
|
+
emitTradeJournal(symbol, side, action, quantity, price) {
|
|
1480
|
+
// Gather market structure snapshot (if available)
|
|
1481
|
+
const ms = this.lastMarketStructure;
|
|
1482
|
+
const find = (tf) => ms?.timeframes.find(t => t.timeframe === tf) ?? null;
|
|
1483
|
+
// Gather risk snapshot
|
|
1484
|
+
const risk = this.computeRiskMetrics();
|
|
1485
|
+
const entry = {
|
|
1486
|
+
id: `journal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1487
|
+
symbol,
|
|
1488
|
+
side,
|
|
1489
|
+
action,
|
|
1490
|
+
quantity,
|
|
1491
|
+
price,
|
|
1492
|
+
marketContext: {
|
|
1493
|
+
direction5m: find('5m')?.direction ?? null,
|
|
1494
|
+
direction1h: find('1h')?.direction ?? null,
|
|
1495
|
+
direction4h: find('4h')?.direction ?? null,
|
|
1496
|
+
alignment: ms?.alignment?.summary ?? null,
|
|
1497
|
+
fundingRate: this.lastCryptoMetrics?.fundingRate ?? null,
|
|
1498
|
+
volumeExpansion: this.lastVolumeAnalysis?.volumeExpansion ?? null,
|
|
1499
|
+
closePosition: this.lastVolumeAnalysis?.closePosition ?? null,
|
|
1500
|
+
},
|
|
1501
|
+
riskContext: {
|
|
1502
|
+
grossExposure: risk.utilization.grossExposure,
|
|
1503
|
+
netExposure: risk.utilization.netExposure,
|
|
1504
|
+
dailyDrawdown: risk.utilization.dailyDrawdown,
|
|
1505
|
+
heatScore: this.computeHeatScoreSimple(risk),
|
|
1506
|
+
},
|
|
1507
|
+
timestamp: new Date().toISOString(),
|
|
1508
|
+
};
|
|
1509
|
+
logger.info(TAG, `Trade journal: ${action} ${side} ${quantity} ${symbol} @ ${price}`);
|
|
1510
|
+
this.fire('tradeJournal', entry);
|
|
1511
|
+
}
|
|
1512
|
+
/** Simple heat score (0–100) matching webapp's HeatGauge logic */
|
|
1513
|
+
computeHeatScoreSimple(metrics) {
|
|
1514
|
+
const { grossExposure, dailyDrawdown, ordersPerMinute, cancelsPerMinute } = metrics.utilization;
|
|
1515
|
+
const limits = DEFAULT_RISK_LIMITS;
|
|
1516
|
+
const scores = [
|
|
1517
|
+
limits.position.maxGrossExposure > 0 ? (grossExposure / limits.position.maxGrossExposure) * 100 : 0,
|
|
1518
|
+
limits.loss.maxDailyDrawdown < 0 ? (dailyDrawdown / limits.loss.maxDailyDrawdown) * 100 : 0,
|
|
1519
|
+
limits.rate.maxOrdersPerMinute > 0 ? (ordersPerMinute / limits.rate.maxOrdersPerMinute) * 100 : 0,
|
|
1520
|
+
limits.rate.maxCancelsPerMinute > 0 ? (cancelsPerMinute / limits.rate.maxCancelsPerMinute) * 100 : 0,
|
|
1521
|
+
];
|
|
1522
|
+
return Math.min(100, Math.max(0, Math.max(...scores)));
|
|
1523
|
+
}
|
|
1524
|
+
/** Cache last market structure for journal entries */
|
|
1525
|
+
lastMarketStructure = null;
|
|
1526
|
+
// ---- Snapshot fetch helpers (fresh data with cache fallback) ----
|
|
1527
|
+
async fetchFreshPositions() {
|
|
1528
|
+
const tool = this.toolMap.fetch_positions;
|
|
1529
|
+
if (!tool || !this.http)
|
|
1530
|
+
return this.positions;
|
|
1531
|
+
try {
|
|
1532
|
+
// Fetch ALL positions (no symbol filter) so multi-symbol trades are visible
|
|
1533
|
+
const result = await this.http.invoke(tool, {});
|
|
1534
|
+
if (!Array.isArray(result.data)) {
|
|
1535
|
+
logger.warn(TAG, `fetch_positions returned non-array: ${typeof result.data}`);
|
|
1536
|
+
return this.positions;
|
|
1537
|
+
}
|
|
1538
|
+
return result.data;
|
|
1539
|
+
}
|
|
1540
|
+
catch (err) {
|
|
1541
|
+
logger.warn(TAG, `Fresh positions fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
|
|
1542
|
+
return this.positions;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
async fetchFreshBalance() {
|
|
1546
|
+
const tool = this.toolMap.fetch_balance;
|
|
1547
|
+
if (!tool || !this.http)
|
|
1548
|
+
return this.balance;
|
|
1549
|
+
try {
|
|
1550
|
+
const result = await this.http.invoke(tool, {});
|
|
1551
|
+
// Extract trading mode piggybacked on balance response (Phase 9b)
|
|
1552
|
+
const mode = result.data.tradingMode;
|
|
1553
|
+
if (isTradingMode(mode)) {
|
|
1554
|
+
// Persist the confirmed mode to disk so the next skill restart starts
|
|
1555
|
+
// up reading the correct value instead of the 'PAPER' constructor
|
|
1556
|
+
// default. Cheap, idempotent — safe to write on every balance fetch
|
|
1557
|
+
// even when no transition happened.
|
|
1558
|
+
saveLastTradingMode(mode);
|
|
1559
|
+
if (mode !== this.tradingMode) {
|
|
1560
|
+
const prevMode = this.tradingMode;
|
|
1561
|
+
this.tradingMode = mode;
|
|
1562
|
+
this.fire('tradingModeUpdate', {
|
|
1563
|
+
event: 'trading_mode',
|
|
1564
|
+
mode: this.tradingMode,
|
|
1565
|
+
timestamp: new Date().toISOString(),
|
|
1566
|
+
});
|
|
1567
|
+
logger.info(TAG, `Trading mode: ${prevMode} → ${this.tradingMode}`);
|
|
1568
|
+
// Reset session NAV when mode changes — different modes have different
|
|
1569
|
+
// balances (paper ~$8k vs real ~$65), so the old anchor would produce
|
|
1570
|
+
// wildly wrong Day P&L. Delete the persisted file too so
|
|
1571
|
+
// trySetSessionStartNav() recomputes from the new mode's equity.
|
|
1572
|
+
this.sessionStartNav = -1;
|
|
1573
|
+
this.sessionDate = null;
|
|
1574
|
+
this.hasReceivedBalance = false;
|
|
1575
|
+
this.hasReceivedPositions = false;
|
|
1576
|
+
this.pendingEmptyPoll = false;
|
|
1577
|
+
try {
|
|
1578
|
+
unlinkSync(getSessionNavPath());
|
|
1579
|
+
}
|
|
1580
|
+
catch { /* ok if missing */ }
|
|
1581
|
+
logger.info(TAG, `Session start NAV reset on mode change (${prevMode} → ${this.tradingMode})`);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
// Extract realized P&L fields (roundtrip-fee inclusive, plugin-computed).
|
|
1585
|
+
if (typeof result.data.realizedPnlToday === 'number') {
|
|
1586
|
+
this.realizedPnlToday = result.data.realizedPnlToday;
|
|
1587
|
+
}
|
|
1588
|
+
if (typeof result.data.realizedPnlAllTime === 'number') {
|
|
1589
|
+
this.realizedPnlAllTime = result.data.realizedPnlAllTime;
|
|
1590
|
+
}
|
|
1591
|
+
// Paper session execution costs (M1) — paper only; LiveAdapter omits them.
|
|
1592
|
+
if (typeof result.data.sessionFeesPaid === 'number') {
|
|
1593
|
+
this.paperSessionFees = result.data.sessionFeesPaid;
|
|
1594
|
+
}
|
|
1595
|
+
if (typeof result.data.sessionAvgSlippageBps === 'number') {
|
|
1596
|
+
this.paperAvgSlippageBps = result.data.sessionAvgSlippageBps;
|
|
1597
|
+
}
|
|
1598
|
+
if (typeof result.data.equity === 'number') {
|
|
1599
|
+
this.equity = result.data.equity;
|
|
1600
|
+
}
|
|
1601
|
+
// In non-PAPER modes, the plugin anchors sessionStartNav to Binance's
|
|
1602
|
+
// /fapi/v1/income at UTC midnight on every balance fetch — that's the
|
|
1603
|
+
// authoritative truth. Sync continuously, not just on first set: the
|
|
1604
|
+
// pre-2026-05-14 guard `this.sessionStartNav < 0` meant once the skill
|
|
1605
|
+
// had ANY value (from file load or computeEquity), the plugin's correct
|
|
1606
|
+
// value was ignored forever. The skill's self-computed value drifts
|
|
1607
|
+
// whenever trading has already happened before the skill first polls
|
|
1608
|
+
// (operator screenshot 2026-05-14 found a $3.92 dashboard-vs-Binance
|
|
1609
|
+
// gap from a $1095.95 self-computed seed vs the plugin's $1099.86
|
|
1610
|
+
// midnight-anchored truth). Crossing UTC midnight is also handled
|
|
1611
|
+
// automatically — plugin's anchor refreshes, skill syncs next poll.
|
|
1612
|
+
if (this.tradingMode !== 'PAPER') {
|
|
1613
|
+
const pluginNav = result.data.sessionStartNav;
|
|
1614
|
+
if (typeof pluginNav === 'number' && pluginNav > 0
|
|
1615
|
+
&& Math.abs(this.sessionStartNav - pluginNav) > 0.001) {
|
|
1616
|
+
const prev = this.sessionStartNav;
|
|
1617
|
+
this.sessionStartNav = pluginNav;
|
|
1618
|
+
saveSessionStartNav(pluginNav);
|
|
1619
|
+
logger.info(TAG, `Session start NAV synced from plugin: ${prev.toFixed(4)} -> ${pluginNav} (${this.tradingMode} mode)`);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return mapCcxtBalance(result.data, this.quoteCurrency);
|
|
1623
|
+
}
|
|
1624
|
+
catch (err) {
|
|
1625
|
+
logger.warn(TAG, `Fresh balance fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
|
|
1626
|
+
return this.balance;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
async fetchFreshOpenOrders() {
|
|
1630
|
+
const tool = this.toolMap.fetch_open_orders;
|
|
1631
|
+
if (!tool || !this.http)
|
|
1632
|
+
return this.openOrders;
|
|
1633
|
+
try {
|
|
1634
|
+
// Fetch ALL open orders (no symbol filter). Multi-symbol orphan orders
|
|
1635
|
+
// submitted while the browser was disconnected must show up in the
|
|
1636
|
+
// reconcile snapshot — single-symbol filtering here was the orphan-order
|
|
1637
|
+
// failure mode CLAUDE.md warns about.
|
|
1638
|
+
const result = await this.http.invoke(tool, {});
|
|
1639
|
+
if (!Array.isArray(result.data)) {
|
|
1640
|
+
logger.warn(TAG, `fetch_open_orders returned non-array: ${typeof result.data}`);
|
|
1641
|
+
return this.openOrders;
|
|
1642
|
+
}
|
|
1643
|
+
const mapped = result.data
|
|
1644
|
+
.map((o) => mapCcxtOrder(o))
|
|
1645
|
+
.filter((o) => o !== null);
|
|
1646
|
+
const dropped = result.data.length - mapped.length;
|
|
1647
|
+
if (dropped > 0) {
|
|
1648
|
+
logger.error(TAG, `Snapshot: ${dropped}/${result.data.length} open orders dropped (malformed data)`);
|
|
1649
|
+
}
|
|
1650
|
+
return mapped;
|
|
1651
|
+
}
|
|
1652
|
+
catch (err) {
|
|
1653
|
+
logger.warn(TAG, `Fresh open orders fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
|
|
1654
|
+
return this.openOrders;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
async fetchAgentHealth() {
|
|
1658
|
+
const ws = this.wsClient;
|
|
1659
|
+
if (!ws)
|
|
1660
|
+
return this.buildAgentState();
|
|
1661
|
+
try {
|
|
1662
|
+
// health RPC returns an agent health snapshot from the gateway. Its shape
|
|
1663
|
+
// doesn't map directly to our AgentStateData format (we keep our internal,
|
|
1664
|
+
// event-driven state for mode/health), but it can carry the resolved model
|
|
1665
|
+
// id — so we mine it for identity as a cheap secondary source.
|
|
1666
|
+
const resp = await ws.sendRpc('health', {});
|
|
1667
|
+
const model = this.extractAgentModel(resp);
|
|
1668
|
+
if (model)
|
|
1669
|
+
this.mergeAgentIdentity({ model });
|
|
1670
|
+
return this.buildAgentState();
|
|
1671
|
+
}
|
|
1672
|
+
catch (err) {
|
|
1673
|
+
logger.warn(TAG, `Health RPC failed, using internal state: ${err instanceof Error ? err.message : String(err)}`);
|
|
1674
|
+
return this.buildAgentState();
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Resolve the agent's display name + live model id directly from the gateway.
|
|
1679
|
+
* The primary carrier is the `sessions.list` RPC (rows carry agentId + the
|
|
1680
|
+
* live model); `health` (above) is a secondary feed. Best-effort and
|
|
1681
|
+
* fail-open — the gateway/version may not expose either, in which case the
|
|
1682
|
+
* header simply falls back to its placeholder. Re-emits agent_state on a hit
|
|
1683
|
+
* so the dashboard updates without waiting for the next periodic tick.
|
|
1684
|
+
*/
|
|
1685
|
+
async refreshAgentIdentity() {
|
|
1686
|
+
const ws = this.wsClient;
|
|
1687
|
+
if (!ws)
|
|
1688
|
+
return;
|
|
1689
|
+
let sessionsResp;
|
|
1690
|
+
let sessionsErr;
|
|
1691
|
+
try {
|
|
1692
|
+
sessionsResp = await ws.sendRpc('sessions.list', {});
|
|
1693
|
+
}
|
|
1694
|
+
catch (err) {
|
|
1695
|
+
sessionsErr = err instanceof Error ? err.message : String(err);
|
|
1696
|
+
}
|
|
1697
|
+
// Model from the gateway (live, authoritative); name from the local
|
|
1698
|
+
// OpenClaw workspace IDENTITY.md (sessions rows carry only chat ids).
|
|
1699
|
+
const model = this.extractAgentModel(sessionsResp);
|
|
1700
|
+
const name = this.resolveAgentNameFromIdentity();
|
|
1701
|
+
// One-time rollout probe: the sessions.list shape isn't contractually stable
|
|
1702
|
+
// across gateway versions, so log its top-level keys + what we resolved on the
|
|
1703
|
+
// first attempt — makes an unexpected shape diagnosable instead of silent.
|
|
1704
|
+
if (!this.loggedAgentIdentityProbe) {
|
|
1705
|
+
this.loggedAgentIdentityProbe = true;
|
|
1706
|
+
const keys = sessionsResp && typeof sessionsResp === 'object'
|
|
1707
|
+
? Object.keys(sessionsResp).join(',') || '(none)'
|
|
1708
|
+
: sessionsErr
|
|
1709
|
+
? `error:${sessionsErr}`
|
|
1710
|
+
: typeof sessionsResp;
|
|
1711
|
+
logger.info(TAG, `Agent identity probe: sessions.list keys=[${keys}] -> model=${model ?? '(none)'} identityName=${name ?? '(none)'}`);
|
|
1712
|
+
}
|
|
1713
|
+
if (this.mergeAgentIdentity({ name, model })) {
|
|
1714
|
+
logger.info(TAG, `Agent identity updated: name=${this.agentIdentity.name ?? '(none)'} model=${this.agentIdentity.model ?? '(none)'}`);
|
|
1715
|
+
this.emitAgentState();
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
|
|
1719
|
+
mergeAgentIdentity(found) {
|
|
1720
|
+
let changed = false;
|
|
1721
|
+
if (found.name && found.name !== this.agentIdentity.name) {
|
|
1722
|
+
this.agentIdentity.name = found.name;
|
|
1723
|
+
changed = true;
|
|
1724
|
+
}
|
|
1725
|
+
if (found.model && found.model !== this.agentIdentity.model) {
|
|
1726
|
+
this.agentIdentity.model = found.model;
|
|
1727
|
+
changed = true;
|
|
1728
|
+
}
|
|
1729
|
+
return changed;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Extract the live model id from a `sessions.list` (or `health`) gateway
|
|
1733
|
+
* payload. We deliberately do NOT take the agent NAME from here: sessions
|
|
1734
|
+
* rows are per-chat and their `name` is a channel/chat id (e.g. a Telegram
|
|
1735
|
+
* id), not the agent's identity — the display name comes from
|
|
1736
|
+
* resolveAgentNameFromIdentity(). The exact shape isn't contractually stable,
|
|
1737
|
+
* so probe the documented paths (row → runtime → defaults → root) and accept
|
|
1738
|
+
* either a bare string or a `{ primary }` object.
|
|
1739
|
+
*/
|
|
1740
|
+
extractAgentModel(payload) {
|
|
1741
|
+
if (!payload || typeof payload !== 'object')
|
|
1742
|
+
return undefined;
|
|
1743
|
+
const pickStr = (v) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
|
|
1744
|
+
const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
1745
|
+
const flatModel = (v) => {
|
|
1746
|
+
const o = asObj(v);
|
|
1747
|
+
return pickStr(v) ?? pickStr(o?.primary) ?? pickStr(o?.id);
|
|
1748
|
+
};
|
|
1749
|
+
const root = payload;
|
|
1750
|
+
const rowsRaw = (Array.isArray(payload) && payload) ||
|
|
1751
|
+
(Array.isArray(root.sessions) && root.sessions) ||
|
|
1752
|
+
(Array.isArray(root.items) && root.items) ||
|
|
1753
|
+
(Array.isArray(root.data) && root.data) ||
|
|
1754
|
+
[root];
|
|
1755
|
+
const rows = rowsRaw.filter((r) => !!r && typeof r === 'object');
|
|
1756
|
+
const row = rows.find((r) => r.agentId === 'main') ?? rows[0];
|
|
1757
|
+
const runtime = row ? (asObj(row.agentRuntime) ?? asObj(row.runtime)) : undefined;
|
|
1758
|
+
const defaults = asObj(root.defaults);
|
|
1759
|
+
return flatModel(row?.model) ?? flatModel(runtime?.model) ?? flatModel(defaults?.model) ?? flatModel(root.model);
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Resolve the agent's display name from the OpenClaw workspace IDENTITY.md
|
|
1763
|
+
* (`- **Name:** <X>`), local to this deployment (per-tenant correct — each
|
|
1764
|
+
* trader's own agent has its own IDENTITY.md). Best-effort + fail-open:
|
|
1765
|
+
* returns undefined when the file is missing/unreadable, the field is the
|
|
1766
|
+
* unfilled placeholder, or the value looks like an id rather than a name.
|
|
1767
|
+
*/
|
|
1768
|
+
resolveAgentNameFromIdentity() {
|
|
1769
|
+
const candidates = [
|
|
1770
|
+
process.env.OPENCLAW_WORKSPACE ? join(process.env.OPENCLAW_WORKSPACE, 'IDENTITY.md') : undefined,
|
|
1771
|
+
join(homedir(), '.openclaw', 'workspace', 'IDENTITY.md'),
|
|
1772
|
+
].filter((p) => !!p);
|
|
1773
|
+
for (const p of candidates) {
|
|
1774
|
+
try {
|
|
1775
|
+
const txt = readFileSync(p, 'utf8');
|
|
1776
|
+
const m = txt.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
|
|
1777
|
+
let name = m?.[1]?.trim();
|
|
1778
|
+
if (!name)
|
|
1779
|
+
continue;
|
|
1780
|
+
name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim(); // strip stray markdown emphasis
|
|
1781
|
+
if (!name || /^\d+$/.test(name) || /\btbd\b|fill this in/i.test(name))
|
|
1782
|
+
continue;
|
|
1783
|
+
return name;
|
|
1784
|
+
}
|
|
1785
|
+
catch {
|
|
1786
|
+
/* not found / unreadable — try the next candidate */
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return undefined;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
|
|
1793
|
+
* from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
|
|
1794
|
+
* heartbeat job's `schedule.everyMs`). This is the source of truth: the old
|
|
1795
|
+
* hardcoded 1800 and `agents.defaults.heartbeat` are both stale/wrong once
|
|
1796
|
+
* the operator edits the cron (`openclaw cron ... --every 15m`). Drives both
|
|
1797
|
+
* the "beat every Nm" label and the unresponsive (2×) threshold. Cached 60s;
|
|
1798
|
+
* returns undefined (caller defaults to 1800) when unreadable.
|
|
1799
|
+
*/
|
|
1800
|
+
resolveHeartbeatSeconds() {
|
|
1801
|
+
const now = Date.now();
|
|
1802
|
+
if (this.heartbeatSeconds !== undefined && now - this.heartbeatReadAtMs < 60_000) {
|
|
1803
|
+
return this.heartbeatSeconds;
|
|
1804
|
+
}
|
|
1805
|
+
const candidates = [
|
|
1806
|
+
process.env.OPENCLAW_HOME ? join(process.env.OPENCLAW_HOME, 'cron', 'jobs.json') : undefined,
|
|
1807
|
+
join(homedir(), '.openclaw', 'cron', 'jobs.json'),
|
|
1808
|
+
].filter((p) => !!p);
|
|
1809
|
+
const everyMsOf = (j) => {
|
|
1810
|
+
if (!j || typeof j !== 'object')
|
|
1811
|
+
return undefined;
|
|
1812
|
+
const job = j;
|
|
1813
|
+
if (job.enabled === false)
|
|
1814
|
+
return undefined;
|
|
1815
|
+
const s = job.schedule;
|
|
1816
|
+
return s?.kind === 'every' && typeof s.everyMs === 'number' && s.everyMs > 0 ? s.everyMs : undefined;
|
|
1817
|
+
};
|
|
1818
|
+
const nameOf = (j) => {
|
|
1819
|
+
const n = j?.name;
|
|
1820
|
+
return typeof n === 'string' ? n : '';
|
|
1821
|
+
};
|
|
1822
|
+
for (const p of candidates) {
|
|
1823
|
+
try {
|
|
1824
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
1825
|
+
const jobs = parsed?.jobs;
|
|
1826
|
+
if (!Array.isArray(jobs))
|
|
1827
|
+
continue;
|
|
1828
|
+
const everyJobs = jobs.filter((j) => everyMsOf(j) !== undefined);
|
|
1829
|
+
// Prefer the explicitly heartbeat-named job; else fall back only when a
|
|
1830
|
+
// single recurring job exists (avoid mislabelling some other cron).
|
|
1831
|
+
const hb = everyJobs.find((j) => /heart\s*beat/i.test(nameOf(j))) ??
|
|
1832
|
+
(everyJobs.length === 1 ? everyJobs[0] : undefined);
|
|
1833
|
+
const ms = hb ? everyMsOf(hb) : undefined;
|
|
1834
|
+
if (ms) {
|
|
1835
|
+
this.heartbeatSeconds = Math.round(ms / 1000);
|
|
1836
|
+
this.heartbeatReadAtMs = now;
|
|
1837
|
+
if (!this.loggedHeartbeat) {
|
|
1838
|
+
this.loggedHeartbeat = true;
|
|
1839
|
+
logger.info(TAG, `Heartbeat cadence resolved: ${this.heartbeatSeconds}s (~${Math.round(this.heartbeatSeconds / 60)}m) from ${p}`);
|
|
1840
|
+
}
|
|
1841
|
+
return this.heartbeatSeconds;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
catch {
|
|
1845
|
+
/* unreadable / malformed — try the next candidate */
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
this.heartbeatReadAtMs = now; // don't hammer the FS when absent
|
|
1849
|
+
return this.heartbeatSeconds; // last known (may be undefined)
|
|
1850
|
+
}
|
|
1851
|
+
/** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
|
|
1852
|
+
buildAgentState() {
|
|
1853
|
+
return {
|
|
1854
|
+
mode: this.agentMode,
|
|
1855
|
+
strategy: this.lastKnownStrategy,
|
|
1856
|
+
// Conditional spread keeps the payload byte-identical to the pre-feature
|
|
1857
|
+
// shape when the gateway hasn't surfaced an identity yet.
|
|
1858
|
+
...(this.agentIdentity.name ? { agentName: this.agentIdentity.name } : {}),
|
|
1859
|
+
...(this.agentIdentity.model ? { model: this.agentIdentity.model } : {}),
|
|
1860
|
+
health: {
|
|
1861
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
1862
|
+
lastDecision: this.eventParser?.getLastDecision() ?? new Date(this.startTime).toISOString(),
|
|
1863
|
+
lastTrade: this.lastTradeTs,
|
|
1864
|
+
errorRate: this.eventParser?.getErrorRate() ?? 0,
|
|
1865
|
+
thinkingInterval: this.resolveHeartbeatSeconds() ?? 1800,
|
|
1866
|
+
gatewayWs: this.wsClient?.getState() ?? 'disconnected',
|
|
1867
|
+
},
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
// ---- State emission ----
|
|
1871
|
+
emitAgentState() {
|
|
1872
|
+
this.fire('agentState', {
|
|
1873
|
+
state: this.buildAgentState(),
|
|
1874
|
+
timestamp: new Date().toISOString(),
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
emitRiskUpdate() {
|
|
1878
|
+
const metrics = this.computeRiskMetrics();
|
|
1879
|
+
// Auto-flatten on RED zone: if drawdown exceeds -2.5% and agent is active, flatten all.
|
|
1880
|
+
// SAFETY guards — all three must be clear before we trust the RED verdict:
|
|
1881
|
+
// 1. Startup grace (30s): on restart, wallet loads instantly but positions lag,
|
|
1882
|
+
// making equity look artificially low.
|
|
1883
|
+
// 2. Balance freshness: hasReceivedBalance must be true AND balance.total > 0.
|
|
1884
|
+
// Binance 429 rate limits were leaving `this.balance` stale at 0 in live mode,
|
|
1885
|
+
// and computeEquity() (paper-mode formula) then read equity ≈ position_notional
|
|
1886
|
+
// only — a $186 account with a $20 short showed as -89% drawdown, triggering
|
|
1887
|
+
// an auto-flatten loop every minute. If we don't have fresh wallet data we
|
|
1888
|
+
// cannot trust the drawdown number; wait for the next poll.
|
|
1889
|
+
// 3. Sanity floor: a single-minute drawdown more negative than -50% is
|
|
1890
|
+
// physically impossible under normal market conditions on a bracketed
|
|
1891
|
+
// account. If the math says worse than that, it's bad data — log loudly
|
|
1892
|
+
// and skip instead of closing real positions on a calculation error.
|
|
1893
|
+
const uptimeMs = Date.now() - this.startedAt;
|
|
1894
|
+
const STARTUP_GRACE_PERIOD_MS = 30_000;
|
|
1895
|
+
const IMPOSSIBLE_DRAWDOWN = -0.5;
|
|
1896
|
+
if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
|
|
1897
|
+
if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
|
|
1898
|
+
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
|
|
1899
|
+
}
|
|
1900
|
+
else if (!this.hasReceivedBalance || this.balance.total <= 0) {
|
|
1901
|
+
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but balance data is stale/missing (received=${this.hasReceivedBalance}, total=${this.balance.total}) — skipping auto-flatten, waiting for fresh wallet data`);
|
|
1902
|
+
}
|
|
1903
|
+
else if (metrics.utilization.dailyDrawdown < IMPOSSIBLE_DRAWDOWN) {
|
|
1904
|
+
logger.error(TAG, `RED zone drawdown ${(metrics.utilization.dailyDrawdown * 100).toFixed(1)}% exceeds sanity floor ${IMPOSSIBLE_DRAWDOWN * 100}% — refusing auto-flatten, likely equity calculation error (balance.total=${this.balance.total}, sessionStartNav=${this.sessionStartNav}, positions=${this.positions.length})`);
|
|
1905
|
+
}
|
|
1906
|
+
else {
|
|
1907
|
+
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
|
|
1908
|
+
// Route through executeEmergency (NOT executeFlatten directly) so the
|
|
1909
|
+
// dual-fire dedup guard applies: emitRiskUpdate runs on every risk
|
|
1910
|
+
// recompute, and a fire-and-forget flatten only flips agentMode→STOPPED
|
|
1911
|
+
// once it resolves. Two rapid RED recomputes (or a concurrent operator
|
|
1912
|
+
// Flatten) would otherwise each launch a separate flatten — a second
|
|
1913
|
+
// market close against not-yet-settled positions is a position-flip
|
|
1914
|
+
// risk. The dedup collapses them to ONE in-flight execution.
|
|
1915
|
+
void this.executeEmergency('flatten').catch(err => {
|
|
1916
|
+
logger.error(TAG, `Auto-flatten failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
// Include position snapshots + balance in risk_update so the webapp can
|
|
1921
|
+
// update KPI/PnL without depending on market_data ticker events.
|
|
1922
|
+
const equity = this.computeEquity();
|
|
1923
|
+
const nav = this.sessionStartNav > 0 ? this.sessionStartNav : Math.max(equity, 1);
|
|
1924
|
+
const positionSnapshots = this.positions
|
|
1925
|
+
.filter(p => (p.contracts ?? 0) !== 0)
|
|
1926
|
+
.map(p => {
|
|
1927
|
+
const contracts = Math.abs(p.contracts ?? 0);
|
|
1928
|
+
const contractSize = p.contractSize ?? 1;
|
|
1929
|
+
const quantity = contracts * contractSize;
|
|
1930
|
+
const entryPrice = p.entryPrice ?? 0;
|
|
1931
|
+
const currentPrice = p.markPrice ?? entryPrice;
|
|
1932
|
+
const pnlMul = p.side === 'short' ? -1 : 1;
|
|
1933
|
+
const unrealizedPnl = p.unrealizedPnl ?? (currentPrice - entryPrice) * quantity * pnlMul;
|
|
1934
|
+
const costBasis = entryPrice * quantity;
|
|
1935
|
+
const liqFields = extractLiquidationFields(p);
|
|
1936
|
+
const bracketField = extractBracketField(p);
|
|
1937
|
+
return {
|
|
1938
|
+
symbol: p.symbol,
|
|
1939
|
+
side: (p.side === 'short' ? 'SHORT' : 'LONG'),
|
|
1940
|
+
quantity,
|
|
1941
|
+
entryPrice,
|
|
1942
|
+
currentPrice,
|
|
1943
|
+
unrealizedPnl: +unrealizedPnl.toFixed(4),
|
|
1944
|
+
unrealizedPnlPercent: costBasis > 0 ? +((unrealizedPnl / costBasis) * 100).toFixed(2) : 0,
|
|
1945
|
+
portfolioPercent: +(((quantity * currentPrice) / nav) * 100).toFixed(2),
|
|
1946
|
+
// Component C: MFE / give-back passthrough (see snapshotPositions site).
|
|
1947
|
+
mfeR: typeof p.mfeR === 'number' ? p.mfeR : undefined,
|
|
1948
|
+
mfePeakPrice: typeof p.mfePeakPrice === 'number' ? p.mfePeakPrice : undefined,
|
|
1949
|
+
giveBackRatio: typeof p.giveBackRatio === 'number' ? p.giveBackRatio : undefined,
|
|
1950
|
+
originalStopPrice: typeof p.originalStopPrice === 'number' ? p.originalStopPrice : undefined,
|
|
1951
|
+
...(bracketField !== undefined ? { bracket: bracketField } : {}),
|
|
1952
|
+
...liqFields,
|
|
1953
|
+
};
|
|
1954
|
+
});
|
|
1955
|
+
// Live KPI NAV must equal Binance's "Margin Balance" = wallet +
|
|
1956
|
+
// unrealized. Compute it directly from the fresh poller wallet
|
|
1957
|
+
// (this.balance.total — BNFCR-aware, uPnl-excluded) + the unrealized
|
|
1958
|
+
// already summed across positionSnapshots. We deliberately do NOT use
|
|
1959
|
+
// this.equity here: it is only ever assigned in fetchFreshBalance(),
|
|
1960
|
+
// which is dormant under userDataStream.mode=enforce, so it stayed null
|
|
1961
|
+
// and the old `this.equity ?? this.balance.total` collapsed NAV to
|
|
1962
|
+
// wallet-only (2026-05-16: dashboard NAV ≈ wallet, Realized off by the
|
|
1963
|
+
// unrealized vs the Binance app). Paper keeps computeEquity (wallet +
|
|
1964
|
+
// notional + unrealized) — correct there because paper deducts the full
|
|
1965
|
+
// notional at open.
|
|
1966
|
+
const liveUnrealizedTotal = positionSnapshots.reduce((sum, p) => sum + p.unrealizedPnl, 0);
|
|
1967
|
+
const balanceTotal = this.tradingMode !== 'PAPER'
|
|
1968
|
+
? this.balance.total + liveUnrealizedTotal // live: wallet + unrealized = Binance Margin Balance
|
|
1969
|
+
: equity; // paper: wallet + notional + unrealized
|
|
1970
|
+
this.fire('riskUpdate', {
|
|
1971
|
+
event: 'risk_update',
|
|
1972
|
+
metrics,
|
|
1973
|
+
positions: positionSnapshots,
|
|
1974
|
+
balance: { total: balanceTotal, available: this.balance.available, locked: balanceTotal - this.balance.available },
|
|
1975
|
+
sessionStartNav: this.sessionStartNav > 0 ? this.sessionStartNav : undefined,
|
|
1976
|
+
realizedPnlToday: this.realizedPnlToday ?? undefined,
|
|
1977
|
+
realizedPnlAllTime: this.realizedPnlAllTime ?? undefined,
|
|
1978
|
+
sessionFeesPaid: this.paperSessionFees ?? undefined,
|
|
1979
|
+
sessionAvgSlippageBps: this.paperAvgSlippageBps ?? undefined,
|
|
1980
|
+
equity: this.equity ?? undefined,
|
|
1981
|
+
timestamp: new Date().toISOString(),
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
/** Compute risk metrics — delegates to pure functions in risk-calculator.ts */
|
|
1985
|
+
computeRiskMetrics() {
|
|
1986
|
+
const result = _computeRiskMetrics({
|
|
1987
|
+
balanceTotal: this.balance.total,
|
|
1988
|
+
positions: this.positions,
|
|
1989
|
+
lastTickerPrice: this.lastTicker?.lastPrice ?? null,
|
|
1990
|
+
sessionStartNav: this.sessionStartNav,
|
|
1991
|
+
orderTimestamps: this.orderTimestamps,
|
|
1992
|
+
cancelTimestamps: this.cancelTimestamps,
|
|
1993
|
+
atrData: this.atrData ?? undefined,
|
|
1994
|
+
regime: this.currentRegime,
|
|
1995
|
+
realizedPnlToday: this.realizedPnlToday ?? undefined,
|
|
1996
|
+
});
|
|
1997
|
+
// Store pruned timestamps back (non-mutating function returns new arrays)
|
|
1998
|
+
this.orderTimestamps = result.prunedOrderTimestamps;
|
|
1999
|
+
this.cancelTimestamps = result.prunedCancelTimestamps;
|
|
2000
|
+
return result.metrics;
|
|
2001
|
+
}
|
|
2002
|
+
/** Compute total equity — delegates to pure function in risk-calculator.ts */
|
|
2003
|
+
computeEquity() {
|
|
2004
|
+
return _computeEquity({
|
|
2005
|
+
balanceTotal: this.balance.total,
|
|
2006
|
+
positions: this.positions,
|
|
2007
|
+
lastTickerPrice: this.lastTicker?.lastPrice ?? null,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
/** Compute signed notional — delegates to pure function in risk-calculator.ts */
|
|
2011
|
+
computePositionNotional(pos) {
|
|
2012
|
+
return _computePositionNotional(pos, this.lastTicker?.lastPrice ?? null);
|
|
2013
|
+
}
|
|
2014
|
+
// ---- Trade result reporting to Intelligence API ----
|
|
2015
|
+
/**
|
|
2016
|
+
* Report a completed trade to the Intelligence API for persistent cross-session analytics.
|
|
2017
|
+
* Called when a position closes (detected via position polling diffs).
|
|
2018
|
+
* Handles partial closes: updates remaining quantity in openTradeEntries instead of deleting.
|
|
2019
|
+
*
|
|
2020
|
+
* @param key Composite key `symbol:side` into openTradeEntries
|
|
2021
|
+
* @param symbol CCXT symbol (e.g. "BTC/USDT")
|
|
2022
|
+
* @param exitPrice Fill price for the close
|
|
2023
|
+
* @param closedQty Quantity actually closed (may be less than full position for partial closes)
|
|
2024
|
+
*/
|
|
2025
|
+
reportTradeResult(key, symbol, exitPrice, closedQty) {
|
|
2026
|
+
const entry = this.openTradeEntries.get(key);
|
|
2027
|
+
if (!entry) {
|
|
2028
|
+
logger.debug(TAG, `No tracked entry for ${key} — skipping trade result report`);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
// Determine if this is a partial or full close
|
|
2032
|
+
const remaining = entry.quantity - closedQty;
|
|
2033
|
+
if (remaining > 0.0001) {
|
|
2034
|
+
// Partial close — update remaining quantity, keep entry alive
|
|
2035
|
+
this.openTradeEntries.set(key, { ...entry, quantity: remaining });
|
|
2036
|
+
}
|
|
2037
|
+
else {
|
|
2038
|
+
// Full close — remove entry
|
|
2039
|
+
this.openTradeEntries.delete(key);
|
|
2040
|
+
}
|
|
2041
|
+
const url = this.config.intelligenceUrl;
|
|
2042
|
+
const token = this.config.connectionToken;
|
|
2043
|
+
if (!url || !token) {
|
|
2044
|
+
logger.debug(TAG, 'Intelligence not configured — skipping trade result report');
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
const direction = entry.side === 'long' ? 'LONG' : 'SHORT';
|
|
2048
|
+
const pnlMultiplier = entry.side === 'long' ? 1 : -1;
|
|
2049
|
+
const realizedPnl = (exitPrice - entry.entryPrice) * closedQty * pnlMultiplier;
|
|
2050
|
+
const realizedPnlPct = entry.entryPrice > 0
|
|
2051
|
+
? ((exitPrice - entry.entryPrice) / entry.entryPrice) * 100 * pnlMultiplier
|
|
2052
|
+
: 0;
|
|
2053
|
+
const durationSeconds = Math.floor((Date.now() - new Date(entry.entryTime).getTime()) / 1000);
|
|
2054
|
+
const tradeResult = {
|
|
2055
|
+
missionId: entry.missionId ?? `trade-${Date.now()}`,
|
|
2056
|
+
symbol: symbol.replace('/', ''), // BTC/USDT → BTCUSDT for Intelligence API
|
|
2057
|
+
direction,
|
|
2058
|
+
strategy: this.lastKnownStrategy.name,
|
|
2059
|
+
regime: entry.regime ?? this.currentRegime,
|
|
2060
|
+
entryPrice: entry.entryPrice,
|
|
2061
|
+
exitPrice,
|
|
2062
|
+
quantity: closedQty,
|
|
2063
|
+
realizedPnl: +realizedPnl.toFixed(4),
|
|
2064
|
+
realizedPnlPct: +realizedPnlPct.toFixed(4),
|
|
2065
|
+
durationSeconds,
|
|
2066
|
+
fees: 0, // Fees tracked separately by simulator
|
|
2067
|
+
timestamp: new Date().toISOString(),
|
|
2068
|
+
setupType: entry.setupType,
|
|
2069
|
+
confluenceScore: entry.confluenceScore,
|
|
2070
|
+
};
|
|
2071
|
+
const fullUrl = `${url}/api/trades`;
|
|
2072
|
+
const report = fetch(fullUrl, {
|
|
2073
|
+
method: 'POST',
|
|
2074
|
+
headers: {
|
|
2075
|
+
'Content-Type': 'application/json',
|
|
2076
|
+
Authorization: `Bearer ${token}`,
|
|
2077
|
+
},
|
|
2078
|
+
body: JSON.stringify(tradeResult),
|
|
2079
|
+
signal: AbortSignal.timeout(10_000),
|
|
2080
|
+
})
|
|
2081
|
+
.then((res) => {
|
|
2082
|
+
if (!res.ok) {
|
|
2083
|
+
logger.warn(TAG, `Trade result report failed: HTTP ${res.status} — queuing for retry`);
|
|
2084
|
+
this.enqueueFailedReport(tradeResult);
|
|
2085
|
+
}
|
|
2086
|
+
else {
|
|
2087
|
+
logger.info(TAG, `Trade result reported: ${symbol} ${direction} ${closedQty} @ ${exitPrice} PnL=${realizedPnl > 0 ? '+' : ''}${realizedPnl.toFixed(2)} (${durationSeconds}s)`);
|
|
2088
|
+
}
|
|
2089
|
+
})
|
|
2090
|
+
.catch((err) => {
|
|
2091
|
+
logger.warn(TAG, `Trade result report error: ${formatError(err)} — queuing for retry`);
|
|
2092
|
+
this.enqueueFailedReport(tradeResult);
|
|
2093
|
+
})
|
|
2094
|
+
.finally(() => {
|
|
2095
|
+
this.inFlightReports = this.inFlightReports.filter((p) => p !== report);
|
|
2096
|
+
});
|
|
2097
|
+
this.inFlightReports.push(report);
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Seed lastTradeTs from Intelligence API on startup. One-shot, best-effort.
|
|
2101
|
+
* Prevents "Last Trade: Never" after every restart when the agent is flat.
|
|
2102
|
+
*/
|
|
2103
|
+
async seedLastTradeTs() {
|
|
2104
|
+
if (this.lastTradeTs)
|
|
2105
|
+
return; // Already set (e.g. from WS event during init)
|
|
2106
|
+
const url = this.config.intelligenceUrl;
|
|
2107
|
+
const token = this.config.connectionToken;
|
|
2108
|
+
if (!url || !token)
|
|
2109
|
+
return;
|
|
2110
|
+
try {
|
|
2111
|
+
const res = await fetch(`${url}/api/analytics/${this.config.symbol.replace('/', '')}/trades?limit=1`, {
|
|
2112
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2113
|
+
signal: AbortSignal.timeout(5_000),
|
|
2114
|
+
});
|
|
2115
|
+
if (!res.ok)
|
|
2116
|
+
return;
|
|
2117
|
+
const trades = await res.json();
|
|
2118
|
+
if (trades.length > 0 && trades[0].timestamp) {
|
|
2119
|
+
this.lastTradeTs = trades[0].timestamp;
|
|
2120
|
+
logger.info(TAG, `Seeded lastTradeTs from Intelligence: ${this.lastTradeTs}`);
|
|
2121
|
+
this.emitAgentState(); // Re-emit with updated timestamp
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
catch {
|
|
2125
|
+
// Best-effort — don't log noise on failure
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
// ---- Intelligence service polling ----
|
|
2129
|
+
/** Symbol formatted for intelligence API (BTC/USDT → BTCUSDT). */
|
|
2130
|
+
get intelligenceSymbol() {
|
|
2131
|
+
return this.config.symbol.replace('/', '');
|
|
2132
|
+
}
|
|
2133
|
+
/** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
|
|
2134
|
+
startIntelligencePoller(name, endpoint, intervalMs, onData, intervalRef, delayFirstMs = 0) {
|
|
2135
|
+
const url = this.config.intelligenceUrl;
|
|
2136
|
+
const token = this.config.connectionToken;
|
|
2137
|
+
if (!url || !token) {
|
|
2138
|
+
if (name === 'regime') {
|
|
2139
|
+
logger.info(TAG, 'Intelligence service not configured — intelligence polling disabled');
|
|
2140
|
+
}
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const symbol = this.intelligenceSymbol;
|
|
2144
|
+
const fullUrl = `${url}/api/${endpoint}/${symbol}`;
|
|
2145
|
+
logger.info(TAG, `Starting ${name} poller: ${fullUrl}`);
|
|
2146
|
+
const poll = async () => {
|
|
2147
|
+
if (!this.started)
|
|
2148
|
+
return;
|
|
2149
|
+
try {
|
|
2150
|
+
const res = await fetch(fullUrl, {
|
|
2151
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2152
|
+
signal: AbortSignal.timeout(10_000),
|
|
2153
|
+
});
|
|
2154
|
+
if (!res.ok) {
|
|
2155
|
+
this.trackIntelError(name, `HTTP ${res.status}`);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
// Reset error tracking on success
|
|
2159
|
+
this.intelErrors[name] = 0;
|
|
2160
|
+
onData(await res.json());
|
|
2161
|
+
}
|
|
2162
|
+
catch (err) {
|
|
2163
|
+
this.trackIntelError(name, formatError(err));
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2166
|
+
const start = () => {
|
|
2167
|
+
void poll();
|
|
2168
|
+
this[intervalRef] = setInterval(() => void poll(), intervalMs);
|
|
2169
|
+
};
|
|
2170
|
+
if (delayFirstMs > 0) {
|
|
2171
|
+
const timer = setTimeout(start, delayFirstMs);
|
|
2172
|
+
this.pendingStartTimers.push(timer);
|
|
2173
|
+
}
|
|
2174
|
+
else {
|
|
2175
|
+
start();
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/** Log first error per poller, suppress subsequent repeats. */
|
|
2179
|
+
trackIntelError(name, detail) {
|
|
2180
|
+
const count = (this.intelErrors[name] ?? 0) + 1;
|
|
2181
|
+
this.intelErrors[name] = count;
|
|
2182
|
+
if (count === 1) {
|
|
2183
|
+
logger.warn(TAG, `${name} poll failed: ${detail} (suppressing repeats)`);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
startRegimePoller() {
|
|
2187
|
+
this.startIntelligencePoller('regime', 'regime', 60_000, (raw) => {
|
|
2188
|
+
const data = raw;
|
|
2189
|
+
const symbol = this.intelligenceSymbol;
|
|
2190
|
+
// Store regime for regime-conditional risk limits
|
|
2191
|
+
if (data.regime) {
|
|
2192
|
+
this.currentRegime = data.regime;
|
|
2193
|
+
}
|
|
2194
|
+
this.fire('regimeUpdate', { ...data, event: 'regime_update', symbol });
|
|
2195
|
+
}, 'regimeInterval');
|
|
2196
|
+
}
|
|
2197
|
+
startSignalPoller() {
|
|
2198
|
+
this.startIntelligencePoller('signals', 'signals', 10_000, (raw) => {
|
|
2199
|
+
const data = raw;
|
|
2200
|
+
this.fire('signalUpdate', { ...data, event: 'signal_update' });
|
|
2201
|
+
}, 'signalInterval');
|
|
2202
|
+
}
|
|
2203
|
+
startMissionPoller() {
|
|
2204
|
+
this.startIntelligencePoller('missions', 'missions', 10_000, (raw) => {
|
|
2205
|
+
const data = raw;
|
|
2206
|
+
this.fire('missionUpdate', { ...data, event: 'mission_update' });
|
|
2207
|
+
// Detect new PENDING missions — present to agent for execution
|
|
2208
|
+
const currentIds = new Set(data.activeMissions.map((m) => m.missionId));
|
|
2209
|
+
for (const m of data.activeMissions) {
|
|
2210
|
+
if (!this.lastSeenMissionIds.has(m.missionId)) {
|
|
2211
|
+
logger.info(TAG, `New mission: ${m.missionId} (${m.strategy} ${m.direction} ${m.symbol}, size: ${(m.sizePct * 100).toFixed(2)}%)`);
|
|
2212
|
+
if (m.status === 'PENDING') {
|
|
2213
|
+
void this.presentMissionToAgent(m);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
this.lastSeenMissionIds = currentIds;
|
|
2218
|
+
}, 'missionInterval');
|
|
2219
|
+
}
|
|
2220
|
+
startAnalyticsPoller() {
|
|
2221
|
+
this.startIntelligencePoller('analytics', 'analytics', 30_000, (raw) => {
|
|
2222
|
+
const data = raw;
|
|
2223
|
+
this.fire('analyticsUpdate', { ...data, event: 'analytics_update' });
|
|
2224
|
+
}, 'analyticsInterval', 15_000);
|
|
2225
|
+
}
|
|
2226
|
+
startDecisionTracePoller() {
|
|
2227
|
+
this.startIntelligencePoller('decision-trace', 'decision-trace', 30_000, (raw) => {
|
|
2228
|
+
const data = raw;
|
|
2229
|
+
this.fire('decisionTraceUpdate', { ...data, event: 'decision_trace' });
|
|
2230
|
+
}, 'decisionTraceInterval', 10_000);
|
|
2231
|
+
}
|
|
2232
|
+
// ---- Mission → Agent presentation ----
|
|
2233
|
+
static STRATEGY_LABELS = {
|
|
2234
|
+
trend_continuation: 'Trend Continuation',
|
|
2235
|
+
liquidity_sweep: 'Liquidity Sweep',
|
|
2236
|
+
funding_reversion: 'Funding Reversion',
|
|
2237
|
+
};
|
|
2238
|
+
async presentMissionToAgent(mission) {
|
|
2239
|
+
const ws = this.wsClient;
|
|
2240
|
+
if (!ws || !this.started)
|
|
2241
|
+
return;
|
|
2242
|
+
// Compute absolute position size from sizePct * NAV
|
|
2243
|
+
const nav = this.computeEquity();
|
|
2244
|
+
if (nav <= 0) {
|
|
2245
|
+
logger.warn(TAG, `Cannot present mission ${mission.missionId}: NAV is ${nav}`);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
const absoluteSize = nav * mission.sizePct;
|
|
2249
|
+
const lastPrice = this.lastTicker?.lastPrice ?? mission.entryZone.high;
|
|
2250
|
+
const quantity = lastPrice > 0 ? absoluteSize / lastPrice : 0;
|
|
2251
|
+
const strategyLabel = GatewayProvider.STRATEGY_LABELS[mission.strategy] ?? mission.strategy;
|
|
2252
|
+
const conditionsSummary = mission.conditions
|
|
2253
|
+
.map((c) => `${c.met ? '[x]' : '[ ]'} ${c.description}`)
|
|
2254
|
+
.join('\n');
|
|
2255
|
+
const targetLines = mission.targets
|
|
2256
|
+
.map((t, i) => ` T${i + 1}: $${t.price.toFixed(2)} (${(t.pct * 100).toFixed(0)}%)`)
|
|
2257
|
+
.join('\n');
|
|
2258
|
+
const message = [
|
|
2259
|
+
`TRADE MISSION: ${strategyLabel} ${mission.direction} ${mission.symbol}`,
|
|
2260
|
+
'',
|
|
2261
|
+
`Strategy: ${strategyLabel}`,
|
|
2262
|
+
`Direction: ${mission.direction}`,
|
|
2263
|
+
`Regime: ${mission.regime} (${(mission.regimeConfidence * 100).toFixed(0)}% confidence)`,
|
|
2264
|
+
`Confluence: ${mission.confluenceScore}/${mission.conditions.length} conditions met`,
|
|
2265
|
+
'',
|
|
2266
|
+
'Conditions:',
|
|
2267
|
+
conditionsSummary,
|
|
2268
|
+
'',
|
|
2269
|
+
`Entry zone: $${mission.entryZone.low.toFixed(2)} — $${mission.entryZone.high.toFixed(2)}`,
|
|
2270
|
+
`Stop: $${mission.stopLevel.toFixed(2)}`,
|
|
2271
|
+
`Targets:`,
|
|
2272
|
+
targetLines,
|
|
2273
|
+
'',
|
|
2274
|
+
`Position size: ${(mission.sizePct * 100).toFixed(2)}% of NAV = $${absoluteSize.toFixed(2)} (≈${quantity.toFixed(6)} ${mission.symbol.replace(/\/.*/, '')})`,
|
|
2275
|
+
`Sizing: Kelly=${(mission.sizingDetails.kellyFraction * 100).toFixed(1)}%, WR=${(mission.sizingDetails.winRate * 100).toFixed(0)}%, ${mission.sizingDetails.tradeCount} trades${mission.sizingDetails.cappedReason ? `, capped: ${mission.sizingDetails.cappedReason}` : ''}`,
|
|
2276
|
+
'',
|
|
2277
|
+
`Mission ID: ${mission.missionId}`,
|
|
2278
|
+
`Expires: ${new Date(mission.expiresAt).toLocaleTimeString()}`,
|
|
2279
|
+
'',
|
|
2280
|
+
'Evaluate this setup and execute if you agree. Use place_order to enter. Respect your risk limits.',
|
|
2281
|
+
].join('\n');
|
|
2282
|
+
try {
|
|
2283
|
+
const idempotencyKey = `mission-${mission.missionId}-${Date.now()}`;
|
|
2284
|
+
await ws.sendRpc('agent', { message, idempotencyKey, agentId: 'main' });
|
|
2285
|
+
logger.info(TAG, `Presented mission ${mission.missionId} to agent ($${absoluteSize.toFixed(2)}, ${quantity.toFixed(6)} units)`);
|
|
2286
|
+
}
|
|
2287
|
+
catch (err) {
|
|
2288
|
+
logger.error(TAG, `Failed to present mission ${mission.missionId}: ${formatError(err)}`);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
// ---- Event emission ----
|
|
2292
|
+
fire(event, ...args) {
|
|
2293
|
+
for (const listener of this.listeners[event]) {
|
|
2294
|
+
try {
|
|
2295
|
+
listener(...args);
|
|
2296
|
+
}
|
|
2297
|
+
catch (err) {
|
|
2298
|
+
logger.error(TAG, `Event listener error (${event}): ${formatError(err)}`);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|