simmer-automaton 0.1.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 ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Simmer API client for the automaton plugin.
3
+ * Thin wrapper around fetch — reads automaton state, skills registry.
4
+ */
5
+ export interface AutomatonState {
6
+ initialized: boolean;
7
+ budget_usd: number;
8
+ spent_usd: number;
9
+ remaining_usd: number;
10
+ halted: boolean;
11
+ tier: string;
12
+ horizon_days: number;
13
+ started_at: string | null;
14
+ }
15
+ export interface Skill {
16
+ id: string;
17
+ name: string;
18
+ description: string;
19
+ category: string;
20
+ tags: string[];
21
+ difficulty: string;
22
+ install: string;
23
+ clawhub_url: string;
24
+ requires: string[];
25
+ best_when: string | null;
26
+ }
27
+ export declare class SimmerApi {
28
+ private baseUrl;
29
+ private apiKey;
30
+ constructor(apiKey: string, baseUrl?: string);
31
+ private request;
32
+ getAutomatonState(): Promise<AutomatonState>;
33
+ initAutomaton(budgetUsd: number, horizonDays?: number): Promise<unknown>;
34
+ halt(): Promise<unknown>;
35
+ resume(): Promise<unknown>;
36
+ getSkills(): Promise<{
37
+ skills: Skill[];
38
+ total: number;
39
+ }>;
40
+ }
package/dist/api.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Simmer API client for the automaton plugin.
3
+ * Thin wrapper around fetch — reads automaton state, skills registry.
4
+ */
5
+ export class SimmerApi {
6
+ baseUrl;
7
+ apiKey;
8
+ constructor(apiKey, baseUrl = "https://api.simmer.markets") {
9
+ this.apiKey = apiKey;
10
+ this.baseUrl = baseUrl;
11
+ }
12
+ async request(path, opts) {
13
+ const res = await fetch(`${this.baseUrl}${path}`, {
14
+ ...opts,
15
+ headers: {
16
+ Authorization: `Bearer ${this.apiKey}`,
17
+ "Content-Type": "application/json",
18
+ ...(opts?.headers || {}),
19
+ },
20
+ });
21
+ if (!res.ok) {
22
+ const body = await res.text().catch(() => "");
23
+ throw new Error(`Simmer API ${res.status}: ${body}`);
24
+ }
25
+ return res.json();
26
+ }
27
+ async getAutomatonState() {
28
+ return this.request("/api/sdk/automaton/state");
29
+ }
30
+ async initAutomaton(budgetUsd, horizonDays = 30) {
31
+ return this.request("/api/sdk/automaton/init", {
32
+ method: "POST",
33
+ body: JSON.stringify({ budget_usd: budgetUsd, horizon_days: horizonDays }),
34
+ });
35
+ }
36
+ async halt() {
37
+ return this.request("/api/sdk/automaton/halt", { method: "POST" });
38
+ }
39
+ async resume() {
40
+ return this.request("/api/sdk/automaton/resume", { method: "POST" });
41
+ }
42
+ async getSkills() {
43
+ return this.request("/api/sdk/skills");
44
+ }
45
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Epsilon-greedy bandit for skill selection.
3
+ * Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
4
+ */
5
+ export interface SkillState {
6
+ slug: string;
7
+ enabled: boolean;
8
+ timesSelected: number;
9
+ timesRewarded: number;
10
+ totalPnl: number;
11
+ consecutiveZeroSignals: number;
12
+ signalsFoundTotal: number;
13
+ tradesExecutedTotal: number;
14
+ lastCycle?: {
15
+ skipCounts?: Record<string, number>;
16
+ };
17
+ }
18
+ export interface SelectionMeta {
19
+ slug: string;
20
+ reason: string;
21
+ score: number | null;
22
+ }
23
+ export declare function tierMaxSkills(tier: string, maxConcurrent: number): number;
24
+ export declare function tierEffectiveEpsilon(tier: string, epsilon: number): number;
25
+ export declare function selectSkills(skills: SkillState[], n: number, tier: string, epsilon: number): {
26
+ selected: string[];
27
+ meta: SelectionMeta[];
28
+ };
package/dist/bandit.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Epsilon-greedy bandit for skill selection.
3
+ * Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
4
+ */
5
+ function avgReward(skill) {
6
+ if (skill.timesSelected === 0)
7
+ return Infinity;
8
+ return skill.totalPnl / skill.timesSelected;
9
+ }
10
+ export function tierMaxSkills(tier, maxConcurrent) {
11
+ if (tier === "thriving" || tier === "normal")
12
+ return maxConcurrent;
13
+ if (tier === "conserving" || tier === "critical")
14
+ return 1;
15
+ return 0; // dead
16
+ }
17
+ export function tierEffectiveEpsilon(tier, epsilon) {
18
+ if (tier === "thriving" || tier === "normal")
19
+ return epsilon;
20
+ if (tier === "conserving")
21
+ return epsilon * 0.5;
22
+ return 0.0; // critical, dead — pure exploit or no runs
23
+ }
24
+ export function selectSkills(skills, n, tier, epsilon) {
25
+ const enabled = skills.filter((s) => s.enabled);
26
+ if (enabled.length === 0)
27
+ return { selected: [], meta: [] };
28
+ const effectiveEpsilon = tierEffectiveEpsilon(tier, epsilon);
29
+ const count = Math.min(n, enabled.length);
30
+ // Unplayed skills get priority
31
+ const unplayed = enabled.filter((s) => s.timesSelected === 0);
32
+ if (unplayed.length > 0) {
33
+ const shuffled = [...unplayed].sort(() => Math.random() - 0.5);
34
+ const picks = shuffled.slice(0, count);
35
+ const meta = picks.map((s) => ({
36
+ slug: s.slug,
37
+ reason: "unplayed",
38
+ score: null,
39
+ }));
40
+ if (picks.length < count) {
41
+ const remaining = enabled.filter((s) => !picks.includes(s));
42
+ const extra = remaining
43
+ .sort(() => Math.random() - 0.5)
44
+ .slice(0, count - picks.length);
45
+ picks.push(...extra);
46
+ meta.push(...extra.map((s) => ({
47
+ slug: s.slug,
48
+ reason: "backfill",
49
+ score: Math.round(avgReward(s) * 10000) / 10000,
50
+ })));
51
+ }
52
+ return {
53
+ selected: picks.map((s) => s.slug),
54
+ meta,
55
+ };
56
+ }
57
+ // Critical tier: always pick best
58
+ if (tier === "critical") {
59
+ const ranked = [...enabled].sort((a, b) => avgReward(b) - avgReward(a));
60
+ const picks = ranked.slice(0, count);
61
+ return {
62
+ selected: picks.map((s) => s.slug),
63
+ meta: picks.map((s) => ({
64
+ slug: s.slug,
65
+ reason: "critical_exploit",
66
+ score: Math.round(avgReward(s) * 10000) / 10000,
67
+ })),
68
+ };
69
+ }
70
+ // Epsilon-greedy
71
+ const selected = [];
72
+ const meta = [];
73
+ const available = [...enabled];
74
+ for (let i = 0; i < count && available.length > 0; i++) {
75
+ let pick;
76
+ let reason;
77
+ if (Math.random() < effectiveEpsilon) {
78
+ // Explore: random
79
+ const idx = Math.floor(Math.random() * available.length);
80
+ pick = available[idx];
81
+ reason = `explore (ε=${effectiveEpsilon.toFixed(2)})`;
82
+ }
83
+ else {
84
+ // Exploit: best avg reward
85
+ pick = available.reduce((best, s) => avgReward(s) > avgReward(best) ? s : best);
86
+ reason = "exploit";
87
+ }
88
+ selected.push(pick);
89
+ meta.push({
90
+ slug: pick.slug,
91
+ reason,
92
+ score: Math.round(avgReward(pick) * 10000) / 10000,
93
+ });
94
+ available.splice(available.indexOf(pick), 1);
95
+ }
96
+ return { selected: selected.map((s) => s.slug), meta };
97
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Simmer Automaton — OpenClaw Plugin
3
+ *
4
+ * Budget-enforced skill orchestration with survival tiers.
5
+ * Algorithm decides skill selection (bandit), budget, circuit breakers.
6
+ * LLM decides market-level trades within selected skills.
7
+ * API enforces hard spending limits on every trade.
8
+ */
9
+ interface PluginApi {
10
+ pluginConfig?: Record<string, unknown>;
11
+ logger: {
12
+ info: (msg: string) => void;
13
+ warn: (msg: string) => void;
14
+ error: (msg: string) => void;
15
+ };
16
+ on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
17
+ registerService: (service: {
18
+ id: string;
19
+ start: (ctx: ServiceCtx) => Promise<void>;
20
+ stop?: (ctx: ServiceCtx) => Promise<void>;
21
+ }) => void;
22
+ registerCommand: (cmd: {
23
+ name: string;
24
+ description: string;
25
+ acceptsArgs?: boolean;
26
+ handler: (ctx: CommandCtx) => Promise<{
27
+ text: string;
28
+ }>;
29
+ }) => void;
30
+ }
31
+ interface ServiceCtx {
32
+ stateDir: string;
33
+ logger: {
34
+ info: (msg: string) => void;
35
+ warn: (msg: string) => void;
36
+ error: (msg: string) => void;
37
+ };
38
+ }
39
+ interface CommandCtx {
40
+ args?: string;
41
+ }
42
+ export default function register(pluginApi: PluginApi): void;
43
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Simmer Automaton — OpenClaw Plugin
3
+ *
4
+ * Budget-enforced skill orchestration with survival tiers.
5
+ * Algorithm decides skill selection (bandit), budget, circuit breakers.
6
+ * LLM decides market-level trades within selected skills.
7
+ * API enforces hard spending limits on every trade.
8
+ */
9
+ import { SimmerApi } from "./api.js";
10
+ import { selectSkills, tierMaxSkills } from "./bandit.js";
11
+ import { computeTier } from "./tiers.js";
12
+ import { generateTuningHints } from "./tuning.js";
13
+ // Plugin-local state (in-memory, refreshed from API each cycle)
14
+ let api;
15
+ let cachedState = null;
16
+ let cachedSkills = [];
17
+ let banditState = [];
18
+ let currentTier = "normal";
19
+ let cycleCount = 0;
20
+ let serviceRunning = false;
21
+ let cycleTimer = null;
22
+ // Config defaults
23
+ let config = {
24
+ apiKey: "",
25
+ apiUrl: "https://api.simmer.markets",
26
+ cycleIntervalMs: 300_000,
27
+ epsilon: 0.2,
28
+ epsilonDecay: 0.995,
29
+ minEpsilon: 0.05,
30
+ maxConcurrent: 2,
31
+ venue: "simmer",
32
+ };
33
+ function loadConfig(pluginConfig) {
34
+ if (!pluginConfig)
35
+ return;
36
+ if (pluginConfig.apiKey)
37
+ config.apiKey = pluginConfig.apiKey;
38
+ if (pluginConfig.apiUrl)
39
+ config.apiUrl = pluginConfig.apiUrl;
40
+ if (pluginConfig.cycleIntervalMs)
41
+ config.cycleIntervalMs = pluginConfig.cycleIntervalMs;
42
+ if (pluginConfig.epsilon)
43
+ config.epsilon = pluginConfig.epsilon;
44
+ if (pluginConfig.epsilonDecay)
45
+ config.epsilonDecay = pluginConfig.epsilonDecay;
46
+ if (pluginConfig.minEpsilon)
47
+ config.minEpsilon = pluginConfig.minEpsilon;
48
+ if (pluginConfig.maxConcurrent)
49
+ config.maxConcurrent = pluginConfig.maxConcurrent;
50
+ if (pluginConfig.venue)
51
+ config.venue = pluginConfig.venue;
52
+ }
53
+ async function refreshState(logger) {
54
+ try {
55
+ cachedState = await api.getAutomatonState();
56
+ const skillsRes = await api.getSkills();
57
+ cachedSkills = skillsRes.skills;
58
+ if (cachedState.initialized) {
59
+ // Compute tier (totalPnl = 0 for now, will be enriched when P&L tracking is added)
60
+ currentTier = computeTier(cachedState, 0);
61
+ }
62
+ }
63
+ catch (e) {
64
+ logger.error(`[simmer] Failed to refresh state: ${e}`);
65
+ }
66
+ }
67
+ function buildPromptContext() {
68
+ if (!cachedState || !cachedState.initialized) {
69
+ return "[Simmer Automaton] Not initialized. Run: POST /api/sdk/automaton/init";
70
+ }
71
+ const lines = [
72
+ "## Simmer Automaton — Survival Context (read-only)",
73
+ "",
74
+ `**Tier:** ${currentTier} | **Venue:** ${config.venue}`,
75
+ `**Budget:** $${cachedState.budget_usd.toFixed(2)} | **Spent:** $${cachedState.spent_usd.toFixed(2)} | **Remaining:** $${cachedState.remaining_usd.toFixed(2)}`,
76
+ `**Halted:** ${cachedState.halted ? "YES — all trades blocked" : "no"}`,
77
+ `**Horizon:** ${cachedState.horizon_days} days`,
78
+ "",
79
+ ];
80
+ // Skill picks from bandit (if any)
81
+ if (banditState.length > 0) {
82
+ const n = tierMaxSkills(currentTier, config.maxConcurrent);
83
+ const { selected, meta } = selectSkills(banditState, n, currentTier, config.epsilon);
84
+ if (selected.length > 0) {
85
+ lines.push("**Selected skills this cycle:**");
86
+ for (const m of meta) {
87
+ lines.push(`- ${m.slug} (${m.reason}${m.score !== null ? `, score=${m.score}` : ""})`);
88
+ }
89
+ lines.push("");
90
+ }
91
+ }
92
+ // Tuning hints
93
+ const hints = generateTuningHints(banditState, cachedState.budget_usd);
94
+ if (hints.length > 0) {
95
+ lines.push("**Tuning hints:**");
96
+ for (const h of hints) {
97
+ lines.push(`- [${h.skill}] ${h.issue}: ${h.suggestion}`);
98
+ }
99
+ lines.push("");
100
+ }
101
+ if (currentTier === "critical") {
102
+ lines.push("**WARNING:** Critical tier — only exploiting best skill, no exploration.");
103
+ }
104
+ else if (currentTier === "dead") {
105
+ lines.push("**DEAD:** Budget exhausted or horizon expired. No skills will run.");
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+ function formatStatus() {
110
+ if (!cachedState || !cachedState.initialized) {
111
+ return "Automaton not initialized. Use POST /api/sdk/automaton/init to start.";
112
+ }
113
+ const lines = [
114
+ `Tier: ${currentTier}`,
115
+ `Venue: ${config.venue}`,
116
+ `Budget: $${cachedState.budget_usd.toFixed(2)}`,
117
+ `Spent: $${cachedState.spent_usd.toFixed(2)}`,
118
+ `Remaining: $${cachedState.remaining_usd.toFixed(2)}`,
119
+ `Halted: ${cachedState.halted ? "YES" : "no"}`,
120
+ `Horizon: ${cachedState.horizon_days} days`,
121
+ `Cycles: ${cycleCount}`,
122
+ `Skills available: ${cachedSkills.length}`,
123
+ ];
124
+ return lines.join("\n");
125
+ }
126
+ // =============================================================================
127
+ // Plugin registration
128
+ // =============================================================================
129
+ export default function register(pluginApi) {
130
+ loadConfig(pluginApi.pluginConfig);
131
+ if (!config.apiKey) {
132
+ pluginApi.logger.error("[simmer] No apiKey configured — plugin disabled");
133
+ return;
134
+ }
135
+ api = new SimmerApi(config.apiKey, config.apiUrl);
136
+ const logger = pluginApi.logger;
137
+ // --- Hook: before_prompt_build ---
138
+ // Inject survival context into every LLM prompt
139
+ pluginApi.on("before_prompt_build", async () => {
140
+ if (!cachedState) {
141
+ await refreshState(logger);
142
+ }
143
+ return { prependContext: buildPromptContext() };
144
+ });
145
+ // --- Service: background bandit cycle ---
146
+ pluginApi.registerService({
147
+ id: "simmer-automaton",
148
+ start: async (ctx) => {
149
+ ctx.logger.info("[simmer] Automaton service starting");
150
+ serviceRunning = true;
151
+ // Initial state fetch
152
+ await refreshState(ctx.logger);
153
+ // Periodic refresh
154
+ cycleTimer = setInterval(async () => {
155
+ if (!serviceRunning)
156
+ return;
157
+ cycleCount++;
158
+ await refreshState(ctx.logger);
159
+ // Decay epsilon
160
+ config.epsilon = Math.max(config.minEpsilon, config.epsilon * config.epsilonDecay);
161
+ ctx.logger.info(`[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | skills=${cachedSkills.length}`);
162
+ }, config.cycleIntervalMs);
163
+ },
164
+ stop: async (ctx) => {
165
+ ctx.logger.info("[simmer] Automaton service stopping");
166
+ serviceRunning = false;
167
+ if (cycleTimer) {
168
+ clearInterval(cycleTimer);
169
+ cycleTimer = null;
170
+ }
171
+ },
172
+ });
173
+ // --- Commands ---
174
+ pluginApi.registerCommand({
175
+ name: "simmer",
176
+ description: "Show Simmer automaton status",
177
+ acceptsArgs: true,
178
+ handler: async (ctx) => {
179
+ const subcommand = ctx.args?.trim().split(/\s+/)[0] || "status";
180
+ if (subcommand === "status") {
181
+ await refreshState(logger);
182
+ return { text: formatStatus() };
183
+ }
184
+ if (subcommand === "halt") {
185
+ try {
186
+ await api.halt();
187
+ await refreshState(logger);
188
+ return { text: "Automaton halted. All trades will be rejected." };
189
+ }
190
+ catch (e) {
191
+ return { text: `Failed to halt: ${e}` };
192
+ }
193
+ }
194
+ if (subcommand === "resume") {
195
+ try {
196
+ await api.resume();
197
+ await refreshState(logger);
198
+ return { text: "Automaton resumed. Trading is active." };
199
+ }
200
+ catch (e) {
201
+ return { text: `Failed to resume: ${e}` };
202
+ }
203
+ }
204
+ if (subcommand === "skills") {
205
+ await refreshState(logger);
206
+ if (cachedSkills.length === 0) {
207
+ return { text: "No skills in registry." };
208
+ }
209
+ const lines = cachedSkills.map((s) => `- ${s.name} (${s.id}) — ${s.category}, ${s.difficulty}`);
210
+ return { text: `Skills (${cachedSkills.length}):\n${lines.join("\n")}` };
211
+ }
212
+ return {
213
+ text: "Usage: /simmer [status|halt|resume|skills]",
214
+ };
215
+ },
216
+ });
217
+ logger.info("[simmer] Plugin registered");
218
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Survival tier computation.
3
+ * Ported from automaton.py — compute_tier.
4
+ */
5
+ import type { AutomatonState } from "./api.js";
6
+ export type Tier = "thriving" | "normal" | "conserving" | "critical" | "dead";
7
+ export declare function computeTier(state: AutomatonState, totalPnl: number): Tier;
package/dist/tiers.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Survival tier computation.
3
+ * Ported from automaton.py — compute_tier.
4
+ */
5
+ export function computeTier(state, totalPnl) {
6
+ if (state.budget_usd <= 0)
7
+ return "dead";
8
+ const budgetRemainingPct = (state.budget_usd - state.spent_usd + totalPnl) / state.budget_usd;
9
+ const startedAt = state.started_at ? new Date(state.started_at) : new Date();
10
+ const daysElapsed = (Date.now() - startedAt.getTime()) / (1000 * 60 * 60 * 24);
11
+ if (budgetRemainingPct <= 0 || daysElapsed >= state.horizon_days)
12
+ return "dead";
13
+ if (budgetRemainingPct < 0.1)
14
+ return "critical";
15
+ if (budgetRemainingPct < 0.3)
16
+ return "conserving";
17
+ if (totalPnl > 0 && budgetRemainingPct > 0.7)
18
+ return "thriving";
19
+ return "normal";
20
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tuning hints for the Clawbot LLM.
3
+ * Ported from automaton.py — generate_tuning_hints.
4
+ */
5
+ import type { SkillState } from "./bandit.js";
6
+ export interface TuningHint {
7
+ skill: string;
8
+ issue: string;
9
+ suggestion: string;
10
+ [key: string]: unknown;
11
+ }
12
+ export declare function generateTuningHints(skills: SkillState[], budgetUsd: number): TuningHint[];
package/dist/tuning.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tuning hints for the Clawbot LLM.
3
+ * Ported from automaton.py — generate_tuning_hints.
4
+ */
5
+ export function generateTuningHints(skills, budgetUsd) {
6
+ const hints = [];
7
+ for (const sk of skills) {
8
+ if (!sk.enabled || sk.timesSelected === 0)
9
+ continue;
10
+ // 1. Zero signals streak
11
+ if (sk.consecutiveZeroSignals >= 5) {
12
+ hints.push({
13
+ skill: sk.slug,
14
+ issue: "zero_signals_streak",
15
+ cycles: sk.consecutiveZeroSignals,
16
+ suggestion: `0 signals for ${sk.consecutiveZeroSignals} cycles — loosen thresholds or widen time windows`,
17
+ });
18
+ }
19
+ // 2. Concentrated loss
20
+ if (sk.totalPnl < 0 && budgetUsd > 0) {
21
+ const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
22
+ if (lossPct > 20) {
23
+ hints.push({
24
+ skill: sk.slug,
25
+ issue: "concentrated_loss",
26
+ pnl: Math.round(sk.totalPnl * 100) / 100,
27
+ pct_of_budget: Math.round(lossPct * 10) / 10,
28
+ suggestion: `Lost $${Math.abs(sk.totalPnl).toFixed(2)} (${lossPct.toFixed(0)}% of budget) — consider disabling or reducing max bet`,
29
+ });
30
+ }
31
+ }
32
+ // 3. Inert — finds signals but never executes
33
+ if (sk.signalsFoundTotal > 50 && sk.tradesExecutedTotal === 0) {
34
+ hints.push({
35
+ skill: sk.slug,
36
+ issue: "inert",
37
+ signals: sk.signalsFoundTotal,
38
+ suggestion: `${sk.signalsFoundTotal} signals found, 0 executed — execution thresholds likely too tight`,
39
+ });
40
+ }
41
+ // 4. Win rate collapse
42
+ if (sk.timesSelected >= 10) {
43
+ const winRate = sk.timesRewarded / sk.timesSelected;
44
+ if (winRate < 0.2) {
45
+ hints.push({
46
+ skill: sk.slug,
47
+ issue: "win_rate_collapse",
48
+ win_rate: Math.round(winRate * 1000) / 10,
49
+ runs: sk.timesSelected,
50
+ suggestion: `Win rate ${(winRate * 100).toFixed(0)}% over ${sk.timesSelected} cycles — strategy may not suit current markets`,
51
+ });
52
+ }
53
+ }
54
+ // 5. Safeguard dominant
55
+ const skipCounts = sk.lastCycle?.skipCounts || {};
56
+ const totalSkips = Object.values(skipCounts).reduce((a, b) => a + b, 0);
57
+ const safeguardSkips = skipCounts["safeguard"] || 0;
58
+ if (totalSkips >= 3 && safeguardSkips / totalSkips > 0.8) {
59
+ hints.push({
60
+ skill: sk.slug,
61
+ issue: "safeguard_dominant",
62
+ safeguard_pct: Math.round((safeguardSkips / totalSkips) * 100),
63
+ suggestion: "Most skips are safeguard blocks — markets may be too volatile or near resolution",
64
+ });
65
+ }
66
+ }
67
+ return hints;
68
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "id": "simmer-automaton",
3
+ "name": "Simmer Automaton",
4
+ "description": "Budget-enforced skill orchestration with survival tiers and bandit selection",
5
+ "version": "0.1.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "apiKey": {
11
+ "type": "string",
12
+ "description": "Simmer API key (sk_live_...)"
13
+ },
14
+ "apiUrl": {
15
+ "type": "string",
16
+ "default": "https://api.simmer.markets"
17
+ },
18
+ "cycleIntervalMs": {
19
+ "type": "number",
20
+ "default": 300000,
21
+ "description": "Bandit cycle interval in milliseconds (default: 5 min)"
22
+ },
23
+ "epsilon": {
24
+ "type": "number",
25
+ "default": 0.2,
26
+ "description": "Exploration rate for epsilon-greedy bandit (0-1)"
27
+ },
28
+ "epsilonDecay": {
29
+ "type": "number",
30
+ "default": 0.995,
31
+ "description": "Epsilon decay per cycle"
32
+ },
33
+ "minEpsilon": {
34
+ "type": "number",
35
+ "default": 0.05,
36
+ "description": "Minimum exploration rate"
37
+ },
38
+ "maxConcurrent": {
39
+ "type": "number",
40
+ "default": 2,
41
+ "description": "Max skills to run per cycle"
42
+ },
43
+ "venue": {
44
+ "type": "string",
45
+ "default": "simmer",
46
+ "description": "Trading venue (simmer for paper, polymarket for live)"
47
+ }
48
+ },
49
+ "required": ["apiKey"]
50
+ },
51
+ "uiHints": {
52
+ "apiKey": {
53
+ "label": "Simmer API Key",
54
+ "sensitive": true,
55
+ "placeholder": "sk_live_...",
56
+ "help": "Get from simmer.markets/dashboard"
57
+ },
58
+ "venue": {
59
+ "label": "Trading Venue",
60
+ "help": "Use 'simmer' for paper trading, 'polymarket' for live"
61
+ },
62
+ "epsilon": {
63
+ "label": "Exploration Rate",
64
+ "advanced": true
65
+ },
66
+ "cycleIntervalMs": {
67
+ "label": "Cycle Interval (ms)",
68
+ "advanced": true
69
+ }
70
+ }
71
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "simmer-automaton",
3
+ "version": "0.1.0",
4
+ "description": "Simmer Automaton plugin for OpenClaw — budget-enforced skill orchestration",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "dev": "tsc --watch"
10
+ },
11
+ "keywords": ["openclaw", "plugin", "simmer", "prediction-markets", "trading"],
12
+ "license": "MIT",
13
+ "devDependencies": {
14
+ "typescript": "^5.4.0"
15
+ }
16
+ }
package/src/api.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Simmer API client for the automaton plugin.
3
+ * Thin wrapper around fetch — reads automaton state, skills registry.
4
+ */
5
+
6
+ export interface AutomatonState {
7
+ initialized: boolean;
8
+ budget_usd: number;
9
+ spent_usd: number;
10
+ remaining_usd: number;
11
+ halted: boolean;
12
+ tier: string;
13
+ horizon_days: number;
14
+ started_at: string | null;
15
+ }
16
+
17
+ export interface Skill {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ category: string;
22
+ tags: string[];
23
+ difficulty: string;
24
+ install: string;
25
+ clawhub_url: string;
26
+ requires: string[];
27
+ best_when: string | null;
28
+ }
29
+
30
+ export class SimmerApi {
31
+ private baseUrl: string;
32
+ private apiKey: string;
33
+
34
+ constructor(apiKey: string, baseUrl = "https://api.simmer.markets") {
35
+ this.apiKey = apiKey;
36
+ this.baseUrl = baseUrl;
37
+ }
38
+
39
+ private async request<T>(path: string, opts?: RequestInit): Promise<T> {
40
+ const res = await fetch(`${this.baseUrl}${path}`, {
41
+ ...opts,
42
+ headers: {
43
+ Authorization: `Bearer ${this.apiKey}`,
44
+ "Content-Type": "application/json",
45
+ ...(opts?.headers || {}),
46
+ },
47
+ });
48
+ if (!res.ok) {
49
+ const body = await res.text().catch(() => "");
50
+ throw new Error(`Simmer API ${res.status}: ${body}`);
51
+ }
52
+ return res.json() as Promise<T>;
53
+ }
54
+
55
+ async getAutomatonState(): Promise<AutomatonState> {
56
+ return this.request("/api/sdk/automaton/state");
57
+ }
58
+
59
+ async initAutomaton(budgetUsd: number, horizonDays = 30) {
60
+ return this.request("/api/sdk/automaton/init", {
61
+ method: "POST",
62
+ body: JSON.stringify({ budget_usd: budgetUsd, horizon_days: horizonDays }),
63
+ });
64
+ }
65
+
66
+ async halt() {
67
+ return this.request("/api/sdk/automaton/halt", { method: "POST" });
68
+ }
69
+
70
+ async resume() {
71
+ return this.request("/api/sdk/automaton/resume", { method: "POST" });
72
+ }
73
+
74
+ async getSkills(): Promise<{ skills: Skill[]; total: number }> {
75
+ return this.request("/api/sdk/skills");
76
+ }
77
+ }
package/src/bandit.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Epsilon-greedy bandit for skill selection.
3
+ * Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
4
+ */
5
+
6
+ export interface SkillState {
7
+ slug: string;
8
+ enabled: boolean;
9
+ timesSelected: number;
10
+ timesRewarded: number;
11
+ totalPnl: number;
12
+ consecutiveZeroSignals: number;
13
+ signalsFoundTotal: number;
14
+ tradesExecutedTotal: number;
15
+ lastCycle?: {
16
+ skipCounts?: Record<string, number>;
17
+ };
18
+ }
19
+
20
+ export interface SelectionMeta {
21
+ slug: string;
22
+ reason: string;
23
+ score: number | null;
24
+ }
25
+
26
+ function avgReward(skill: SkillState): number {
27
+ if (skill.timesSelected === 0) return Infinity;
28
+ return skill.totalPnl / skill.timesSelected;
29
+ }
30
+
31
+ export function tierMaxSkills(tier: string, maxConcurrent: number): number {
32
+ if (tier === "thriving" || tier === "normal") return maxConcurrent;
33
+ if (tier === "conserving" || tier === "critical") return 1;
34
+ return 0; // dead
35
+ }
36
+
37
+ export function tierEffectiveEpsilon(tier: string, epsilon: number): number {
38
+ if (tier === "thriving" || tier === "normal") return epsilon;
39
+ if (tier === "conserving") return epsilon * 0.5;
40
+ return 0.0; // critical, dead — pure exploit or no runs
41
+ }
42
+
43
+ export function selectSkills(
44
+ skills: SkillState[],
45
+ n: number,
46
+ tier: string,
47
+ epsilon: number,
48
+ ): { selected: string[]; meta: SelectionMeta[] } {
49
+ const enabled = skills.filter((s) => s.enabled);
50
+ if (enabled.length === 0) return { selected: [], meta: [] };
51
+
52
+ const effectiveEpsilon = tierEffectiveEpsilon(tier, epsilon);
53
+ const count = Math.min(n, enabled.length);
54
+
55
+ // Unplayed skills get priority
56
+ const unplayed = enabled.filter((s) => s.timesSelected === 0);
57
+ if (unplayed.length > 0) {
58
+ const shuffled = [...unplayed].sort(() => Math.random() - 0.5);
59
+ const picks = shuffled.slice(0, count);
60
+ const meta: SelectionMeta[] = picks.map((s) => ({
61
+ slug: s.slug,
62
+ reason: "unplayed",
63
+ score: null,
64
+ }));
65
+
66
+ if (picks.length < count) {
67
+ const remaining = enabled.filter((s) => !picks.includes(s));
68
+ const extra = remaining
69
+ .sort(() => Math.random() - 0.5)
70
+ .slice(0, count - picks.length);
71
+ picks.push(...extra);
72
+ meta.push(
73
+ ...extra.map((s) => ({
74
+ slug: s.slug,
75
+ reason: "backfill",
76
+ score: Math.round(avgReward(s) * 10000) / 10000,
77
+ })),
78
+ );
79
+ }
80
+
81
+ return {
82
+ selected: picks.map((s) => s.slug),
83
+ meta,
84
+ };
85
+ }
86
+
87
+ // Critical tier: always pick best
88
+ if (tier === "critical") {
89
+ const ranked = [...enabled].sort(
90
+ (a, b) => avgReward(b) - avgReward(a),
91
+ );
92
+ const picks = ranked.slice(0, count);
93
+ return {
94
+ selected: picks.map((s) => s.slug),
95
+ meta: picks.map((s) => ({
96
+ slug: s.slug,
97
+ reason: "critical_exploit",
98
+ score: Math.round(avgReward(s) * 10000) / 10000,
99
+ })),
100
+ };
101
+ }
102
+
103
+ // Epsilon-greedy
104
+ const selected: SkillState[] = [];
105
+ const meta: SelectionMeta[] = [];
106
+ const available = [...enabled];
107
+
108
+ for (let i = 0; i < count && available.length > 0; i++) {
109
+ let pick: SkillState;
110
+ let reason: string;
111
+
112
+ if (Math.random() < effectiveEpsilon) {
113
+ // Explore: random
114
+ const idx = Math.floor(Math.random() * available.length);
115
+ pick = available[idx];
116
+ reason = `explore (ε=${effectiveEpsilon.toFixed(2)})`;
117
+ } else {
118
+ // Exploit: best avg reward
119
+ pick = available.reduce((best, s) =>
120
+ avgReward(s) > avgReward(best) ? s : best,
121
+ );
122
+ reason = "exploit";
123
+ }
124
+
125
+ selected.push(pick);
126
+ meta.push({
127
+ slug: pick.slug,
128
+ reason,
129
+ score: Math.round(avgReward(pick) * 10000) / 10000,
130
+ });
131
+ available.splice(available.indexOf(pick), 1);
132
+ }
133
+
134
+ return { selected: selected.map((s) => s.slug), meta };
135
+ }
package/src/index.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Simmer Automaton — OpenClaw Plugin
3
+ *
4
+ * Budget-enforced skill orchestration with survival tiers.
5
+ * Algorithm decides skill selection (bandit), budget, circuit breakers.
6
+ * LLM decides market-level trades within selected skills.
7
+ * API enforces hard spending limits on every trade.
8
+ */
9
+
10
+ import { SimmerApi } from "./api.js";
11
+ import type { AutomatonState, Skill } from "./api.js";
12
+ import { selectSkills, tierMaxSkills, type SkillState } from "./bandit.js";
13
+ import { computeTier, type Tier } from "./tiers.js";
14
+ import { generateTuningHints } from "./tuning.js";
15
+
16
+ // OpenClaw types — we declare minimal interfaces to avoid requiring the SDK as a dependency
17
+ interface PluginApi {
18
+ pluginConfig?: Record<string, unknown>;
19
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
20
+ on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
21
+ registerService: (service: { id: string; start: (ctx: ServiceCtx) => Promise<void>; stop?: (ctx: ServiceCtx) => Promise<void> }) => void;
22
+ registerCommand: (cmd: { name: string; description: string; acceptsArgs?: boolean; handler: (ctx: CommandCtx) => Promise<{ text: string }> }) => void;
23
+ }
24
+
25
+ interface ServiceCtx {
26
+ stateDir: string;
27
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
28
+ }
29
+
30
+ interface CommandCtx {
31
+ args?: string;
32
+ }
33
+
34
+ // Plugin-local state (in-memory, refreshed from API each cycle)
35
+ let api: SimmerApi;
36
+ let cachedState: AutomatonState | null = null;
37
+ let cachedSkills: Skill[] = [];
38
+ let banditState: SkillState[] = [];
39
+ let currentTier: Tier = "normal";
40
+ let cycleCount = 0;
41
+ let serviceRunning = false;
42
+ let cycleTimer: ReturnType<typeof setInterval> | null = null;
43
+
44
+ // Config defaults
45
+ let config = {
46
+ apiKey: "",
47
+ apiUrl: "https://api.simmer.markets",
48
+ cycleIntervalMs: 300_000,
49
+ epsilon: 0.2,
50
+ epsilonDecay: 0.995,
51
+ minEpsilon: 0.05,
52
+ maxConcurrent: 2,
53
+ venue: "simmer",
54
+ };
55
+
56
+ function loadConfig(pluginConfig?: Record<string, unknown>) {
57
+ if (!pluginConfig) return;
58
+ if (pluginConfig.apiKey) config.apiKey = pluginConfig.apiKey as string;
59
+ if (pluginConfig.apiUrl) config.apiUrl = pluginConfig.apiUrl as string;
60
+ if (pluginConfig.cycleIntervalMs) config.cycleIntervalMs = pluginConfig.cycleIntervalMs as number;
61
+ if (pluginConfig.epsilon) config.epsilon = pluginConfig.epsilon as number;
62
+ if (pluginConfig.epsilonDecay) config.epsilonDecay = pluginConfig.epsilonDecay as number;
63
+ if (pluginConfig.minEpsilon) config.minEpsilon = pluginConfig.minEpsilon as number;
64
+ if (pluginConfig.maxConcurrent) config.maxConcurrent = pluginConfig.maxConcurrent as number;
65
+ if (pluginConfig.venue) config.venue = pluginConfig.venue as string;
66
+ }
67
+
68
+ async function refreshState(logger: { info: (m: string) => void; error: (m: string) => void }) {
69
+ try {
70
+ cachedState = await api.getAutomatonState();
71
+ const skillsRes = await api.getSkills();
72
+ cachedSkills = skillsRes.skills;
73
+
74
+ if (cachedState.initialized) {
75
+ // Compute tier (totalPnl = 0 for now, will be enriched when P&L tracking is added)
76
+ currentTier = computeTier(cachedState, 0);
77
+ }
78
+ } catch (e) {
79
+ logger.error(`[simmer] Failed to refresh state: ${e}`);
80
+ }
81
+ }
82
+
83
+ function buildPromptContext(): string {
84
+ if (!cachedState || !cachedState.initialized) {
85
+ return "[Simmer Automaton] Not initialized. Run: POST /api/sdk/automaton/init";
86
+ }
87
+
88
+ const lines = [
89
+ "## Simmer Automaton — Survival Context (read-only)",
90
+ "",
91
+ `**Tier:** ${currentTier} | **Venue:** ${config.venue}`,
92
+ `**Budget:** $${cachedState.budget_usd.toFixed(2)} | **Spent:** $${cachedState.spent_usd.toFixed(2)} | **Remaining:** $${cachedState.remaining_usd.toFixed(2)}`,
93
+ `**Halted:** ${cachedState.halted ? "YES — all trades blocked" : "no"}`,
94
+ `**Horizon:** ${cachedState.horizon_days} days`,
95
+ "",
96
+ ];
97
+
98
+ // Skill picks from bandit (if any)
99
+ if (banditState.length > 0) {
100
+ const n = tierMaxSkills(currentTier, config.maxConcurrent);
101
+ const { selected, meta } = selectSkills(banditState, n, currentTier, config.epsilon);
102
+ if (selected.length > 0) {
103
+ lines.push("**Selected skills this cycle:**");
104
+ for (const m of meta) {
105
+ lines.push(`- ${m.slug} (${m.reason}${m.score !== null ? `, score=${m.score}` : ""})`);
106
+ }
107
+ lines.push("");
108
+ }
109
+ }
110
+
111
+ // Tuning hints
112
+ const hints = generateTuningHints(banditState, cachedState.budget_usd);
113
+ if (hints.length > 0) {
114
+ lines.push("**Tuning hints:**");
115
+ for (const h of hints) {
116
+ lines.push(`- [${h.skill}] ${h.issue}: ${h.suggestion}`);
117
+ }
118
+ lines.push("");
119
+ }
120
+
121
+ if (currentTier === "critical") {
122
+ lines.push("**WARNING:** Critical tier — only exploiting best skill, no exploration.");
123
+ } else if (currentTier === "dead") {
124
+ lines.push("**DEAD:** Budget exhausted or horizon expired. No skills will run.");
125
+ }
126
+
127
+ return lines.join("\n");
128
+ }
129
+
130
+ function formatStatus(): string {
131
+ if (!cachedState || !cachedState.initialized) {
132
+ return "Automaton not initialized. Use POST /api/sdk/automaton/init to start.";
133
+ }
134
+
135
+ const lines = [
136
+ `Tier: ${currentTier}`,
137
+ `Venue: ${config.venue}`,
138
+ `Budget: $${cachedState.budget_usd.toFixed(2)}`,
139
+ `Spent: $${cachedState.spent_usd.toFixed(2)}`,
140
+ `Remaining: $${cachedState.remaining_usd.toFixed(2)}`,
141
+ `Halted: ${cachedState.halted ? "YES" : "no"}`,
142
+ `Horizon: ${cachedState.horizon_days} days`,
143
+ `Cycles: ${cycleCount}`,
144
+ `Skills available: ${cachedSkills.length}`,
145
+ ];
146
+ return lines.join("\n");
147
+ }
148
+
149
+ // =============================================================================
150
+ // Plugin registration
151
+ // =============================================================================
152
+
153
+ export default function register(pluginApi: PluginApi) {
154
+ loadConfig(pluginApi.pluginConfig);
155
+
156
+ if (!config.apiKey) {
157
+ pluginApi.logger.error("[simmer] No apiKey configured — plugin disabled");
158
+ return;
159
+ }
160
+
161
+ api = new SimmerApi(config.apiKey, config.apiUrl);
162
+ const logger = pluginApi.logger;
163
+
164
+ // --- Hook: before_prompt_build ---
165
+ // Inject survival context into every LLM prompt
166
+ pluginApi.on("before_prompt_build", async () => {
167
+ if (!cachedState) {
168
+ await refreshState(logger);
169
+ }
170
+ return { prependContext: buildPromptContext() };
171
+ });
172
+
173
+ // --- Service: background bandit cycle ---
174
+ pluginApi.registerService({
175
+ id: "simmer-automaton",
176
+ start: async (ctx: ServiceCtx) => {
177
+ ctx.logger.info("[simmer] Automaton service starting");
178
+ serviceRunning = true;
179
+
180
+ // Initial state fetch
181
+ await refreshState(ctx.logger);
182
+
183
+ // Periodic refresh
184
+ cycleTimer = setInterval(async () => {
185
+ if (!serviceRunning) return;
186
+ cycleCount++;
187
+ await refreshState(ctx.logger);
188
+
189
+ // Decay epsilon
190
+ config.epsilon = Math.max(
191
+ config.minEpsilon,
192
+ config.epsilon * config.epsilonDecay,
193
+ );
194
+
195
+ ctx.logger.info(
196
+ `[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | skills=${cachedSkills.length}`,
197
+ );
198
+ }, config.cycleIntervalMs);
199
+ },
200
+ stop: async (ctx: ServiceCtx) => {
201
+ ctx.logger.info("[simmer] Automaton service stopping");
202
+ serviceRunning = false;
203
+ if (cycleTimer) {
204
+ clearInterval(cycleTimer);
205
+ cycleTimer = null;
206
+ }
207
+ },
208
+ });
209
+
210
+ // --- Commands ---
211
+
212
+ pluginApi.registerCommand({
213
+ name: "simmer",
214
+ description: "Show Simmer automaton status",
215
+ acceptsArgs: true,
216
+ handler: async (ctx: CommandCtx) => {
217
+ const subcommand = ctx.args?.trim().split(/\s+/)[0] || "status";
218
+
219
+ if (subcommand === "status") {
220
+ await refreshState(logger);
221
+ return { text: formatStatus() };
222
+ }
223
+
224
+ if (subcommand === "halt") {
225
+ try {
226
+ await api.halt();
227
+ await refreshState(logger);
228
+ return { text: "Automaton halted. All trades will be rejected." };
229
+ } catch (e) {
230
+ return { text: `Failed to halt: ${e}` };
231
+ }
232
+ }
233
+
234
+ if (subcommand === "resume") {
235
+ try {
236
+ await api.resume();
237
+ await refreshState(logger);
238
+ return { text: "Automaton resumed. Trading is active." };
239
+ } catch (e) {
240
+ return { text: `Failed to resume: ${e}` };
241
+ }
242
+ }
243
+
244
+ if (subcommand === "skills") {
245
+ await refreshState(logger);
246
+ if (cachedSkills.length === 0) {
247
+ return { text: "No skills in registry." };
248
+ }
249
+ const lines = cachedSkills.map(
250
+ (s) => `- ${s.name} (${s.id}) — ${s.category}, ${s.difficulty}`,
251
+ );
252
+ return { text: `Skills (${cachedSkills.length}):\n${lines.join("\n")}` };
253
+ }
254
+
255
+ return {
256
+ text: "Usage: /simmer [status|halt|resume|skills]",
257
+ };
258
+ },
259
+ });
260
+
261
+ logger.info("[simmer] Plugin registered");
262
+ }
package/src/tiers.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Survival tier computation.
3
+ * Ported from automaton.py — compute_tier.
4
+ */
5
+
6
+ import type { AutomatonState } from "./api.js";
7
+
8
+ export type Tier = "thriving" | "normal" | "conserving" | "critical" | "dead";
9
+
10
+ export function computeTier(state: AutomatonState, totalPnl: number): Tier {
11
+ if (state.budget_usd <= 0) return "dead";
12
+
13
+ const budgetRemainingPct =
14
+ (state.budget_usd - state.spent_usd + totalPnl) / state.budget_usd;
15
+
16
+ const startedAt = state.started_at ? new Date(state.started_at) : new Date();
17
+ const daysElapsed =
18
+ (Date.now() - startedAt.getTime()) / (1000 * 60 * 60 * 24);
19
+
20
+ if (budgetRemainingPct <= 0 || daysElapsed >= state.horizon_days) return "dead";
21
+ if (budgetRemainingPct < 0.1) return "critical";
22
+ if (budgetRemainingPct < 0.3) return "conserving";
23
+ if (totalPnl > 0 && budgetRemainingPct > 0.7) return "thriving";
24
+ return "normal";
25
+ }
package/src/tuning.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tuning hints for the Clawbot LLM.
3
+ * Ported from automaton.py — generate_tuning_hints.
4
+ */
5
+
6
+ import type { SkillState } from "./bandit.js";
7
+
8
+ export interface TuningHint {
9
+ skill: string;
10
+ issue: string;
11
+ suggestion: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export function generateTuningHints(
16
+ skills: SkillState[],
17
+ budgetUsd: number,
18
+ ): TuningHint[] {
19
+ const hints: TuningHint[] = [];
20
+
21
+ for (const sk of skills) {
22
+ if (!sk.enabled || sk.timesSelected === 0) continue;
23
+
24
+ // 1. Zero signals streak
25
+ if (sk.consecutiveZeroSignals >= 5) {
26
+ hints.push({
27
+ skill: sk.slug,
28
+ issue: "zero_signals_streak",
29
+ cycles: sk.consecutiveZeroSignals,
30
+ suggestion: `0 signals for ${sk.consecutiveZeroSignals} cycles — loosen thresholds or widen time windows`,
31
+ });
32
+ }
33
+
34
+ // 2. Concentrated loss
35
+ if (sk.totalPnl < 0 && budgetUsd > 0) {
36
+ const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
37
+ if (lossPct > 20) {
38
+ hints.push({
39
+ skill: sk.slug,
40
+ issue: "concentrated_loss",
41
+ pnl: Math.round(sk.totalPnl * 100) / 100,
42
+ pct_of_budget: Math.round(lossPct * 10) / 10,
43
+ suggestion: `Lost $${Math.abs(sk.totalPnl).toFixed(2)} (${lossPct.toFixed(0)}% of budget) — consider disabling or reducing max bet`,
44
+ });
45
+ }
46
+ }
47
+
48
+ // 3. Inert — finds signals but never executes
49
+ if (sk.signalsFoundTotal > 50 && sk.tradesExecutedTotal === 0) {
50
+ hints.push({
51
+ skill: sk.slug,
52
+ issue: "inert",
53
+ signals: sk.signalsFoundTotal,
54
+ suggestion: `${sk.signalsFoundTotal} signals found, 0 executed — execution thresholds likely too tight`,
55
+ });
56
+ }
57
+
58
+ // 4. Win rate collapse
59
+ if (sk.timesSelected >= 10) {
60
+ const winRate = sk.timesRewarded / sk.timesSelected;
61
+ if (winRate < 0.2) {
62
+ hints.push({
63
+ skill: sk.slug,
64
+ issue: "win_rate_collapse",
65
+ win_rate: Math.round(winRate * 1000) / 10,
66
+ runs: sk.timesSelected,
67
+ suggestion: `Win rate ${(winRate * 100).toFixed(0)}% over ${sk.timesSelected} cycles — strategy may not suit current markets`,
68
+ });
69
+ }
70
+ }
71
+
72
+ // 5. Safeguard dominant
73
+ const skipCounts = sk.lastCycle?.skipCounts || {};
74
+ const totalSkips = Object.values(skipCounts).reduce(
75
+ (a, b) => a + b,
76
+ 0,
77
+ );
78
+ const safeguardSkips = skipCounts["safeguard"] || 0;
79
+ if (totalSkips >= 3 && safeguardSkips / totalSkips > 0.8) {
80
+ hints.push({
81
+ skill: sk.slug,
82
+ issue: "safeguard_dominant",
83
+ safeguard_pct: Math.round((safeguardSkips / totalSkips) * 100),
84
+ suggestion:
85
+ "Most skips are safeguard blocks — markets may be too volatile or near resolution",
86
+ });
87
+ }
88
+ }
89
+
90
+ return hints;
91
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "declaration": true,
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }