@reefclaw/connect 0.1.36 → 0.1.38

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 (50) hide show
  1. package/assets/bridge/gateway/gateway-ws-client.d.ts +2 -0
  2. package/assets/bridge/gateway/gateway-ws-client.js +6 -0
  3. package/assets/bridge/heartbeat-runs-state.d.ts +16 -0
  4. package/assets/bridge/heartbeat-runs-state.js +58 -0
  5. package/assets/bridge/heartbeat-runs.d.ts +99 -0
  6. package/assets/bridge/heartbeat-runs.js +300 -0
  7. package/assets/bridge/heartbeat-transcript.d.ts +209 -0
  8. package/assets/bridge/heartbeat-transcript.js +688 -0
  9. package/assets/bridge/index.js +31 -0
  10. package/assets/bridge/model-health.d.ts +37 -0
  11. package/assets/bridge/model-health.js +97 -0
  12. package/assets/bridge/provider.d.ts +5 -1
  13. package/assets/bridge/providers/gateway.d.ts +25 -1
  14. package/assets/bridge/providers/gateway.js +167 -2
  15. package/assets/bridge/providers/mock.js +1 -0
  16. package/assets/bridge/shock-wake.d.ts +80 -0
  17. package/assets/bridge/shock-wake.js +291 -0
  18. package/assets/bridge/types.d.ts +41 -0
  19. package/assets/plugin/config/agent-config-client.d.ts +3 -1
  20. package/assets/plugin/config/agent-config-client.js +4 -0
  21. package/assets/plugin/config/gate-store.d.ts +3 -0
  22. package/assets/plugin/config/gate-store.js +11 -2
  23. package/assets/plugin/config/loss-streak-config.d.ts +2 -0
  24. package/assets/plugin/config/loss-streak-config.js +33 -0
  25. package/assets/plugin/config/plugin-config-io.d.ts +19 -0
  26. package/assets/plugin/config/reentry-cooldown-config.d.ts +7 -0
  27. package/assets/plugin/config/reentry-cooldown-config.js +59 -0
  28. package/assets/plugin/index.js +5 -0
  29. package/assets/plugin/ingest/position-auto-capture.js +35 -2
  30. package/assets/plugin/openclaw.plugin.json +1 -1
  31. package/assets/plugin/portfolio/directional-scoreboard.d.ts +17 -0
  32. package/assets/plugin/portfolio/directional-scoreboard.js +71 -0
  33. package/assets/plugin/portfolio/reentry-tracker.d.ts +38 -1
  34. package/assets/plugin/portfolio/reentry-tracker.js +49 -0
  35. package/assets/plugin/signals/change-of-character.d.ts +38 -0
  36. package/assets/plugin/signals/change-of-character.js +93 -0
  37. package/assets/plugin/signals/types.js +1 -1
  38. package/assets/plugin/simulator/types.d.ts +11 -0
  39. package/assets/plugin/strategy/evaluator.d.ts +4 -0
  40. package/assets/plugin/tools/create-order.js +72 -2
  41. package/assets/plugin/tools/reentry-cooldown.d.ts +33 -0
  42. package/assets/plugin/tools/reentry-cooldown.js +74 -0
  43. package/assets/plugin/tools/scan-pairs.d.ts +7 -0
  44. package/assets/plugin/tools/scan-pairs.js +47 -0
  45. package/assets/shared/signals/change-of-character.d.ts +38 -0
  46. package/assets/shared/signals/change-of-character.js +86 -0
  47. package/assets/skill/SKILL.md +2 -2
  48. package/dist/cli.js +11 -2
  49. package/dist/plugin.js +70 -28
  50. package/package.json +1 -1
