@reefclaw/openclaw-plugin 0.1.6 → 0.1.7

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.
Files changed (52) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/public-market-data-api.d.ts +14 -0
  8. package/ccxt/public-market-data-api.js +15 -1
  9. package/config/plugin-config-io.d.ts +7 -0
  10. package/config/plugin-config-io.js +15 -0
  11. package/index.js +107 -29
  12. package/ingest/position-auto-capture.d.ts +68 -0
  13. package/ingest/position-auto-capture.js +321 -23
  14. package/ingest/position-decisions-client.d.ts +7 -2
  15. package/ingest/position-decisions-client.js +13 -3
  16. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  17. package/ingest/reconcile-db-vs-exchange.js +66 -10
  18. package/live/fill-price.d.ts +13 -0
  19. package/live/fill-price.js +37 -0
  20. package/live/live-adapter.d.ts +33 -1
  21. package/live/live-adapter.js +176 -47
  22. package/live/position-state-store.d.ts +4 -0
  23. package/live/stop-watcher.d.ts +8 -1
  24. package/live/stop-watcher.js +5 -2
  25. package/onboarding/runtime.d.ts +6 -0
  26. package/onboarding/runtime.js +13 -2
  27. package/package.json +2 -2
  28. package/portfolio/reentry-tracker.d.ts +36 -0
  29. package/portfolio/reentry-tracker.js +127 -0
  30. package/signals/conditions/registry.js +11 -2
  31. package/signals/strategy-adapter.js +17 -7
  32. package/simulator/exchange-simulator.d.ts +12 -0
  33. package/simulator/exchange-simulator.js +73 -3
  34. package/simulator/types.d.ts +4 -0
  35. package/skills/reefclaw/SKILL.md +2 -0
  36. package/tools/assessment-validation.d.ts +21 -0
  37. package/tools/assessment-validation.js +58 -0
  38. package/tools/attach-brackets.js +165 -0
  39. package/tools/audit-bracket-protection.js +157 -1
  40. package/tools/bracket-control.d.ts +12 -0
  41. package/tools/bracket-control.js +35 -0
  42. package/tools/create-order.js +30 -2
  43. package/tools/get-setup-detail.js +12 -1
  44. package/tools/modify-stop.js +5 -5
  45. package/tools/modify-target.js +5 -5
  46. package/tools/scan-pairs.d.ts +4 -0
  47. package/tools/scan-pairs.js +4 -1
  48. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  49. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  50. package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
  51. package/venues/hyperliquid/hl-live-adapter.js +380 -5
  52. package/venues/hyperliquid/hl-public.js +8 -1
@@ -15,6 +15,7 @@ import { PaperAdapter } from '../paper-adapter.js';
15
15
  import { LiveAdapter } from '../live/live-adapter.js';
16
16
  import { PositionWatcher } from '../live/stop-watcher.js';
17
17
  import { loadBracketMode } from '../config/brackets-config.js';
18
+ import { loadStopWatcherIntervalMs } from '../config/plugin-config-io.js';
18
19
  import { logger, formatError } from '../logger.js';
19
20
  const TAG = 'plugin-runtime';
20
21
  /** Pure-ish factory: builds an adapter for the requested mode.
@@ -57,6 +58,11 @@ export class PluginRuntime {
57
58
  _marketFeed;
58
59
  operationLock;
59
60
  wave9LiveLifecycleHooks;
61
+ /** Observer applied to EVERY stop-watcher this runtime creates (reconnects
62
+ * included). index.ts uses it to attach the journal auto-capture listener
63
+ * for watcher closes (issue #199) — without it, a live<->paper reconnect
64
+ * would silently shed the capture wiring. */
65
+ onWatcherCreated;
60
66
  /** Reconnect is serialized — a second caller waits for the first to finish
61
67
  * so we never tear down an adapter that's mid-rebuild. */
62
68
  reconnectInFlight = null;
