simmer-automaton 0.4.0 → 0.5.1

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
@@ -90,4 +90,24 @@ export declare class SimmerApi {
90
90
  outcomes: SkillOutcome[];
91
91
  since: string;
92
92
  }>;
93
+ postCycle(data: {
94
+ active_skills: string[];
95
+ cycle_number: number;
96
+ selection_meta: Array<{
97
+ slug: string;
98
+ reason: string;
99
+ score: number | null;
100
+ }>;
101
+ config_changes: Array<{
102
+ slug: string;
103
+ env: string;
104
+ old: any;
105
+ new: any;
106
+ reason: string;
107
+ }>;
108
+ }): Promise<{
109
+ ok: boolean;
110
+ active_skills: string[];
111
+ cycle_number: number;
112
+ }>;
93
113
  }
package/dist/api.js CHANGED
@@ -70,4 +70,10 @@ export class SimmerApi {
70
70
  const params = new URLSearchParams({ since });
71
71
  return this.request(`/api/sdk/automaton/outcomes?${params}`);
72
72
  }
73
+ async postCycle(data) {
74
+ return this.request("/api/sdk/automaton/cycle", {
75
+ method: "POST",
76
+ body: JSON.stringify(data),
77
+ });
78
+ }
73
79
  }
package/dist/bandit.js CHANGED
@@ -5,7 +5,12 @@
5
5
  function avgReward(skill) {
6
6
  if (skill.timesSelected === 0)
7
7
  return Infinity;
8
- return skill.totalPnl / skill.timesSelected;
8
+ // Use P&L as primary signal. When P&L is unavailable (0), fall back to
9
+ // trade execution rate as bootstrap — a skill that finds and executes trades
10
+ // is better than one that finds nothing.
11
+ if (skill.totalPnl !== 0)
12
+ return skill.totalPnl / skill.timesSelected;
13
+ return skill.tradesExecutedTotal / skill.timesSelected;
9
14
  }
10
15
  export function tierMaxSkills(tier, maxConcurrent) {
11
16
  if (tier === "thriving" || tier === "normal")
@@ -49,6 +54,8 @@ export function selectSkills(skills, n, tier, epsilon) {
49
54
  score: Math.round(avgReward(s) * 10000) / 10000,
50
55
  })));
51
56
  }