@@ -0,0 +1,17 @@
1
+ import type { ReentryExitRecord } from './reentry-tracker.js';
2
+ export interface ScoreboardInputs {
3
+ /** Tracked open positions on the current book (state store, not exchange). */
4
+ openPositions: Array<{
5
+ side: 'long' | 'short';
6
+ }>;
7
+ /** Tracker exit records, oldest first (as stored). */
8
+ exitRecords: readonly ReentryExitRecord[];
9
+ book: 'paper' | 'live';
10
+ /** e.g. "BTC 4h −2.4% (−2.3×ATR)" — from the leader fact's changeOfCharacter. */
11
+ tapeLine?: string;
12
+ /** How many recent closes to summarize. */
13
+ lastN?: number;
14
+ }
15
+ /** Build the scoreboard line, or undefined when there is nothing to show
16
+ * (no open positions AND no recent closes on this book). */
17
+ export declare function buildDirectionalScoreboard(inputs: ScoreboardInputs): string | undefined;
@@ -0,0 +1,71 @@
1
+ // Directional scoreboard — WS2 of docs/MARKET_ADAPTIVITY_PLAN.md.
2
+ //
3
+ // One compact line of counter-evidence attached to the entry funnel
4
+ // (scan_pairs): the book's current directional tilt, how the last N closes
5
+ // per direction actually went, and what the market-leader tape did — the
6
+ // three facts an anchored agent never sees together. Pure indication: it
7
+ // never blocks or vetoes anything (that is the refuted-ledger's territory).
8
+ //
9
+ // Data sources are all local + free: the PositionStateStore (tracked open
10
+ // positions — no exchange round-trip, so no HL address-budget cost) and the
11
+ // ReentryTracker exit records (persisted; realizedR/realizedPnl where a
12
+ // source had them). The tape line comes from the market leader's
13
+ // change-of-character block on the intel facts the caller already fetched.
14
+ const emptyStats = () => ({
15
+ wins: 0, losses: 0, unknown: 0, pnl: 0, pnlKnown: false, rSum: 0, rKnown: false,
16
+ });
17
+ function fmtDir(label, s) {
18
+ const n = s.wins + s.losses + s.unknown;
19
+ if (n === 0)
20
+ return undefined;
21
+ let out = `${label} ${s.wins}W/${s.losses}L${s.unknown > 0 ? `/${s.unknown}?` : ''}`;
22
+ if (s.rKnown)
23
+ out += ` ${s.rSum >= 0 ? '+' : ''}${Math.round(s.rSum * 100) / 100}R`;
24
+ else if (s.pnlKnown) {
25
+ const abs = Math.round(Math.abs(s.pnl) * 100) / 100;
26
+ out += ` ${s.pnl >= 0 ? '+' : '-'}$${abs}`;
27
+ }
28
+ return out;
29
+ }
30
+ /** Build the scoreboard line, or undefined when there is nothing to show
31
+ * (no open positions AND no recent closes on this book). */
32
+ export function buildDirectionalScoreboard(inputs) {
33
+ const lastN = inputs.lastN ?? 8;
34
+ const longs = inputs.openPositions.filter((p) => p.side === 'long').length;
35
+ const shorts = inputs.openPositions.filter((p) => p.side === 'short').length;
36
+ // Newest-first walk over this book's records (legacy untagged match either).
37
+ const recent = [];
38
+ for (let i = inputs.exitRecords.length - 1; i >= 0 && recent.length < lastN; i--) {
39
+ const r = inputs.exitRecords[i];
40
+ if (r.mode && r.mode !== inputs.book)
41
+ continue;
42
+ recent.push(r);
43
+ }
44
+ if (longs + shorts === 0 && recent.length === 0)
45
+ return undefined;
46
+ const stats = { long: emptyStats(), short: emptyStats() };
47
+ for (const r of recent) {
48
+ const s = stats[r.side];
49
+ if (r.wasLoss === true)
50
+ s.losses++;
51
+ else if (r.wasLoss === false)
52
+ s.wins++;
53
+ else
54
+ s.unknown++;
55
+ if (typeof r.realizedPnl === 'number' && Number.isFinite(r.realizedPnl)) {
56
+ s.pnl += r.realizedPnl;
57
+ s.pnlKnown = true;
58
+ }
59
+ if (typeof r.realizedR === 'number' && Number.isFinite(r.realizedR)) {
60
+ s.rSum += r.realizedR;
61
+ s.rKnown = true;
62
+ }
63
+ }
64
+ const parts = [`book: ${longs}L/${shorts}S open`];
65
+ const closeBits = [fmtDir('longs', stats.long), fmtDir('shorts', stats.short)].filter((v) => v !== undefined);
66
+ if (closeBits.length > 0)
67
+ parts.push(`last ${recent.length} closes: ${closeBits.join(', ')}`);
68
+ if (inputs.tapeLine)
69
+ parts.push(`tape: ${inputs.tapeLine}`);
70
+ return parts.join(' · ');
71
+ }
@@ -4,8 +4,24 @@ export interface ReentryExitRecord {
4
4
  /** setup_type / strategy name from the entry metadata, when known. */
5
5
  setupType?: string;
6
6
  side: 'long' | 'short';
7
- /** Whether the closed trade realized a loss (drives the stronger caution). */
7
+ /** Whether the closed trade realized a loss (drives the stronger caution
8
+ * and arms the reentryCooldown gate). */
8
9
  wasLoss?: boolean;
10
+ /** Where the wasLoss sign came from, for auditing sign-accuracy before the
11
+ * cooldown gate is promoted past shadow: engine-exact ('paper_engine',
12
+ * 'ws_fill') vs the agent's own close assessment ('assessment_r'). */
13
+ lossSource?: 'paper_engine' | 'assessment_r' | 'ws_fill';
14
+ /** Trading book at close time. The cooldown gate filters to the current
15
+ * book so a paper loss can't cool down a live entry after a mode flip.
16
+ * Legacy records (absent) match either book — they age out of any
17
+ * realistic cooldown window within the hour anyway. */
18
+ mode?: 'paper' | 'live';
19
+ /** Realized R at close where a source had it (agent assessment on live
20
+ * tool-closes). Feeds the WS2 directional scoreboard. */
21
+ realizedR?: number;
22
+ /** Realized net PnL (quote ccy) where engine/exchange-exact (paper engine,
23
+ * WS bracket fills). Feeds the WS2 directional scoreboard. */
24
+ realizedPnl?: number;
9
25
  closedAtMs: number;
10
26
  }
11
27
  /** Signal-bar duration for a strategy/setup name. Name-suffix inference:
@@ -26,6 +42,27 @@ export declare class ReentryTracker {
26
42
  /** Most recent exit for (symbol[, setup]). A setup-specific record wins over
27
43
  * a symbol-only match so multi-strategy books get precise cautions. */
