simmer-automaton 0.3.1 → 0.4.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/dist/api.d.ts CHANGED
@@ -10,8 +10,18 @@ export interface AutomatonState {
10
10
  halted: boolean;
11
11
  tier: string;
12
12
  horizon_days: number;
13
+ venue: string;
13
14
  started_at: string | null;
14
15
  }
16
+ export interface Tunable {
17
+ env: string;
18
+ type: "number" | "string" | "boolean" | "enum";
19
+ default: number | string | boolean;
20
+ label: string;
21
+ range?: [number, number];
22
+ step?: number;
23
+ options?: string[];
24
+ }
15
25
  export interface Skill {
16
26
  id: string;
17
27
  name: string;
@@ -20,6 +30,16 @@ export interface Skill {
20
30
  tags: string[];
21
31
  difficulty: string;
22
32
  enabled: boolean;
33
+ entrypoint?: string;
34
+ tunables?: Tunable[];
35
+ config?: Record<string, number | string | boolean>;
36
+ pinned?: string[];
37
+ }
38
+ export interface SkillOutcome {
39
+ skill_slug: string;
40
+ trades: number;
41
+ total_cost: number;
42
+ period_pnl: number;
23
43
  }
24
44
  export declare class SimmerApi {
25
45
  private baseUrl;
@@ -65,4 +85,9 @@ export declare class SimmerApi {
65
85
  cycles: Array<Record<string, unknown>>;
66
86
  total: number;
67
87
  }>;
88
+ setSkillConfig(slug: string, config: Record<string, number | string | boolean | string[]>): Promise<unknown>;
89
+ getOutcomes(since: string): Promise<{
90
+ outcomes: SkillOutcome[];
91
+ since: string;
92
+ }>;
68
93
  }
package/dist/api.js CHANGED
@@ -60,4 +60,14 @@ export class SimmerApi {
60
60
  params.set("since", since);
61
61
  return this.request(`/api/sdk/automaton/cycles?${params}`);
62
62
  }
63
+ async setSkillConfig(slug, config) {
64
+ return this.request(`/api/sdk/automaton/skills/${encodeURIComponent(slug)}/config`, {
65
+ method: "POST",
66
+ body: JSON.stringify(config),
67
+ });
68
+ }
69
+ async getOutcomes(since) {
70
+ const params = new URLSearchParams({ since });
71
+ return this.request(`/api/sdk/automaton/outcomes?${params}`);
72
+ }
63
73
  }
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { SimmerApi } from "./api.js";
10
10
  import { selectSkills, tierMaxSkills } from "./bandit.js";
11
11
  import { computeTier } from "./tiers.js";
12
- import { generateTuningHints } from "./tuning.js";
12
+ import { generateTuningHints, computeTuningChanges } from "./tuning.js";
13
13
  // Plugin-local state (in-memory, refreshed from API each cycle)
14
14
  let api;
15
15
  let cachedState = null;
@@ -21,6 +21,7 @@ let lastPromptCycle = -1; // Track which cycle was last issued to prevent re-run
21
21
  let currentSelectedMeta = [];
22
22
  let serviceRunning = false;
23
23
  let cycleTimer = null;
24
+ let lastCycleTimestamp = new Date().toISOString();
24
25
  // Config defaults
25
26
  let config = {
26
27
  apiKey: "",
@@ -66,6 +67,10 @@ async function refreshState(logger) {
66
67
  cachedSkills = res.skills;
67
68
  }
68
69
  if (cachedState.initialized) {
70
+ // Sync venue from server state (source of truth — server enforces it)
71
+ if (cachedState.venue) {
72
+ config.venue = cachedState.venue;
73
+ }
69
74
  // Compute tier (totalPnl = 0 for now, will be enriched when P&L tracking is added)
70
75
  currentTier = computeTier(cachedState, 0);
71
76
  // Sync banditState from fetched skills — preserve memory for existing, seed new ones
@@ -130,10 +135,22 @@ function buildPromptContext() {
130
135
  lastPromptCycle = cycleCount;
131
136
  lines.push(`## Simmer Automaton — Cycle ${cycleCount}`);
132
137
  lines.push("");
133
- const venueFlag = config.venue !== "simmer" ? ` TRADING_VENUE=${config.venue}` : "";
134
- lines.push(`**ACTION: Run these skills now.**${venueFlag ? ` Set env:${venueFlag}` : ""} Use --live --quiet flags.`);
138
+ lines.push("**ACTION: Run these skills now** (copy-paste each command):");
139
+ lines.push("");
140
+ // Build slug → entrypoint map from cachedSkills
141
+ const entrypointMap = new Map(cachedSkills.map((s) => [s.id, s.entrypoint]));
135
142
  for (const m of currentSelectedMeta) {
136
- lines.push(`- ${m.slug}`);
143
+ const entrypoint = entrypointMap.get(m.slug);
144
+ if (entrypoint) {
145
+ const skillConfig = cachedSkills.find((s) => s.id === m.slug)?.config || {};
146
+ const envVars = Object.entries(skillConfig).map(([k, v]) => `${k}=${v}`).join(" ");
147
+ const envPrefix = envVars ? `${envVars} ` : "";
148
+ lines.push(`- \`TRADING_VENUE=${config.venue} ${envPrefix}python ${entrypoint} --live --quiet\``);
149
+ }
150
+ else {
151
+ // No entrypoint in registry — bot should find the script from SKILL.md
152
+ lines.push(`- ${m.slug} (check SKILL.md for entrypoint)`);
153
+ }
137
154
  }
138
155
  lines.push("");
139
156
  lines.push("After running each skill, briefly note: trades executed, signals found, or why it was skipped.");
@@ -166,6 +183,8 @@ function buildPromptContext() {
166
183
  // --- Instructions for human-facing queries ---
167
184
  lines.push("");
168
185
  lines.push("**When your human asks about the automaton:** Report tier, budget, burn rate, which skills are running, and any tuning hints. Use `/simmer history` for recent cycle decisions. Don't dump raw data — summarize.");
186
+ lines.push("");
187
+ lines.push("**Currency formatting:** $SIM amounts must be written as `XXX $SIM` (e.g. `25.00 $SIM`, `100.00 $SIM`). NEVER write `$SIM25` or `$SIMxx` — the `$SIM` suffix goes AFTER the number. Real USDC uses `$` prefix (e.g. `$25.00`).");
169
188
  return lines.join("\n");
170
189
  }
171
190
  function formatStatus() {
@@ -221,7 +240,52 @@ export default function register(pluginApi) {
221
240
  if (!serviceRunning)
222
241
  return;
223
242
  cycleCount++;
243
+ const cycleStarted = lastCycleTimestamp;
244
+ lastCycleTimestamp = new Date().toISOString();
224
245
  await refreshState(ctx.logger);
246
+ // Query outcomes since last cycle and update bandit reward data
247
+ try {
248
+ const outcomeRes = await api.getOutcomes(cycleStarted);
249
+ for (const o of outcomeRes.outcomes) {
250
+ const skill = banditState.find((s) => s.slug === o.skill_slug);
251
+ if (skill) {
252
+ skill.tradesExecutedTotal += o.trades;
253
+ skill.timesRewarded += o.trades > 0 ? 1 : 0;
254
+ skill.totalPnl += o.period_pnl;
255
+ skill.consecutiveZeroSignals = o.trades > 0 ? 0 : skill.consecutiveZeroSignals + 1;
256
+ }
257
+ }
258
+ if (outcomeRes.outcomes.length > 0) {
259
+ ctx.logger.info(`[simmer] Outcomes: ${outcomeRes.outcomes.map((o) => `${o.skill_slug}:${o.trades}t`).join(", ")}`);
260
+ }
261
+ }
262
+ catch (e) {
263
+ ctx.logger.error(`[simmer] Failed to fetch outcomes: ${e}`);
264
+ }
265
+ // Apply deterministic tuning
266
+ let tuningChanges = [];
267
+ if (cycleCount >= 5) {
268
+ const tunableSkills = cachedSkills
269
+ .filter((s) => s.tunables && s.tunables.length > 0)
270
+ .map((s) => ({
271
+ slug: s.id,
272
+ tunables: s.tunables,
273
+ config: s.config || {},
274
+ pinned: s.pinned || [],
275
+ }));
276
+ tuningChanges = computeTuningChanges(banditState, tunableSkills, cycleCount, cachedState?.budget_usd ?? 0);
277
+ for (const change of tuningChanges) {
278
+ const skill = tunableSkills.find((s) => s.slug === change.slug);
279
+ if (skill) {
280
+ const newConfig = { ...skill.config, [change.env]: change.newValue };
281
+ api.setSkillConfig(change.slug, newConfig)
282
+ .catch((e) => ctx.logger.error(`[simmer] Failed to apply config: ${e}`));
283
+ }
284
+ }
285
+ if (tuningChanges.length > 0) {
286
+ ctx.logger.info(`[simmer] Tuning: ${tuningChanges.map((c) => `${c.slug}.${c.env}: ${c.oldValue} → ${c.newValue}`).join(", ")}`);
287
+ }
288
+ }
225
289
  // Decay epsilon
226
290
  config.epsilon = Math.max(config.minEpsilon, config.epsilon * config.epsilonDecay);
227
291
  // Select skills and generate hints for this cycle
@@ -230,7 +294,7 @@ export default function register(pluginApi) {
230
294
  currentSelectedMeta = meta;
231
295
  const hints = generateTuningHints(banditState, cachedState?.budget_usd ?? 0);
232
296
  // Record cycle to API (fire-and-forget — don't block the loop)
233
- api.recordCycle({
297
+ const cycleData = {
234
298
  cycle_num: cycleCount,
235
299
  tier: currentTier,
236
300
  epsilon: parseFloat(config.epsilon.toFixed(4)),
@@ -238,7 +302,11 @@ export default function register(pluginApi) {
238
302
  tuning_hints: hints,
239
303
  budget_usd: cachedState?.budget_usd,
240
304
  spent_usd: cachedState?.spent_usd,
241
- }).catch((e) => ctx.logger.error(`[simmer] Failed to record cycle: ${e}`));
305
+ };
306
+ if (tuningChanges.length > 0) {
307
+ cycleData.config_changes = tuningChanges.map((c) => ({ slug: c.slug, env: c.env, old: c.oldValue, new: c.newValue, reason: c.reason }));
308
+ }
309
+ api.recordCycle(cycleData).catch((e) => ctx.logger.error(`[simmer] Failed to record cycle: ${e}`));
242
310
  ctx.logger.info(`[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | selected=${selected.length} skills`);
243
311
  }, config.cycleIntervalMs);
244
312
  },
@@ -343,8 +411,75 @@ export default function register(pluginApi) {
343
411
  return { text: `Failed to fetch history: ${e}` };
344
412
  }
345
413
  }
414
+ if (subcommand === "config") {
415
+ const slug = ctx.args?.trim().split(/\s+/)[1];
416
+ if (!slug) {
417
+ return { text: "Usage: /simmer config <skill-slug>" };
418
+ }
419
+ await refreshState(logger);
420
+ const skill = cachedSkills.find((s) => s.id === slug);
421
+ if (!skill) {
422
+ return { text: `Skill not found: ${slug}` };
423
+ }
424
+ if (!skill.tunables || skill.tunables.length === 0) {
425
+ return { text: `${slug} has no tunables.` };
426
+ }
427
+ const pinnedSet = new Set(skill.pinned || []);
428
+ const lines = skill.tunables.map((t) => {
429
+ const cur = (skill.config || {})[t.env] ?? t.default;
430
+ const isDefault = cur === t.default;
431
+ const pinLabel = pinnedSet.has(t.env) ? " [PINNED]" : "";
432
+ return ` ${t.env}: ${cur}${isDefault ? "" : ` (default: ${t.default})`}${pinLabel}`;
433
+ });
434
+ return { text: `${slug} config:\n${lines.join("\n")}` };
435
+ }
436
+ if (subcommand === "tune") {
437
+ const parts = ctx.args?.trim().split(/\s+/) || [];
438
+ const slug = parts[1];
439
+ const envVar = parts[2];
440
+ const rawValue = parts[3];
441
+ if (!slug || !envVar || rawValue === undefined) {
442
+ return { text: "Usage: /simmer tune <skill-slug> <ENV_VAR> <value>" };
443
+ }
444
+ await refreshState(logger);
445
+ const skill = cachedSkills.find((s) => s.id === slug);
446
+ if (!skill) {
447
+ return { text: `Skill not found: ${slug}` };
448
+ }
449
+ // Parse value — try number, then boolean, then string
450
+ let parsedValue = rawValue;
451
+ if (rawValue === "true")
452
+ parsedValue = true;
453
+ else if (rawValue === "false")
454
+ parsedValue = false;
455
+ else if (!isNaN(Number(rawValue)))
456
+ parsedValue = Number(rawValue);
457
+ const currentConfig = skill.config || {};
458
+ const currentPinned = new Set(skill.pinned || []);
459
+ currentPinned.add(envVar);
460
+ try {
461
+ await api.setSkillConfig(slug, { ...currentConfig, [envVar]: parsedValue, _pinned: [...currentPinned] });
462
+ return { text: `Set ${slug} ${envVar}=${parsedValue} (pinned — automaton won't override it).` };
463
+ }
464
+ catch (e) {
465
+ return { text: `Failed to set config: ${e}` };
466
+ }
467
+ }
468
+ if (subcommand === "reset") {
469
+ const slug = ctx.args?.trim().split(/\s+/)[1];
470
+ if (!slug) {
471
+ return { text: "Usage: /simmer reset <skill-slug>" };
472
+ }
473
+ try {
474
+ await api.setSkillConfig(slug, { _pinned: [] });
475
+ return { text: `Reset ${slug} to defaults. All pins cleared.` };
476
+ }
477
+ catch (e) {
478
+ return { text: `Failed to reset config: ${e}` };
479
+ }
480
+ }
346
481
  return {
347
- text: "Usage: /simmer [status|halt|resume|skills|history [N]|disable <slug>|enable <slug>]",
482
+ text: "Usage: /simmer [status|halt|resume|skills|history [N]|disable <slug>|enable <slug>|config <slug>|tune <slug> <ENV> <val>|reset <slug>]",
348
483
  };
349
484
  },
350
485
  });
