@coinrithm/mcp-trading 0.1.8 → 0.2.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.
@@ -0,0 +1,85 @@
1
+ // Parse the model's single text response into a strict, structured Decision.
2
+ // v1 accepts FUTURES actions only. Anything else — invalid JSON, an unknown
3
+ // action type, a free-form endpoint/tool name, extra unknown fields, or a
4
+ // missing required field — fails closed (the runner then skips the cycle).
5
+ import { z } from "zod";
6
+ const futuresOpen = z
7
+ .object({
8
+ type: z.literal("futures_open"),
9
+ symbol: z.string().min(1),
10
+ side: z.enum(["long", "short"]),
11
+ leverage: z.number().positive(),
12
+ marginMusd: z.number().positive(),
13
+ stopLossPrice: z.number().nullable().optional(),
14
+ takeProfitPrice: z.number().nullable().optional(),
15
+ confidence: z.number().min(0).max(1).optional(),
16
+ rationaleSummary: z.string().optional(),
17
+ })
18
+ .strict();
19
+ const futuresClose = z
20
+ .object({
21
+ type: z.literal("futures_close"),
22
+ positionId: z.number(),
23
+ fraction: z.number().positive().max(1).optional(),
24
+ confidence: z.number().min(0).max(1).optional(),
25
+ rationaleSummary: z.string().optional(),
26
+ })
27
+ .strict();
28
+ const futuresSetSltp = z
29
+ .object({
30
+ type: z.literal("futures_set_sltp"),
31
+ positionId: z.number(),
32
+ stopLossPrice: z.number().nullable().optional(),
33
+ takeProfitPrice: z.number().nullable().optional(),
34
+ })
35
+ .strict();
36
+ const actionSchema = z.discriminatedUnion("type", [
37
+ futuresOpen,
38
+ futuresClose,
39
+ futuresSetSltp,
40
+ ]);
41
+ const decisionSchema = z
42
+ .object({
43
+ decision: z.enum(["skip", "act"]),
44
+ confidence: z.number().min(0).max(1).optional(),
45
+ reason: z.string().optional(),
46
+ actions: z.array(actionSchema).default([]),
47
+ })
48
+ .strict();
49
+ // Pull a JSON object out of a model response that may be fenced or wrapped.
50
+ function coerceJson(text) {
51
+ let s = text.trim();
52
+ const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(s);
53
+ if (fence)
54
+ s = fence[1].trim();
55
+ if (!s.startsWith("{")) {
56
+ const i = s.indexOf("{");
57
+ const j = s.lastIndexOf("}");
58
+ if (i >= 0 && j > i)
59
+ s = s.slice(i, j + 1);
60
+ }
61
+ return JSON.parse(s); // throws on invalid JSON -> caller treats as fail-closed
62
+ }
63
+ export function parseDecision(text) {
64
+ let obj;
65
+ try {
66
+ obj = coerceJson(text);
67
+ }
68
+ catch (err) {
69
+ return { ok: false, error: `model output is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
70
+ }
71
+ const res = decisionSchema.safeParse(obj);
72
+ if (!res.success) {
73
+ return {
74
+ ok: false,
75
+ error: res.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "),
76
+ };
77
+ }
78
+ const d = res.data;
79
+ // A "skip" decision ignores any actions; an "act" with no actions is a skip.
80
+ const actions = d.decision === "act" ? d.actions : [];
81
+ return {
82
+ ok: true,
83
+ decision: { decision: d.decision, confidence: d.confidence, reason: d.reason, actions },
84
+ };
85
+ }
@@ -0,0 +1,107 @@
1
+ // The decision gate: re-check EVERY proposed action against the spec's hard caps
2
+ // BEFORE any write. The model only proposes; this disposes. Because the caps
3
+ // come from the spec (not the observation or the model), a prompt-injection in
4
+ // market text cannot widen a limit or force a trade. v1 = futures only.
5
+ import { ok, fail, actionVenue, } from "./types.js";
6
+ const SERVER_MAX_LEVERAGE = 20;
7
+ export function validateAction(action, ctx) {
8
+ const { spec, observation } = ctx;
9
+ // v1 scope: futures only.
10
+ if (action.type === "spot_order" || action.type === "spot_cancel" || action.type === "pm_open") {
11
+ return fail("out_of_scope_v1", `action "${action.type}" is out of v1 scope (futures only)`);
12
+ }
13
+ const venue = actionVenue(action);
14
+ if (!spec.venues.includes(venue)) {
15
+ return fail("venue_not_allowed", `venue ${venue} not in [${spec.venues.join(", ")}]`);
16
+ }
17
+ if (spec.sync.requirePollBeforeWrite && !observation.polledBeforeWrite) {
18
+ return fail("no_poll_before_write", "must successfully poll /trades before writing");
19
+ }
20
+ if (ctx.writesThisCycle >= spec.limits.maxWritesPerCycle) {
21
+ return fail("write_budget_exceeded", `maxWritesPerCycle ${spec.limits.maxWritesPerCycle} reached`);
22
+ }
23
+ if (ctx.writesToday >= spec.limits.maxTradesPerDay) {
24
+ return fail("daily_trade_cap", `maxTradesPerDay ${spec.limits.maxTradesPerDay} reached`);
25
+ }
26
+ if (action.type === "futures_open") {
27
+ // Daily realized-loss stop: once today's loss hits the cap, open no new risk.
28
+ if (spec.limits.maxDailyLossMusd > 0 && ctx.realizedLossTodayMusd >= spec.limits.maxDailyLossMusd) {
29
+ return fail("daily_loss_cap", `today's realized loss ${ctx.realizedLossTodayMusd} >= ${spec.limits.maxDailyLossMusd}`);
30
+ }
31
+ const entry = observation.watch.find((w) => w.symbol.toUpperCase() === action.symbol.toUpperCase());
32
+ if (!entry)
33
+ return fail("unknown_symbol", `${action.symbol} is not on the watchlist`);
34
+ if (!entry.coinId)
35
+ return fail("unresolved_symbol", `${action.symbol} did not resolve to a coin`);
36
+ if (action.leverage > spec.risk.maxLeverage) {
37
+ return fail("leverage_exceeds_cap", `leverage ${action.leverage} > cap ${spec.risk.maxLeverage}`);
38
+ }
39
+ if (action.leverage > SERVER_MAX_LEVERAGE) {
40
+ return fail("leverage_exceeds_server", `leverage ${action.leverage} > server cap ${SERVER_MAX_LEVERAGE}`);
41
+ }
42
+ if (action.marginMusd > spec.risk.perTradeMarginMusd) {
43
+ return fail("margin_exceeds_cap", `margin ${action.marginMusd} > cap ${spec.risk.perTradeMarginMusd}`);
44
+ }
45
+ // Aggregate exposure ceiling (existing open margin + this cycle) — the cap
46
+ // that perTradeMargin × maxConcurrentPositions would otherwise blow past.
47
+ if (ctx.openMarginMusd + action.marginMusd > spec.limits.maxOpenMarginMusd) {
48
+ return fail("open_margin_exceeds_cap", `open margin ${ctx.openMarginMusd} + ${action.marginMusd} > ${spec.limits.maxOpenMarginMusd}`);
49
+ }
50
+ if (ctx.openCount >= spec.risk.maxConcurrentPositions) {
51
+ return fail("max_positions", `already ${ctx.openCount} open >= ${spec.risk.maxConcurrentPositions}`);
52
+ }
53
+ if (ctx.cashAvailableMusd != null && action.marginMusd > ctx.cashAvailableMusd) {
54
+ return fail("insufficient_balance", `margin ${action.marginMusd} > available ${ctx.cashAvailableMusd}`);
55
+ }
56
+ // NOTE: minConfidence keys on the model's SELF-REPORTED confidence — a
57
+ // cooperation hint, NOT an injection-resistant control. The hard caps above
58
+ // (which come from the spec, never the observation) are what actually bind.
59
+ if ((action.confidence ?? 0) < spec.abstention.minConfidence) {
60
+ return fail("below_min_confidence", `confidence ${action.confidence ?? 0} < min ${spec.abstention.minConfidence}`);
61
+ }
62
+ if (spec.risk.requireStopLoss) {
63
+ const sl = action.stopLossPrice;
64
+ if (sl == null || !Number.isFinite(sl) || sl <= 0) {
65
+ return fail("missing_stop_loss", "requireStopLoss is set but no valid (finite, positive) stopLossPrice was proposed");
66
+ }
67
+ // Side-aware corridor: a long's stop must be BELOW entry, a short's ABOVE.
68
+ // A wrong-side "stop" is a dead trigger that never protects.
69
+ const e = ctx.quote?.entryPrice;
70
+ if (typeof e === "number" && Number.isFinite(e)) {
71
+ if (action.side === "long" && sl >= e) {
72
+ return fail("stop_loss_wrong_side", `long stop ${sl} must be below entry ${e}`);
73
+ }
74
+ if (action.side === "short" && sl <= e) {
75
+ return fail("stop_loss_wrong_side", `short stop ${sl} must be above entry ${e}`);
76
+ }
77
+ }
78
+ }
79
+ if (!ctx.quote)
80
+ return fail("missing_quote", "no quote evidence was fetched for this open");
81
+ if (!ctx.quote.eligible) {
82
+ return fail("quote_ineligible", `quote blocked: ${JSON.stringify(ctx.quote.blockReasons ?? [])}`);
83
+ }
84
+ // FAIL-CLOSED: a missing freshness block is treated as not-fresh.
85
+ if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
86
+ return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
87
+ }
88
+ return ok();
89
+ }
90
+ if (action.type === "futures_close" || action.type === "futures_set_sltp") {
91
+ const pos = observation.openPositions.find((p) => p.id === action.positionId && p.venue === "futures");
92
+ if (!pos)
93
+ return fail("unknown_position", `no open futures position ${action.positionId}`);
94
+ // No double-acting on the same position within one cycle.
95
+ if (ctx.targetedPositionIds.includes(action.positionId)) {
96
+ return fail("position_already_targeted", `position ${action.positionId} already acted on this cycle`);
97
+ }
98
+ if (action.type === "futures_set_sltp") {
99
+ const hasTrigger = [action.stopLossPrice, action.takeProfitPrice].some((v) => typeof v === "number" && Number.isFinite(v) && v > 0);
100
+ if (!hasTrigger) {
101
+ return fail("sltp_no_op", "futures_set_sltp must set at least one positive stopLossPrice or takeProfitPrice");
102
+ }
103
+ }
104
+ return ok();
105
+ }
106
+ return fail("unknown_action", "unsupported action type");
107
+ }
@@ -0,0 +1,5 @@
1
+ // Defensive extractors for untyped API JSON.
2
+ export const asObj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
3
+ export const asArr = (v) => (Array.isArray(v) ? v : []);
4
+ export const asNum = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
5
+ export const asStr = (v) => typeof v === "string" ? v : undefined;
@@ -0,0 +1,19 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ export function parseFrontmatter(text) {
3
+ const src = text.replace(/\r\n/g, "\n");
4
+ const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(src);
5
+ if (!m) {
6
+ throw new Error("skill file has no YAML frontmatter (expected a `---` block at the top)");
7
+ }
8
+ let data;
9
+ try {
10
+ data = parseYaml(m[1]);
11
+ }
12
+ catch (err) {
13
+ throw new Error(`skill frontmatter is not valid YAML: ${err instanceof Error ? err.message : String(err)}`);
14
+ }
15
+ if (data == null || typeof data !== "object" || Array.isArray(data)) {
16
+ throw new Error("skill frontmatter must be a YAML mapping (key: value pairs)");
17
+ }
18
+ return { data: data, body: (m[2] ?? "").trim() };
19
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // coinrithm-agent CLI entrypoint.
3
+ import { main } from "./cli.js";
4
+ main(process.argv.slice(2))
5
+ .then((code) => process.exit(code))
6
+ .catch((err) => {
7
+ // eslint-disable-next-line no-console
8
+ console.error(err instanceof Error ? err.message : String(err));
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,40 @@
1
+ // The frozen, fully-resolved agent definition. Writing meta/manifest.lock.json
2
+ // makes a decomposed folder as reproducible as pinning a single SKILL.md by
3
+ // commit SHA: anyone can see exactly which knob values (and from which files)
4
+ // produced a run. Deterministic by construction — no timestamps, keys sorted.
5
+ import { writeFileSync, mkdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { sha256, stableStringify, toPosix } from "./util.js";
8
+ import { RESOLVER_VERSION, RUNNER_VERSION, MANIFEST_SCHEMA } from "./version.js";
9
+ export function buildManifest(resolved, spec) {
10
+ // configHash binds the RESOLVED spec to the resolver + schema version, so a
11
+ // run reproduces only against the same compile (not just the same files).
12
+ const configHash = sha256(stableStringify({
13
+ resolvedSpec: spec,
14
+ resolverVersion: RESOLVER_VERSION,
15
+ schema: MANIFEST_SCHEMA,
16
+ }));
17
+ return {
18
+ schema: MANIFEST_SCHEMA,
19
+ spec: spec.spec || "coinrithm.agent.v1",
20
+ resolverVersion: RESOLVER_VERSION,
21
+ runnerVersion: RUNNER_VERSION,
22
+ resolvedConfig: resolved.rawFrontmatter,
23
+ resolvedSpec: spec,
24
+ provenance: resolved.provenance,
25
+ contentHashes: resolved.contentHashes,
26
+ configHash,
27
+ };
28
+ }
29
+ // Serialize deterministically (sorted keys, no timestamps) so two compiles of
30
+ // the same inputs on Windows and Linux are byte-identical.
31
+ export function serializeManifest(manifest) {
32
+ return stableStringify(manifest) + "\n";
33
+ }
34
+ export function writeManifest(agentDir, manifest) {
35
+ const metaDir = join(agentDir, "meta");
36
+ mkdirSync(metaDir, { recursive: true });
37
+ const out = join(metaDir, "manifest.lock.json");
38
+ writeFileSync(out, serializeManifest(manifest), "utf8");
39
+ return toPosix(out);
40
+ }
@@ -0,0 +1,82 @@
1
+ // Deterministic merge rules for the agent resolver.
2
+ //
3
+ // Codex audit, adopted: HARD CAPS merge by "most-restrictive-wins", NOT
4
+ // last-writer-wins. A skill/tactic module may only TIGHTEN a cap; any attempt
5
+ // to WIDEN one is rejected (fail-closed), never silently ignored. This makes a
6
+ // decomposed agent strictly safer than its inline form can be — a part-file or
7
+ // a tactic can never loosen a limit, regardless of file/merge order.
8
+ //
9
+ // Normal (non-cap) config uses simple precedence (inline > $ref > defaults),
10
+ // handled in the resolver; this module owns only the cap arithmetic.
11
+ // risk.* hard caps a tactic module may tighten.
12
+ export const RISK_CAPS = {
13
+ maxLeverage: "lower",
14
+ perTradeMarginMusd: "lower",
15
+ maxConcurrentPositions: "lower",
16
+ requireStopLoss: "true",
17
+ };
18
+ // limits.* throughput/spend caps a tactic module may tighten.
19
+ export const LIMIT_CAPS = {
20
+ maxTradesPerDay: "lower",
21
+ maxWritesPerCycle: "lower",
22
+ maxDailyLossMusd: "lower",
23
+ maxOpenMarginMusd: "lower",
24
+ };
25
+ // Is `candidate` at least as restrictive as `base` (i.e. a legal tightening)?
26
+ export function isAtLeastAsRestrictive(dir, candidate, base) {
27
+ if (dir === "true") {
28
+ // true is tighter than false. candidate must not be LESS strict than base.
29
+ // legal: base=false→candidate any; base=true→candidate must be true.
30
+ return base === true ? candidate === true : true;
31
+ }
32
+ if (typeof candidate !== "number" || typeof base !== "number") {
33
+ // a non-numeric cap candidate where a number is expected is not a legal
34
+ // tightening (caller will surface a type issue separately).
35
+ return false;
36
+ }
37
+ return dir === "lower" ? candidate <= base : candidate >= base;
38
+ }
39
+ // The more-restrictive of two values for a cap field.
40
+ export function mostRestrictive(dir, a, b) {
41
+ if (dir === "true")
42
+ return a === true || b === true ? true : false;
43
+ if (typeof a !== "number")
44
+ return b;
45
+ if (typeof b !== "number")
46
+ return a;
47
+ return dir === "lower" ? Math.min(a, b) : Math.max(a, b);
48
+ }
49
+ // Merge a tactic-module cap patch onto a base cap block under tighten-only +
50
+ // most-restrictive-wins. `caps` is the field→direction map (RISK_CAPS/LIMIT_CAPS).
51
+ // A patch key that is NOT a known cap, or that WIDENS a cap, is an issue.
52
+ export function mergeCapPatch(base, patch, caps, sourceLabel) {
53
+ const merged = { ...base };
54
+ const issues = [];
55
+ for (const [key, value] of Object.entries(patch)) {
56
+ const dir = caps[key];
57
+ if (!dir) {
58
+ issues.push({
59
+ code: "skill_patch_unknown_cap",
60
+ path: sourceLabel,
61
+ message: `tactic "${sourceLabel}" may only tighten known caps (${Object.keys(caps).join(", ")}); got "${key}"`,
62
+ });
63
+ continue;
64
+ }
65
+ if (!(key in base) || base[key] === undefined) {
66
+ // No base to tighten against — accept the value as-is (it can't widen
67
+ // something that wasn't set; the top-level/defaults still bound it).
68
+ merged[key] = value;
69
+ continue;
70
+ }
71
+ if (!isAtLeastAsRestrictive(dir, value, base[key])) {
72
+ issues.push({
73
+ code: "skill_patch_widens_cap",
74
+ path: sourceLabel,
75
+ message: `tactic "${sourceLabel}" tries to WIDEN ${key} (${JSON.stringify(base[key])} → ${JSON.stringify(value)}); a tactic may only tighten`,
76
+ });
77
+ continue;
78
+ }
79
+ merged[key] = mostRestrictive(dir, base[key], value);
80
+ }
81
+ return { merged, issues };
82
+ }
@@ -0,0 +1,110 @@
1
+ // Observe phase: read CoinRithm state into one Observation. Sync-polls /trades
2
+ // before any write (polledBeforeWrite=true only after that succeeds). If a
3
+ // required read fails, or no watchlist symbol resolves, the cycle SKIPS writes.
4
+ import { asObj, asArr, asNum, asStr } from "./extract.js";
5
+ function freshnessOf(block) {
6
+ const fr = asObj(block.freshness);
7
+ const status = asStr(fr.status);
8
+ return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
9
+ }
10
+ function emptyObservation(state, scopes = []) {
11
+ return {
12
+ asOf: state.cursor ?? new Date().toISOString(),
13
+ scopes,
14
+ cashAvailableMusd: null,
15
+ equityMusd: null,
16
+ openPositions: [],
17
+ watch: [],
18
+ syncCursor: state.cursor,
19
+ newClosedTrades: [],
20
+ polledBeforeWrite: false,
21
+ };
22
+ }
23
+ export async function observe(client, spec, state, trace) {
24
+ const meR = await client.me(trace);
25
+ if (!meR.ok)
26
+ return { observation: emptyObservation(state), skip: `me failed (HTTP ${meR.status})` };
27
+ const scopes = asArr(asObj(meR.data).scopes).filter((s) => typeof s === "string");
28
+ const [portR, walletR, posR] = await Promise.all([
29
+ client.portfolio(trace),
30
+ client.wallet(undefined, trace),
31
+ client.futuresPositions(undefined, trace),
32
+ ]);
33
+ if (!portR.ok || !walletR.ok || !posR.ok) {
34
+ return { observation: emptyObservation(state, scopes), skip: "required reads failed (portfolio/wallet/positions)" };
35
+ }
36
+ const usdt = asObj(asObj(walletR.data).usdt);
37
+ const equity = asObj(asObj(portR.data).equity);
38
+ const cashAvailableMusd = asNum(usdt.available) ?? asNum(equity.availableUsd) ?? null;
39
+ const equityMusd = asNum(equity.totalUsd) ?? asNum(asObj(portR.data).equityUsd) ?? null;
40
+ const openPositions = asArr(asObj(posR.data).positions)
41
+ .map(asObj)
42
+ .filter((p) => (asStr(p.status) ?? "open") === "open")
43
+ .map((p) => ({
44
+ venue: "futures",
45
+ id: Number(asNum(p.id) ?? p.id),
46
+ coinId: asStr(p.coinId),
47
+ symbol: asStr(p.symbol),
48
+ side: asStr(p.side),
49
+ status: asStr(p.status) ?? "open",
50
+ marginMusd: asNum(p.marginMusd),
51
+ unrealizedPnlMusd: asNum(p.unrealizedPnlMusd),
52
+ }));
53
+ // Sync poll: /trades since the persisted cursor.
54
+ const tradesR = await client.trades({ venue: "futures", updatedSince: state.cursor ?? undefined, limit: state.cursor ? undefined : 1 }, trace);
55
+ let polledBeforeWrite = false;
56
+ let newClosedTrades = [];
57
+ let syncCursor = state.cursor;
58
+ if (tradesR.ok) {
59
+ polledBeforeWrite = true;
60
+ const td = asObj(tradesR.data);
61
+ syncCursor = asStr(td.asOf) ?? state.cursor;
62
+ newClosedTrades = asArr(td.trades)
63
+ .map(asObj)
64
+ .filter((t) => !state.seen.includes(`${asStr(t.venue) ?? "futures"}:${asNum(t.id) ?? t.id}`));
65
+ }
66
+ // Watchlist market context.
67
+ const watch = [];
68
+ let resolvedAny = false;
69
+ for (const symbol of spec.risk.watchlist) {
70
+ const rs = await client.resolve(symbol, trace);
71
+ const match = asObj(asObj(rs.data).match);
72
+ const coinId = rs.ok && match.coinId != null ? String(match.coinId) : null;
73
+ if (!coinId) {
74
+ watch.push({ symbol, coinId: null });
75
+ continue;
76
+ }
77
+ resolvedAny = true;
78
+ const mk = await client.market(coinId, trace);
79
+ const m = asObj(mk.data);
80
+ const price = asObj(m.price);
81
+ watch.push({
82
+ symbol,
83
+ coinId,
84
+ name: asStr(match.name),
85
+ priceUsd: asNum(price.usd),
86
+ change1h: asNum(price.change1h),
87
+ change24h: asNum(price.change24h),
88
+ change7d: asNum(price.change7d),
89
+ // Freshness lives under the response's `observation` block.
90
+ freshness: freshnessOf(asObj(m.observation)),
91
+ });
92
+ }
93
+ const observation = {
94
+ asOf: syncCursor ?? new Date().toISOString(),
95
+ scopes,
96
+ cashAvailableMusd,
97
+ equityMusd,
98
+ openPositions,
99
+ watch,
100
+ syncCursor,
101
+ newClosedTrades,
102
+ polledBeforeWrite,
103
+ };
104
+ if (!resolvedAny)
105
+ return { observation, skip: "no watchlist symbol resolved to a coin" };
106
+ if (spec.sync.requirePollBeforeWrite && !polledBeforeWrite) {
107
+ return { observation, skip: "poll-before-write required but /trades poll failed" };
108
+ }
109
+ return { observation };
110
+ }
@@ -0,0 +1,45 @@
1
+ // Build the system + user prompts for one decide step. The system prompt is the
2
+ // static character (cached prefix); the user prompt is the fresh observation.
3
+ // The model only PROPOSES — the runner re-checks every action against the caps,
4
+ // so the prompt states the caps but never relies on the model to honor them.
5
+ export function buildSystemPrompt(spec, mergedProse) {
6
+ const r = spec.risk;
7
+ return [
8
+ "You operate a CoinRithm PAPER-TRADING futures agent (simulated 50,000 mUSD; not real money, not financial advice).",
9
+ "You only PROPOSE actions as structured JSON. A separate runner re-validates every action against hard caps and executes it; you cannot bypass a cap.",
10
+ "",
11
+ "## Your strategy (your borders)",
12
+ mergedProse.trim() || "(no strategy prose provided)",
13
+ "",
14
+ "## Hard caps the runner enforces (do not exceed; proposing over a cap wastes the cycle)",
15
+ `- venues: ${spec.venues.join(", ")} (v1 executes FUTURES only)`,
16
+ `- maxLeverage ${r.maxLeverage}; perTradeMarginMusd ${r.perTradeMarginMusd}; maxConcurrentPositions ${r.maxConcurrentPositions}; requireStopLoss ${r.requireStopLoss}`,
17
+ `- watchlist (only these): ${r.watchlist.join(", ")}`,
18
+ `- abstention.minConfidence ${spec.abstention.minConfidence}; a skipped cycle is correct and cheap`,
19
+ "",
20
+ "## Output contract — return ONLY this JSON object, nothing else:",
21
+ `{"decision":"skip"|"act","confidence":0..1,"reason":"short","actions":[]}`,
22
+ 'Each action is one of: {"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice"},',
23
+ '{"type":"futures_close","positionId","fraction"}, {"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}.',
24
+ "Always include a stopLossPrice on a futures_open. Prefer skip when the signal is weak or data is stale.",
25
+ ].join("\n");
26
+ }
27
+ export function buildUserPrompt(obs) {
28
+ return [
29
+ "Decide for THIS cycle using only the observation below (data available now — no look-ahead).",
30
+ "",
31
+ "```json",
32
+ JSON.stringify({
33
+ asOf: obs.asOf,
34
+ cashAvailableMusd: obs.cashAvailableMusd,
35
+ equityMusd: obs.equityMusd,
36
+ openPositions: obs.openPositions,
37
+ watch: obs.watch,
38
+ newClosedTrades: obs.newClosedTrades,
39
+ polledBeforeWrite: obs.polledBeforeWrite,
40
+ }, null, 2),
41
+ "```",
42
+ "",
43
+ "Return ONLY the JSON decision object.",
44
+ ].join("\n");
45
+ }
@@ -0,0 +1,134 @@
1
+ // BYO-model provider layer. The user's model API key comes from the ENV ONLY,
2
+ // never from an agent file. One call returns one chunk of text that must be a
3
+ // single structured-JSON decision (parsed in decision.ts). No free-form tool
4
+ // execution — the model only proposes; the runner disposes.
5
+ function envKey(provider, env) {
6
+ switch (provider) {
7
+ case "anthropic":
8
+ return env.ANTHROPIC_API_KEY;
9
+ case "openai":
10
+ return env.OPENAI_API_KEY;
11
+ case "groq":
12
+ return env.GROQ_API_KEY;
13
+ case "openai-compatible":
14
+ return env.MODEL_API_KEY ?? env.OPENAI_API_KEY;
15
+ }
16
+ }
17
+ function baseUrlFor(provider, configured) {
18
+ switch (provider) {
19
+ case "openai":
20
+ return "https://api.openai.com/v1";
21
+ case "groq":
22
+ return "https://api.groq.com/openai/v1";
23
+ case "openai-compatible":
24
+ return (configured ?? "").replace(/\/+$/, "");
25
+ case "anthropic":
26
+ return "https://api.anthropic.com/v1";
27
+ }
28
+ }
29
+ class AnthropicProvider {
30
+ model;
31
+ apiKey;
32
+ fetchFn;
33
+ label;
34
+ constructor(model, apiKey, fetchFn) {
35
+ this.model = model;
36
+ this.apiKey = apiKey;
37
+ this.fetchFn = fetchFn;
38
+ this.label = `anthropic/${model}`;
39
+ }
40
+ async decide(input) {
41
+ try {
42
+ const res = await this.fetchFn("https://api.anthropic.com/v1/messages", {
43
+ method: "POST",
44
+ headers: {
45
+ "x-api-key": this.apiKey,
46
+ "anthropic-version": "2023-06-01",
47
+ "content-type": "application/json",
48
+ },
49
+ body: JSON.stringify({
50
+ model: this.model,
51
+ max_tokens: input.maxTokens ?? 1024,
52
+ system: input.system,
53
+ messages: [{ role: "user", content: input.user }],
54
+ }),
55
+ });
56
+ if (!res.ok)
57
+ return { ok: false, error: `anthropic HTTP ${res.status}: ${await res.text()}` };
58
+ const json = (await res.json());
59
+ const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
60
+ return text ? { ok: true, text } : { ok: false, error: "anthropic returned empty content" };
61
+ }
62
+ catch (err) {
63
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
64
+ }
65
+ }
66
+ }
67
+ class OpenAiCompatProvider {
68
+ model;
69
+ apiKey;
70
+ baseUrl;
71
+ fetchFn;
72
+ label;
73
+ constructor(model, apiKey, baseUrl, fetchFn) {
74
+ this.model = model;
75
+ this.apiKey = apiKey;
76
+ this.baseUrl = baseUrl;
77
+ this.fetchFn = fetchFn;
78
+ this.label = `${baseUrl}/${model}`;
79
+ }
80
+ async decide(input) {
81
+ try {
82
+ const res = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
83
+ method: "POST",
84
+ headers: {
85
+ Authorization: `Bearer ${this.apiKey}`,
86
+ "content-type": "application/json",
87
+ },
88
+ body: JSON.stringify({
89
+ model: this.model,
90
+ temperature: 0.2,
91
+ max_tokens: input.maxTokens ?? 1024,
92
+ response_format: { type: "json_object" },
93
+ messages: [
94
+ { role: "system", content: input.system },
95
+ { role: "user", content: input.user },
96
+ ],
97
+ }),
98
+ });
99
+ if (!res.ok)
100
+ return { ok: false, error: `provider HTTP ${res.status}: ${await res.text()}` };
101
+ const json = (await res.json());
102
+ const text = json.choices?.[0]?.message?.content ?? "";
103
+ return text ? { ok: true, text } : { ok: false, error: "provider returned empty content" };
104
+ }
105
+ catch (err) {
106
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
107
+ }
108
+ }
109
+ }
110
+ // Build the provider from the spec's model block + env key. Throws a clear
111
+ // error if no model is configured (self-host requires one) or the env key is
112
+ // missing. fetch is injectable for tests.
113
+ export function selectProvider(spec, env, fetchFn = fetch) {
114
+ if (!spec.model) {
115
+ throw new Error("no model configured: set model.provider + model.name in the agent (self-host needs an explicit model)");
116
+ }
117
+ const { provider, name, baseUrl } = spec.model;
118
+ const key = envKey(provider, env);
119
+ if (!key) {
120
+ const varName = provider === "anthropic"
121
+ ? "ANTHROPIC_API_KEY"
122
+ : provider === "groq"
123
+ ? "GROQ_API_KEY"
124
+ : "OPENAI_API_KEY / MODEL_API_KEY";
125
+ throw new Error(`missing model API key: set ${varName} in the environment (never in an agent file)`);
126
+ }
127
+ if (provider === "anthropic")
128
+ return new AnthropicProvider(name, key, fetchFn);
129
+ const resolvedBase = baseUrlFor(provider, baseUrl);
130
+ if (!resolvedBase) {
131
+ throw new Error("openai-compatible provider needs model.baseUrl");
132
+ }
133
+ return new OpenAiCompatProvider(name, key, resolvedBase, fetchFn);
134
+ }