28
44
  lastExit(symbol: string, setupType?: string): ReentryExitRecord | undefined;
45
+ /** Most recent LOSSY exit for `symbol` within `windowMs`, or undefined.
46
+ * Feeds the reentryCooldown gate (evidence 2026-09-02: 58 re-entries within
47
+ * 60m of a same-symbol losing close ran −0.077R mean vs +0.090R baseline).
48
+ * Any direction, any setup — the measured pathology is symbol churn, not
49
+ * setup churn. `mode` filters to the current trading book; records without
50
+ * a mode tag (pre-upgrade) match either book. */
51
+ lastLossyExit(symbol: string, windowMs: number, opts?: {
52
+ mode?: 'paper' | 'live';
53
+ nowMs?: number;
54
+ }): ReentryExitRecord | undefined;
55
+ /** Trailing consecutive LOSSY closes on `book`, newest first, across ALL
56
+ * symbols — the live feed for the graduated loss-streak sizing brake in
57
+ * preTradeRiskCheck (WS1, docs/MARKET_ADAPTIVITY_PLAN.md §3; live was fed a
58
+ * hardcoded 0 since the beginning while paper computed it from engine
59
+ * history). Semantics: a WIN breaks the streak, and so does an
60
+ * UNKNOWN-outcome record (wasLoss undefined) — unknown is never counted as
61
+ * a loss, and stopping there under-counts, which only makes the brake
62
+ * gentler. Records from the OTHER book are skipped entirely (a paper loss
63
+ * neither extends nor breaks a live streak); legacy untagged records match
64
+ * either book, same as lastLossyExit. */
65
+ consecutiveLosses(book: 'paper' | 'live'): number;
29
66
  /** Structured caution when (symbol, strategy) was already traded within the
30
67
  * current signal bar. Undefined = no caution. Pure indication (issue #204):
31
68
  * the agent decides; nothing here blocks an order. */
@@ -98,6 +98,55 @@ export class ReentryTracker {
98
98
  }
99
99
  return bySetup ?? bySymbol;
100
100
  }
101
+ /** Most recent LOSSY exit for `symbol` within `windowMs`, or undefined.
102
+ * Feeds the reentryCooldown gate (evidence 2026-09-02: 58 re-entries within
103
+ * 60m of a same-symbol losing close ran −0.077R mean vs +0.090R baseline).
104
+ * Any direction, any setup — the measured pathology is symbol churn, not
105
+ * setup churn. `mode` filters to the current trading book; records without
106
+ * a mode tag (pre-upgrade) match either book. */
107
+ lastLossyExit(symbol, windowMs, opts) {
108
+ const key = normalizeBracketSymbol(symbol);
109
+ const now = opts?.nowMs ?? Date.now();
110
+ for (let i = this.records.length - 1; i >= 0; i--) {
111
+ const r = this.records[i];
112
+ if (r.symbol !== key)
113
+ continue;
114
+ if (r.wasLoss !== true)
115
+ continue;
116
+ if (opts?.mode && r.mode && r.mode !== opts.mode)
117
+ continue;
118
+ const ageMs = now - r.closedAtMs;
119
+ // Tolerate small clock skew on exchange timestamps (up to 60s in the
120
+ // future still counts as "just closed").
121
+ if (ageMs >= -60_000 && ageMs <= windowMs)
122
+ return r;
123
+ }
124
+ return undefined;
125
+ }
126
+ /** Trailing consecutive LOSSY closes on `book`, newest first, across ALL
127
+ * symbols — the live feed for the graduated loss-streak sizing brake in
128
+ * preTradeRiskCheck (WS1, docs/MARKET_ADAPTIVITY_PLAN.md §3; live was fed a
129
+ * hardcoded 0 since the beginning while paper computed it from engine
130
+ * history). Semantics: a WIN breaks the streak, and so does an
131
+ * UNKNOWN-outcome record (wasLoss undefined) — unknown is never counted as
132
+ * a loss, and stopping there under-counts, which only makes the brake
133
+ * gentler. Records from the OTHER book are skipped entirely (a paper loss
134
+ * neither extends nor breaks a live streak); legacy untagged records match
135
+ * either book, same as lastLossyExit. */
136
+ consecutiveLosses(book) {
137
+ let n = 0;
138
+ for (let i = this.records.length - 1; i >= 0; i--) {
139
+ const r = this.records[i];
140
+ if (r.mode && r.mode !== book)
141
+ continue;
142
+ if (r.wasLoss === true) {
143
+ n++;
144
+ continue;
145
+ }
146
+ break; // win or unknown outcome ends the trailing streak
147
+ }
148
+ return n;
149
+ }
101
150
  /** Structured caution when (symbol, strategy) was already traded within the
102
151
  * current signal bar. Undefined = no caution. Pure indication (issue #204):
103
152
  * the agent decides; nothing here blocks an order. */
