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 +25 -0
- package/dist/api.js +10 -0
- package/dist/index.js +142 -7
- package/dist/tuning.d.ts +19 -2
- package/dist/tuning.js +130 -7
- package/package.json +1 -1
- package/src/api.ts +34 -0
- package/src/index.ts +145 -11
- package/src/tuning.ts +155 -13
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
|
-
|
|
134
|
-
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]));
|
|
135
142
|
for (const m of currentSelectedMeta) {
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
3
|
-
*
|
|
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
|
|
3
|
-
*
|
|
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
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 =
|
|
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:
|
|
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
|
-
|
|
155
|
-
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]));
|
|
156
163
|
for (const m of currentSelectedMeta) {
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
|
3
|
-
*
|
|
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
|
+
}
|