@coinrithm/mcp-trading 0.7.7 → 0.7.8

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.
@@ -54,8 +54,14 @@ function normalCdf(z) {
54
54
  return z >= 0 ? 1 - p : p;
55
55
  }
56
56
  // Max peak-to-trough drawdown (mUSD, >= 0) on a cumulative series.
57
+ //
58
+ // The peak seeds at 0, the curve's implicit origin (cumulative realized PnL
59
+ // starts at zero before the first trade). Seeding at -Infinity made the first
60
+ // point its own peak, so a record opening with losses under-reported: [-500,
61
+ // +500, -300] gave 300 instead of 500 and a monotone-losing record gave 0
62
+ // (2026-09-01 rigor review).
57
63
  function maxDrawdown(cumulative) {
58
- let peak = -Infinity;
64
+ let peak = 0;
59
65
  let maxDd = 0;
60
66
  for (const c of cumulative) {
61
67
  if (!Number.isFinite(c))
@@ -63,6 +63,20 @@ function buildModel(raw) {
63
63
  baseUrl: typeof m.baseUrl === "string" ? m.baseUrl : undefined,
64
64
  };
65
65
  }
66
+ // No implicit opt-in and no invented risk percentages. Invalid supplied values
67
+ // remain visibly invalid until validateSkill rejects the raw configuration.
68
+ function buildCapitalSizing(raw) {
69
+ const policy = obj(raw);
70
+ return {
71
+ version: policy.version,
72
+ futuresRiskPct: num(policy.futuresRiskPct, Number.NaN),
73
+ pmMaxLossPct: num(policy.pmMaxLossPct, Number.NaN),
74
+ perTicketCapitalPct: num(policy.perTicketCapitalPct, Number.NaN),
75
+ totalCapitalPct: num(policy.totalCapitalPct, Number.NaN),
76
+ cashReservePct: num(policy.cashReservePct, Number.NaN),
77
+ minRewardRisk: num(policy.minRewardRisk, Number.NaN),
78
+ };
79
+ }
66
80
  // Coerce raw frontmatter into a best-effort AgentSpec. This NEVER throws on bad
67
81
  // values — it fills in what it can and lets validateSkill report problems
68
82
  // against the raw frontmatter. The runner only proceeds when validation passes.
@@ -85,6 +99,9 @@ export function buildSpec(raw) {
85
99
  },
86
100
  model: buildModel(raw.model),
87
101
  venues,
102
+ ...(raw.capitalSizing !== undefined
103
+ ? { capitalSizing: buildCapitalSizing(raw.capitalSizing) }
104
+ : {}),
88
105
  risk: {
89
106
  maxLeverage: num(risk.maxLeverage, 1),
90
107
  perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
@@ -4,4 +4,5 @@ export interface SkillValidation {
4
4
  valid: boolean;
5
5
  issues: ValidationResult[];
6
6
  }
7
+ export declare function validateCapitalSizingPolicy(value: unknown): ValidationResult[];
7
8
  export declare function validateSkill(parsed: ParsedSkill, mode?: SkillValidationMode): SkillValidation;
@@ -4,6 +4,58 @@ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
4
  const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5
5
  // maxTradesPerDay accepts 0 as the explicit "unlimited daily trades" sentinel.
6
6
  const isNonNegNum = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
7
+ // Shared by skill loading and the runtime: persisted specs can bypass the
8
+ // loader, so a present malformed policy must never become permissive NaN math.
9
+ export function validateCapitalSizingPolicy(value) {
10
+ const issues = [];
11
+ const add = (code, reason) => issues.push(fail(code, reason));
12
+ const p = value;
13
+ if (!isObj(p)) {
14
+ add("skill_capital_sizing", "capitalSizing must be an object");
15
+ return issues;
16
+ }
17
+ const percentKeys = [
18
+ "futuresRiskPct",
19
+ "pmMaxLossPct",
20
+ "perTicketCapitalPct",
21
+ "totalCapitalPct",
22
+ ];
23
+ const allowedKeys = new Set([
24
+ "version",
25
+ ...percentKeys,
26
+ "cashReservePct",
27
+ "minRewardRisk",
28
+ ]);
29
+ for (const key of Object.keys(p)) {
30
+ if (!allowedKeys.has(key))
31
+ add("skill_capital_sizing_unknown_key", `unknown capitalSizing key "${key}"`);
32
+ }
33
+ if (p.version !== "equity_fraction_v1") {
34
+ add("skill_capital_sizing_version", 'capitalSizing.version must be "equity_fraction_v1"');
35
+ }
36
+ for (const key of percentKeys) {
37
+ if (!isPosNum(p[key]) || p[key] > 100) {
38
+ add("skill_capital_sizing_percent", `capitalSizing.${key} must be a finite number in (0, 100]`);
39
+ }
40
+ }
41
+ if (!isNonNegNum(p.cashReservePct) || p.cashReservePct >= 100) {
42
+ add("skill_capital_sizing_reserve", "capitalSizing.cashReservePct must be a finite number in [0, 100)");
43
+ }
44
+ if (!isPosNum(p.minRewardRisk) || p.minRewardRisk < 1) {
45
+ add("skill_capital_sizing_reward_risk", "capitalSizing.minRewardRisk must be a finite number >= 1");
46
+ }
47
+ if (isPosNum(p.perTicketCapitalPct) &&
48
+ isPosNum(p.totalCapitalPct) &&
49
+ p.perTicketCapitalPct > p.totalCapitalPct) {
50
+ add("skill_capital_sizing_ticket_cap", "capitalSizing.perTicketCapitalPct cannot exceed totalCapitalPct");
51
+ }
52
+ if (isPosNum(p.totalCapitalPct) &&
53
+ isNonNegNum(p.cashReservePct) &&
54
+ p.totalCapitalPct + p.cashReservePct > 100) {
55
+ add("skill_capital_sizing_total_reserve", "capitalSizing.totalCapitalPct + cashReservePct cannot exceed 100");
56
+ }
57
+ return issues;
58
+ }
7
59
  export function validateSkill(parsed, mode = "self-host") {
8
60
  const raw = parsed.raw;
9
61
  const issues = [];
@@ -61,6 +113,10 @@ export function validateSkill(parsed, mode = "self-host") {
61
113
  r.direction !== "short_only")
62
114
  add("skill_risk_direction", 'risk.direction must be "long_only" or "short_only" (omit for both)');
63
115
  }
116
+ // Opt-in capital sizing is strict in BOTH hosting modes. The older `sizing`
117
+ // prose block remains soft guidance and never silently activates this policy.
118
+ if (raw.capitalSizing !== undefined)
119
+ issues.push(...validateCapitalSizingPolicy(raw.capitalSizing));
64
120
  // Model
65
121
  if (raw.model === undefined) {
66
122
  if (mode === "self-host")
@@ -11,6 +11,7 @@ export function newState(runId) {
11
11
  runId,
12
12
  cyclesRun: 0,
13
13
  writesToday: 0,
14
+ riskIncreasesToday: 0,
14
15
  realizedPnlMusd: 0,
15
16
  peakRealizedMusd: 0,
16
17
  consecutiveRejectCycles: 0,
@@ -41,6 +42,10 @@ export function loadState(file, runId) {
41
42
  return rollDay({
42
43
  ...base,
43
44
  ...parsed,
45
+ // Pre-field state counted every write. Conservatively carry that count
46
+ // into the entry budget until the next UTC rollover rather than silently
47
+ // granting additional entries on deployment.
48
+ riskIncreasesToday: parsed.riskIncreasesToday ?? parsed.writesToday ?? 0,
44
49
  seen: Array.isArray(parsed.seen) ? parsed.seen : [],
45
50
  intentSeq: parsed.intentSeq &&
46
51
  typeof parsed.intentSeq === "object" &&
@@ -62,6 +67,7 @@ export function rollDay(state) {
62
67
  if (state.dayKey !== today) {
63
68
  state.dayKey = today;
64
69
  state.writesToday = 0;
70
+ state.riskIncreasesToday = 0;
65
71
  state.realizedPnlTodayMusd = 0;
66
72
  }
67
73
  return state;
@@ -17,6 +17,7 @@ const ALLOWED_KEYS = {
17
17
  "venues",
18
18
  "risk",
19
19
  "sizing",
20
+ "capitalSizing",
20
21
  "limits",
21
22
  "abstention",
22
23
  "sync",
@@ -50,6 +51,15 @@ const ALLOWED_KEYS = {
50
51
  "direction",
51
52
  ],
52
53
  sizing: null,
54
+ capitalSizing: [
55
+ "version",
56
+ "futuresRiskPct",
57
+ "pmMaxLossPct",
58
+ "perTicketCapitalPct",
59
+ "totalCapitalPct",
60
+ "cashReservePct",
61
+ "minRewardRisk",
62
+ ],
53
63
  limits: [
54
64
  "maxTradesPerDay",
55
65
  "maxWritesPerCycle",
@@ -123,6 +133,7 @@ export function strictLint(raw) {
123
133
  "triggerPolicy",
124
134
  "model",
125
135
  "risk",
136
+ "capitalSizing",
126
137
  "limits",
127
138
  "abstention",
128
139
  "sync",
@@ -133,6 +144,14 @@ export function strictLint(raw) {
133
144
  lintKeys(block, raw[block], issues);
134
145
  }
135
146
  // Enum checks.
147
+ const capitalSizing = raw.capitalSizing;
148
+ if (isObj(capitalSizing) && capitalSizing.version !== "equity_fraction_v1") {
149
+ issues.push({
150
+ code: "bad_enum",
151
+ path: "capitalSizing.version",
152
+ message: 'capitalSizing.version must be "equity_fraction_v1"',
153
+ });
154
+ }
136
155
  const model = raw.model;
137
156
  if (isObj(model) &&
138
157
  typeof model.provider === "string" &&
@@ -0,0 +1,40 @@
1
+ import { Observation, OpenPosition, PmPosition, PositionThesis, RunState, Thesis, ThesisInvalidation, ThesisView, Venue } from "./types.js";
2
+ export declare const THESIS_MIN_HOLD_MINUTES = 60;
3
+ export declare const THESIS_MAX_HOLD_MINUTES: number;
4
+ export declare const THESIS_SUMMARY_MAX_CHARS = 200;
5
+ export declare const THESIS_CATALYST_MAX_CHARS = 160;
6
+ export declare const MAX_PERSISTED_THESES = 60;
7
+ export declare function thesisKey(venue: Venue, positionId: number): string;
8
+ export declare function hasInvalidationCondition(inv: ThesisInvalidation): boolean;
9
+ export declare function coerceThesis(raw: unknown): Thesis | undefined;
10
+ export interface BindThesisInput {
11
+ thesis: Thesis;
12
+ venue: "futures" | "pm";
13
+ positionId: number;
14
+ openedAt: string;
15
+ side?: string;
16
+ symbol?: string;
17
+ entryPrice?: number;
18
+ source?: string;
19
+ slug?: string;
20
+ outcomeExternalMarketId?: string;
21
+ entryProbability?: number;
22
+ }
23
+ export interface BoundThesis {
24
+ thesis: PositionThesis;
25
+ notes: string[];
26
+ }
27
+ export declare function bindThesis(input: BindThesisInput): BoundThesis;
28
+ export declare function holdMinutesOf(openedAt: string | undefined, fallback: string, nowMs: number): number;
29
+ export declare function evaluateFuturesThesis(t: PositionThesis, pos: OpenPosition, nowMs: number): ThesisView;
30
+ export declare function evaluatePmThesis(t: PositionThesis, pos: PmPosition, nowMs: number): ThesisView;
31
+ export declare function attachTheses(observation: Observation, state: RunState, nowMs: number, opts: {
32
+ prunePm: boolean;
33
+ }): {
34
+ attached: number;
35
+ pruned: string[];
36
+ };
37
+ export declare function thesisExits(observation: Observation): OpenPosition[];
38
+ export declare function rememberThesis(state: RunState, thesis: PositionThesis): void;
39
+ export declare function forgetThesis(state: RunState, key: string): void;
40
+ export declare function describeInvalidation(inv: ThesisInvalidation): string;
@@ -0,0 +1,319 @@
1
+ // Thesis exits (slice 2 of "house agents are not fun", 2026-09-02).
2
+ //
3
+ // A position is opened ON a thesis and leaves when that thesis is INVALIDATED,
4
+ // not only when a stop-loss / take-profit fires or a small adverse tick spooks
5
+ // the model. The model states the thesis inside its open action; the runner
6
+ // sanitizes it side-aware, persists it with the position (RunState.theses,
7
+ // keyed "<venue>:<positionId>") and re-evaluates the machine-checkable parts
8
+ // every cycle: price levels against the live mark, probability levels against
9
+ // the outcome's current market probability, time in trade against the time
10
+ // stop. A futures position whose thesis broke is closed by the runner; PM has
11
+ // no close endpoint, so an invalidated PM thesis is surfaced to the model (do
12
+ // not add; let it settle). The free-text catalyst is never machine-evaluated:
13
+ // the model re-judges it while it manages the position.
14
+ //
15
+ // Pure functions over plain objects; nowMs is injected so every rule is
16
+ // testable without timers. Nothing here widens a cap or touches the kill
17
+ // switch: a thesis exit is a risk-REDUCING close, and the runner executes it
18
+ // only after the kill-switch and drawdown checks have passed.
19
+ // A time stop shorter than an hour on a 3-minute cadence is the churn this
20
+ // slice exists to end; longer than 30 days is not a time stop.
21
+ export const THESIS_MIN_HOLD_MINUTES = 60;
22
+ export const THESIS_MAX_HOLD_MINUTES = 60 * 24 * 30;
23
+ export const THESIS_SUMMARY_MAX_CHARS = 200;
24
+ export const THESIS_CATALYST_MAX_CHARS = 160;
25
+ // Bound the persisted map so a runaway fleet member cannot grow the state JSON.
26
+ export const MAX_PERSISTED_THESES = 60;
27
+ const LEVEL_KEYS = [
28
+ "priceBelow",
29
+ "priceAbove",
30
+ "probabilityBelow",
31
+ "probabilityAbove",
32
+ "maxHoldMinutes",
33
+ ];
34
+ export function thesisKey(venue, positionId) {
35
+ return `${venue}:${positionId}`;
36
+ }
37
+ // A finite positive number, tolerating the stringified numbers small models
38
+ // emit ("64000"). Anything else is absent.
39
+ function finitePositive(v) {
40
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
41
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : undefined;
42
+ }
43
+ function cleanText(v, max) {
44
+ if (typeof v !== "string")
45
+ return undefined;
46
+ const s = v.replace(/\s+/g, " ").trim();
47
+ return s ? s.slice(0, max) : undefined;
48
+ }
49
+ function readInvalidation(raw) {
50
+ const out = {};
51
+ for (const k of LEVEL_KEYS) {
52
+ const n = finitePositive(raw[k]);
53
+ if (n != null)
54
+ out[k] = n;
55
+ }
56
+ const catalyst = cleanText(raw.catalyst, THESIS_CATALYST_MAX_CHARS);
57
+ if (catalyst)
58
+ out.catalyst = catalyst;
59
+ return out;
60
+ }
61
+ export function hasInvalidationCondition(inv) {
62
+ return LEVEL_KEYS.some((k) => inv[k] != null) || !!inv.catalyst;
63
+ }
64
+ // Parse a model-emitted thesis. TOLERANT by design (like forecastProbability):
65
+ // a missing / malformed thesis becomes undefined and never fails the action.
66
+ // Accepts the nested contract {summary, invalidation:{...}} and, for weak
67
+ // models, a flattened {summary, priceBelow, ...}. Numbers may be strings.
68
+ export function coerceThesis(raw) {
69
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
70
+ return undefined;
71
+ const o = raw;
72
+ const nested = o.invalidation &&
73
+ typeof o.invalidation === "object" &&
74
+ !Array.isArray(o.invalidation)
75
+ ? o.invalidation
76
+ : undefined;
77
+ const invalidation = readInvalidation(nested ?? o);
78
+ const summary = cleanText(o.summary, THESIS_SUMMARY_MAX_CHARS);
79
+ if (!summary && !hasInvalidationCondition(invalidation))
80
+ return undefined;
81
+ return { summary: summary ?? "(no summary stated)", invalidation };
82
+ }
83
+ // Bind a thesis to a freshly opened position, sanitizing it SIDE-AWARE so a
84
+ // wrong-side level can never fire on the next tick: a rising price is never
85
+ // what invalidates a long, a falling one never invalidates a short; for a YES
86
+ // bet the odds must FALL to break it, for a NO bet they must RISE. Levels on the
87
+ // wrong side of entry, levels for the other venue, and out-of-range values are
88
+ // dropped (never silently re-signed); the time stop is clamped to the floor /
89
+ // ceiling. Every drop or clamp is reported in `notes`.
90
+ export function bindThesis(input) {
91
+ const notes = [];
92
+ const inv = { ...input.thesis.invalidation };
93
+ const side = (input.side ?? "").toLowerCase();
94
+ const drop = (k, why) => {
95
+ if (inv[k] == null)
96
+ return;
97
+ delete inv[k];
98
+ notes.push(`dropped ${k}: ${why}`);
99
+ };
100
+ if (input.venue === "futures") {
101
+ drop("probabilityBelow", "not a coin condition");
102
+ drop("probabilityAbove", "not a coin condition");
103
+ if (side === "short") {
104
+ drop("priceBelow", "a falling price never invalidates a short");
105
+ if (inv.priceAbove != null &&
106
+ input.entryPrice != null &&
107
+ inv.priceAbove <= input.entryPrice) {
108
+ drop("priceAbove", `must be above entry ${input.entryPrice}`);
109
+ }
110
+ }
111
+ else {
112
+ drop("priceAbove", "a rising price never invalidates a long");
113
+ if (inv.priceBelow != null &&
114
+ input.entryPrice != null &&
115
+ inv.priceBelow >= input.entryPrice) {
116
+ drop("priceBelow", `must be below entry ${input.entryPrice}`);
117
+ }
118
+ }
119
+ }
120
+ else {
121
+ drop("priceBelow", "not a prediction-market condition");
122
+ drop("priceAbove", "not a prediction-market condition");
123
+ for (const k of ["probabilityBelow", "probabilityAbove"]) {
124
+ if (inv[k] != null && inv[k] > 100)
125
+ drop(k, "0..100 points");
126
+ }
127
+ if (side === "no") {
128
+ drop("probabilityBelow", "falling odds never invalidate a NO");
129
+ if (inv.probabilityAbove != null &&
130
+ input.entryProbability != null &&
131
+ inv.probabilityAbove <= input.entryProbability) {
132
+ drop("probabilityAbove", `must be above entry ${input.entryProbability}`);
133
+ }
134
+ }
135
+ else {
136
+ drop("probabilityAbove", "rising odds never invalidate a YES");
137
+ if (inv.probabilityBelow != null &&
138
+ input.entryProbability != null &&
139
+ inv.probabilityBelow >= input.entryProbability) {
140
+ drop("probabilityBelow", `must be below entry ${input.entryProbability}`);
141
+ }
142
+ }
143
+ }
144
+ if (inv.maxHoldMinutes != null) {
145
+ const raw = inv.maxHoldMinutes;
146
+ const clamped = Math.min(THESIS_MAX_HOLD_MINUTES, Math.max(THESIS_MIN_HOLD_MINUTES, Math.round(raw)));
147
+ if (clamped !== raw) {
148
+ notes.push(`maxHoldMinutes ${raw} clamped to ${clamped}`);
149
+ inv.maxHoldMinutes = clamped;
150
+ }
151
+ }
152
+ const thesis = {
153
+ summary: input.thesis.summary,
154
+ invalidation: inv,
155
+ venue: input.venue,
156
+ positionId: input.positionId,
157
+ openedAt: input.openedAt,
158
+ };
159
+ if (input.symbol)
160
+ thesis.symbol = input.symbol;
161
+ if (side)
162
+ thesis.side = side;
163
+ if (input.source)
164
+ thesis.source = input.source;
165
+ if (input.slug)
166
+ thesis.slug = input.slug;
167
+ if (input.outcomeExternalMarketId)
168
+ thesis.outcomeExternalMarketId = input.outcomeExternalMarketId;
169
+ if (input.entryPrice != null)
170
+ thesis.entryPrice = input.entryPrice;
171
+ if (input.entryProbability != null)
172
+ thesis.entryProbability = input.entryProbability;
173
+ return { thesis, notes };
174
+ }
175
+ // Whole minutes in the trade, from the position's own openedAt (the server's
176
+ // clock) when present, else the openedAt recorded at bind time.
177
+ export function holdMinutesOf(openedAt, fallback, nowMs) {
178
+ const t = Date.parse(openedAt ?? fallback);
179
+ if (!Number.isFinite(t))
180
+ return 0;
181
+ return Math.max(0, Math.round((nowMs - t) / 60_000));
182
+ }
183
+ function view(t, holdMinutes, invalidatedBy) {
184
+ return {
185
+ summary: t.summary,
186
+ invalidation: t.invalidation,
187
+ holdMinutes,
188
+ status: invalidatedBy ? "invalidated" : "intact",
189
+ ...(invalidatedBy ? { invalidatedBy } : {}),
190
+ };
191
+ }
192
+ // Evaluate a futures thesis against the live position for THIS cycle. Price
193
+ // levels are checked against the mark (at-or-beyond), then the time stop. A
194
+ // position with no mark this cycle is judged on the time stop only.
195
+ export function evaluateFuturesThesis(t, pos, nowMs) {
196
+ const holdMinutes = holdMinutesOf(pos.openedAt, t.openedAt, nowMs);
197
+ const inv = t.invalidation;
198
+ const mark = pos.markPrice;
199
+ let invalidatedBy;
200
+ if (typeof mark === "number" && Number.isFinite(mark)) {
201
+ if (inv.priceBelow != null && mark <= inv.priceBelow) {
202
+ invalidatedBy = `mark ${mark} at or below priceBelow ${inv.priceBelow}`;
203
+ }
204
+ else if (inv.priceAbove != null && mark >= inv.priceAbove) {
205
+ invalidatedBy = `mark ${mark} at or above priceAbove ${inv.priceAbove}`;
206
+ }
207
+ }
208
+ if (!invalidatedBy &&
209
+ inv.maxHoldMinutes != null &&
210
+ holdMinutes >= inv.maxHoldMinutes) {
211
+ invalidatedBy = `held ${holdMinutes}m, time stop ${inv.maxHoldMinutes}m`;
212
+ }
213
+ return view(t, holdMinutes, invalidatedBy);
214
+ }
215
+ // Evaluate a prediction-market thesis against the outcome's CURRENT market
216
+ // probability (0..100 points, present only while the position is open).
217
+ export function evaluatePmThesis(t, pos, nowMs) {
218
+ const holdMinutes = holdMinutesOf(pos.openedAt, t.openedAt, nowMs);
219
+ const inv = t.invalidation;
220
+ const cur = pos.currentProbability;
221
+ let invalidatedBy;
222
+ if (typeof cur === "number" && Number.isFinite(cur)) {
223
+ if (inv.probabilityBelow != null && cur <= inv.probabilityBelow) {
224
+ invalidatedBy = `probability ${cur} at or below probabilityBelow ${inv.probabilityBelow}`;
225
+ }
226
+ else if (inv.probabilityAbove != null && cur >= inv.probabilityAbove) {
227
+ invalidatedBy = `probability ${cur} at or above probabilityAbove ${inv.probabilityAbove}`;
228
+ }
229
+ }
230
+ if (!invalidatedBy &&
231
+ inv.maxHoldMinutes != null &&
232
+ holdMinutes >= inv.maxHoldMinutes) {
233
+ invalidatedBy = `held ${holdMinutes}m, time stop ${inv.maxHoldMinutes}m`;
234
+ }
235
+ return view(t, holdMinutes, invalidatedBy);
236
+ }
237
+ // Attach the evaluated thesis to every open position that has one and prune
238
+ // theses whose position is gone (closed, stopped, liquidated, settled). Futures
239
+ // positions are a required read, so a missing futures position is truly gone;
240
+ // PM theses are pruned only when the caller actually read the pm book.
241
+ export function attachTheses(observation, state, nowMs, opts) {
242
+ const theses = state.theses;
243
+ if (!theses)
244
+ return { attached: 0, pruned: [] };
245
+ const live = new Set();
246
+ let attached = 0;
247
+ for (const pos of observation.openPositions) {
248
+ if (pos.venue !== "futures")
249
+ continue;
250
+ const key = thesisKey("futures", pos.id);
251
+ live.add(key);
252
+ const t = theses[key];
253
+ if (!t)
254
+ continue;
255
+ pos.thesis = evaluateFuturesThesis(t, pos, nowMs);
256
+ attached += 1;
257
+ }
258
+ for (const pos of observation.pmPositions ?? []) {
259
+ const key = thesisKey("pm", pos.id);
260
+ live.add(key);
261
+ const t = theses[key];
262
+ if (!t)
263
+ continue;
264
+ pos.thesis = evaluatePmThesis(t, pos, nowMs);
265
+ attached += 1;
266
+ }
267
+ const pruned = [];
268
+ for (const key of Object.keys(theses)) {
269
+ if (live.has(key))
270
+ continue;
271
+ const t = theses[key];
272
+ if (t.venue === "pm" && !opts.prunePm)
273
+ continue;
274
+ if (t.venue === "spot")
275
+ continue; // never bound today; defensive
276
+ delete theses[key];
277
+ pruned.push(key);
278
+ }
279
+ return { attached, pruned };
280
+ }
281
+ // The futures positions the runner must close this cycle: thesis invalidated.
282
+ export function thesisExits(observation) {
283
+ return observation.openPositions.filter((p) => p.venue === "futures" && p.thesis?.status === "invalidated");
284
+ }
285
+ export function rememberThesis(state, thesis) {
286
+ const theses = { ...(state.theses ?? {}) };
287
+ theses[thesisKey(thesis.venue, thesis.positionId)] = thesis;
288
+ const keys = Object.keys(theses);
289
+ if (keys.length > MAX_PERSISTED_THESES) {
290
+ keys.sort((a, b) => Date.parse(theses[a].openedAt) - Date.parse(theses[b].openedAt));
291
+ for (const k of keys.slice(0, keys.length - MAX_PERSISTED_THESES))
292
+ delete theses[k];
293
+ }
294
+ state.theses = theses;
295
+ }
296
+ export function forgetThesis(state, key) {
297
+ if (!state.theses || !(key in state.theses))
298
+ return;
299
+ const theses = { ...state.theses };
300
+ delete theses[key];
301
+ state.theses = theses;
302
+ }
303
+ // One-line rendering for logs and the journal.
304
+ export function describeInvalidation(inv) {
305
+ const parts = [];
306
+ if (inv.priceBelow != null)
307
+ parts.push(`priceBelow ${inv.priceBelow}`);
308
+ if (inv.priceAbove != null)
309
+ parts.push(`priceAbove ${inv.priceAbove}`);
310
+ if (inv.probabilityBelow != null)
311
+ parts.push(`probabilityBelow ${inv.probabilityBelow}`);
312
+ if (inv.probabilityAbove != null)
313
+ parts.push(`probabilityAbove ${inv.probabilityAbove}`);
314
+ if (inv.maxHoldMinutes != null)
315
+ parts.push(`time stop ${inv.maxHoldMinutes}m`);
316
+ if (inv.catalyst)
317
+ parts.push(`catalyst: ${inv.catalyst}`);
318
+ return parts.length > 0 ? parts.join(", ") : "no condition";
319
+ }