@@ -0,0 +1,38 @@
1
+ /** 30m move ≥ this ×ATR(1h) ⇒ 'shock'. */
2
+ export declare const SHOCK_ATR_MULT_30M = 2;
3
+ /** 4h tape opposing a directional regime label by ≥ this ×ATR(1h) ⇒ 'regime_tape_disagreement'. */
4
+ export declare const DISAGREEMENT_ATR_MULT_4H = 2;
5
+ export type ChangeOfCharacterFlag = 'shock' | 'regime_tape_disagreement';
6
+ export interface ChangeOfCharacter {
7
+ /** |price now − close ~30m ago| ÷ ATR(1h). */
8
+ shock30mAtr: number;
9
+ /** (max high − min low) over the last ~30m ÷ ATR(1h) — catches a spike-and-revert the close-to-close move misses. */
10
+ range30mAtr: number;
11
+ /** % return vs the close ~1h ago. */
12
+ return1hPct: number;
13
+ /** % return vs the close ~4h ago. */
14
+ return4hPct: number;
15
+ /** Signed (price now − close ~4h ago) ÷ ATR(1h) — the tape the regime label must answer to. */
16
+ tape4hAtr: number;
17
+ /** ATR(1h) as % of price — the volatility yardstick the multiples are in. */
18
+ atrPct: number;
19
+ flags: ChangeOfCharacterFlag[];
20
+ /** Agent-facing one-liner. Present ONLY when a flag fired — silence stays silent. */
21
+ summary?: string;
22
+ }
23
+ interface BarLike {
24
+ high: number;
25
+ low: number;
26
+ close: number;
27
+ }
28
+ export interface ChangeOfCharacterInputs {
29
+ /** 5m bars, oldest first (≥7 required). */
30
+ ohlcv5m: BarLike[];
31
+ /** 1h bars, oldest first (≥5 required). */
32
+ ohlcv1h: BarLike[];
33
+ atr14: number;
34
+ currentPrice: number;
35
+ regime: string;
36
+ }
37
+ export declare function computeChangeOfCharacter(inputs: ChangeOfCharacterInputs): ChangeOfCharacter | undefined;
38
+ export {};
@@ -0,0 +1,93 @@
1
+ // ⚠️ GENERATED FILE — DO NOT EDIT.
2
+ // Canonical source of truth: shared/src/signals/change-of-character.ts
3
+ // Regenerate: node scripts/sync-shared-code.mjs (enforced by shared-code-sync.test.ts)
4
+ //
5
+ // This copy exists because this package builds with tsc and deploys as a
6
+ // self-contained tree that strips workspace deps, so it cannot import
7
+ // @reefclaw/shared runtime code across the deploy boundary.
8
+ // Change-of-character detection — WS2 of docs/MARKET_ADAPTIVITY_PLAN.md.
9
+ //
10
+ // The 2026-09 adaptivity investigation found the agent keeps trading a stale
11
+ // directional view after event-driven regime flips because (a) the regime
12
+ // label is computed from realized bars and lags a shock by hours, and (b) no
13
+ // input ever shows it the contradiction. This module computes the two fast
14
+ // counter-evidence facts from data the fact-computer already has in hand —
15
+ // NO new DB queries (the intel hot-query chunk-exclusion rule is not in play):
16
+ //
17
+ // - SHOCK: the last ~30 minutes moved a multiple of the hourly ATR. An
18
+ // hourly ATR is roughly "a typical hour's range", so 2×ATR inside 30m is
19
+ // ~4× the typical rate — announcement territory.
20
+ // - REGIME/TAPE DISAGREEMENT: the regime label says TREND_UP (or DOWN) but
21
+ // the realized 4h tape moved ≥2×ATR the other way — the label is lagging.
22
+ //
23
+ // Pure function, deterministic, fail-open: insufficient bars or a degenerate
24
+ // ATR returns undefined and every consumer treats that as "no signal".
25
+ // Thresholds are exported so the soak can be re-cut without archaeology.
26
+ // Canonical copy lives in shared/src/signals/ and is synced to
27
+ // intelligence/ + plugin/ via scripts/sync-shared-code.mjs (drift-tested).
28
+ /** 30m move ≥ this ×ATR(1h) ⇒ 'shock'. */
29
+ export const SHOCK_ATR_MULT_30M = 2.0;
30
+ /** 4h tape opposing a directional regime label by ≥ this ×ATR(1h) ⇒ 'regime_tape_disagreement'. */
31
+ export const DISAGREEMENT_ATR_MULT_4H = 2.0;
32
+ const fin = (v) => Number.isFinite(v);
33
+ const round2 = (v) => Math.round(v * 100) / 100;
34
+ export function computeChangeOfCharacter(inputs) {
35
+ const { ohlcv5m, ohlcv1h, atr14, currentPrice, regime } = inputs;
36
+ if (!fin(atr14) || atr14 <= 0 || !fin(currentPrice) || currentPrice <= 0)
37
+ return undefined;
38
+ if (!Array.isArray(ohlcv5m) || ohlcv5m.length < 7)
39
+ return undefined;
40
+ if (!Array.isArray(ohlcv1h) || ohlcv1h.length < 5)
41
+ return undefined;
42
+ // ~30m window = last 6 closed 5m bars; the reference close sits just before it.
43
+ const last6 = ohlcv5m.slice(-6);
44
+ const ref30m = ohlcv5m[ohlcv5m.length - 7].close;
45
+ const ref1h = ohlcv1h[ohlcv1h.length - 2].close;
46
+ const ref4h = ohlcv1h[ohlcv1h.length - 5].close;
47
+ if (!fin(ref30m) || ref30m <= 0 || !fin(ref1h) || ref1h <= 0 || !fin(ref4h) || ref4h <= 0) {
48
+ return undefined;
49
+ }
50
+ const shock30mAtr = Math.abs(currentPrice - ref30m) / atr14;
51
+ const hi30 = Math.max(...last6.map((b) => b.high));
52
+ const lo30 = Math.min(...last6.map((b) => b.low));
53
+ const range30mAtr = fin(hi30) && fin(lo30) && hi30 >= lo30 ? (hi30 - lo30) / atr14 : 0;
54
+ const return1hPct = (currentPrice / ref1h - 1) * 100;
55
+ const return4hPct = (currentPrice / ref4h - 1) * 100;
56
+ const tape4hAtr = (currentPrice - ref4h) / atr14;
57
+ const atrPct = (atr14 / currentPrice) * 100;
58
+ const flags = [];
59
+ if (Math.max(shock30mAtr, range30mAtr) >= SHOCK_ATR_MULT_30M)
60
+ flags.push('shock');
61
+ const labelDir = regime === 'TREND_UP' ? 1 : regime === 'TREND_DOWN' ? -1 : 0;
62
+ const opposing = labelDir !== 0 && Math.sign(tape4hAtr) === -labelDir;
63
+ if (opposing && Math.abs(tape4hAtr) >= DISAGREEMENT_ATR_MULT_4H) {
64
+ flags.push('regime_tape_disagreement');
65
+ }
66
+ const out = {
67
+ shock30mAtr: round2(shock30mAtr),
68
+ range30mAtr: round2(range30mAtr),
69
+ return1hPct: round2(return1hPct),
70
+ return4hPct: round2(return4hPct),
71
+ tape4hAtr: round2(tape4hAtr),
72
+ atrPct: round2(atrPct),
73
+ flags,
74
+ };
75
+ if (flags.length > 0)
76
+ out.summary = buildSummary(out, regime);
77
+ return out;
78
+ }
79
+ function buildSummary(c, regime) {
80
+ const parts = [];
81
+ if (c.flags.includes('shock')) {
82
+ parts.push(`SHOCK: last 30m moved ${round2(Math.max(c.shock30mAtr, c.range30mAtr))}×ATR ` +
83
+ `(${c.return1hPct >= 0 ? '+' : ''}${c.return1hPct}% on the hour)`);
84
+ }
85
+ if (c.flags.includes('regime_tape_disagreement')) {
86
+ parts.push(`regime label '${regime}' contradicts the 4h tape ` +
87
+ `(${c.tape4hAtr >= 0 ? '+' : ''}${c.tape4hAtr}×ATR / ` +
88
+ `${c.return4hPct >= 0 ? '+' : ''}${c.return4hPct}%) — the label LAGS realized price`);
89
+ }
90
+ return (`MARKET SHIFT — ${parts.join('; ')}. Re-derive direction from fresh price data ` +
91
+ `before entering; on open positions, check the pinned invalidation FIRST and do not ` +
92
+ `lean on prior theses formed before this move.`);
93
+ }
@@ -1,6 +1,6 @@
1
1
  // Re-export shim for the plugin-side strategy evaluator (facts-out).