57
+ for (const p of picks)
58
+ p.timesSelected++;
52
59
  return {
53
60
  selected: picks.map((s) => s.slug),
54
61
  meta,
@@ -58,6 +65,8 @@ export function selectSkills(skills, n, tier, epsilon) {
58
65
  if (tier === "critical") {
59
66
  const ranked = [...enabled].sort((a, b) => avgReward(b) - avgReward(a));
60
67
  const picks = ranked.slice(0, count);
68
+ for (const p of picks)
69
+ p.timesSelected++;
61
70
  return {
62
71
  selected: picks.map((s) => s.slug),
63
72
  meta: picks.map((s) => ({
@@ -85,6 +94,7 @@ export function selectSkills(skills, n, tier, epsilon) {
85
94
  pick = available.reduce((best, s) => avgReward(s) > avgReward(best) ? s : best);
86
95
  reason = "exploit";
87
96
  }
97
+ pick.timesSelected++;
88
98
  selected.push(pick);
89
99
  meta.push({
90
100
  slug: pick.slug,
package/dist/index.js CHANGED
@@ -17,11 +17,11 @@ let cachedSkills = [];
17
17
  let banditState = [];
18
18
  let currentTier = "normal";
19
19
  let cycleCount = 0;
20
- let lastPromptCycle = -1; // Track which cycle was last issued to prevent re-running
21
20
  let currentSelectedMeta = [];
22
21
  let serviceRunning = false;
23
22
  let cycleTimer = null;
24
23
  let lastCycleTimestamp = new Date().toISOString();
24
+ let lastKnownStartedAt = null;
25
25
  // Config defaults
26
26
  let config = {
27
27
  apiKey: "",
@@ -67,6 +67,17 @@ async function refreshState(logger) {
67
67
  cachedSkills = res.skills;
68
68
  }
69
69
  if (cachedState.initialized) {
70
+ // Detect re-init (started_at changed) — reset in-memory state
71
+ if (cachedState.started_at && cachedState.started_at !== lastKnownStartedAt) {
72
+ if (lastKnownStartedAt !== null) {
73
+ logger.info(`[simmer] Detected re-init (started_at changed) — resetting cycle counter and bandit state`);
74
+ cycleCount = 0;
75
+ banditState = [];
76
+ currentSelectedMeta = [];
77
+ lastCycleTimestamp = new Date().toISOString();
78
+ }
79
+ lastKnownStartedAt = cachedState.started_at;
80
+ }
70
81
  // Sync venue from server state (source of truth — server enforces it)
71
82
  if (cachedState.venue) {
72
83
  config.venue = cachedState.venue;
@@ -128,40 +139,17 @@ function buildPromptContext() {
128
139
  "Re-initialize with a new budget to restart.",
129
140
  ].join("\n");
130
141
  }
131
- const isNewCycle = cycleCount !== lastPromptCycle;
132
142
  const lines = [];
133
- // --- ACTION block: only on new cycles ---
134
- if (isNewCycle && currentSelectedMeta.length > 0) {
135
- lastPromptCycle = cycleCount;
136
- lines.push(`## Simmer Automaton Cycle ${cycleCount}`);
137
- lines.push("");
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]));
142
- for (const m of currentSelectedMeta) {
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
- }
154
- }
155
- lines.push("");
156
- lines.push("After running each skill, briefly note: trades executed, signals found, or why it was skipped.");
143
+ lines.push(`## Simmer Automaton Cycle ${cycleCount}`);
144
+ lines.push("");
145
+ if (currentSelectedMeta.length > 0) {
146
+ lines.push(`**Active skills:** ${currentSelectedMeta.map((m) => m.slug).join(", ")}`);
157
147
  }
158
148
  else {
159
- lines.push("## Simmer Automaton Status");
160
- if (currentSelectedMeta.length > 0) {
161
- lines.push("");
162
- lines.push(`Skills already issued this cycle (${cycleCount}): ${currentSelectedMeta.map((m) => m.slug).join(", ")}`);
163
- }
149
+ lines.push("**Active skills:** none selected yet");
164
150
  }
151
+ lines.push("**Status:** Skills run independently. The automaton selects which ones can trade.");
152
+ lines.push(`**IMPORTANT:** All skill cron jobs MUST include \`TRADING_VENUE=${config.venue}\` in their environment. Skills default to polymarket otherwise.`);
165
153
  // --- Status ---
166
154
  lines.push("");
167
155
  lines.push(`**Tier:** ${currentTier} | **Venue:** ${config.venue}`);
@@ -255,6 +243,17 @@ export default function register(pluginApi) {
255
243
  skill.consecutiveZeroSignals = o.trades > 0 ? 0 : skill.consecutiveZeroSignals + 1;
256
244
  }
257
245
  }
246
+ // Skills that were selected last cycle but had zero trades won't appear in outcomes
247
+ // (GROUP BY only returns rows with trades). Increment consecutiveZeroSignals for them.
248
+ const outcomeSkillSlugs = new Set(outcomeRes.outcomes.map((o) => o.skill_slug));
249
+ for (const m of currentSelectedMeta) {
250
+ if (!outcomeSkillSlugs.has(m.slug)) {
251
+ const skill = banditState.find((s) => s.slug === m.slug);
252
+ if (skill) {
253
+ skill.consecutiveZeroSignals++;
254
+ }
255
+ }
256
+ }
258
257
  if (outcomeRes.outcomes.length > 0) {
259
258
  ctx.logger.info(`[simmer] Outcomes: ${outcomeRes.outcomes.map((o) => `${o.skill_slug}:${o.trades}t`).join(", ")}`);
260
259
  }
@@ -274,14 +273,6 @@ export default function register(pluginApi) {
274
273
  pinned: s.pinned || [],
275
274
  }));
276
275
  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
276
  if (tuningChanges.length > 0) {
286
277
  ctx.logger.info(`[simmer] Tuning: ${tuningChanges.map((c) => `${c.slug}.${c.env}: ${c.oldValue} → ${c.newValue}`).join(", ")}`);
287
278
  }
@@ -293,7 +284,27 @@ export default function register(pluginApi) {
293
284
  const { selected, meta } = selectSkills(banditState, n, currentTier, config.epsilon);
294
285
  currentSelectedMeta = meta;
295
286
  const hints = generateTuningHints(banditState, cachedState?.budget_usd ?? 0);
296
- // Record cycle to API (fire-and-forget don't block the loop)
287
+ // POST cycle to API — writes active_skills + config_changes atomically
288
+ // This replaces both individual setSkillConfig calls and the old recordCycle
289
+ const configChangesPayload = tuningChanges.map((c) => ({
290
+ slug: c.slug,
291
+ env: c.env,
292
+ old: c.oldValue,
293
+ new: c.newValue,
294
+ reason: c.reason,
295
+ }));
296
+ try {
297
+ await api.postCycle({
298
+ active_skills: selected,
299
+ cycle_number: cycleCount,
300
+ selection_meta: meta.map((m) => ({ slug: m.slug, reason: m.reason, score: m.score })),
301
+ config_changes: configChangesPayload,
302
+ });
303
+ }
304
+ catch (e) {
305
+ ctx.logger.warn(`[simmer] Failed to post cycle (active_skills may be stale): ${e}`);
306
+ }
307
+ // Also record cycle history (fire-and-forget)
297
308
  const cycleData = {
298
309
  cycle_num: cycleCount,
299
310
  tier: currentTier,
@@ -304,7 +315,7 @@ export default function register(pluginApi) {
304
315
  spent_usd: cachedState?.spent_usd,
305
316
  };
306
317
  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 }));
318
+ cycleData.config_changes = configChangesPayload;
308
319
  }
309
320
  api.recordCycle(cycleData).catch((e) => ctx.logger.error(`[simmer] Failed to record cycle: ${e}`));
310
321
  ctx.logger.info(`[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | selected=${selected.length} skills`);
package/dist/tuning.js CHANGED
@@ -6,9 +6,11 @@
6
6
  */
7
7
  // Track last tuned cycle per skill to enforce cooldowns
8
8
  const lastTunedCycle = new Map();
9
- function isThresholdTunable(t) {
9
+ function isEntryThresholdTunable(t) {
10
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");
11
+ // Only widen entry-side thresholds (lower = accept more signals).
12
+ // Exit/confidence thresholds should NOT be lowered — that degrades trade quality.
13
+ return l.includes("entry") || l.includes("edge") || l.includes("min split") || l.includes("momentum");
12
14
  }
13
15
  function isMaxBetTunable(t) {
14
16
  const l = t.label.toLowerCase();
@@ -103,7 +105,7 @@ export function computeTuningChanges(skills, tunableSkills, cycleCount, budgetUs
103
105
  // Rule 1: consecutiveZeroSignals >= 5 → widen thresholds by 20% (cooldown 10)
104
106
  if (sk.consecutiveZeroSignals >= 5 && cycleCount - lastTuned >= 10) {
105
107
  for (const t of ts.tunables) {
106
- if (t.type !== "number" || pinnedSet.has(t.env) || !isThresholdTunable(t))
108
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isEntryThresholdTunable(t))
107
109
  continue;
108
110
  const cur = currentValue(t, ts.config);
109
111
  if (typeof cur !== "number")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simmer-automaton",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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
@@ -138,4 +138,16 @@ export class SimmerApi {
138
138
  const params = new URLSearchParams({ since });
139
139
  return this.request(`/api/sdk/automaton/outcomes?${params}`);
140
140
  }
141
+
142
+ async postCycle(data: {
143
+ active_skills: string[];
144
+ cycle_number: number;
145
+ selection_meta: Array<{ slug: string; reason: string; score: number | null }>;
146
+ config_changes: Array<{ slug: string; env: string; old: any; new: any; reason: string }>;
147
+ }): Promise<{ ok: boolean; active_skills: string[]; cycle_number: number }> {
148
+ return this.request("/api/sdk/automaton/cycle", {
149
+ method: "POST",
150
+ body: JSON.stringify(data),
151
+ });
152
+ }
141
153
  }
package/src/bandit.ts CHANGED
@@ -25,7 +25,11 @@ export interface SelectionMeta {
25
25
 
26
26
  function avgReward(skill: SkillState): number {
27
27
  if (skill.timesSelected === 0) return Infinity;
28
- return skill.totalPnl / skill.timesSelected;
28
+ // Use P&L as primary signal. When P&L is unavailable (0), fall back to
29
+ // trade execution rate as bootstrap — a skill that finds and executes trades
30
+ // is better than one that finds nothing.
31
+ if (skill.totalPnl !== 0) return skill.totalPnl / skill.timesSelected;
32
+ return skill.tradesExecutedTotal / skill.timesSelected;
29
33
  }
30
34
 
31
35
  export function tierMaxSkills(tier: string, maxConcurrent: number): number {
@@ -78,6 +82,7 @@ export function selectSkills(
78
82
  );
79
83
  }
80
84
 
85
+ for (const p of picks) p.timesSelected++;
81
86
  return {
82
87
  selected: picks.map((s) => s.slug),
83
88
  meta,
@@ -90,6 +95,7 @@ export function selectSkills(
90
95
  (a, b) => avgReward(b) - avgReward(a),
91
96
  );
92
97
  const picks = ranked.slice(0, count);
98
+ for (const p of picks) p.timesSelected++;
93
99
  return {
94
100
  selected: picks.map((s) => s.slug),
95
101
  meta: picks.map((s) => ({
@@ -122,6 +128,7 @@ export function selectSkills(
122
128
  reason = "exploit";
123
129
  }
124
130
 
131
+ pick.timesSelected++;
125
132
  selected.push(pick);
126
133
  meta.push({
127
134
  slug: pick.slug,
package/src/index.ts CHANGED
@@ -38,11 +38,11 @@ let cachedSkills: Skill[] = [];
38
38
  let banditState: SkillState[] = [];
39
39
  let currentTier: Tier = "normal";
40
40
  let cycleCount = 0;
41
- let lastPromptCycle = -1; // Track which cycle was last issued to prevent re-running
42
41
  let currentSelectedMeta: Array<{ slug: string; reason: string; score: number | null }> = [];
43
42
  let serviceRunning = false;
44
43
  let cycleTimer: ReturnType<typeof setInterval> | null = null;
45
44
  let lastCycleTimestamp: string = new Date().toISOString();
45
+ let lastKnownStartedAt: string | null = null;
46
46
 
47
47
  // Config defaults
48
48
  let config = {
@@ -82,6 +82,17 @@ async function refreshState(logger: { info: (m: string) => void; error: (m: stri
82
82
  }
83
83
 
84
84
  if (cachedState.initialized) {
85
+ // Detect re-init (started_at changed) — reset in-memory state
86
+ if (cachedState.started_at && cachedState.started_at !== lastKnownStartedAt) {
87
+ if (lastKnownStartedAt !== null) {
88
+ logger.info(`[simmer] Detected re-init (started_at changed) — resetting cycle counter and bandit state`);
89
+ cycleCount = 0;
90
+ banditState = [];
91
+ currentSelectedMeta = [];
92
+ lastCycleTimestamp = new Date().toISOString();
93
+ }
94
+ lastKnownStartedAt = cachedState.started_at;
95
+ }
85
96
  // Sync venue from server state (source of truth — server enforces it)
86
97
  if (cachedState.venue) {
87
98
  config.venue = cachedState.venue;
@@ -148,39 +159,17 @@ function buildPromptContext(): string {
148
159
  ].join("\n");
149
160
  }
150
161
 
151
- const isNewCycle = cycleCount !== lastPromptCycle;
152
162
  const lines: string[] = [];
153
163
 
154
- // --- ACTION block: only on new cycles ---
155
- if (isNewCycle && currentSelectedMeta.length > 0) {
156
- lastPromptCycle = cycleCount;
157
- lines.push(`## Simmer Automaton Cycle ${cycleCount}`);
158
- lines.push("");
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]));
163
- for (const m of currentSelectedMeta) {
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
- }
174
- }
175
- lines.push("");
176
- lines.push("After running each skill, briefly note: trades executed, signals found, or why it was skipped.");
164
+ lines.push(`## Simmer Automaton Cycle ${cycleCount}`);
165
+ lines.push("");
166
+ if (currentSelectedMeta.length > 0) {
167
+ lines.push(`**Active skills:** ${currentSelectedMeta.map((m) => m.slug).join(", ")}`);
177
168
  } else {
178
- lines.push("## Simmer Automaton Status");
179
- if (currentSelectedMeta.length > 0) {
180
- lines.push("");
181
- lines.push(`Skills already issued this cycle (${cycleCount}): ${currentSelectedMeta.map((m) => m.slug).join(", ")}`);
182
- }
169
+ lines.push("**Active skills:** none selected yet");
183
170
  }
171
+ lines.push("**Status:** Skills run independently. The automaton selects which ones can trade.");
172
+ lines.push(`**IMPORTANT:** All skill cron jobs MUST include \`TRADING_VENUE=${config.venue}\` in their environment. Skills default to polymarket otherwise.`);
184
173
 
185
174
  // --- Status ---
186
175
  lines.push("");
@@ -291,6 +280,17 @@ export default function register(pluginApi: PluginApi) {
291
280
  skill.consecutiveZeroSignals = o.trades > 0 ? 0 : skill.consecutiveZeroSignals + 1;
292
281
  }
293
282
  }
283
+ // Skills that were selected last cycle but had zero trades won't appear in outcomes
284
+ // (GROUP BY only returns rows with trades). Increment consecutiveZeroSignals for them.
285
+ const outcomeSkillSlugs = new Set(outcomeRes.outcomes.map((o: SkillOutcome) => o.skill_slug));
286
+ for (const m of currentSelectedMeta) {
287
+ if (!outcomeSkillSlugs.has(m.slug)) {
288
+ const skill = banditState.find((s) => s.slug === m.slug);
289
+ if (skill) {
290
+ skill.consecutiveZeroSignals++;
291
+ }
292
+ }
293
+ }
294
294
  if (outcomeRes.outcomes.length > 0) {
295
295
  ctx.logger.info(`[simmer] Outcomes: ${outcomeRes.outcomes.map((o: SkillOutcome) => `${o.skill_slug}:${o.trades}t`).join(", ")}`);
296
296
  }
@@ -310,14 +310,6 @@ export default function register(pluginApi: PluginApi) {
310
310
  pinned: s.pinned || [],
311
311
  }));
312
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
313
  if (tuningChanges.length > 0) {
322
314
  ctx.logger.info(`[simmer] Tuning: ${tuningChanges.map((c) => `${c.slug}.${c.env}: ${c.oldValue} → ${c.newValue}`).join(", ")}`);
323
315
  }
@@ -335,7 +327,27 @@ export default function register(pluginApi: PluginApi) {
335
327
  currentSelectedMeta = meta;
336
328
  const hints = generateTuningHints(banditState, cachedState?.budget_usd ?? 0);
337
329
 
338
- // Record cycle to API (fire-and-forget don't block the loop)
330
+ // POST cycle to API — writes active_skills + config_changes atomically
331
+ // This replaces both individual setSkillConfig calls and the old recordCycle
332
+ const configChangesPayload = tuningChanges.map((c) => ({
333
+ slug: c.slug,
334
+ env: c.env,
335
+ old: c.oldValue,
336
+ new: c.newValue,
337
+ reason: c.reason,
338
+ }));
339
+ try {
340
+ await api.postCycle({
341
+ active_skills: selected,
342
+ cycle_number: cycleCount,
343
+ selection_meta: meta.map((m) => ({ slug: m.slug, reason: m.reason, score: m.score })),
344
+ config_changes: configChangesPayload,
345
+ });
346
+ } catch (e) {
347
+ ctx.logger.warn(`[simmer] Failed to post cycle (active_skills may be stale): ${e}`);
348
+ }
349
+
350
+ // Also record cycle history (fire-and-forget)
339
351
  const cycleData: Record<string, unknown> = {
340
352
  cycle_num: cycleCount,
341
353
  tier: currentTier,
@@ -346,7 +358,7 @@ export default function register(pluginApi: PluginApi) {
346
358
  spent_usd: cachedState?.spent_usd,
347
359
  };
348
360
  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 }));
361
+ cycleData.config_changes = configChangesPayload;
350
362
  }
351
363
  api.recordCycle(cycleData as any).catch((e) => ctx.logger.error(`[simmer] Failed to record cycle: ${e}`));
352
364
 
package/src/tuning.ts CHANGED
@@ -33,9 +33,11 @@ export interface TunableSkill {
33
33
  // Track last tuned cycle per skill to enforce cooldowns
34
34
  const lastTunedCycle = new Map<string, number>();
35
35
 
36
- function isThresholdTunable(t: Tunable): boolean {
36
+ function isEntryThresholdTunable(t: Tunable): boolean {
37
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");
38
+ // Only widen entry-side thresholds (lower = accept more signals).
39
+ // Exit/confidence thresholds should NOT be lowered — that degrades trade quality.
40
+ return l.includes("entry") || l.includes("edge") || l.includes("min split") || l.includes("momentum");
39
41
  }
40
42
 
41
43
  function isMaxBetTunable(t: Tunable): boolean {
@@ -151,7 +153,7 @@ export function computeTuningChanges(
151
153
  // Rule 1: consecutiveZeroSignals >= 5 → widen thresholds by 20% (cooldown 10)
152
154
  if (sk.consecutiveZeroSignals >= 5 && cycleCount - lastTuned >= 10) {
153
155
  for (const t of ts.tunables) {
154
- if (t.type !== "number" || pinnedSet.has(t.env) || !isThresholdTunable(t)) continue;
156
+ if (t.type !== "number" || pinnedSet.has(t.env) || !isEntryThresholdTunable(t)) continue;
155
157
  const cur = currentValue(t, ts.config);
156
158
  if (typeof cur !== "number") continue;
157
159
  let newVal = cur * 0.8;