@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,684 @@
|
|
|
1
|
+
// In-memory exchange simulator.
|
|
2
|
+
// Maintains wallet, positions, open orders, and trade history.
|
|
3
|
+
// Emits 'stateChanged' event for persistence.
|
|
4
|
+
//
|
|
5
|
+
// Phase 9a: Supports realistic fills (book-aware VWAP, latency, maker/taker fees)
|
|
6
|
+
// via SimulationConfig and OrderBookDepth.
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { logger } from '../logger.js';
|
|
10
|
+
import { MAX_TRADE_HISTORY, DEFAULT_SIMULATION_CONFIG } from './types.js';
|
|
11
|
+
import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
|
|
12
|
+
import { updateMfe } from '../mfe.js';
|
|
13
|
+
import { computeInvalidationHit } from '../pinned-plan.js';
|
|
14
|
+
const TAG = 'simulator';
|
|
15
|
+
export class ExchangeSimulator extends EventEmitter {
|
|
16
|
+
state;
|
|
17
|
+
lastTicker = new Map();
|
|
18
|
+
lastOrderBook = new Map();
|
|
19
|
+
simulationConfig;
|
|
20
|
+
/** Metadata for pending limit orders, keyed by order ID. Cleaned up on fill/cancel. */
|
|
21
|
+
pendingOrderMetadata = new Map();
|
|
22
|
+
// ---- Volatility caching (set by plugin after market structure fetch) ----
|
|
23
|
+
cachedVolFactor = 1.0;
|
|
24
|
+
baselineAtr = 0;
|
|
25
|
+
atrSampleCount = 0;
|
|
26
|
+
// ---- Startup trade lockout ----
|
|
27
|
+
// Prevents stale agent sessions from executing trades during gateway restart.
|
|
28
|
+
// Without this, the agent resumes mid-action and sells positions it doesn't remember.
|
|
29
|
+
startupTime = Date.now();
|
|
30
|
+
hadPositionsAtStartup;
|
|
31
|
+
static STARTUP_LOCKOUT_MS = 15_000; // 15 seconds
|
|
32
|
+
constructor(initialState, config) {
|
|
33
|
+
super();
|
|
34
|
+
this.state = initialState;
|
|
35
|
+
this.simulationConfig = config ?? DEFAULT_SIMULATION_CONFIG;
|
|
36
|
+
this.hadPositionsAtStartup = initialState.positions.length > 0;
|
|
37
|
+
if (this.hadPositionsAtStartup) {
|
|
38
|
+
logger.info(TAG, `Startup lockout ACTIVE for 15s — ${initialState.positions.length} existing position(s) protected`);
|
|
39
|
+
}
|
|
40
|
+
logger.info(TAG, `Simulator initialized: ${this.state.positions.length} positions, ${this.state.openOrders.length} open orders (realistic fills enabled)`);
|
|
41
|
+
}
|
|
42
|
+
// ---- Simulation config ----
|
|
43
|
+
getSimulationConfig() {
|
|
44
|
+
return this.simulationConfig;
|
|
45
|
+
}
|
|
46
|
+
// ---- Volatility factor accessors ----
|
|
47
|
+
setVolFactor(factor) {
|
|
48
|
+
this.cachedVolFactor = factor;
|
|
49
|
+
}
|
|
50
|
+
getVolFactor() {
|
|
51
|
+
return this.cachedVolFactor;
|
|
52
|
+
}
|
|
53
|
+
/** Update baseline ATR using exponential moving average of samples. */
|
|
54
|
+
updateBaselineAtr(atr14) {
|
|
55
|
+
if (atr14 <= 0)
|
|
56
|
+
return;
|
|
57
|
+
this.atrSampleCount++;
|
|
58
|
+
if (this.baselineAtr === 0) {
|
|
59
|
+
this.baselineAtr = atr14;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// EMA with alpha = 2/(n+1), capped at 20 samples for stability
|
|
63
|
+
const n = Math.min(this.atrSampleCount, 20);
|
|
64
|
+
const alpha = 2 / (n + 1);
|
|
65
|
+
this.baselineAtr = alpha * atr14 + (1 - alpha) * this.baselineAtr;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
getBaselineAtr() {
|
|
69
|
+
return this.baselineAtr;
|
|
70
|
+
}
|
|
71
|
+
// ---- Session start NAV ----
|
|
72
|
+
/**
|
|
73
|
+
* Returns today's session-start NAV, lazily seeding it from current equity
|
|
74
|
+
* if unset or stale (different UTC day). This is the plugin-side anchor used
|
|
75
|
+
* by the pre-trade risk gate; a stale/missing anchor silently disables the
|
|
76
|
+
* drawdown zones, so callers must never see a zero.
|
|
77
|
+
*/
|
|
78
|
+
getSessionStartNav() {
|
|
79
|
+
this.ensureSessionAnchor();
|
|
80
|
+
return this.state.sessionStartNav ?? 0;
|
|
81
|
+
}
|
|
82
|
+
setSessionStartNav(nav) {
|
|
83
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
84
|
+
if (this.state.sessionDate !== today || this.state.sessionStartNav === undefined) {
|
|
85
|
+
this.state.sessionStartNav = nav;
|
|
86
|
+
this.state.sessionDate = today;
|
|
87
|
+
this.emitStateChanged();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Seed sessionStartNav from current equity on the first check of each UTC day.
|
|
91
|
+
* Safe to call repeatedly — no-op once the anchor matches today's date. */
|
|
92
|
+
ensureSessionAnchor() {
|
|
93
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
94
|
+
if (this.state.sessionDate === today && this.state.sessionStartNav !== undefined) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const equity = this.computeEquity();
|
|
98
|
+
this.state.sessionStartNav = equity;
|
|
99
|
+
this.state.sessionDate = today;
|
|
100
|
+
logger.info(TAG, `Session NAV anchored: $${equity.toFixed(2)} for ${today} (was: date=${this.state.sessionDate ?? 'none'}, nav=${this.state.sessionStartNav ?? 'none'})`);
|
|
101
|
+
this.emitStateChanged();
|
|
102
|
+
}
|
|
103
|
+
/** Mark-to-market equity = walletTotal + for each open position, the
|
|
104
|
+
* collateral locked at entry + current unrealized P&L. Paper simulator is
|
|
105
|
+
* SPOT-collateral style: the full entry notional was deducted from wallet
|
|
106
|
+
* on open and is released on close, so we must add it back here. */
|
|
107
|
+
computeEquity() {
|
|
108
|
+
const quote = this.getQuoteCurrency();
|
|
109
|
+
const walletTotal = this.state.wallet[quote]?.total ?? 0;
|
|
110
|
+
let positionEquity = 0;
|
|
111
|
+
for (const pos of this.state.positions) {
|
|
112
|
+
const ticker = this.lastTicker.get(pos.symbol);
|
|
113
|
+
const mark = ticker?.last ?? pos.entryPrice;
|
|
114
|
+
const entryNotional = pos.entryPrice * pos.quantity;
|
|
115
|
+
const unrealized = pos.side === 'long'
|
|
116
|
+
? (mark - pos.entryPrice) * pos.quantity
|
|
117
|
+
: (pos.entryPrice - mark) * pos.quantity;
|
|
118
|
+
positionEquity += entryNotional + unrealized;
|
|
119
|
+
}
|
|
120
|
+
return walletTotal + positionEquity;
|
|
121
|
+
}
|
|
122
|
+
getQuoteCurrency() {
|
|
123
|
+
return this.state.config?.quoteCurrency ?? 'USDT';
|
|
124
|
+
}
|
|
125
|
+
// ---- Read operations (for tools) ----
|
|
126
|
+
getBalance() {
|
|
127
|
+
const free = {};
|
|
128
|
+
const used = {};
|
|
129
|
+
const total = {};
|
|
130
|
+
const result = { free, used, total };
|
|
131
|
+
// Derivatives model: only quote currency (USDT) in wallet
|
|
132
|
+
for (const [currency, bal] of Object.entries(this.state.wallet)) {
|
|
133
|
+
free[currency] = bal.available;
|
|
134
|
+
used[currency] = bal.locked;
|
|
135
|
+
total[currency] = bal.total;
|
|
136
|
+
result[currency] = {
|
|
137
|
+
free: bal.available,
|
|
138
|
+
used: bal.locked,
|
|
139
|
+
total: bal.total,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// All-time realized P&L from trade history (restart-proof).
|
|
143
|
+
// Includes BOTH open-side and close-side fees — prior versions omitted the
|
|
144
|
+
// open-side fee and under-reported fee drag by ~50%.
|
|
145
|
+
let realizedPnlAllTime = 0;
|
|
146
|
+
let totalRoundtripFees = 0;
|
|
147
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
148
|
+
let realizedPnlToday = 0;
|
|
149
|
+
for (const t of this.state.tradeHistory) {
|
|
150
|
+
const roundtripFee = t.fee + (t.openFee ?? 0);
|
|
151
|
+
const net = t.realizedPnl - roundtripFee;
|
|
152
|
+
realizedPnlAllTime += net;
|
|
153
|
+
totalRoundtripFees += roundtripFee;
|
|
154
|
+
if (typeof t.closedAt === 'string' && t.closedAt.slice(0, 10) === today) {
|
|
155
|
+
realizedPnlToday += net;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const round4 = (n) => +n.toFixed(4);
|
|
159
|
+
result.realizedPnlAllTime = round4(realizedPnlAllTime);
|
|
160
|
+
result.realizedPnlToday = round4(realizedPnlToday);
|
|
161
|
+
result.totalRoundtripFees = round4(totalRoundtripFees);
|
|
162
|
+
// Session execution-quality costs (since boot) for the dashboard cost cells
|
|
163
|
+
// (M1). These are the SAME fees/slippage already baked into equity/NAV —
|
|
164
|
+
// surfaced so the "Fees" / "Avg Slip" breakdown cells don't read $0 while
|
|
165
|
+
// the headline PnL clearly shows the drag.
|
|
166
|
+
const execStats = this.state.executionStats;
|
|
167
|
+
result.sessionFeesPaid = round4(execStats?.totalFeesPaid ?? 0);
|
|
168
|
+
result.sessionAvgSlippageBps = round4(execStats?.avgSlippageBps ?? 0);
|
|
169
|
+
// Equity = mark-to-market total account value. Use this (NOT walletTotal)
|
|
170
|
+
// for heat / leverage / exposure math — walletTotal drops as notional is
|
|
171
|
+
// locked into open positions, but that collateral is released on close.
|
|
172
|
+
const equity = this.computeEquity();
|
|
173
|
+
result.equity = round4(equity);
|
|
174
|
+
// Session anchor (for drawdown zone calculation). Side-effect: seeds on
|
|
175
|
+
// first read each UTC day if stale/missing.
|
|
176
|
+
const sessionNav = this.getSessionStartNav();
|
|
177
|
+
result.sessionStartNav = round4(sessionNav);
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
getPositions(symbol) {
|
|
181
|
+
const positions = symbol
|
|
182
|
+
? this.state.positions.filter(p => p.symbol === symbol)
|
|
183
|
+
: this.state.positions;
|
|
184
|
+
return positions.map(p => {
|
|
185
|
+
const ticker = this.lastTicker.get(p.symbol);
|
|
186
|
+
const markPrice = ticker?.last ?? p.entryPrice;
|
|
187
|
+
const notional = p.quantity * markPrice;
|
|
188
|
+
const pnlMultiplier = p.side === 'long' ? 1 : -1;
|
|
189
|
+
const unrealizedPnl = (markPrice - p.entryPrice) * p.quantity * pnlMultiplier;
|
|
190
|
+
const percentage = p.entryPrice > 0
|
|
191
|
+
? ((markPrice - p.entryPrice) / p.entryPrice) * 100 * pnlMultiplier
|
|
192
|
+
: 0;
|
|
193
|
+
return {
|
|
194
|
+
symbol: p.symbol,
|
|
195
|
+
side: p.side,
|
|
196
|
+
contracts: p.quantity,
|
|
197
|
+
contractSize: 1,
|
|
198
|
+
entryPrice: p.entryPrice,
|
|
199
|
+
markPrice,
|
|
200
|
+
notional,
|
|
201
|
+
unrealizedPnl,
|
|
202
|
+
percentage,
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
datetime: new Date().toISOString(),
|
|
205
|
+
// Surface entry metadata (if available)
|
|
206
|
+
...(p.metadata && {
|
|
207
|
+
setupType: p.metadata.setupType,
|
|
208
|
+
thesis: p.metadata.thesis,
|
|
209
|
+
stopPrice: p.metadata.stopPrice,
|
|
210
|
+
targetPrice: p.metadata.targetPrice,
|
|
211
|
+
regime: p.metadata.regime,
|
|
212
|
+
regimeConfidence: p.metadata.regimeConfidence,
|
|
213
|
+
scorecardVerdict: p.metadata.scorecardVerdict,
|
|
214
|
+
confluenceScore: p.metadata.confluenceScore,
|
|
215
|
+
originalStopPrice: p.metadata.originalStopPrice,
|
|
216
|
+
mfePeakPrice: p.metadata.mfePeakPrice,
|
|
217
|
+
mfeR: p.metadata.mfeR,
|
|
218
|
+
giveBackRatio: p.metadata.giveBackRatio,
|
|
219
|
+
invalidationPrice: p.metadata.invalidationPrice,
|
|
220
|
+
realizationRule: p.metadata.realizationRule,
|
|
221
|
+
invalidationHit: computeInvalidationHit(p.side, markPrice, p.metadata.invalidationPrice),
|
|
222
|
+
}),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
getOpenOrders(symbol) {
|
|
227
|
+
const orders = symbol
|
|
228
|
+
? this.state.openOrders.filter(o => o.symbol === symbol)
|
|
229
|
+
: this.state.openOrders;
|
|
230
|
+
return orders.map(o => this.toCcxtOrder(o));
|
|
231
|
+
}
|
|
232
|
+
/** Get cumulative execution quality stats. */
|
|
233
|
+
getExecutionStats() {
|
|
234
|
+
return this.state.executionStats;
|
|
235
|
+
}
|
|
236
|
+
// ---- Order book ----
|
|
237
|
+
/** Cache the latest order book snapshot for a symbol. */
|
|
238
|
+
updateOrderBook(symbol, orderbook) {
|
|
239
|
+
this.lastOrderBook.set(symbol, orderbook);
|
|
240
|
+
}
|
|
241
|
+
getLastOrderBook(symbol) {
|
|
242
|
+
return this.lastOrderBook.get(symbol);
|
|
243
|
+
}
|
|
244
|
+
// ---- Write operations (for tools) ----
|
|
245
|
+
createOrder(symbol, side, type, amount, price, metadata) {
|
|
246
|
+
// ---- Startup trade lockout ----
|
|
247
|
+
// Block trades during the first 15s after gateway restart IF there were
|
|
248
|
+
// existing positions at startup. This prevents stale agent sessions from
|
|
249
|
+
// selling positions before the session is cleared and the agent re-reads SKILL.md.
|
|
250
|
+
// Only activates when positions exist (nothing to protect if starting empty).
|
|
251
|
+
const elapsed = Date.now() - this.startupTime;
|
|
252
|
+
if (this.hadPositionsAtStartup && elapsed < ExchangeSimulator.STARTUP_LOCKOUT_MS) {
|
|
253
|
+
const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
|
|
254
|
+
logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
|
|
255
|
+
throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
|
|
256
|
+
}
|
|
257
|
+
if (amount <= 0) {
|
|
258
|
+
throw new Error('Order amount must be positive');
|
|
259
|
+
}
|
|
260
|
+
if (type === 'limit' && (price === undefined || price <= 0)) {
|
|
261
|
+
throw new Error('Limit orders require a positive price');
|
|
262
|
+
}
|
|
263
|
+
const now = new Date().toISOString();
|
|
264
|
+
const order = {
|
|
265
|
+
id: randomUUID(),
|
|
266
|
+
symbol,
|
|
267
|
+
side,
|
|
268
|
+
type,
|
|
269
|
+
status: 'open',
|
|
270
|
+
amount,
|
|
271
|
+
price: type === 'limit' ? price : null,
|
|
272
|
+
filled: 0,
|
|
273
|
+
average: null,
|
|
274
|
+
cost: 0,
|
|
275
|
+
fee: { cost: 0, currency: parseSymbol(symbol).quote },
|
|
276
|
+
createdAt: now,
|
|
277
|
+
};
|
|
278
|
+
if (type === 'market') {
|
|
279
|
+
// Market orders fill immediately at current price
|
|
280
|
+
const ticker = this.lastTicker.get(symbol);
|
|
281
|
+
if (!ticker) {
|
|
282
|
+
throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
|
|
283
|
+
}
|
|
284
|
+
return this.executeMarketFill(order, ticker.last, metadata);
|
|
285
|
+
}
|
|
286
|
+
// Limit order — check if it crosses the current price
|
|
287
|
+
const ticker = this.lastTicker.get(symbol);
|
|
288
|
+
if (ticker && this.shouldFillLimit(order, ticker.last)) {
|
|
289
|
+
return this.executeLimitFill(order, ticker.last, metadata);
|
|
290
|
+
}
|
|
291
|
+
// Limit order doesn't cross — add to open orders. Pin the entry metadata
|
|
292
|
+
// ONTO the order itself (durable home, persisted in state.json) AND in the
|
|
293
|
+
// in-memory cache (fast path). order.metadata is the one that survives a
|
|
294
|
+
// restart / two-process reload, so a resting limit never fills naked.
|
|
295
|
+
if (metadata) {
|
|
296
|
+
order.metadata = metadata;
|
|
297
|
+
this.pendingOrderMetadata.set(order.id, metadata);
|
|
298
|
+
}
|
|
299
|
+
this.state.openOrders.push(order);
|
|
300
|
+
this.emitStateChanged();
|
|
301
|
+
logger.info(TAG, `Limit order created: ${side} ${amount} ${symbol} @ ${price}`);
|
|
302
|
+
return this.toCcxtOrder(order);
|
|
303
|
+
}
|
|
304
|
+
cancelOrder(orderId) {
|
|
305
|
+
const idx = this.state.openOrders.findIndex(o => o.id === orderId);
|
|
306
|
+
if (idx === -1) {
|
|
307
|
+
throw new Error(`Order not found: ${orderId}`);
|
|
308
|
+
}
|
|
309
|
+
const order = this.state.openOrders.splice(idx, 1)[0];
|
|
310
|
+
order.status = 'canceled';
|
|
311
|
+
this.pendingOrderMetadata.delete(orderId);
|
|
312
|
+
this.emitStateChanged();
|
|
313
|
+
logger.info(TAG, `Order cancelled: ${orderId}`);
|
|
314
|
+
return this.toCcxtOrder(order);
|
|
315
|
+
}
|
|
316
|
+
cancelAllOrders(symbol) {
|
|
317
|
+
const cancelled = [];
|
|
318
|
+
const remaining = [];
|
|
319
|
+
for (const order of this.state.openOrders) {
|
|
320
|
+
if (!symbol || order.symbol === symbol) {
|
|
321
|
+
order.status = 'canceled';
|
|
322
|
+
cancelled.push(order);
|
|
323
|
+
this.pendingOrderMetadata.delete(order.id);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
remaining.push(order);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
this.state.openOrders = remaining;
|
|
330
|
+
if (cancelled.length > 0) {
|
|
331
|
+
this.emitStateChanged();
|
|
332
|
+
logger.info(TAG, `Cancelled ${cancelled.length} orders${symbol ? ` for ${symbol}` : ''}`);
|
|
333
|
+
}
|
|
334
|
+
return cancelled.map(o => this.toCcxtOrder(o));
|
|
335
|
+
}
|
|
336
|
+
closePosition(symbol, closeReason) {
|
|
337
|
+
const position = this.state.positions.find(p => p.symbol === symbol);
|
|
338
|
+
if (!position) {
|
|
339
|
+
throw new Error(`No open position for ${symbol}`);
|
|
340
|
+
}
|
|
341
|
+
// Tag the position's metadata with the close reason BEFORE firing the close
|
|
342
|
+
// order. The closing Trade record copies `existingPosition.metadata` in
|
|
343
|
+
// fill-engine.executeOrderFill, so this is what makes the reason reach
|
|
344
|
+
// the trade history (and therefore the agent on its next heartbeat).
|
|
345
|
+
if (closeReason) {
|
|
346
|
+
position.metadata = { ...(position.metadata ?? {}), closeReason };
|
|
347
|
+
}
|
|
348
|
+
// Create opposing market order to close the position
|
|
349
|
+
const closeSide = position.side === 'long' ? 'sell' : 'buy';
|
|
350
|
+
return this.createOrder(symbol, closeSide, 'market', position.quantity);
|
|
351
|
+
}
|
|
352
|
+
/** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
|
|
353
|
+
* targetPrice) in place and persist, WITHOUT the close+reopen round-trip
|
|
354
|
+
* (which pays an extra taker fee and resets the R/MFE denominators). The
|
|
355
|
+
* frozen originalStopPrice / originalEntryPrice are intentionally left
|
|
356
|
+
* untouched so R-multiples stay anchored at entry. The paper stop-watcher
|
|
357
|
+
* and getPositions both read metadata.stopPrice, so a moved stop takes
|
|
358
|
+
* effect on the next watcher tick. Throws if there is no open position. (M9) */
|
|
359
|
+
updatePositionMetadata(symbol, patch) {
|
|
360
|
+
const pos = this.state.positions.find(p => p.symbol === symbol);
|
|
361
|
+
if (!pos) {
|
|
362
|
+
throw new Error(`No open position for ${symbol}`);
|
|
363
|
+
}
|
|
364
|
+
const meta = { ...(pos.metadata ?? {}) };
|
|
365
|
+
if (patch.stopPrice !== undefined)
|
|
366
|
+
meta.stopPrice = patch.stopPrice;
|
|
367
|
+
if (patch.targetPrice !== undefined)
|
|
368
|
+
meta.targetPrice = patch.targetPrice;
|
|
369
|
+
pos.metadata = meta;
|
|
370
|
+
this.emitStateChanged();
|
|
371
|
+
}
|
|
372
|
+
// ---- Ticker updates (checks pending limit fills) ----
|
|
373
|
+
updateTicker(ticker) {
|
|
374
|
+
this.lastTicker.set(ticker.symbol, ticker);
|
|
375
|
+
this.refreshMfeForSymbol(ticker.symbol, ticker.last);
|
|
376
|
+
// Check if any pending limit orders should fill
|
|
377
|
+
const toFill = [];
|
|
378
|
+
const remaining = [];
|
|
379
|
+
for (const order of this.state.openOrders) {
|
|
380
|
+
if (order.symbol === ticker.symbol && this.shouldFillLimit(order, ticker.last)) {
|
|
381
|
+
toFill.push(order);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
remaining.push(order);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (toFill.length > 0) {
|
|
388
|
+
this.state.openOrders = remaining;
|
|
389
|
+
for (const order of toFill) {
|
|
390
|
+
// Prefer the durable metadata pinned on the order (survives restart);
|
|
391
|
+
// fall back to the in-memory cache for any legacy in-flight order.
|
|
392
|
+
const meta = order.metadata ?? this.pendingOrderMetadata.get(order.id);
|
|
393
|
+
try {
|
|
394
|
+
this.executeLimitFill(order, ticker.last, meta);
|
|
395
|
+
// Clear the cache only AFTER a successful fill so a throw-then-retry
|
|
396
|
+
// (e.g. transient insufficient balance) keeps the metadata for the
|
|
397
|
+
// next tick instead of filling naked.
|
|
398
|
+
this.pendingOrderMetadata.delete(order.id);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
// Fill failed (e.g. insufficient balance) — restore order to open
|
|
402
|
+
// list. order.metadata stays intact, so the retry is not naked.
|
|
403
|
+
order.status = 'open';
|
|
404
|
+
this.state.openOrders.push(order);
|
|
405
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
406
|
+
logger.warn(TAG, `Limit fill failed for ${order.id}, order restored: ${message}`);
|
|
407
|
+
this.emit('fillError', {
|
|
408
|
+
orderId: order.id,
|
|
409
|
+
symbol: order.symbol,
|
|
410
|
+
error: message,
|
|
411
|
+
timestamp: new Date().toISOString(),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
getLastTicker(symbol) {
|
|
418
|
+
return this.lastTicker.get(symbol);
|
|
419
|
+
}
|
|
420
|
+
/** Walk every position for `symbol` and refresh MFE / give-back from the
|
|
421
|
+
* latest mark. Idempotent — pure update of `metadata.mfePeakPrice` (only
|
|
422
|
+
* ratchets favourably) plus derived `mfeR` and `giveBackRatio`. Safe to
|
|
423
|
+
* call before any positions exist (no-op). */
|
|
424
|
+
refreshMfeForSymbol(symbol, mark) {
|
|
425
|
+
if (!Number.isFinite(mark) || mark <= 0)
|
|
426
|
+
return;
|
|
427
|
+
let peakAdvanced = false;
|
|
428
|
+
for (const p of this.state.positions) {
|
|
429
|
+
if (p.symbol !== symbol)
|
|
430
|
+
continue;
|
|
431
|
+
const meta = { ...(p.metadata ?? {}) };
|
|
432
|
+
const priorPeak = meta.mfePeakPrice;
|
|
433
|
+
const out = updateMfe({
|
|
434
|
+
side: p.side,
|
|
435
|
+
// R/MFE are denominated against the FROZEN original entry (and stop),
|
|
436
|
+
// never the running averaged entryPrice — otherwise a scale-in
|
|
437
|
+
// retroactively shrinks an already-achieved excursion (M4).
|
|
438
|
+
entryPrice: meta.originalEntryPrice ?? p.entryPrice,
|
|
439
|
+
originalStopPrice: meta.originalStopPrice,
|
|
440
|
+
markPrice: mark,
|
|
441
|
+
priorPeakPrice: meta.mfePeakPrice,
|
|
442
|
+
});
|
|
443
|
+
if (out.mfePeakPrice !== priorPeak)
|
|
444
|
+
peakAdvanced = true;
|
|
445
|
+
meta.mfePeakPrice = out.mfePeakPrice;
|
|
446
|
+
meta.mfeR = out.mfeR;
|
|
447
|
+
meta.giveBackRatio = out.giveBackRatio;
|
|
448
|
+
p.metadata = meta;
|
|
449
|
+
}
|
|
450
|
+
// Persist ONLY when a peak strictly advances (M3). The monotone peak moves
|
|
451
|
+
// less and less over time and debouncedSave coalesces to <=1/s, so this
|
|
452
|
+
// does not flood — and it is what makes the MFE ratchet survive a restart
|
|
453
|
+
// and a reloadState() (which previously discarded the unpersisted peak,
|
|
454
|
+
// feeding a stale/zeroed mfeR to the agent and the exit gate). mfeR /
|
|
455
|
+
// giveBackRatio are recomputed from the persisted peak on read, so they
|
|
456
|
+
// don't need a per-tick write.
|
|
457
|
+
if (peakAdvanced) {
|
|
458
|
+
this.emitStateChanged();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// ---- State management ----
|
|
462
|
+
/** Replace internal state with a fresh copy from disk.
|
|
463
|
+
* Used by the gateway process to pick up state saved by the agent process.
|
|
464
|
+
*
|
|
465
|
+
* Non-destructive for the MFE ratchet (M3): the disk copy can lag the live
|
|
466
|
+
* peak (refreshMfeForSymbol ratchets every tick; persistence coalesces), so
|
|
467
|
+
* a wholesale swap would discard it. For each position present in BOTH
|
|
468
|
+
* snapshots (same symbol+side+openedAt) we carry over the MORE-favourable
|
|
469
|
+
* monotone peak and recompute mfeR/giveBackRatio from the current mark, so a
|
|
470
|
+
* reload never regresses the agent's give-back signal. */
|
|
471
|
+
replaceState(newState) {
|
|
472
|
+
// M8: refuse a disk snapshot OLDER than what we already hold in memory.
|
|
473
|
+
// reloadState() reads disk that lags memory by the debounce window; without
|
|
474
|
+
// this guard a reload fired within ~1s of a local mutation would revert it
|
|
475
|
+
// (and a second mutation could then re-persist the reverted state, dropping
|
|
476
|
+
// a position + its realized PnL). A genuinely newer cross-process write
|
|
477
|
+
// (savedAt >= ours) still installs. Equal stamps install (idempotent).
|
|
478
|
+
const incomingAt = newState.savedAt ?? 0;
|
|
479
|
+
const currentAt = this.state.savedAt ?? 0;
|
|
480
|
+
if (incomingAt < currentAt) {
|
|
481
|
+
logger.debug(TAG, `Ignoring stale state reload (disk savedAt=${incomingAt} < memory savedAt=${currentAt})`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const priorPeakByKey = new Map();
|
|
485
|
+
for (const p of this.state.positions) {
|
|
486
|
+
if (p.metadata?.mfePeakPrice !== undefined) {
|
|
487
|
+
priorPeakByKey.set(`${p.symbol}|${p.side}|${p.openedAt}`, p.metadata.mfePeakPrice);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
this.state = newState;
|
|
491
|
+
for (const p of this.state.positions) {
|
|
492
|
+
const priorPeak = priorPeakByKey.get(`${p.symbol}|${p.side}|${p.openedAt}`);
|
|
493
|
+
if (priorPeak === undefined)
|
|
494
|
+
continue;
|
|
495
|
+
const meta = { ...(p.metadata ?? {}) };
|
|
496
|
+
const diskPeak = meta.mfePeakPrice;
|
|
497
|
+
// Monotone: keep the better of disk vs in-memory (max for long, min short).
|
|
498
|
+
const keptPeak = diskPeak === undefined
|
|
499
|
+
? priorPeak
|
|
500
|
+
: (p.side === 'long' ? Math.max(priorPeak, diskPeak) : Math.min(priorPeak, diskPeak));
|
|
501
|
+
const mark = this.lastTicker.get(p.symbol)?.last ?? meta.originalEntryPrice ?? p.entryPrice;
|
|
502
|
+
const out = updateMfe({
|
|
503
|
+
side: p.side,
|
|
504
|
+
entryPrice: meta.originalEntryPrice ?? p.entryPrice,
|
|
505
|
+
originalStopPrice: meta.originalStopPrice,
|
|
506
|
+
markPrice: mark,
|
|
507
|
+
priorPeakPrice: keptPeak,
|
|
508
|
+
});
|
|
509
|
+
meta.mfePeakPrice = out.mfePeakPrice;
|
|
510
|
+
meta.mfeR = out.mfeR;
|
|
511
|
+
meta.giveBackRatio = out.giveBackRatio;
|
|
512
|
+
p.metadata = meta;
|
|
513
|
+
}
|
|
514
|
+
logger.debug(TAG, `State replaced: ${newState.positions.length} positions, ${newState.openOrders.length} orders`);
|
|
515
|
+
}
|
|
516
|
+
getState() {
|
|
517
|
+
// Spread each element + deep-copy ONLY the nested objects that need
|
|
518
|
+
// isolation (metadata — which holds the nested realizationRule.scale array
|
|
519
|
+
// — plus executionQuality and fee). This keeps a consumer mutating the
|
|
520
|
+
// snapshot, or the JSON-persisted copy, from corrupting live state, while
|
|
521
|
+
// staying cheap on the hot persistence path: elements without metadata
|
|
522
|
+
// (the common case) pay only a shallow spread, never a full structuredClone.
|
|
523
|
+
return {
|
|
524
|
+
...this.state,
|
|
525
|
+
wallet: Object.fromEntries(Object.entries(this.state.wallet).map(([k, v]) => [k, { ...v }])),
|
|
526
|
+
// Positions + open orders ARE mutated in place (updatePositionMetadata,
|
|
527
|
+
// refreshMfeForSymbol, scale-in, limit metadata), so deep-copy their
|
|
528
|
+
// nested metadata to keep a snapshot consumer from corrupting live state.
|
|
529
|
+
positions: this.state.positions.map(p => ({
|
|
530
|
+
...p,
|
|
531
|
+
metadata: p.metadata ? structuredClone(p.metadata) : undefined,
|
|
532
|
+
})),
|
|
533
|
+
openOrders: this.state.openOrders.map(o => ({
|
|
534
|
+
...o,
|
|
535
|
+
fee: { ...o.fee },
|
|
536
|
+
metadata: o.metadata ? structuredClone(o.metadata) : undefined,
|
|
537
|
+
})),
|
|
538
|
+
// Trade records are append-only + immutable after close, so a shallow copy
|
|
539
|
+
// is safe and keeps getState() O(n)-cheap on the hot persistence path even
|
|
540
|
+
// with a full 1000-entry history (JSON persistence still deep-serializes).
|
|
541
|
+
tradeHistory: this.state.tradeHistory.map(t => ({ ...t })),
|
|
542
|
+
executionStats: this.state.executionStats ? { ...this.state.executionStats } : undefined,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
// ---- Private helpers ----
|
|
546
|
+
shouldFillLimit(order, currentPrice) {
|
|
547
|
+
if (order.price === null)
|
|
548
|
+
return false;
|
|
549
|
+
// BUY limit fills when price <= limit price
|
|
550
|
+
// SELL limit fills when price >= limit price
|
|
551
|
+
return order.side === 'buy'
|
|
552
|
+
? currentPrice <= order.price
|
|
553
|
+
: currentPrice >= order.price;
|
|
554
|
+
}
|
|
555
|
+
executeMarketFill(order, currentPrice, metadata) {
|
|
556
|
+
const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
|
|
557
|
+
const orderbook = this.lastOrderBook.get(order.symbol) ?? null;
|
|
558
|
+
const realistic = {
|
|
559
|
+
orderbook,
|
|
560
|
+
config: this.simulationConfig,
|
|
561
|
+
volFactor: this.cachedVolFactor,
|
|
562
|
+
metadata,
|
|
563
|
+
};
|
|
564
|
+
const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
|
|
565
|
+
const ccxtOrder = this.applyFillResult(result);
|
|
566
|
+
const eq = result.executionQuality;
|
|
567
|
+
if (eq) {
|
|
568
|
+
logger.info(TAG, `Market order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}` +
|
|
569
|
+
` (decision: ${eq.decisionPrice.toFixed(2)}, slippage: ${eq.slippageBps.toFixed(2)}bps` +
|
|
570
|
+
`, latency: ${eq.latencyMs.toFixed(0)}ms, fee: ${eq.feeRate * 100}%` +
|
|
571
|
+
`, book: ${eq.bookDepthAvailable ? `${eq.bookLevelsConsumed} levels` : 'unavailable'})`);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
logger.info(TAG, `Market order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}`);
|
|
575
|
+
}
|
|
576
|
+
return ccxtOrder;
|
|
577
|
+
}
|
|
578
|
+
executeLimitFill(order, decisionPrice, metadata) {
|
|
579
|
+
const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
|
|
580
|
+
const result = fillLimitOrder(order, this.state.wallet, position, this.simulationConfig, decisionPrice, metadata);
|
|
581
|
+
const ccxtOrder = this.applyFillResult(result);
|
|
582
|
+
const eq = result.executionQuality;
|
|
583
|
+
if (eq) {
|
|
584
|
+
logger.info(TAG, `Limit order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}` +
|
|
585
|
+
` (maker fee: ${eq.feeRate * 100}%)`);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
logger.info(TAG, `Limit order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}`);
|
|
589
|
+
}
|
|
590
|
+
return ccxtOrder;
|
|
591
|
+
}
|
|
592
|
+
/** Apply a fill result: update positions, record trade, track stats, emit state change. */
|
|
593
|
+
applyFillResult(result) {
|
|
594
|
+
this.updatePosition(result.order.symbol, result.position);
|
|
595
|
+
if (result.trade)
|
|
596
|
+
this.addTrade(result.trade);
|
|
597
|
+
if (result.executionQuality)
|
|
598
|
+
this.updateExecutionStats(result.executionQuality);
|
|
599
|
+
this.emitStateChanged();
|
|
600
|
+
// Emit fill event for shadow tracker (Phase 9b)
|
|
601
|
+
this.emit('fill', {
|
|
602
|
+
trade: result.trade,
|
|
603
|
+
orderId: result.order.id,
|
|
604
|
+
symbol: result.order.symbol,
|
|
605
|
+
side: result.order.side,
|
|
606
|
+
type: result.order.type,
|
|
607
|
+
amount: result.order.amount,
|
|
608
|
+
fillPrice: result.order.average ?? 0,
|
|
609
|
+
fee: result.order.fee.cost,
|
|
610
|
+
});
|
|
611
|
+
return this.toCcxtOrder(result.order);
|
|
612
|
+
}
|
|
613
|
+
updatePosition(symbol, newPosition) {
|
|
614
|
+
const idx = this.state.positions.findIndex(p => p.symbol === symbol);
|
|
615
|
+
if (newPosition) {
|
|
616
|
+
if (idx >= 0) {
|
|
617
|
+
this.state.positions[idx] = newPosition;
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
this.state.positions.push(newPosition);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
else if (idx >= 0) {
|
|
624
|
+
this.state.positions.splice(idx, 1);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
addTrade(trade) {
|
|
628
|
+
this.state.tradeHistory.push(trade);
|
|
629
|
+
// Keep only the last MAX_TRADE_HISTORY trades
|
|
630
|
+
if (this.state.tradeHistory.length > MAX_TRADE_HISTORY) {
|
|
631
|
+
this.state.tradeHistory = this.state.tradeHistory.slice(-MAX_TRADE_HISTORY);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** Update cumulative execution stats from a fill's execution quality. */
|
|
635
|
+
updateExecutionStats(eq) {
|
|
636
|
+
if (!this.state.executionStats) {
|
|
637
|
+
this.state.executionStats = {
|
|
638
|
+
totalTrades: 0,
|
|
639
|
+
totalSlippageBps: 0,
|
|
640
|
+
totalFeesPaid: 0,
|
|
641
|
+
worstSlippageBps: 0,
|
|
642
|
+
avgSlippageBps: 0,
|
|
643
|
+
avgLatencyMs: 0,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const stats = this.state.executionStats;
|
|
647
|
+
stats.totalTrades++;
|
|
648
|
+
const absSlippage = Math.abs(eq.slippageBps);
|
|
649
|
+
stats.totalSlippageBps += absSlippage;
|
|
650
|
+
stats.totalFeesPaid += eq.feePaid;
|
|
651
|
+
if (absSlippage > stats.worstSlippageBps) {
|
|
652
|
+
stats.worstSlippageBps = absSlippage;
|
|
653
|
+
}
|
|
654
|
+
stats.avgSlippageBps = stats.totalSlippageBps / stats.totalTrades;
|
|
655
|
+
stats.avgLatencyMs =
|
|
656
|
+
(stats.avgLatencyMs * (stats.totalTrades - 1) + eq.latencyMs) / stats.totalTrades;
|
|
657
|
+
}
|
|
658
|
+
emitStateChanged() {
|
|
659
|
+
// Stamp the mutation time so a stale-disk reloadState() can be detected and
|
|
660
|
+
// dropped (M8). Must be set BEFORE getState() snapshots the state.
|
|
661
|
+
this.state.savedAt = Date.now();
|
|
662
|
+
this.emit('stateChanged', this.getState());
|
|
663
|
+
}
|
|
664
|
+
toCcxtOrder(order) {
|
|
665
|
+
const ts = new Date(order.createdAt).getTime();
|
|
666
|
+
return {
|
|
667
|
+
id: order.id,
|
|
668
|
+
symbol: order.symbol,
|
|
669
|
+
side: order.side,
|
|
670
|
+
type: order.type,
|
|
671
|
+
status: order.status,
|
|
672
|
+
amount: order.amount,
|
|
673
|
+
filled: order.filled,
|
|
674
|
+
remaining: order.amount - order.filled,
|
|
675
|
+
average: order.average,
|
|
676
|
+
price: order.price,
|
|
677
|
+
cost: order.cost,
|
|
678
|
+
fee: order.fee,
|
|
679
|
+
timestamp: ts,
|
|
680
|
+
datetime: order.createdAt,
|
|
681
|
+
timeInForce: 'GTC',
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|