2
2
  //
3
- // The generated eval-core copies (registry, strategy-adapter, direction/entry/
3
+ // The generated evaluator-core copies (registry, strategy-adapter, direction/entry/
4
4
  // stop-rules) import their types from `./types.js`. The canonical definitions
5
5
  // live in @reefclaw/shared (type-only → erased at runtime), so this shim makes
6
6
  // those relative imports resolve inside the plugin tree without pulling any
@@ -150,6 +150,17 @@ export interface PositionMetadata {
150
150
  /** The agent's stated profit-realization plan, pinned at entry (indication,
151
151
  * not an enforced mechanic). */
152
152
  realizationRule?: RealizationRule;
153
+ /** Re-entry cooldown gate evaluation, present ONLY when the gate TRIGGERED on this
154
+ * entry (a same-symbol loss within the window) and the order fired anyway
155
+ * (shadow/observe). Journaled to position_entries.metadata.reentry_cooldown
156
+ * so the shadow soak can measure the would-block cohort's forward outcomes
157
+ * against untagged entries (tools/reentry-cooldown.ts). */
158
+ reentryCooldown?: {
159
+ mode: 'shadow' | 'observe' | 'enforce';
160
+ minutesSinceLoss: number;
161
+ cooldownMinutes: number;
162
+ wouldBlock: boolean;
163
+ };
153
164
  /** Entry price as it was at the moment of the FIRST fill, frozen. MFE-in-R is
154
165
  * denominated against this (and originalStopPrice), never against the
155
166
  * position's running averaged entryPrice — otherwise a scale-in would
@@ -5,6 +5,10 @@ export interface SymbolFact {
5
5
  conditions: Record<string, boolean>;
6
6
  regime: string;
7
7
  regime_confidence: number;
8
+ /** Change-of-character block computed by the intel fact-computer (WS2,
9
+ * docs/MARKET_ADAPTIVITY_PLAN.md §3). Absent on older intel builds or when
10
+ * inputs were insufficient — consumers treat absence as "no signal". */
11
+ changeOfCharacter?: import('../signals/change-of-character.js').ChangeOfCharacter;
8
12
  }