package/dist/tuning.d.ts CHANGED
@@ -1,12 +1,29 @@
1
1
  /**
2
- * Tuning hints for the Clawbot LLM.
3
- * Ported from automaton.py — generate_tuning_hints.
2
+ * Tuning engine for the automaton plugin.
3
+ * Two layers:
4
+ * 1. generateTuningHints() — text hints for the LLM prompt
5
+ * 2. computeTuningChanges() — deterministic config changes applied via API
4
6
  */
5
7
  import type { SkillState } from "./bandit.js";
8
+ import type { Tunable } from "./api.js";
6
9
  export interface TuningHint {
7
10
  skill: string;
8
11
  issue: string;
9
12
  suggestion: string;
10
13
  [key: string]: unknown;
11
14
  }
15
+ export interface ConfigChange {
16
+ slug: string;
17
+ env: string;
18
+ oldValue: number | string | boolean;
19
+ newValue: number | string | boolean;
20
+ reason: string;
21
+ }
22
+ export interface TunableSkill {
23
+ slug: string;
24
+ tunables: Tunable[];
25
+ config: Record<string, any>;
26
+ pinned: string[];
27
+ }
12
28
  export declare function generateTuningHints(skills: SkillState[], budgetUsd: number): TuningHint[];
29
+ export declare function computeTuningChanges(skills: SkillState[], tunableSkills: TunableSkill[], cycleCount: number, budgetUsd?: number): ConfigChange[];
package/dist/tuning.js CHANGED
@@ -1,13 +1,40 @@
1
1
  /**
2
- * Tuning hints for the Clawbot LLM.
3
- * Ported from automaton.py — generate_tuning_hints.
2
+ * Tuning engine for the automaton plugin.
3
+ * Two layers:
4
+ * 1. generateTuningHints() — text hints for the LLM prompt
5
+ * 2. computeTuningChanges() — deterministic config changes applied via API
4
6
  */