@@ -67,6 +73,7 @@ export class PluginRuntime {
67
73
  this._stopWatcher = initial.stopWatcher ?? null;
68
74
  this._marketFeed = initial.marketFeed ?? null;
69
75
  this.operationLock = initial.operationLock;
76
+ this.onWatcherCreated = initial.onWatcherCreated;
70
77
  }
71
78
  get adapter() { return this._adapter; }
72
79
  get mode() { return this._mode; }
@@ -152,9 +159,13 @@ export class PluginRuntime {
152
159
  this._adapter = fresh;
153
160
  this._mode = next.mode;
154
161
  deps.adapterDeps.adapter = fresh;
155
- // 6. Start a new stop-watcher bound to the new adapter.
156
- const watcher = new PositionWatcher(fresh, undefined, this.operationLock);
162
+ // 6. Start a new stop-watcher bound to the new adapter. Re-read the
163
+ // operator's cadence override building with `undefined` here
164
+ // silently reverted plugin-config `stopWatcher.intervalMs` to the
165
+ // default on every reconnect.
166
+ const watcher = new PositionWatcher(fresh, loadStopWatcherIntervalMs(), this.operationLock);
157
167
  this.wave9LiveLifecycleHooks?.configurePositionWatcher?.(watcher, fresh);
168
+ this.onWatcherCreated?.(watcher);
158
169
  watcher.start();
159
170
  this._stopWatcher = watcher;
160
171
  // 7. Paper market feed follows the mode: run it when the new adapter is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "ReefClaw trading plugin for OpenClaw \u2014 paper trading with real Binance market data, plus the ReefClaw dashboard connector (supervised by OpenClaw, no service manager needed). Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -22,7 +22,7 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@reefclaw/shared": "0.1.1",
25
+ "@reefclaw/shared": "0.1.2",
26
26
  "ccxt": "4.5.37",
27
27
  "json5": "2.2.3",
28
28
  "ws": "8.19.0"
@@ -0,0 +1,36 @@
1
+ export interface ReentryExitRecord {
2
+ /** Canonical symbol (no settle suffix). */
3
+ symbol: string;
4
+ /** setup_type / strategy name from the entry metadata, when known. */
5
+ setupType?: string;
6
+ side: 'long' | 'short';
7
+ /** Whether the closed trade realized a loss (drives the stronger caution). */
8
+ wasLoss?: boolean;
9
+ closedAtMs: number;
10
+ }
11
+ /** Signal-bar duration for a strategy/setup name. Name-suffix inference:
12
+ * `..._4h` → 240min, `..._1d` / daily → 1440min, `..._Nh` → N×60. Unknown
13
+ * shapes default to 60min so short-TF strategies are never over-warned. */
14
+ export declare function strategyBarMinutes(name: string | undefined): number;
15
+ export declare class ReentryTracker {
16
+ private records;
17
+ private readonly filePath;
18
+ private readonly dir;
19
+ constructor(pluginId?: string, opts?: {
20
+ basePath?: string;
21
+ });
22
+ /** Record a position exit. Never throws (best-effort persistence). */
23
+ recordExit(record: Omit<ReentryExitRecord, 'symbol'> & {
24
+ symbol: string;
25
+ }): void;
26
+ /** Most recent exit for (symbol[, setup]). A setup-specific record wins over
27
+ * a symbol-only match so multi-strategy books get precise cautions. */
28
+ lastExit(symbol: string, setupType?: string): ReentryExitRecord | undefined;
29
+ /** Structured caution when (symbol, strategy) was already traded within the
30
+ * current signal bar. Undefined = no caution. Pure indication (issue #204):
31
+ * the agent decides; nothing here blocks an order. */
32
+ cautionFor(symbol: string, strategy: string | undefined, nowMs?: number): string | undefined;
33
+ /** Test seam. */
34
+ getRecords(): readonly ReentryExitRecord[];
35
+ private persist;
36
+ }
@@ -0,0 +1,127 @@
1
+ // Re-entry tracker — records recent position exits per (symbol, setup) so the
2
+ // entry funnel (scan_pairs / get_signals) can flag setups the agent already
3
+ // traded within the current signal bar (issue #204).
4
+ //
5
+ // WHY: the validated backtests for the 4h/1d templates have one-trade-per-
6
+ // signal semantics — a signal bar produces at most one trade. Live, the 4h
7
+ // condition stays true for hours, so after every exit the next heartbeat
8
+ // re-entered the same setup: measured 19× the backtest cadence with 79-minute
9
+ // median holds, including 22 same-direction re-entries within one bar of a
10
+ // LOSING close (2026-07-14 → 07-20 HL soak). Per the tools-not-mandates
11
+ // doctrine this ships as STRUCTURED INDICATION — the annotation tells the
12
+ // agent the setup was already traded this bar; it never blocks the order.
13
+ //
14
+ // Persistence: small JSON in the plugins base dir (same pattern as
15
+ // position-state-store) so restarts don't blind the indication. Best-effort —
16
+ // a persistence failure degrades to in-memory-only, never throws into the
17
+ // trading path.
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { logger, formatError } from '../logger.js';
21
+ import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
22
+ import { resolvePluginsBaseDir } from '../util/plugin-paths.js';
23
+ const TAG = 'reentry-tracker';
24
+ const DEFAULT_PLUGIN_ID = 'reefclaw-paper-trading';
25
+ const STATE_FILENAME = 'reentry-log.json';
26
+ const MAX_RECORDS = 300;
27
+ /** Signal-bar duration for a strategy/setup name. Name-suffix inference:
28
+ * `..._4h` → 240min, `..._1d` / daily → 1440min, `..._Nh` → N×60. Unknown
29
+ * shapes default to 60min so short-TF strategies are never over-warned. */
30
+ export function strategyBarMinutes(name) {
31
+ if (!name)
32
+ return 60;
33
+ const lower = name.toLowerCase();
34
+ const hourMatch = lower.match(/_(\d{1,2})h\b|_(\d{1,2})h_|_(\d{1,2})h$/);
35
+ if (hourMatch) {
36
+ const h = Number(hourMatch[1] ?? hourMatch[2] ?? hourMatch[3]);
37
+ if (Number.isFinite(h) && h > 0)
38
+ return h * 60;
39
+ }
40
+ if (/_1d\b|_1d_|_1d$|daily/.test(lower))
41
+ return 1440;
42
+ return 60;
43
+ }
44
+ export class ReentryTracker {
45
+ records = [];
46
+ filePath;
47
+ dir;
48
+ constructor(pluginId, opts) {
49
+ const base = resolvePluginsBaseDir(opts?.basePath);
50
+ this.dir = join(base, pluginId ?? DEFAULT_PLUGIN_ID);
51
+ this.filePath = join(this.dir, STATE_FILENAME);
52
+ try {
53
+ if (!existsSync(this.dir))
54
+ mkdirSync(this.dir, { recursive: true });
55
+ if (existsSync(this.filePath)) {
56
+ const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8'));
57
+ if (Array.isArray(parsed?.records)) {
58
+ this.records = parsed.records.filter((r) => typeof r?.symbol === 'string' && Number.isFinite(r?.closedAtMs));
59
+ }
60
+ }
61
+ }
62
+ catch (err) {
63
+ logger.warn(TAG, `load failed (${formatError(err)}) — starting empty`);
64
+ this.records = [];
65
+ }
66
+ }
67
+ /** Record a position exit. Never throws (best-effort persistence). */
68
+ recordExit(record) {
69
+ try {
70
+ this.records.push({ ...record, symbol: normalizeBracketSymbol(record.symbol) });
71
+ if (this.records.length > MAX_RECORDS) {
72
+ this.records = this.records.slice(-MAX_RECORDS);
73
+ }
74
+ this.persist();
75
+ }
76
+ catch (err) {
77
+ logger.warn(TAG, `recordExit failed: ${formatError(err)}`);
78
+ }
79
+ }
80
+ /** Most recent exit for (symbol[, setup]). A setup-specific record wins over
81
+ * a symbol-only match so multi-strategy books get precise cautions. */
82
+ lastExit(symbol, setupType) {
83
+ const key = normalizeBracketSymbol(symbol);
84
+ let bySetup;
85
+ let bySymbol;
86
+ for (let i = this.records.length - 1; i >= 0; i--) {
87
+ const r = this.records[i];
88
+ if (r.symbol !== key)
89
+ continue;
90
+ if (!bySymbol)
91
+ bySymbol = r;
92
+ if (setupType && r.setupType === setupType) {
93
+ bySetup = r;
94
+ break;
95
+ }
96
+ if (!setupType)
97
+ break;
98
+ }
99
+ return bySetup ?? bySymbol;
100
+ }
101
+ /** Structured caution when (symbol, strategy) was already traded within the
102
+ * current signal bar. Undefined = no caution. Pure indication (issue #204):
103
+ * the agent decides; nothing here blocks an order. */
104
+ cautionFor(symbol, strategy, nowMs = Date.now()) {
105
+ const last = this.lastExit(symbol, strategy);
106
+ if (!last)
107
+ return undefined;
108
+ const barMin = strategyBarMinutes(strategy ?? last.setupType);
109
+ const agoMin = Math.round((nowMs - last.closedAtMs) / 60_000);
110
+ if (agoMin < 0 || agoMin > barMin)
111
+ return undefined;
112
+ const lossNote = last.wasLoss ? ' at a LOSS' : '';
113
+ return (`already traded this signal bar: exited a ${last.side} on this setup ${agoMin}m ago${lossNote} ` +
114
+ `(bar=${barMin}m). The validated backtest takes ONE trade per signal bar — re-enter only if ` +
115
+ `you can name what NEW information arrived since that exit.`);
116
+ }
117
+ /** Test seam. */
118
+ getRecords() {
119
+ return this.records;
120
+ }
121
+ persist() {
122
+ const payload = { schemaVersion: 1, records: this.records };
123
+ const tmp = `${this.filePath}.tmp`;
124
+ writeFileSync(tmp, JSON.stringify(payload), 'utf-8');
125
+ renameSync(tmp, this.filePath);
126
+ }
127
+ }
@@ -160,11 +160,20 @@ register('price_sweep', (ctx, params, _dir, condCtx) => {
160
160
  sweepLevel = nearestHigh;
161
161
  }
162
162
  }
163
- // Store in shared context for entry/stop rules and direction
163
+ // Store in shared context for entry/stop rules and direction.
164
+ // sweptDirection is written non-destructively, like the funding_extreme /
165
+ // cvd_divergence setters: only when a sweep actually resolved a side. An
166
+ // unconditional write (including null on a no-sweep bar) clobbered a
167
+ // direction set by an earlier condition in the same evaluation, so a
168
+ // from_sweep/from_funding strategy listing price_sweep AFTER another
169
+ // setter could never fire.
164
170
  condCtx.sweepLevel = sweepLevel;
165
171
  condCtx.recentLow = recentLow;
166
172
  condCtx.recentHigh = recentHigh;
167
- condCtx.sweptDirection = sweptLow ? 'LONG' : sweptHigh ? 'SHORT' : null;
173
+ if (sweptLow)
174
+ condCtx.sweptDirection = 'LONG';
175
+ else if (sweptHigh)
176
+ condCtx.sweptDirection = 'SHORT';
168
177
  const met = sweptLow || sweptHigh;
169
178
  return {
170
179
  met,
@@ -170,15 +170,25 @@ export function adaptStrategy(config, gateNamespace) {
170
170
  const { conditions: pass1, condCtx } = evaluateConditions(config.conditions, ectx, null);
171
171
  // Determine direction
172
172
  const direction = resolveDirection(config.directionRule, ectx, condCtx);
173
- // Pass 2: re-evaluate direction-sensitive conditions now that we know direction
174
- // (orderbook_imbalance and funding_contrarian behave differently per direction)
175
- const directionSensitive = new Set(['orderbook_imbalance', 'funding_contrarian', 'funding_extreme_skip', 'funding_position_ok', 'return_momentum']);
176
- const hasDirSensitive = config.conditions.some(c => directionSensitive.has(c.type));
173
+ // Pass 2: once the direction is known, re-evaluate the FULL condition
174
+ // array with it. Many registry conditions branch on `direction`
175
+ // (orderbook_imbalance, funding_contrarian, macd_crossover,
176
+ // bollinger_breakout, vwap_position, stoch_rsi_extreme, ichimoku_cloud,
177
+ // supertrend_direction, …) and their pass-1 result was computed against
178
+ // direction=null — typically the permissive either-side branch. A
179
+ // hand-curated allowlist here (the pre-fix 5-type set) silently drifted
180
+ // out of sync with the registry, so non-listed direction-dependent
181
+ // conditions kept their permissive pass-1 result forever (e.g. a
182
+ // fixed_long strategy's macd_crossover accepted a BEARISH crossover).
183
+ // Pass 2 always evaluated the whole array anyway — use it wholesale.
184
+ // Direction-insensitive conditions are pure functions of (ctx, params)
185
+ // and return identical results in both passes; entry/stop computation
186
+ // below deliberately keeps pass 1's condCtx (setter values don't depend
187
+ // on direction).
177
188
  let finalConditions = pass1;
178
- if (direction && hasDirSensitive) {
189
+ if (direction) {
179
190
  const { conditions: pass2 } = evaluateConditions(config.conditions, ectx, direction);
180
- // Merge: use pass2 results for direction-sensitive, pass1 for others
181
- finalConditions = pass1.map((c, i) => directionSensitive.has(config.conditions[i].type) ? pass2[i] : c);
191
+ finalConditions = pass2;
182
192
  }
183
193
  const allMet = finalConditions.every(c => c.met);
184
194
  let trade;
@@ -79,6 +79,18 @@ export declare class ExchangeSimulator extends EventEmitter {
79
79
  * reload never regresses the agent's give-back signal. */
80
80
  replaceState(newState: SimulatorState): void;
81
81
  getState(): SimulatorState;
82
+ /** Default max quote age a NEW-exposure fill may price against (issue #202).
83
+ * Generous vs the 5s paper feed cadence; env RC_PAPER_MAX_QUOTE_AGE_MS
84
+ * overrides. */
85
+ static readonly DEFAULT_MAX_QUOTE_AGE_MS = 45000;
86
+ private maxQuoteAgeMs;
87
+ /** Quote age from the ticker's own timestamp. A missing/invalid timestamp
88
+ * reads as age 0 (fail-open — the guard cannot fire on it). */
89
+ private quoteAgeMs;
90
+ /** Reject fills that would OPEN or GROW exposure on a stale quote. Risk-
91
+ * reducing fills (closes/partials against an existing position) are always
92
+ * allowed — blocking a close on a broken feed compounds the risk. */
93
+ private assertQuoteFresh;
82
94
  private shouldFillLimit;
83
95
  private executeMarketFill;
84
96
  private executeLimitFill;
@@ -286,11 +286,18 @@ export class ExchangeSimulator extends EventEmitter {
286
286
  if (!ticker) {
287
287
  throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
288
288
  }
289
+ // Stale-quote guard (issue #202): a market fill priced off an aged quote
290
+ // books phantom P&L the moment a fresh price arrives (measured up to
291
+ // ~0.6% off on the HL paper book — a real −$22.99 in 4 seconds). Reject
292
+ // instead; the agent refreshes via fetch_ticker and retries.
293
+ this.assertQuoteFresh(symbol, ticker);
289
294
  return this.executeMarketFill(order, ticker.last, metadata);
290
295
  }
291
- // Limit order — check if it crosses the current price
296
+ // Limit order — check if it crosses the current price. A stale quote must
297
+ // not price an immediate cross-fill (same hazard as market fills); the
298
+ // order RESTS instead and fills on the next fresh tick via updateTicker.
292
299
  const ticker = this.lastTicker.get(symbol);
293
- if (ticker && this.shouldFillLimit(order, ticker.last)) {
300
+ if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
294
301
  return this.executeLimitFill(order, ticker.last, metadata);
295
302
  }
296
303
  // Limit order doesn't cross — add to open orders. Pin the entry metadata
@@ -548,6 +555,45 @@ export class ExchangeSimulator extends EventEmitter {
548
555
  };
549
556
  }
550
557
  // ---- Private helpers ----
558
+ /** Default max quote age a NEW-exposure fill may price against (issue #202).
559
+ * Generous vs the 5s paper feed cadence; env RC_PAPER_MAX_QUOTE_AGE_MS
560
+ * overrides. */
561
+ static DEFAULT_MAX_QUOTE_AGE_MS = 45_000;
562
+ maxQuoteAgeMs() {
563
+ const raw = Number(process.env.RC_PAPER_MAX_QUOTE_AGE_MS);
564
+ return Number.isFinite(raw) && raw > 0 ? raw : ExchangeSimulator.DEFAULT_MAX_QUOTE_AGE_MS;
565
+ }
566
+ /** Quote age from the ticker's own timestamp. A missing/invalid timestamp
567
+ * reads as age 0 (fail-open — the guard cannot fire on it). */
568
+ quoteAgeMs(ticker) {
569
+ const ts = ticker.timestamp;
570
+ if (!Number.isFinite(ts) || ts <= 0)
571
+ return 0;
572
+ return Math.max(0, Date.now() - ts);
573
+ }
574
+ /** Reject fills that would OPEN or GROW exposure on a stale quote. Risk-
575
+ * reducing fills (closes/partials against an existing position) are always
576
+ * allowed — blocking a close on a broken feed compounds the risk. */
577
+ assertQuoteFresh(symbol, ticker) {
578
+ const age = this.quoteAgeMs(ticker);
579
+ const max = this.maxQuoteAgeMs();
580
+ if (age <= max)
581
+ return;
582
+ const pos = this.state.positions.find(p => p.symbol === symbol);
583
+ // The order side reaching here is the one being filled — derive reduce vs
584
+ // grow from the position side at the call site instead? The market path
585
+ // calls this before fill with the order side unavailable; use position
586
+ // presence: any existing position keeps closes flowing, and a stale-quote
587
+ // scale-in on an open position is bounded by the feed refreshing open
588
+ // symbols every 5s (only NEW symbols go minutes without a tick).
589
+ if (pos) {
590
+ logger.warn(TAG, `Stale quote for ${symbol} (${Math.round(age / 1000)}s old) — allowing fill because an open ` +
591
+ `position exists (risk-reducing paths are never blocked)`);
592
+ return;
593
+ }
594
+ throw new Error(`Order rejected: market data for ${symbol} is stale (${Math.round(age / 1000)}s old, ` +
595
+ `max ${Math.round(max / 1000)}s). Refresh the price (fetch_ticker) and retry.`);
596
+ }
551
597
  shouldFillLimit(order, currentPrice) {
552
598
  if (order.price === null)
553
599
  return false;
@@ -567,6 +613,12 @@ export class ExchangeSimulator extends EventEmitter {
567
613
  metadata,
568
614
  };
569
615
  const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
616
+ // Observability for issue #202: stamp the quote's age onto the fill's
617
+ // execution-quality record so staleness is visible in trade history.
618
+ const tickerAtFill = this.lastTicker.get(order.symbol);
619
+ if (result.executionQuality && tickerAtFill) {
620
+ result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
621
+ }
570
622
  const ccxtOrder = this.applyFillResult(result);
571
623
  const eq = result.executionQuality;
572
624
  if (eq) {
@@ -613,7 +665,25 @@ export class ExchangeSimulator extends EventEmitter {
613
665
  fillPrice: result.order.average ?? 0,
614
666
  fee: result.order.fee.cost,
615
667
  });
616
- return this.toCcxtOrder(result.order);
668
+ const ccxtOrder = this.toCcxtOrder(result.order);
669
+ // Reducing fills carry a Trade record — attach its engine-exact economics
670
+ // (net-of-fee P&L + both fee legs) so the journal close capture records
671
+ // NET, matching the live book's convention (issue #201). The wallet/NAV
672
+ // was already net; only the journal was blind to fees.
673
+ if (result.trade) {
674
+ const t = result.trade;
675
+ ccxtOrder.info = {
676
+ ...(ccxtOrder.info ?? {}),
677
+ paperTrade: {
678
+ grossRealizedPnl: t.realizedPnl,
679
+ netRealizedPnl: t.realizedPnl - (t.fee + (t.openFee ?? 0)),
680
+ openFee: t.openFee ?? 0,
681
+ closeFee: t.fee,
682
+ ...(typeof t.metadata?.setupType === 'string' ? { setupType: t.metadata.setupType } : {}),
683
+ },
684
+ };
685
+ }
686
+ return ccxtOrder;
617
687
  }
618
688
  updatePosition(symbol, newPosition) {
619
689
  const idx = this.state.positions.findIndex(p => p.symbol === symbol);
@@ -38,6 +38,10 @@ export interface ExecutionQuality {
38
38
  feePaid: number;
39
39
  bookLevelsConsumed: number;
40
40
  bookDepthAvailable: boolean;
41
+ /** Age of the quote the fill priced against (fill time − ticker.timestamp).
42
+ * Surfaces feed staleness (issue #202); absent on records from before the
43
+ * field existed or when the ticker carried no usable timestamp. */
44
+ quoteAgeMs?: number;
41
45
  }
42
46
  export interface SimulatorState {
43
47
  version: string;
@@ -80,6 +80,8 @@ OpenClaw can install the ReefClaw plugin through its own owner chat command —
80
80
  2. Tell the user to type `/restart` in this chat.
81
81
  3. Then the user types: `/plugins install clawhub:@reefclaw/openclaw-plugin` — the install restarts OpenClaw automatically; the bundled connector starts by itself and the dashboard flips to Connected.
82
82
 
83
+ If that install is **cancelled asking for a ClawHub risk acknowledgement** (OpenClaw 2026.7+ does this; older versions never ask and reject the flag), rerun the identical command with the acknowledgement appended: `/plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk`. If it is still refused, use the `npx @reefclaw/connect` fallback — it does not go through ClawHub.
84
+
83
85
  Only fall back to `npx @reefclaw/connect` (below) when the chat install is unavailable.
84
86
 
85
87
  ## If you cannot run the installer (elevated exec disabled)
@@ -1,4 +1,25 @@
1
1
  import type { RealizationRule } from '../simulator/types.js';
2
+ /** Minimum pre-entry stop distance in basis points (the SKILL v2.10.0
3
+ * discipline floor's fixed component; the ATR component is enforced by the
4
+ * live bracket path where ATR is authoritative). */
5
+ export declare const MIN_STOP_DISTANCE_BPS = 30;
6
+ export interface ProtectiveGeometryArgs {
7
+ side: 'buy' | 'sell';
8
+ /** Reference entry price: the limit price, or last trade for market orders. */
9
+ refPrice: number;
10
+ stopPrice?: number;
11
+ invalidationPrice?: number;
12
+ targetPrice?: number;
13
+ }
14
+ /** Validate stop / invalidation / target geometry against the entry side.
15
+ *
16
+ * Before this existed the wrong-side check ran ONLY on the wave9 path
17
+ * (create-order.ts) — a generic short entered with its stop BELOW entry
18
+ * passed validation and was closed by the stop-watcher seconds later
19
+ * (7 sub-5-minute kills on the 2026-07 HL soak, issue #200). Returns an
20
+ * error string, or null when the geometry is sound. Fields left undefined
21
+ * are not judged (stop-required policy stays with the caller). */
22
+ export declare function validateProtectiveGeometry(args: ProtectiveGeometryArgs): string | null;
2
23
  /** The five legitimate reasons to manually close an otherwise-bracketed
3
24
  * position (see SKILL.md v2.10.0 Position Management Discipline). Any other
4
25
  * value is rejected. */
@@ -13,6 +13,64 @@
13
13
  // live mode.
14
14
  // Tested in: assessment-validation.test.ts.
15
15
  import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
16
+ // ---- protective geometry (create_order, issue #200) ----
17
+ /** Minimum pre-entry stop distance in basis points (the SKILL v2.10.0
18
+ * discipline floor's fixed component; the ATR component is enforced by the
19
+ * live bracket path where ATR is authoritative). */
20
+ export const MIN_STOP_DISTANCE_BPS = 30;
21
+ /** Validate stop / invalidation / target geometry against the entry side.
22
+ *
23
+ * Before this existed the wrong-side check ran ONLY on the wave9 path
24
+ * (create-order.ts) — a generic short entered with its stop BELOW entry
25
+ * passed validation and was closed by the stop-watcher seconds later
26
+ * (7 sub-5-minute kills on the 2026-07 HL soak, issue #200). Returns an
27
+ * error string, or null when the geometry is sound. Fields left undefined
28
+ * are not judged (stop-required policy stays with the caller). */
29
+ export function validateProtectiveGeometry(args) {
30
+ const { side, refPrice } = args;
31
+ if (!Number.isFinite(refPrice) || refPrice <= 0)
32
+ return null; // no reference — cannot judge
33
+ const dirWord = side === 'buy' ? 'long' : 'short';
34
+ if (args.stopPrice !== undefined) {
35
+ const stop = args.stopPrice;
36
+ if (!Number.isFinite(stop) || stop <= 0) {
37
+ return 'stopPrice must be a positive finite number.';
38
+ }
39
+ if ((side === 'buy' && stop >= refPrice) || (side === 'sell' && stop <= refPrice)) {
40
+ return (`stopPrice ${stop} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: a ${dirWord}'s ` +
41
+ `protective stop must be ${side === 'buy' ? 'below' : 'above'} entry. A wrong-side stop is ` +
42
+ `instantly "breached" and the stop-watcher closes the position seconds after entry.`);
43
+ }
44
+ const distanceBps = (Math.abs(refPrice - stop) / refPrice) * 10_000;
45
+ if (distanceBps < MIN_STOP_DISTANCE_BPS) {
46
+ return (`stopPrice ${stop} is only ${distanceBps.toFixed(1)}bps from entry ${refPrice} — below the ` +
47
+ `${MIN_STOP_DISTANCE_BPS}bps discipline floor. Place the stop at a structural level ` +
48
+ `(≥ max(0.3×ATR, ${MIN_STOP_DISTANCE_BPS}bps) from entry).`);
49
+ }
50
+ }
51
+ if (args.invalidationPrice !== undefined) {
52
+ const inv = args.invalidationPrice;
53
+ if (!Number.isFinite(inv) || inv <= 0) {
54
+ return 'invalidation_price must be a positive finite number.';
55
+ }
56
+ if ((side === 'buy' && inv >= refPrice) || (side === 'sell' && inv <= refPrice)) {
57
+ return (`invalidation_price ${inv} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: ` +
58
+ `invalidation must be ${side === 'buy' ? 'below' : 'above'} entry (it marks where the thesis ` +
59
+ `is WRONG, not where it profits). Fix the pinned plan before entering.`);
60
+ }
61
+ }
62
+ if (args.targetPrice !== undefined) {
63
+ const target = args.targetPrice;
64
+ if (!Number.isFinite(target) || target <= 0) {
65
+ return 'target_price must be a positive finite number.';
66
+ }
67
+ if ((side === 'buy' && target <= refPrice) || (side === 'sell' && target >= refPrice)) {
68
+ return (`target_price ${target} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: ` +
69
+ `the profit target must be ${side === 'buy' ? 'above' : 'below'} entry.`);
70
+ }
71
+ }
72
+ return null;
73
+ }
16
74
  // ---- close_position ----
17
75
  /** The five legitimate reasons to manually close an otherwise-bracketed
18
76
  * position (see SKILL.md v2.10.0 Position Management Discipline). Any other