9
13
  export interface ConditionConfig {
10
14
  type: string;
@@ -7,6 +7,10 @@ import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
7
7
  import { validateCreateOrder, sanitizeRealizationRule, validateProtectiveGeometry } from './assessment-validation.js';
8
8
  import { preTradeRiskCheck, getDefaultPreTradeLimits, computeConsecutiveLosses } from '../risk/pre-trade-check.js';
9
9
  import { bracketsEnabled, loadBracketMode, loadBracketRequirements } from '../config/brackets-config.js';
10
+ import { loadReentryCooldownMinutes, loadReentryCooldownMode, } from '../config/reentry-cooldown-config.js';
11
+ import { resolveLossStreakSizingMode } from '../config/loss-streak-config.js';
12
+ import { buildReentryCooldownRejection, evaluateReentryCooldown, } from './reentry-cooldown.js';
13
+ import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
10
14
  import { onCreateOrderFilled } from '../ingest/position-auto-capture.js';
11
15
  import { wave9ClientOrderId, } from '../wave9/live-execution-ledger.js';
12
16
  import { inspectWave9LiveSymbolOwnership, } from '../wave9/live-symbol-ownership.js';
@@ -1012,7 +1016,24 @@ export async function createOrderTool(args, deps) {
1012
1016
  }
1013
1017
  else {
1014
1018
  portfolio = await (livePortfolioPromise ?? buildPortfolioSnapshotFromAdapter(deps.adapter));
1015
- // In live mode, consecutive losses would come from intelligence DB — use 0 for now
1019
+ // WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3): the graduated loss-streak
1020
+ // sizing brake was fed a hardcoded 0 on live since the beginning. Real
1021
+ // feed = trailing lossy closes on the live book from the ReentryTracker
1022
+ // records. RC_LOSS_STREAK_SIZING = off | log (sizing unaffected) |
1023
+ // enforce (DEFAULT since 2026-09-05 — live/paper parity restored after
1024
+ // the log soak).
1025
+ const streakMode = resolveLossStreakSizingMode();
1026
+ if (streakMode !== 'off' && deps.autoCapture?.reentryTracker) {
1027
+ const liveStreak = deps.autoCapture.reentryTracker.consecutiveLosses('live');
1028
+ if (liveStreak > 0) {
1029
+ logger.info('loss-streak', `live consecutive losses=${liveStreak} (mode=${streakMode}` +
1030
+ (streakMode === 'log'
1031
+ ? ' — sizing UNAFFECTED; RC_LOSS_STREAK_SIZING=enforce applies the graduated reduction)'
1032
+ : ')'));
1033
+ }
1034
+ if (streakMode === 'enforce')
1035
+ consecutiveLosses = liveStreak;
1036
+ }
1016
1037
  }
1017
1038
  // Bracket enforcement only applies in live mode when the feature is enabled.
1018
1039
  // Paper mode uses the stop-watcher and doesn't care about these flags.
@@ -1048,6 +1069,43 @@ export async function createOrderTool(args, deps) {
1048
1069
  const reasons = riskCheck.violations.map(v => v.message).join('; ');
1049
1070
  return rejectWave9Divergence(`Order REJECTED by risk gate [${riskCheck.drawdownZone} zone]: ${reasons}`, `generic_pretrade_divergence:${riskCheck.violations.map((v) => v.rule).join(',')}`);
1050
1071
  }
1072
+ // ---- Re-entry cooldown gate (mode-laddered; ships 'off') ----
1073
+ // Generic NEW entries only: wave9 has its own frozen admission policy,
1074
+ // scale-ins and operator-approved proposal fires are exempt inside the
1075
+ // evaluator, and this branch can only ever suppress a new entry — exits,
1076
+ // closes, stops, and emergency paths never route through it. Mode resolves
1077
+ // PER CALL (central → file → off) so a gate flip hot-applies, same as the
1078
+ // exit gate. See tools/reentry-cooldown.ts for the measurement behind it.
1079
+ let reentryCooldownEval;
1080
+ if (!wave9Claimed) {
1081
+ const rcMode = loadReentryCooldownMode();
1082
+ if (rcMode !== 'off') {
1083
+ const symbolKey = normalizeBracketSymbol(args.symbol);
1084
+ reentryCooldownEval = evaluateReentryCooldown({
1085
+ symbol: args.symbol,
1086
+ mode: rcMode,
1087
+ tracker: deps.autoCapture?.reentryTracker,
1088
+ book: deps.adapter.isLive ? 'live' : 'paper',
1089
+ cooldownMinutes: loadReentryCooldownMinutes(),
1090
+ hasOpenPosition: portfolio.positions.some((p) => normalizeBracketSymbol(p.symbol) === symbolKey && Math.abs(p.quantity) > 0),
1091
+ isListenerFire: deps.entryClientOrderId != null,
1092
+ });
1093
+ if (reentryCooldownEval.triggered) {
1094
+ const detail = `${args.symbol} ${side}: lossy close ${reentryCooldownEval.minutesSinceLoss}m ago ` +
1095
+ `(window=${reentryCooldownEval.cooldownMinutes}m, mode=${rcMode}, ` +
1096
+ `loss_source=${reentryCooldownEval.lastLoss?.lossSource ?? 'unknown'})`;
1097
+ if (rcMode === 'shadow') {
1098
+ logger.info('reentry-cooldown', `WOULD BLOCK (shadow) ${detail}`);
1099
+ }
1100
+ else {
1101
+ logger.warn('reentry-cooldown', `${rcMode === 'enforce' ? 'BLOCKED' : 'WOULD BLOCK (observe)'} ${detail}`);
1102
+ }
1103
+ if (reentryCooldownEval.blocked) {
1104
+ return { error: buildReentryCooldownRejection(reentryCooldownEval, args.symbol) };
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1051
1109
  // Build position metadata from optional args (only if any metadata provided)
1052
1110
  // Sanitize numeric metadata — reject non-finite values, clamp ranges
1053
1111
  const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
@@ -1055,9 +1113,20 @@ export async function createOrderTool(args, deps) {
1055
1113
  const n = num(v);
1056
1114
  return n != null ? Math.max(min, Math.min(max, n)) : undefined;
1057
1115
  };
1116
+ // Journal-tag a TRIGGERED cooldown evaluation (shadow/observe fire-anyway cohort —
1117
+ // the measurement rows the promote-to-enforce decision reads).
1118
+ const reentryCooldownTag = reentryCooldownEval?.triggered
1119
+ ? {
1120
+ mode: reentryCooldownEval.mode,
1121
+ minutesSinceLoss: reentryCooldownEval.minutesSinceLoss ?? 0,
1122
+ cooldownMinutes: reentryCooldownEval.cooldownMinutes,
1123
+ wouldBlock: true,
1124
+ }
1125
+ : undefined;
1058
1126
  const hasMetadata = args.mission_id || args.setup_type || args.thesis || args.target_price != null
1059
1127
  || args.regime || args.scorecard_verdict || args.confluence_score != null
1060
- || args.invalidation_price != null || args.realization_rule != null;
1128
+ || args.invalidation_price != null || args.realization_rule != null
1129
+ || reentryCooldownTag != null;
1061
1130
  const metadata = hasMetadata ? {
1062
1131
  missionId: args.mission_id,
1063
1132
  setupType: args.setup_type,
@@ -1076,6 +1145,7 @@ export async function createOrderTool(args, deps) {
1076
1145
  confluenceScore: clamp(args.confluence_score, 0, 10),
1077
1146
  invalidationPrice: num(args.invalidation_price),
1078
1147
  realizationRule: sanitizeRealizationRule(args.realization_rule),
1148
+ reentryCooldown: reentryCooldownTag,
1079
1149
  } : undefined;
1080
1150
  // ---- Approval-mode branches ----
1081
1151
  // Both shadow and per_trade need full v2.10.0 metadata so the proposal is
@@ -0,0 +1,33 @@
1
+ import type { ReentryExitRecord, ReentryTracker } from '../portfolio/reentry-tracker.js';
2
+ export type ReentryCooldownMode = 'off' | 'shadow' | 'observe' | 'enforce';
3
+ export interface ReentryCooldownEval {
4
+ /** Mode the gate ran under (never 'off' — off is not evaluated). */
5
+ mode: Exclude<ReentryCooldownMode, 'off'>;
6
+ /** A lossy same-symbol close exists within the cooldown window. */
7
+ triggered: boolean;
8
+ /** triggered && mode === 'enforce' — caller must reject the order. */
9
+ blocked: boolean;
10
+ code: 'no_recent_loss' | 'cooldown_active' | 'scale_in_exempt' | 'operator_approved_exempt' | 'tracker_unavailable';
11
+ cooldownMinutes: number;
12
+ /** Minutes since the arming loss (only when triggered). */
13
+ minutesSinceLoss?: number;
14
+ /** The arming loss (only when triggered). */
15
+ lastLoss?: Pick<ReentryExitRecord, 'side' | 'setupType' | 'closedAtMs' | 'lossSource'>;
16
+ }
17
+ export interface ReentryCooldownInputs {
18
+ symbol: string;
19
+ mode: Exclude<ReentryCooldownMode, 'off'>;
20
+ tracker: Pick<ReentryTracker, 'lastLossyExit'> | undefined;
21
+ /** Current trading book — filters tracker records after a paper↔live flip. */
22
+ book: 'paper' | 'live';
23
+ cooldownMinutes: number;
24
+ /** Same-symbol position already open → this order is a scale-in, exempt. */
25
+ hasOpenPosition: boolean;
26
+ /** ProposalDecisionListener fire of an operator-APPROVED proposal, exempt. */
27
+ isListenerFire: boolean;
28
+ nowMs?: number;
29
+ }
30
+ export declare function evaluateReentryCooldown(inputs: ReentryCooldownInputs): ReentryCooldownEval;
31
+ /** Agent-facing rejection for enforce mode. Names the gate, the remaining
32
+ * wait, and the honest path forward — no bypass hint by design. */
33
+ export declare function buildReentryCooldownRejection(ev: ReentryCooldownEval, symbol: string): string;
@@ -0,0 +1,74 @@
1
+ // Re-entry cooldown gate — blocks (mode-laddered) a NEW entry on a symbol
2
+ // whose last close within the cooldown window was a LOSS.
3
+ //
4
+ // Evidence (2026-09-02, full live journal): entries opened within 60 minutes
5
+ // of a same-symbol losing close ran mean −0.077R (n=58) vs +0.090R for all
6
+ // other entries (n=1151) at identical ~41% win rates, plus the extra fee load
7
+ // — the LINK/DOGE churn signature of the 2026-06 investigation recurring on
8
+ // the HL live book. Suggestive, not proven, at n=58 — which is exactly what
9
+ // the shadow rung is for: this ships `off` by default, is flipped shadow-first
10
+ // via the central gate channel (`agent_config.gates.reentryCooldown`), and
11
+ // tags would-block entries into position_entries.metadata.reentry_cooldown so
12
+ // the forward counterfactual is measurable in the journal before any enforce
13
+ // decision.
14
+ //
15
+ // Scope guards (all fail OPEN — this gate can only ever suppress a NEW entry,
16
+ // never an exit, close, stop, or emergency action):
17
+ // - generic entries only (wave9 has its own frozen admission policy);
18
+ // - scale-ins exempt (position already open — entry already happened);
19
+ // - operator-approved proposal fires exempt (human already said yes);
20
+ // - tracker unavailable → pass.
21
+ //
22
+ // Mode semantics mirror the exit gate (docs/CLAUDE/exit-gate.md):
23
+ // off — not evaluated; byte-identical to the pre-gate path.
24
+ // shadow — evaluated + logged + journal-tagged; never affects the order.
25
+ // observe — as shadow, but a triggered evaluation logs at WARN (operator-visible).
26
+ // enforce — a triggered evaluation hard-rejects create_order with a recovery hint.
27
+ export function evaluateReentryCooldown(inputs) {
28
+ const base = {
29
+ mode: inputs.mode,
30
+ triggered: false,
31
+ blocked: false,
32
+ cooldownMinutes: inputs.cooldownMinutes,
33
+ };
34
+ if (inputs.isListenerFire)
35
+ return { ...base, code: 'operator_approved_exempt' };
36
+ if (inputs.hasOpenPosition)
37
+ return { ...base, code: 'scale_in_exempt' };
38
+ if (!inputs.tracker)
39
+ return { ...base, code: 'tracker_unavailable' };
40
+ const nowMs = inputs.nowMs ?? Date.now();
41
+ const windowMs = inputs.cooldownMinutes * 60_000;
42
+ const loss = inputs.tracker.lastLossyExit(inputs.symbol, windowMs, {
43
+ mode: inputs.book,
44
+ nowMs,
45
+ });
46
+ if (!loss)
47
+ return { ...base, code: 'no_recent_loss' };
48
+ const minutesSinceLoss = Math.max(0, Math.round((nowMs - loss.closedAtMs) / 60_000));
49
+ return {
50
+ ...base,
51
+ triggered: true,
52
+ blocked: inputs.mode === 'enforce',
53
+ code: 'cooldown_active',
54
+ minutesSinceLoss,
55
+ lastLoss: {
56
+ side: loss.side,
57
+ setupType: loss.setupType,
58
+ closedAtMs: loss.closedAtMs,
59
+ lossSource: loss.lossSource,
60
+ },
61
+ };
62
+ }
63
+ /** Agent-facing rejection for enforce mode. Names the gate, the remaining
64
+ * wait, and the honest path forward — no bypass hint by design. */
65
+ export function buildReentryCooldownRejection(ev, symbol) {
66
+ const remaining = Math.max(1, ev.cooldownMinutes - (ev.minutesSinceLoss ?? 0));
67
+ return (`create_order rejected (reentry cooldown): ${symbol} closed at a LOSS ` +
68
+ `${ev.minutesSinceLoss}m ago and the operator-configured cooldown is ` +
69
+ `${ev.cooldownMinutes}m — ~${remaining}m remaining. This is a mechanical ` +
70
+ `gate against re-entry churn (measured −0.17R/trade edge gap on re-entries ` +
71
+ `within the window). Do not retry this symbol until the cooldown lapses; ` +
72
+ `spend the time re-scoring the setup — if it is still valid then, enter then. ` +
73
+ `Other symbols are unaffected.`);
74
+ }
@@ -13,6 +13,13 @@ export interface ScanPairsDecisionsDeps {
13
13
  /** Re-entry tracker (issue #204) — flags setups already traded within the
14
14
  * current signal bar. Indication only; nothing is filtered out. */
15
15
  reentryTracker?: ReentryTracker;
16
+ /** WS2 directional scoreboard inputs (docs/MARKET_ADAPTIVITY_PLAN.md §3) —
17
+ * tracked open positions (state store; no exchange round-trip) + the
18
+ * current book. Indication only, like everything else in this block. */
19
+ openPositions?: () => Array<{
20
+ side: 'long' | 'short';
21
+ }>;
22
+ book?: () => 'paper' | 'live';
16
23
  }
17
24
  export declare function scanPairsTool(args: ScanPairsArgs, deps: IntelApiDeps, decisionsDeps?: ScanPairsDecisionsDeps): Promise<Record<string, unknown> | {
18
25
  error: string;