7
+ // Track last tuned cycle per skill to enforce cooldowns
8
+ const lastTunedCycle = new Map();
9
+ function isThresholdTunable(t) {
10
+ const l = t.label.toLowerCase();
11
+ return l.includes("threshold") || l.includes("edge") || l.includes("confidence") || l.includes("min edge") || l.includes("min split");
12
+ }
13
+ function isMaxBetTunable(t) {
14
+ const l = t.label.toLowerCase();
15
+ return l.includes("max bet") || l.includes("max position") || l.includes("max usd");
16
+ }
17
+ function isMaxTradesTunable(t) {
18
+ return t.env.toUpperCase().includes("MAX_TRADES");
19
+ }
20
+ function snapToStep(value, t) {
21
+ if (!t.step)
22
+ return value;
23
+ return Math.round(value / t.step) * t.step;
24
+ }
25
+ function clampToRange(value, t) {
26
+ if (!t.range)
27
+ return value;
28
+ return Math.max(t.range[0], Math.min(t.range[1], value));
29
+ }
30
+ function currentValue(t, config) {
31
+ return config[t.env] ?? t.default;
32
+ }
5
33
  export function generateTuningHints(skills, budgetUsd) {
6
34
  const hints = [];
7
35
  for (const sk of skills) {
8
36
  if (!sk.enabled || sk.timesSelected === 0)
9
37
  continue;
10
- // 1. Zero signals streak
11
38
  if (sk.consecutiveZeroSignals >= 5) {
12
39
  hints.push({
13
40
  skill: sk.slug,
@@ -16,7 +43,6 @@ export function generateTuningHints(skills, budgetUsd) {
16
43
  suggestion: `0 signals for ${sk.consecutiveZeroSignals} cycles — loosen thresholds or widen time windows`,
17
44
  });
18
45
  }
19
- // 2. Concentrated loss
20
46
  if (sk.totalPnl < 0 && budgetUsd > 0) {
21
47
  const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
22
48
  if (lossPct > 20) {
@@ -29,7 +55,6 @@ export function generateTuningHints(skills, budgetUsd) {
29
55
  });
30
56
  }
31
57
  }
32
- // 3. Inert — finds signals but never executes
33
58
  if (sk.signalsFoundTotal > 50 && sk.tradesExecutedTotal === 0) {
34
59
  hints.push({
35
60
  skill: sk.slug,
@@ -38,7 +63,6 @@ export function generateTuningHints(skills, budgetUsd) {
38
63
  suggestion: `${sk.signalsFoundTotal} signals found, 0 executed — execution thresholds likely too tight`,
39
64
  });
40
65
  }
41
- // 4. Win rate collapse
42
66
  if (sk.timesSelected >= 10) {
43
67
  const winRate = sk.timesRewarded / sk.timesSelected;
44
68
  if (winRate < 0.2) {
@@ -51,7 +75,6 @@ export function generateTuningHints(skills, budgetUsd) {
51
75
  });
52
76
  }
53
77
  }
54
- // 5. Safeguard dominant
55
78
  const skipCounts = sk.lastCycle?.skipCounts || {};
56
79
  const totalSkips = Object.values(skipCounts).reduce((a, b) => a + b, 0);
57
80
  const safeguardSkips = skipCounts["safeguard"] || 0;
@@ -66,3 +89,103 @@ export function generateTuningHints(skills, budgetUsd) {
66
89
  }
67
90
  return hints;
68
91
  }
92
+ export function computeTuningChanges(skills, tunableSkills, cycleCount, budgetUsd = 0) {
93
+ if (cycleCount < 5)
94
+ return [];
95
+ const changes = [];
96
+ const skillMap = new Map(skills.map((s) => [s.slug, s]));
97
+ for (const ts of tunableSkills) {
98
+ const sk = skillMap.get(ts.slug);
99
+ if (!sk || !sk.enabled || sk.timesSelected === 0)
100
+ continue;
101
+ let lastTuned = lastTunedCycle.get(ts.slug) ?? 0;
102
+ const pinnedSet = new Set(ts.pinned);
103
+ // Rule 1: consecutiveZeroSignals >= 5 → widen thresholds by 20% (cooldown 10)
104
+ if (sk.consecutiveZeroSignals >= 5 && cycleCount - lastTuned >= 10) {
105
+ for (const t of ts.tunables) {
106
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isThresholdTunable(t))
107
+ continue;
108
+ const cur = currentValue(t, ts.config);
109
+ if (typeof cur !== "number")
110
+ continue;
111
+ let newVal = cur * 0.8;
112
+ newVal = clampToRange(newVal, t);
113
+ newVal = snapToStep(newVal, t);
114
+ if (newVal !== cur) {
115
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `zero signals for ${sk.consecutiveZeroSignals} cycles — widening threshold` });
116
+ lastTunedCycle.set(ts.slug, cycleCount);
117
+ lastTuned = cycleCount;
118
+ }
119
+ }
120
+ }
121
+ // Rule 2: Skill P&L < -15% of budget → halve max bet (cooldown 10)
122
+ if (budgetUsd > 0 && sk.totalPnl < 0 && cycleCount - lastTuned >= 10) {
123
+ const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
124
+ if (lossPct > 15) {
125
+ for (const t of ts.tunables) {
126
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isMaxBetTunable(t))
127
+ continue;
128
+ if (changes.some((c) => c.slug === ts.slug && c.env === t.env))
129
+ continue;
130
+ const cur = currentValue(t, ts.config);
131
+ if (typeof cur !== "number")
132
+ continue;
133
+ let newVal = cur * 0.5;
134
+ newVal = clampToRange(newVal, t);
135
+ newVal = snapToStep(newVal, t);
136
+ if (newVal !== cur) {
137
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `P&L ${sk.totalPnl.toFixed(2)} (${lossPct.toFixed(0)}% of budget) — halving max bet` });
138
+ lastTunedCycle.set(ts.slug, cycleCount);
139
+ lastTuned = cycleCount;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ // Rule 3: Win rate < 20% over last 20 cycles → reduce max trades (cooldown 15)
145
+ if (sk.timesSelected >= 20 && cycleCount - lastTuned >= 15) {
146
+ const winRate = sk.timesRewarded / sk.timesSelected;
147
+ if (winRate < 0.2) {
148
+ for (const t of ts.tunables) {
149
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isMaxTradesTunable(t))
150
+ continue;
151
+ const cur = currentValue(t, ts.config);
152
+ if (typeof cur !== "number")
153
+ continue;
154
+ let newVal = cur - 1;
155
+ newVal = Math.max(1, newVal);
156
+ newVal = clampToRange(newVal, t);
157
+ newVal = snapToStep(newVal, t);
158
+ if (newVal !== cur) {
159
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `win rate ${(winRate * 100).toFixed(0)}% — reducing max trades` });
160
+ lastTunedCycle.set(ts.slug, cycleCount);
161
+ lastTuned = cycleCount;
162
+ }
163
+ }
164
+ }
165
+ }
166
+ // Rule 4: Win rate > 60% over last 20 cycles → increase max bet by 25% (cooldown 10)
167
+ if (sk.timesSelected >= 20 && cycleCount - lastTuned >= 10) {
168
+ const winRate = sk.timesRewarded / sk.timesSelected;
169
+ if (winRate > 0.6) {
170
+ for (const t of ts.tunables) {
171
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isMaxBetTunable(t))
172
+ continue;
173
+ if (changes.some((c) => c.slug === ts.slug && c.env === t.env))
174
+ continue;
175
+ const cur = currentValue(t, ts.config);
176
+ if (typeof cur !== "number")
177
+ continue;
178
+ let newVal = cur * 1.25;
179
+ newVal = clampToRange(newVal, t);
180
+ newVal = snapToStep(newVal, t);
181
+ if (newVal !== cur) {
182
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `win rate ${(winRate * 100).toFixed(0)}% — increasing max bet` });
183
+ lastTunedCycle.set(ts.slug, cycleCount);
184
+ lastTuned = cycleCount;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ return changes;
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simmer-automaton",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Simmer Automaton plugin for OpenClaw — autonomous trading skill orchestration",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/api.ts CHANGED
@@ -11,9 +11,20 @@ export interface AutomatonState {
11
11
  halted: boolean;
12
12
  tier: string;
13
13
  horizon_days: number;
14
+ venue: string;
14
15
  started_at: string | null;
15
16
  }
16
17
 
18
+ export interface Tunable {
19
+ env: string;
20
+ type: "number" | "string" | "boolean" | "enum";
21
+ default: number | string | boolean;
22
+ label: string;
23
+ range?: [number, number];
24
+ step?: number;
25
+ options?: string[];
26
+ }
27
+
17
28
  export interface Skill {
18
29
  id: string;
19
30
  name: string;
@@ -22,6 +33,17 @@ export interface Skill {
22
33
  tags: string[];
23
34
  difficulty: string;
24
35
  enabled: boolean;
36
+ entrypoint?: string;
37
+ tunables?: Tunable[];
38
+ config?: Record<string, number | string | boolean>;
39
+ pinned?: string[];
40
+ }
41
+
42
+ export interface SkillOutcome {
43
+ skill_slug: string;
44
+ trades: number;
45
+ total_cost: number;
46
+ period_pnl: number;
25
47
  }
26
48
 
27
49
  export class SimmerApi {
@@ -104,4 +126,16 @@ export class SimmerApi {
104
126
  if (since) params.set("since", since);
105
127
  return this.request(`/api/sdk/automaton/cycles?${params}`);
106
128
  }
129
+
130
+ async setSkillConfig(slug: string, config: Record<string, number | string | boolean | string[]>) {
131
+ return this.request(`/api/sdk/automaton/skills/${encodeURIComponent(slug)}/config`, {
132
+ method: "POST",
133
+ body: JSON.stringify(config),
134
+ });
135
+ }
136
+
137
+ async getOutcomes(since: string): Promise<{ outcomes: SkillOutcome[]; since: string }> {
138
+ const params = new URLSearchParams({ since });
139
+ return this.request(`/api/sdk/automaton/outcomes?${params}`);
140
+ }
107
141
  }
package/src/index.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import { SimmerApi } from "./api.js";
11
- import type { AutomatonState, Skill } from "./api.js";
11
+ import type { AutomatonState, Skill, SkillOutcome } from "./api.js";
12
12
  import { selectSkills, tierMaxSkills, type SkillState } from "./bandit.js";
13
13
  import { computeTier, type Tier } from "./tiers.js";
14
- import { generateTuningHints } from "./tuning.js";
14
+ import { generateTuningHints, computeTuningChanges, type ConfigChange } from "./tuning.js";
15
15
 
16
16
  // OpenClaw types — we declare minimal interfaces to avoid requiring the SDK as a dependency
17
17
  interface PluginApi {
@@ -42,6 +42,7 @@ let lastPromptCycle = -1; // Track which cycle was last issued to prevent re-run
42
42
  let currentSelectedMeta: Array<{ slug: string; reason: string; score: number | null }> = [];
43
43
  let serviceRunning = false;
44
44
  let cycleTimer: ReturnType<typeof setInterval> | null = null;
45
+ let lastCycleTimestamp: string = new Date().toISOString();
45
46
 
46
47
  // Config defaults
47
48
  let config = {
@@ -81,6 +82,10 @@ async function refreshState(logger: { info: (m: string) => void; error: (m: stri
81
82
  }
82
83
 
83
84
  if (cachedState.initialized) {
85
+ // Sync venue from server state (source of truth — server enforces it)
86
+ if (cachedState.venue) {
87
+ config.venue = cachedState.venue;
88
+ }
84
89
  // Compute tier (totalPnl = 0 for now, will be enriched when P&L tracking is added)
85
90
  currentTier = computeTier(cachedState, 0);
86
91
 
@@ -90,13 +95,13 @@ async function refreshState(logger: { info: (m: string) => void; error: (m: stri
90
95
  const existing = existingBySlug.get(skill.id);
91
96
  if (existing) {
92
97
  // Preserve bandit memory, update enabled status
93
- existing.enabled = (skill as any).enabled !== false;
98
+ existing.enabled = skill.enabled !== false;
94
99
  return existing;
95
100
  }
96
101
  // New skill — seed with zero history (unplayed gets priority)
97
102
  return {
98
103
  slug: skill.id,
99
- enabled: (skill as any).enabled !== false,
104
+ enabled: skill.enabled !== false,
100
105
  timesSelected: 0,
101
106
  timesRewarded: 0,
102
107
  totalPnl: 0,
@@ -151,10 +156,21 @@ function buildPromptContext(): string {
151
156
  lastPromptCycle = cycleCount;
152
157
  lines.push(`## Simmer Automaton — Cycle ${cycleCount}`);
153
158
  lines.push("");
154
- const venueFlag = config.venue !== "simmer" ? ` TRADING_VENUE=${config.venue}` : "";
155
- lines.push(`**ACTION: Run these skills now.**${venueFlag ? ` Set env:${venueFlag}` : ""} Use --live --quiet flags.`);
159
+ lines.push("**ACTION: Run these skills now** (copy-paste each command):");
160
+ lines.push("");
161
+ // Build slug → entrypoint map from cachedSkills
162
+ const entrypointMap = new Map(cachedSkills.map((s) => [s.id, s.entrypoint]));
156
163
  for (const m of currentSelectedMeta) {
157
- lines.push(`- ${m.slug}`);
164
+ const entrypoint = entrypointMap.get(m.slug);
165
+ if (entrypoint) {
166
+ const skillConfig = cachedSkills.find((s) => s.id === m.slug)?.config || {};
167
+ const envVars = Object.entries(skillConfig).map(([k, v]) => `${k}=${v}`).join(" ");
168
+ const envPrefix = envVars ? `${envVars} ` : "";
169
+ lines.push(`- \`TRADING_VENUE=${config.venue} ${envPrefix}python ${entrypoint} --live --quiet\``);
170
+ } else {
171
+ // No entrypoint in registry — bot should find the script from SKILL.md
172
+ lines.push(`- ${m.slug} (check SKILL.md for entrypoint)`);
173
+ }
158
174
  }
159
175
  lines.push("");
160
176
  lines.push("After running each skill, briefly note: trades executed, signals found, or why it was skipped.");
@@ -190,6 +206,8 @@ function buildPromptContext(): string {
190
206
  // --- Instructions for human-facing queries ---
191
207
  lines.push("");
192
208
  lines.push("**When your human asks about the automaton:** Report tier, budget, burn rate, which skills are running, and any tuning hints. Use `/simmer history` for recent cycle decisions. Don't dump raw data — summarize.");
209
+ lines.push("");
210
+ lines.push("**Currency formatting:** $SIM amounts must be written as `XXX $SIM` (e.g. `25.00 $SIM`, `100.00 $SIM`). NEVER write `$SIM25` or `$SIMxx` — the `$SIM` suffix goes AFTER the number. Real USDC uses `$` prefix (e.g. `$25.00`).");
193
211
 
194
212
  return lines.join("\n");
195
213
  }
@@ -256,8 +274,55 @@ export default function register(pluginApi: PluginApi) {
256
274
  cycleTimer = setInterval(async () => {
257
275
  if (!serviceRunning) return;
258
276
  cycleCount++;
277
+ const cycleStarted = lastCycleTimestamp;
278
+ lastCycleTimestamp = new Date().toISOString();
279
+
259
280
  await refreshState(ctx.logger);
260
281
 
282
+ // Query outcomes since last cycle and update bandit reward data
283
+ try {
284
+ const outcomeRes = await api.getOutcomes(cycleStarted);
285
+ for (const o of outcomeRes.outcomes) {
286
+ const skill = banditState.find((s) => s.slug === o.skill_slug);
287
+ if (skill) {
288
+ skill.tradesExecutedTotal += o.trades;
289
+ skill.timesRewarded += o.trades > 0 ? 1 : 0;
290
+ skill.totalPnl += o.period_pnl;
291
+ skill.consecutiveZeroSignals = o.trades > 0 ? 0 : skill.consecutiveZeroSignals + 1;
292
+ }
293
+ }
294
+ if (outcomeRes.outcomes.length > 0) {
295
+ ctx.logger.info(`[simmer] Outcomes: ${outcomeRes.outcomes.map((o: SkillOutcome) => `${o.skill_slug}:${o.trades}t`).join(", ")}`);
296
+ }
297
+ } catch (e) {
298
+ ctx.logger.error(`[simmer] Failed to fetch outcomes: ${e}`);
299
+ }
300
+
301
+ // Apply deterministic tuning
302
+ let tuningChanges: ConfigChange[] = [];
303
+ if (cycleCount >= 5) {
304
+ const tunableSkills = cachedSkills
305
+ .filter((s) => s.tunables && s.tunables.length > 0)
306
+ .map((s) => ({
307
+ slug: s.id,
308
+ tunables: s.tunables!,
309
+ config: s.config || {},
310
+ pinned: s.pinned || [],
311
+ }));
312
+ tuningChanges = computeTuningChanges(banditState, tunableSkills, cycleCount, cachedState?.budget_usd ?? 0);
313
+ for (const change of tuningChanges) {
314
+ const skill = tunableSkills.find((s) => s.slug === change.slug);
315
+ if (skill) {
316
+ const newConfig = { ...skill.config, [change.env]: change.newValue };
317
+ api.setSkillConfig(change.slug, newConfig)
318
+ .catch((e) => ctx.logger.error(`[simmer] Failed to apply config: ${e}`));
319
+ }
320
+ }
321
+ if (tuningChanges.length > 0) {
322
+ ctx.logger.info(`[simmer] Tuning: ${tuningChanges.map((c) => `${c.slug}.${c.env}: ${c.oldValue} → ${c.newValue}`).join(", ")}`);
323
+ }
324
+ }
325
+
261
326
  // Decay epsilon
262
327
  config.epsilon = Math.max(
263
328
  config.minEpsilon,
@@ -271,7 +336,7 @@ export default function register(pluginApi: PluginApi) {
271
336
  const hints = generateTuningHints(banditState, cachedState?.budget_usd ?? 0);
272
337
 
273
338
  // Record cycle to API (fire-and-forget — don't block the loop)
274
- api.recordCycle({
339
+ const cycleData: Record<string, unknown> = {
275
340
  cycle_num: cycleCount,
276
341
  tier: currentTier,
277
342
  epsilon: parseFloat(config.epsilon.toFixed(4)),
@@ -279,7 +344,11 @@ export default function register(pluginApi: PluginApi) {
279
344
  tuning_hints: hints,
280
345
  budget_usd: cachedState?.budget_usd,
281
346
  spent_usd: cachedState?.spent_usd,
282
- }).catch((e) => ctx.logger.error(`[simmer] Failed to record cycle: ${e}`));
347
+ };
348
+ if (tuningChanges.length > 0) {
349
+ cycleData.config_changes = tuningChanges.map((c) => ({ slug: c.slug, env: c.env, old: c.oldValue, new: c.newValue, reason: c.reason }));
350
+ }
351
+ api.recordCycle(cycleData as any).catch((e) => ctx.logger.error(`[simmer] Failed to record cycle: ${e}`));
283
352
 
284
353
  ctx.logger.info(
285
354
  `[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | selected=${selected.length} skills`,
@@ -337,7 +406,7 @@ export default function register(pluginApi: PluginApi) {
337
406
  }
338
407
  const lines = cachedSkills.map(
339
408
  (s) => {
340
- const status = (s as any).enabled === false ? " [DISABLED]" : "";
409
+ const status = s.enabled === false ? " [DISABLED]" : "";
341
410
  return `- ${s.name} (${s.id}) — ${s.category}, ${s.difficulty}${status}`;
342
411
  },
343
412
  );
@@ -394,8 +463,73 @@ export default function register(pluginApi: PluginApi) {
394
463
  }
395
464
  }
396
465
 
466
+ if (subcommand === "config") {
467
+ const slug = ctx.args?.trim().split(/\s+/)[1];
468
+ if (!slug) {
469
+ return { text: "Usage: /simmer config <skill-slug>" };
470
+ }
471
+ await refreshState(logger);
472
+ const skill = cachedSkills.find((s) => s.id === slug);
473
+ if (!skill) {
474
+ return { text: `Skill not found: ${slug}` };
475
+ }
476
+ if (!skill.tunables || skill.tunables.length === 0) {
477
+ return { text: `${slug} has no tunables.` };
478
+ }
479
+ const pinnedSet = new Set(skill.pinned || []);
480
+ const lines = skill.tunables.map((t) => {
481
+ const cur = (skill.config || {})[t.env] ?? t.default;
482
+ const isDefault = cur === t.default;
483
+ const pinLabel = pinnedSet.has(t.env) ? " [PINNED]" : "";
484
+ return ` ${t.env}: ${cur}${isDefault ? "" : ` (default: ${t.default})`}${pinLabel}`;
485
+ });
486
+ return { text: `${slug} config:\n${lines.join("\n")}` };
487
+ }
488
+
489
+ if (subcommand === "tune") {
490
+ const parts = ctx.args?.trim().split(/\s+/) || [];
491
+ const slug = parts[1];
492
+ const envVar = parts[2];
493
+ const rawValue = parts[3];
494
+ if (!slug || !envVar || rawValue === undefined) {
495
+ return { text: "Usage: /simmer tune <skill-slug> <ENV_VAR> <value>" };
496
+ }
497
+ await refreshState(logger);
498
+ const skill = cachedSkills.find((s) => s.id === slug);
499
+ if (!skill) {
500
+ return { text: `Skill not found: ${slug}` };
501
+ }
502
+ // Parse value — try number, then boolean, then string
503
+ let parsedValue: number | string | boolean = rawValue;
504
+ if (rawValue === "true") parsedValue = true;
505
+ else if (rawValue === "false") parsedValue = false;
506
+ else if (!isNaN(Number(rawValue))) parsedValue = Number(rawValue);
507
+ const currentConfig = skill.config || {};
508
+ const currentPinned = new Set(skill.pinned || []);
509
+ currentPinned.add(envVar);
510
+ try {
511
+ await api.setSkillConfig(slug, { ...currentConfig, [envVar]: parsedValue, _pinned: [...currentPinned] });
512
+ return { text: `Set ${slug} ${envVar}=${parsedValue} (pinned — automaton won't override it).` };
513
+ } catch (e) {
514
+ return { text: `Failed to set config: ${e}` };
515
+ }
516
+ }
517
+
518
+ if (subcommand === "reset") {
519
+ const slug = ctx.args?.trim().split(/\s+/)[1];
520
+ if (!slug) {
521
+ return { text: "Usage: /simmer reset <skill-slug>" };
522
+ }
523
+ try {
524
+ await api.setSkillConfig(slug, { _pinned: [] });
525
+ return { text: `Reset ${slug} to defaults. All pins cleared.` };
526
+ } catch (e) {
527
+ return { text: `Failed to reset config: ${e}` };
528
+ }
529
+ }
530
+
397
531
  return {
398
- text: "Usage: /simmer [status|halt|resume|skills|history [N]|disable <slug>|enable <slug>]",
532
+ text: "Usage: /simmer [status|halt|resume|skills|history [N]|disable <slug>|enable <slug>|config <slug>|tune <slug> <ENV> <val>|reset <slug>]",
399
533
  };
400
534
  },
401
535
  });
package/src/tuning.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Tuning hints for the Clawbot LLM.
3
- * Ported from automaton.py — generate_tuning_hints.
2
+ * Tuning engine for the automaton plugin.
3
+ * Two layers:
4
+ * 1. generateTuningHints() — text hints for the LLM prompt
5
+ * 2. computeTuningChanges() — deterministic config changes applied via API
4
6
  */
5
7
 
6
8
  import type { SkillState } from "./bandit.js";
9
+ import type { Tunable } from "./api.js";
7
10
 
8
11
  export interface TuningHint {
9
12
  skill: string;
@@ -12,6 +15,52 @@ export interface TuningHint {
12
15
  [key: string]: unknown;
13
16
  }
14
17
 
18
+ export interface ConfigChange {
19
+ slug: string;
20
+ env: string;
21
+ oldValue: number | string | boolean;
22
+ newValue: number | string | boolean;
23
+ reason: string;
24
+ }
25
+
26
+ export interface TunableSkill {
27
+ slug: string;
28
+ tunables: Tunable[];
29
+ config: Record<string, any>;
30
+ pinned: string[];
31
+ }
32
+
33
+ // Track last tuned cycle per skill to enforce cooldowns
34
+ const lastTunedCycle = new Map<string, number>();
35
+
36
+ function isThresholdTunable(t: Tunable): boolean {
37
+ const l = t.label.toLowerCase();
38
+ return l.includes("threshold") || l.includes("edge") || l.includes("confidence") || l.includes("min edge") || l.includes("min split");
39
+ }
40
+
41
+ function isMaxBetTunable(t: Tunable): boolean {
42
+ const l = t.label.toLowerCase();
43
+ return l.includes("max bet") || l.includes("max position") || l.includes("max usd");
44
+ }
45
+
46
+ function isMaxTradesTunable(t: Tunable): boolean {
47
+ return t.env.toUpperCase().includes("MAX_TRADES");
48
+ }
49
+
50
+ function snapToStep(value: number, t: Tunable): number {
51
+ if (!t.step) return value;
52
+ return Math.round(value / t.step) * t.step;
53
+ }
54
+
55
+ function clampToRange(value: number, t: Tunable): number {
56
+ if (!t.range) return value;
57
+ return Math.max(t.range[0], Math.min(t.range[1], value));
58
+ }
59
+
60
+ function currentValue(t: Tunable, config: Record<string, any>): number | string | boolean {
61
+ return config[t.env] ?? t.default;
62
+ }
63
+
15
64
  export function generateTuningHints(
16
65
  skills: SkillState[],
17
66
  budgetUsd: number,
@@ -21,7 +70,6 @@ export function generateTuningHints(
21
70
  for (const sk of skills) {
22
71
  if (!sk.enabled || sk.timesSelected === 0) continue;
23
72
 
24
- // 1. Zero signals streak
25
73
  if (sk.consecutiveZeroSignals >= 5) {
26
74
  hints.push({
27
75
  skill: sk.slug,
@@ -31,7 +79,6 @@ export function generateTuningHints(
31
79
  });
32
80
  }
33
81
 
34
- // 2. Concentrated loss
35
82
  if (sk.totalPnl < 0 && budgetUsd > 0) {
36
83
  const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
37
84
  if (lossPct > 20) {
@@ -45,7 +92,6 @@ export function generateTuningHints(
45
92
  }
46
93
  }
47
94
 
48
- // 3. Inert — finds signals but never executes
49
95
  if (sk.signalsFoundTotal > 50 && sk.tradesExecutedTotal === 0) {
50
96
  hints.push({
51
97
  skill: sk.slug,
@@ -55,7 +101,6 @@ export function generateTuningHints(
55
101
  });
56
102
  }
57
103
 
58
- // 4. Win rate collapse
59
104
  if (sk.timesSelected >= 10) {
60
105
  const winRate = sk.timesRewarded / sk.timesSelected;
61
106
  if (winRate < 0.2) {
@@ -69,23 +114,120 @@ export function generateTuningHints(
69
114
  }
70
115
  }
71
116
 
72
- // 5. Safeguard dominant
73
117
  const skipCounts = sk.lastCycle?.skipCounts || {};
74
- const totalSkips = Object.values(skipCounts).reduce(
75
- (a, b) => a + b,
76
- 0,
77
- );
118
+ const totalSkips = Object.values(skipCounts).reduce((a, b) => a + b, 0);
78
119
  const safeguardSkips = skipCounts["safeguard"] || 0;
79
120
  if (totalSkips >= 3 && safeguardSkips / totalSkips > 0.8) {
80
121
  hints.push({
81
122
  skill: sk.slug,
82
123
  issue: "safeguard_dominant",
83
124
  safeguard_pct: Math.round((safeguardSkips / totalSkips) * 100),
84
- suggestion:
85
- "Most skips are safeguard blocks — markets may be too volatile or near resolution",
125
+ suggestion: "Most skips are safeguard blocks — markets may be too volatile or near resolution",
86
126
  });
87
127
  }
88
128
  }
89
129
 
90
130
  return hints;
91
131
  }
132
+
133
+ export function computeTuningChanges(
134
+ skills: SkillState[],
135
+ tunableSkills: TunableSkill[],
136
+ cycleCount: number,
137
+ budgetUsd = 0,
138
+ ): ConfigChange[] {
139
+ if (cycleCount < 5) return [];
140
+
141
+ const changes: ConfigChange[] = [];
142
+ const skillMap = new Map(skills.map((s) => [s.slug, s]));
143
+
144
+ for (const ts of tunableSkills) {
145
+ const sk = skillMap.get(ts.slug);
146
+ if (!sk || !sk.enabled || sk.timesSelected === 0) continue;
147
+
148
+ let lastTuned = lastTunedCycle.get(ts.slug) ?? 0;
149
+ const pinnedSet = new Set(ts.pinned);
150
+
151
+ // Rule 1: consecutiveZeroSignals >= 5 → widen thresholds by 20% (cooldown 10)
152
+ if (sk.consecutiveZeroSignals >= 5 && cycleCount - lastTuned >= 10) {
153
+ for (const t of ts.tunables) {
154
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isThresholdTunable(t)) continue;
155
+ const cur = currentValue(t, ts.config);
156
+ if (typeof cur !== "number") continue;
157
+ let newVal = cur * 0.8;
158
+ newVal = clampToRange(newVal, t);
159
+ newVal = snapToStep(newVal, t);
160
+ if (newVal !== cur) {
161
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `zero signals for ${sk.consecutiveZeroSignals} cycles — widening threshold` });
162
+ lastTunedCycle.set(ts.slug, cycleCount);
163
+ lastTuned = cycleCount;
164
+ }
165
+ }
166
+ }
167
+
168
+ // Rule 2: Skill P&L < -15% of budget → halve max bet (cooldown 10)
169
+ if (budgetUsd > 0 && sk.totalPnl < 0 && cycleCount - lastTuned >= 10) {
170
+ const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
171
+ if (lossPct > 15) {
172
+ for (const t of ts.tunables) {
173
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isMaxBetTunable(t)) continue;
174
+ if (changes.some((c) => c.slug === ts.slug && c.env === t.env)) continue;
175
+ const cur = currentValue(t, ts.config);
176
+ if (typeof cur !== "number") continue;
177
+ let newVal = cur * 0.5;
178
+ newVal = clampToRange(newVal, t);
179
+ newVal = snapToStep(newVal, t);
180
+ if (newVal !== cur) {
181
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `P&L ${sk.totalPnl.toFixed(2)} (${lossPct.toFixed(0)}% of budget) — halving max bet` });
182
+ lastTunedCycle.set(ts.slug, cycleCount);
183
+ lastTuned = cycleCount;
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ // Rule 3: Win rate < 20% over last 20 cycles → reduce max trades (cooldown 15)
190
+ if (sk.timesSelected >= 20 && cycleCount - lastTuned >= 15) {
191
+ const winRate = sk.timesRewarded / sk.timesSelected;
192
+ if (winRate < 0.2) {
193
+ for (const t of ts.tunables) {
194
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isMaxTradesTunable(t)) continue;
195
+ const cur = currentValue(t, ts.config);
196
+ if (typeof cur !== "number") continue;
197
+ let newVal = cur - 1;
198
+ newVal = Math.max(1, newVal);
199
+ newVal = clampToRange(newVal, t);
200
+ newVal = snapToStep(newVal, t);
201
+ if (newVal !== cur) {
202
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `win rate ${(winRate * 100).toFixed(0)}% — reducing max trades` });
203
+ lastTunedCycle.set(ts.slug, cycleCount);
204
+ lastTuned = cycleCount;
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ // Rule 4: Win rate > 60% over last 20 cycles → increase max bet by 25% (cooldown 10)
211
+ if (sk.timesSelected >= 20 && cycleCount - lastTuned >= 10) {
212
+ const winRate = sk.timesRewarded / sk.timesSelected;
213
+ if (winRate > 0.6) {
214
+ for (const t of ts.tunables) {
215
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isMaxBetTunable(t)) continue;
216
+ if (changes.some((c) => c.slug === ts.slug && c.env === t.env)) continue;
217
+ const cur = currentValue(t, ts.config);
218
+ if (typeof cur !== "number") continue;
219
+ let newVal = cur * 1.25;
220
+ newVal = clampToRange(newVal, t);
221
+ newVal = snapToStep(newVal, t);
222
+ if (newVal !== cur) {
223
+ changes.push({ slug: ts.slug, env: t.env, oldValue: cur, newValue: newVal, reason: `win rate ${(winRate * 100).toFixed(0)}% — increasing max bet` });
224
+ lastTunedCycle.set(ts.slug, cycleCount);
225
+ lastTuned = cycleCount;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ return changes;
233
+ }