@coinrithm/mcp-trading 0.1.8 → 0.3.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/CHANGELOG.md +32 -0
- package/README.md +43 -19
- package/dist/agent/act.d.ts +4 -0
- package/dist/agent/act.js +114 -0
- package/dist/agent/capabilityGuard.d.ts +2 -0
- package/dist/agent/capabilityGuard.js +131 -0
- package/dist/agent/cli.d.ts +21 -0
- package/dist/agent/cli.js +382 -0
- package/dist/agent/client.d.ts +107 -0
- package/dist/agent/client.js +173 -0
- package/dist/agent/decision.d.ts +137 -0
- package/dist/agent/decision.js +118 -0
- package/dist/agent/decisionValidator.d.ts +16 -0
- package/dist/agent/decisionValidator.js +215 -0
- package/dist/agent/engine.d.ts +10 -0
- package/dist/agent/engine.js +16 -0
- package/dist/agent/extract.d.ts +4 -0
- package/dist/agent/extract.js +5 -0
- package/dist/agent/frontmatter.d.ts +5 -0
- package/dist/agent/frontmatter.js +19 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.js +10 -0
- package/dist/agent/indicators.d.ts +44 -0
- package/dist/agent/indicators.js +135 -0
- package/dist/agent/manifest.d.ts +15 -0
- package/dist/agent/manifest.js +40 -0
- package/dist/agent/mergeRules.d.ts +11 -0
- package/dist/agent/mergeRules.js +82 -0
- package/dist/agent/observe.d.ts +7 -0
- package/dist/agent/observe.js +244 -0
- package/dist/agent/prompt.d.ts +3 -0
- package/dist/agent/prompt.js +76 -0
- package/dist/agent/providers.d.ts +25 -0
- package/dist/agent/providers.js +143 -0
- package/dist/agent/resolve.d.ts +11 -0
- package/dist/agent/resolve.js +499 -0
- package/dist/agent/runEvidence.d.ts +6 -0
- package/dist/agent/runEvidence.js +23 -0
- package/dist/agent/runner.d.ts +19 -0
- package/dist/agent/runner.js +280 -0
- package/dist/agent/skill.d.ts +12 -0
- package/dist/agent/skill.js +136 -0
- package/dist/agent/skillValidator.d.ts +7 -0
- package/dist/agent/skillValidator.js +123 -0
- package/dist/agent/state.d.ts +7 -0
- package/dist/agent/state.js +96 -0
- package/dist/agent/strictLint.d.ts +3 -0
- package/dist/agent/strictLint.js +165 -0
- package/dist/agent/templates.d.ts +14 -0
- package/dist/agent/templates.js +192 -0
- package/dist/agent/types.d.ts +286 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/util.d.ts +13 -0
- package/dist/agent/util.js +116 -0
- package/dist/agent/version.d.ts +11 -0
- package/dist/agent/version.js +16 -0
- package/dist/client.d.ts +162 -0
- package/dist/http.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/tools.d.ts +3 -0
- package/dist/version.d.ts +1 -0
- package/package.json +78 -67
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Deterministic technical-indicator math — pure functions over OHLC candles.
|
|
2
|
+
//
|
|
3
|
+
// Probe-First note: this module is JUST math (no network). It is the
|
|
4
|
+
// runner-computed half of the `indicators` capability (types.ts) — an agent
|
|
5
|
+
// that declares it gets compact, model-friendly signal (RSI/EMA/ATR/Bollinger/
|
|
6
|
+
// breakout levels) instead of raw bars the free-tier brain cannot reason over.
|
|
7
|
+
// Wiring observe() to FETCH the candles is gated on a live probe of
|
|
8
|
+
// GET /api/agent/market/:coinId/candles (see DECISIONS D16); the math here is
|
|
9
|
+
// independently verifiable and shipped ahead of that.
|
|
10
|
+
//
|
|
11
|
+
// Every function returns null when there are too few candles, so callers can
|
|
12
|
+
// omit an indicator from the observation rather than emit a misleading number.
|
|
13
|
+
const closesOf = (c) => c.map((x) => x.close);
|
|
14
|
+
const finite = (n) => Number.isFinite(n);
|
|
15
|
+
// Simple moving average of the last `period` values.
|
|
16
|
+
export function sma(values, period) {
|
|
17
|
+
if (period <= 0 || values.length < period)
|
|
18
|
+
return null;
|
|
19
|
+
const slice = values.slice(-period);
|
|
20
|
+
const sum = slice.reduce((a, b) => a + b, 0);
|
|
21
|
+
return finite(sum) ? sum / period : null;
|
|
22
|
+
}
|
|
23
|
+
// Exponential moving average, seeded with the SMA of the first `period` values
|
|
24
|
+
// (the standard, deterministic seeding).
|
|
25
|
+
export function ema(values, period) {
|
|
26
|
+
if (period <= 0 || values.length < period)
|
|
27
|
+
return null;
|
|
28
|
+
const k = 2 / (period + 1);
|
|
29
|
+
let prev = values.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
|
30
|
+
for (let i = period; i < values.length; i++) {
|
|
31
|
+
prev = values[i] * k + prev * (1 - k);
|
|
32
|
+
}
|
|
33
|
+
return finite(prev) ? prev : null;
|
|
34
|
+
}
|
|
35
|
+
// Wilder's RSI over `period` (default 14). 100 = only gains, 0 = only losses.
|
|
36
|
+
export function rsi(closes, period = 14) {
|
|
37
|
+
if (period <= 0 || closes.length < period + 1)
|
|
38
|
+
return null;
|
|
39
|
+
let gain = 0;
|
|
40
|
+
let loss = 0;
|
|
41
|
+
for (let i = 1; i <= period; i++) {
|
|
42
|
+
const ch = closes[i] - closes[i - 1];
|
|
43
|
+
if (ch >= 0)
|
|
44
|
+
gain += ch;
|
|
45
|
+
else
|
|
46
|
+
loss -= ch;
|
|
47
|
+
}
|
|
48
|
+
let avgGain = gain / period;
|
|
49
|
+
let avgLoss = loss / period;
|
|
50
|
+
for (let i = period + 1; i < closes.length; i++) {
|
|
51
|
+
const ch = closes[i] - closes[i - 1];
|
|
52
|
+
const g = ch >= 0 ? ch : 0;
|
|
53
|
+
const l = ch < 0 ? -ch : 0;
|
|
54
|
+
avgGain = (avgGain * (period - 1) + g) / period;
|
|
55
|
+
avgLoss = (avgLoss * (period - 1) + l) / period;
|
|
56
|
+
}
|
|
57
|
+
if (avgLoss === 0)
|
|
58
|
+
return avgGain === 0 ? 50 : 100;
|
|
59
|
+
const rs = avgGain / avgLoss;
|
|
60
|
+
return 100 - 100 / (1 + rs);
|
|
61
|
+
}
|
|
62
|
+
// Wilder's Average True Range over `period` (default 14) — a volatility gauge.
|
|
63
|
+
export function atr(candles, period = 14) {
|
|
64
|
+
if (period <= 0 || candles.length < period + 1)
|
|
65
|
+
return null;
|
|
66
|
+
const tr = [];
|
|
67
|
+
for (let i = 1; i < candles.length; i++) {
|
|
68
|
+
const h = candles[i].high;
|
|
69
|
+
const l = candles[i].low;
|
|
70
|
+
const pc = candles[i - 1].close;
|
|
71
|
+
tr.push(Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)));
|
|
72
|
+
}
|
|
73
|
+
if (tr.length < period)
|
|
74
|
+
return null;
|
|
75
|
+
let prev = tr.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
|
76
|
+
for (let i = period; i < tr.length; i++) {
|
|
77
|
+
prev = (prev * (period - 1) + tr[i]) / period;
|
|
78
|
+
}
|
|
79
|
+
return finite(prev) ? prev : null;
|
|
80
|
+
}
|
|
81
|
+
// Bollinger bands: SMA(period) ± mult·stddev over the last `period` closes.
|
|
82
|
+
export function bollinger(closes, period = 20, mult = 2) {
|
|
83
|
+
if (period <= 0 || closes.length < period)
|
|
84
|
+
return null;
|
|
85
|
+
const slice = closes.slice(-period);
|
|
86
|
+
const mid = slice.reduce((a, b) => a + b, 0) / period;
|
|
87
|
+
const variance = slice.reduce((a, b) => a + (b - mid) ** 2, 0) / period;
|
|
88
|
+
const sd = Math.sqrt(variance);
|
|
89
|
+
if (!finite(mid) || !finite(sd))
|
|
90
|
+
return null;
|
|
91
|
+
return { upper: mid + mult * sd, mid, lower: mid - mult * sd };
|
|
92
|
+
}
|
|
93
|
+
// Highest high / lowest low over the last `lookback` candles (breakout levels).
|
|
94
|
+
export function recentHighLow(candles, lookback) {
|
|
95
|
+
if (lookback <= 0 || candles.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
const slice = candles.slice(-lookback);
|
|
98
|
+
let high = -Infinity;
|
|
99
|
+
let low = Infinity;
|
|
100
|
+
for (const c of slice) {
|
|
101
|
+
if (c.high > high)
|
|
102
|
+
high = c.high;
|
|
103
|
+
if (c.low < low)
|
|
104
|
+
low = c.low;
|
|
105
|
+
}
|
|
106
|
+
return finite(high) && finite(low) ? { high, low } : null;
|
|
107
|
+
}
|
|
108
|
+
// Compute the compact indicator bundle the runner injects into the observation
|
|
109
|
+
// for an agent that declared the `indicators` capability. The breakout flags
|
|
110
|
+
// compare the latest close against the high/low of the PRECEDING window (so a
|
|
111
|
+
// candle closing at a new 20-bar high reads as a breakout, not a tautology).
|
|
112
|
+
export function computeIndicators(candles, opts = {}) {
|
|
113
|
+
if (candles.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
const closes = closesOf(candles);
|
|
116
|
+
const close = closes[closes.length - 1];
|
|
117
|
+
const { rsiPeriod = 14, emaFast = 20, emaSlow = 50, atrPeriod = 14, bbPeriod = 20, breakoutLookback = 20, } = opts;
|
|
118
|
+
const ema20 = ema(closes, emaFast);
|
|
119
|
+
const ema50 = ema(closes, emaSlow);
|
|
120
|
+
// Breakout vs the window BEFORE the latest candle (exclude the current bar).
|
|
121
|
+
const prior = recentHighLow(candles.slice(0, -1), breakoutLookback);
|
|
122
|
+
return {
|
|
123
|
+
asOfClose: close,
|
|
124
|
+
rsi14: rsi(closes, rsiPeriod),
|
|
125
|
+
ema20,
|
|
126
|
+
ema50,
|
|
127
|
+
atr14: atr(candles, atrPeriod),
|
|
128
|
+
bollinger: bollinger(closes, bbPeriod),
|
|
129
|
+
recent20: recentHighLow(candles, breakoutLookback),
|
|
130
|
+
aboveEma20: ema20 == null ? null : close > ema20,
|
|
131
|
+
ema20AboveEma50: ema20 == null || ema50 == null ? null : ema20 > ema50,
|
|
132
|
+
brokeRecentHigh: prior == null ? null : close >= prior.high,
|
|
133
|
+
brokeRecentLow: prior == null ? null : close <= prior.low,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AgentSpec, ResolvedAgent, Provenance } from "./types.js";
|
|
2
|
+
export interface AgentManifest {
|
|
3
|
+
schema: string;
|
|
4
|
+
spec: string;
|
|
5
|
+
resolverVersion: string;
|
|
6
|
+
runnerVersion: string;
|
|
7
|
+
resolvedConfig: Record<string, unknown>;
|
|
8
|
+
resolvedSpec: AgentSpec;
|
|
9
|
+
provenance: Provenance;
|
|
10
|
+
contentHashes: Record<string, string>;
|
|
11
|
+
configHash: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function buildManifest(resolved: ResolvedAgent, spec: AgentSpec): AgentManifest;
|
|
14
|
+
export declare function serializeManifest(manifest: AgentManifest): string;
|
|
15
|
+
export declare function writeManifest(agentDir: string, manifest: AgentManifest): string;
|
|
@@ -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,11 @@
|
|
|
1
|
+
import { ResolveIssue } from "./types.js";
|
|
2
|
+
export type CapDirection = "lower" | "higher" | "true";
|
|
3
|
+
export declare const RISK_CAPS: Record<string, CapDirection>;
|
|
4
|
+
export declare const LIMIT_CAPS: Record<string, CapDirection>;
|
|
5
|
+
export declare function isAtLeastAsRestrictive(dir: CapDirection, candidate: unknown, base: unknown): boolean;
|
|
6
|
+
export declare function mostRestrictive(dir: CapDirection, a: unknown, b: unknown): unknown;
|
|
7
|
+
export interface CapMergeResult {
|
|
8
|
+
merged: Record<string, unknown>;
|
|
9
|
+
issues: ResolveIssue[];
|
|
10
|
+
}
|
|
11
|
+
export declare function mergeCapPatch(base: Record<string, unknown>, patch: Record<string, unknown>, caps: Record<string, CapDirection>, sourceLabel: string): CapMergeResult;
|
|
@@ -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,7 @@
|
|
|
1
|
+
import { CoinRithmClient } from "./client.js";
|
|
2
|
+
import { AgentSpec, RunState, Observation, AgentTrace } from "./types.js";
|
|
3
|
+
export interface ObserveOutput {
|
|
4
|
+
observation: Observation;
|
|
5
|
+
skip?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function observe(client: CoinRithmClient, spec: AgentSpec, state: RunState, trace?: AgentTrace): Promise<ObserveOutput>;
|
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
import { computeIndicators } from "./indicators.js";
|
|
6
|
+
// Candle granularity feeding the indicators: the 1D range = 5-minute candles
|
|
7
|
+
// (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
|
|
8
|
+
// short cadence the hosted house agents run on. Probe-verified 2026-06-17.
|
|
9
|
+
const INDICATOR_RANGE = "1D";
|
|
10
|
+
// Fetch candles for one coin and reduce them to a compact indicator bundle.
|
|
11
|
+
// Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
|
|
12
|
+
// null so the cycle proceeds with price-only context rather than skipping.
|
|
13
|
+
async function fetchIndicators(client, coinId, trace) {
|
|
14
|
+
const cr = await client.candles(coinId, INDICATOR_RANGE, trace);
|
|
15
|
+
if (!cr.ok)
|
|
16
|
+
return null;
|
|
17
|
+
// Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
|
|
18
|
+
const candles = [];
|
|
19
|
+
for (const raw of asArr(asObj(cr.data).candles)) {
|
|
20
|
+
const c = asObj(raw);
|
|
21
|
+
const open = asNum(c.o);
|
|
22
|
+
const high = asNum(c.h);
|
|
23
|
+
const low = asNum(c.l);
|
|
24
|
+
const close = asNum(c.c);
|
|
25
|
+
if (open == null || high == null || low == null || close == null)
|
|
26
|
+
continue;
|
|
27
|
+
candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
|
|
28
|
+
}
|
|
29
|
+
return computeIndicators(candles);
|
|
30
|
+
}
|
|
31
|
+
function freshnessOf(block) {
|
|
32
|
+
const fr = asObj(block.freshness);
|
|
33
|
+
const status = asStr(fr.status);
|
|
34
|
+
return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
|
|
35
|
+
}
|
|
36
|
+
function emptyObservation(state, scopes = []) {
|
|
37
|
+
return {
|
|
38
|
+
asOf: state.cursor ?? new Date().toISOString(),
|
|
39
|
+
scopes,
|
|
40
|
+
cashAvailableMusd: null,
|
|
41
|
+
equityMusd: null,
|
|
42
|
+
openPositions: [],
|
|
43
|
+
openOrders: [],
|
|
44
|
+
pmPositions: [],
|
|
45
|
+
pmMarkets: [],
|
|
46
|
+
watch: [],
|
|
47
|
+
syncCursor: state.cursor,
|
|
48
|
+
newClosedTrades: [],
|
|
49
|
+
polledBeforeWrite: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function observe(client, spec, state, trace) {
|
|
53
|
+
const meR = await client.me(trace);
|
|
54
|
+
if (!meR.ok)
|
|
55
|
+
return {
|
|
56
|
+
observation: emptyObservation(state),
|
|
57
|
+
skip: `me failed (HTTP ${meR.status})`,
|
|
58
|
+
};
|
|
59
|
+
const scopes = asArr(asObj(meR.data).scopes).filter((s) => typeof s === "string");
|
|
60
|
+
const [portR, walletR, posR] = await Promise.all([
|
|
61
|
+
client.portfolio(trace),
|
|
62
|
+
client.wallet(undefined, trace),
|
|
63
|
+
client.futuresPositions(undefined, trace),
|
|
64
|
+
]);
|
|
65
|
+
if (!portR.ok || !walletR.ok || !posR.ok) {
|
|
66
|
+
return {
|
|
67
|
+
observation: emptyObservation(state, scopes),
|
|
68
|
+
skip: "required reads failed (portfolio/wallet/positions)",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const usdt = asObj(asObj(walletR.data).usdt);
|
|
72
|
+
const equity = asObj(asObj(portR.data).equity);
|
|
73
|
+
const cashAvailableMusd = asNum(usdt.available) ?? asNum(equity.availableUsd) ?? null;
|
|
74
|
+
const equityMusd = asNum(equity.totalUsd) ?? asNum(asObj(portR.data).equityUsd) ?? null;
|
|
75
|
+
const openPositions = asArr(asObj(posR.data).positions)
|
|
76
|
+
.map(asObj)
|
|
77
|
+
.filter((p) => (asStr(p.status) ?? "open") === "open")
|
|
78
|
+
.map((p) => ({
|
|
79
|
+
venue: "futures",
|
|
80
|
+
id: Number(asNum(p.id) ?? p.id),
|
|
81
|
+
coinId: asStr(p.coinId),
|
|
82
|
+
symbol: asStr(p.symbol),
|
|
83
|
+
side: asStr(p.side),
|
|
84
|
+
status: asStr(p.status) ?? "open",
|
|
85
|
+
marginMusd: asNum(p.marginMusd),
|
|
86
|
+
unrealizedPnlMusd: asNum(p.unrealizedPnlMusd),
|
|
87
|
+
}));
|
|
88
|
+
// Sync poll: /trades since the persisted cursor.
|
|
89
|
+
const tradesR = await client.trades({
|
|
90
|
+
venue: "futures",
|
|
91
|
+
updatedSince: state.cursor ?? undefined,
|
|
92
|
+
limit: state.cursor ? undefined : 1,
|
|
93
|
+
}, trace);
|
|
94
|
+
let polledBeforeWrite = false;
|
|
95
|
+
let newClosedTrades = [];
|
|
96
|
+
let syncCursor = state.cursor;
|
|
97
|
+
if (tradesR.ok) {
|
|
98
|
+
polledBeforeWrite = true;
|
|
99
|
+
const td = asObj(tradesR.data);
|
|
100
|
+
syncCursor = asStr(td.asOf) ?? state.cursor;
|
|
101
|
+
newClosedTrades = asArr(td.trades)
|
|
102
|
+
.map(asObj)
|
|
103
|
+
.filter((t) => !state.seen.includes(`${asStr(t.venue) ?? "futures"}:${asNum(t.id) ?? t.id}`));
|
|
104
|
+
}
|
|
105
|
+
// Watchlist market context.
|
|
106
|
+
const watch = [];
|
|
107
|
+
let resolvedAny = false;
|
|
108
|
+
const wantIndicators = spec.capabilities.includes("indicators");
|
|
109
|
+
for (const symbol of spec.risk.watchlist) {
|
|
110
|
+
const rs = await client.resolve(symbol, trace);
|
|
111
|
+
const match = asObj(asObj(rs.data).match);
|
|
112
|
+
const coinId = rs.ok && match.coinId != null ? String(match.coinId) : null;
|
|
113
|
+
if (!coinId) {
|
|
114
|
+
watch.push({ symbol, coinId: null });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
resolvedAny = true;
|
|
118
|
+
const mk = await client.market(coinId, trace);
|
|
119
|
+
const m = asObj(mk.data);
|
|
120
|
+
const price = asObj(m.price);
|
|
121
|
+
const entry = {
|
|
122
|
+
symbol,
|
|
123
|
+
coinId,
|
|
124
|
+
name: asStr(match.name),
|
|
125
|
+
priceUsd: asNum(price.usd),
|
|
126
|
+
change1h: asNum(price.change1h),
|
|
127
|
+
change24h: asNum(price.change24h),
|
|
128
|
+
change7d: asNum(price.change7d),
|
|
129
|
+
// Freshness lives under the response's `observation` block.
|
|
130
|
+
freshness: freshnessOf(asObj(m.observation)),
|
|
131
|
+
};
|
|
132
|
+
// `indicators` capability: enrich the observation with computed TA so the
|
|
133
|
+
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
134
|
+
// of price + %change alone. Backed by the candles endpoint's shared cache.
|
|
135
|
+
if (wantIndicators) {
|
|
136
|
+
const ind = await fetchIndicators(client, coinId, trace);
|
|
137
|
+
if (ind)
|
|
138
|
+
entry.indicators = ind;
|
|
139
|
+
}
|
|
140
|
+
watch.push(entry);
|
|
141
|
+
}
|
|
142
|
+
// Spot resting orders (for cancel + affordability) — only if spot is enabled.
|
|
143
|
+
const wantSpot = spec.venues.includes("spot");
|
|
144
|
+
const wantPm = spec.venues.includes("pm");
|
|
145
|
+
let openOrders = [];
|
|
146
|
+
if (wantSpot) {
|
|
147
|
+
const ordR = await client.openOrders(undefined, trace);
|
|
148
|
+
if (ordR.ok) {
|
|
149
|
+
const od = asObj(ordR.data);
|
|
150
|
+
openOrders = asArr(od.orders ?? od.openOrders)
|
|
151
|
+
.map(asObj)
|
|
152
|
+
.filter((o) => (asStr(o.status) ?? "open") === "open")
|
|
153
|
+
.map((o) => ({
|
|
154
|
+
id: Number(asNum(o.id) ?? o.id),
|
|
155
|
+
coinId: asStr(o.coinId),
|
|
156
|
+
symbol: asStr(o.symbol),
|
|
157
|
+
side: asStr(o.side),
|
|
158
|
+
orderType: asStr(o.orderType),
|
|
159
|
+
quantity: asNum(o.quantity),
|
|
160
|
+
status: asStr(o.status) ?? "open",
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// PM open positions + discovered quote-ready candidates — only if pm enabled.
|
|
165
|
+
let pmPositions = [];
|
|
166
|
+
let pmMarkets = [];
|
|
167
|
+
if (wantPm) {
|
|
168
|
+
const [pmPosR, pmDiscR] = await Promise.all([
|
|
169
|
+
client.pmPositions(undefined, trace),
|
|
170
|
+
client.discoverPmMarkets({ limit: 8 }, trace),
|
|
171
|
+
]);
|
|
172
|
+
if (pmPosR.ok) {
|
|
173
|
+
pmPositions = asArr(asObj(pmPosR.data).positions)
|
|
174
|
+
.map(asObj)
|
|
175
|
+
.filter((p) => (asStr(p.status) ?? "open") === "open")
|
|
176
|
+
.map((p) => ({
|
|
177
|
+
id: Number(asNum(p.id) ?? p.id),
|
|
178
|
+
source: asStr(p.source),
|
|
179
|
+
slug: asStr(p.slug),
|
|
180
|
+
outcomeExternalMarketId: asStr(p.outcomeExternalMarketId),
|
|
181
|
+
stakeMusd: asNum(p.stakeMusd),
|
|
182
|
+
status: asStr(p.status) ?? "open",
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
if (pmDiscR.ok) {
|
|
186
|
+
const dd = asObj(pmDiscR.data);
|
|
187
|
+
// Real /api/agent/pm/discover payload: { data: [event], pagination, meta }.
|
|
188
|
+
// Each EVENT carries source/slug/title/freshness at the top level and the
|
|
189
|
+
// quoteable id NESTED at outcomes[].externalMarketId — so expand one
|
|
190
|
+
// PmMarket per quoteable outcome. (Tolerant `markets`/`results` and flat
|
|
191
|
+
// `outcomeExternalMarketId` fallbacks kept for older/mocked shapes.)
|
|
192
|
+
pmMarkets = asArr(dd.data ?? dd.markets ?? dd.results)
|
|
193
|
+
.map(asObj)
|
|
194
|
+
.flatMap((ev) => {
|
|
195
|
+
const source = (asStr(ev.source) ?? "").toLowerCase();
|
|
196
|
+
const slug = (asStr(ev.slug) ?? "").toLowerCase();
|
|
197
|
+
const title = asStr(ev.title) ?? asStr(ev.question);
|
|
198
|
+
const freshness = freshnessOf(ev); // freshness is event-level
|
|
199
|
+
const outcomes = asArr(ev.outcomes).map(asObj);
|
|
200
|
+
// A market with no outcomes array still round-trips a flat fallback row.
|
|
201
|
+
const rows = outcomes.length > 0 ? outcomes : [ev];
|
|
202
|
+
return rows.map((o) => ({
|
|
203
|
+
source,
|
|
204
|
+
slug,
|
|
205
|
+
outcomeExternalMarketId: asStr(o.externalMarketId) ??
|
|
206
|
+
asStr(o.outcomeExternalMarketId) ??
|
|
207
|
+
"",
|
|
208
|
+
title,
|
|
209
|
+
freshness,
|
|
210
|
+
}));
|
|
211
|
+
})
|
|
212
|
+
.filter((m) => m.source && m.slug && m.outcomeExternalMarketId);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const observation = {
|
|
216
|
+
asOf: syncCursor ?? new Date().toISOString(),
|
|
217
|
+
scopes,
|
|
218
|
+
cashAvailableMusd,
|
|
219
|
+
equityMusd,
|
|
220
|
+
openPositions,
|
|
221
|
+
openOrders,
|
|
222
|
+
pmPositions,
|
|
223
|
+
pmMarkets,
|
|
224
|
+
watch,
|
|
225
|
+
syncCursor,
|
|
226
|
+
newClosedTrades,
|
|
227
|
+
polledBeforeWrite,
|
|
228
|
+
};
|
|
229
|
+
// Skip only when there is NOTHING actionable: no coin resolved (futures/spot)
|
|
230
|
+
// AND no PM candidate (pm). A pm-only agent proceeds on its discovered markets.
|
|
231
|
+
if (!resolvedAny && pmMarkets.length === 0) {
|
|
232
|
+
return {
|
|
233
|
+
observation,
|
|
234
|
+
skip: "no watchlist coin resolved and no PM markets available",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (spec.sync.requirePollBeforeWrite && !polledBeforeWrite) {
|
|
238
|
+
return {
|
|
239
|
+
observation,
|
|
240
|
+
skip: "poll-before-write required but /trades poll failed",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { observation };
|
|
244
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
const v = spec.venues;
|
|
8
|
+
const actions = [];
|
|
9
|
+
if (v.includes("futures")) {
|
|
10
|
+
actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}');
|
|
11
|
+
}
|
|
12
|
+
if (v.includes("spot")) {
|
|
13
|
+
actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1}', '{"type":"spot_cancel","orderId"}');
|
|
14
|
+
}
|
|
15
|
+
if (v.includes("pm")) {
|
|
16
|
+
actions.push('{"type":"pm_open","source","slug","outcomeExternalMarketId","stakeMusd","confidence":0..1} (ONLY a market from observation.pmMarkets; stakeMusd >= 10)');
|
|
17
|
+
}
|
|
18
|
+
return [
|
|
19
|
+
"You operate a CoinRithm PAPER-TRADING agent (simulated 50,000 mUSD; not real money, not financial advice).",
|
|
20
|
+
"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.",
|
|
21
|
+
"",
|
|
22
|
+
"## Your strategy (your borders)",
|
|
23
|
+
mergedProse.trim() || "(no strategy prose provided)",
|
|
24
|
+
"",
|
|
25
|
+
"## Hard caps the runner enforces (do not exceed; proposing over a cap wastes the cycle)",
|
|
26
|
+
`- venues you may act in: ${v.join(", ")}`,
|
|
27
|
+
`- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (futures margin / spot buy notional / PM stake)`,
|
|
28
|
+
`- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
|
|
29
|
+
`- watchlist (spot + futures use ONLY these): ${r.watchlist.join(", ")}`,
|
|
30
|
+
...(r.blocklist && r.blocklist.length > 0
|
|
31
|
+
? [
|
|
32
|
+
`- deny-list (NEVER open these, even if on the watchlist): ${r.blocklist.join(", ")}`,
|
|
33
|
+
]
|
|
34
|
+
: []),
|
|
35
|
+
"- prediction markets: pick ONLY a market listed in observation.pmMarkets; minimum stake 10 mUSD",
|
|
36
|
+
`- abstention.minConfidence ${spec.abstention.minConfidence}; a skipped cycle is correct and cheap`,
|
|
37
|
+
...(spec.capabilities.includes("indicators")
|
|
38
|
+
? [
|
|
39
|
+
"",
|
|
40
|
+
"## Signals — each watch entry may carry `indicators` (computed from 5-minute candles)",
|
|
41
|
+
"- rsi14: momentum (>70 overbought, <30 oversold); ema20 & ema50: trend; atr14: volatility (size stops off it); bollinger {upper,mid,lower}; recent20 {high,low}: breakout levels.",
|
|
42
|
+
"- boolean reads: aboveEma20, ema20AboveEma50 (uptrend when both true), brokeRecentHigh (breakout), brokeRecentLow (breakdown).",
|
|
43
|
+
"- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
|
|
44
|
+
]
|
|
45
|
+
: []),
|
|
46
|
+
"",
|
|
47
|
+
"## Output contract — return ONLY this JSON object, nothing else:",
|
|
48
|
+
'{"decision":"skip"|"act","confidence":0..1,"reason":"short","actions":[]}',
|
|
49
|
+
"Each action is one of:",
|
|
50
|
+
...actions.map((a) => `- ${a}`),
|
|
51
|
+
`Set each opening action's "confidence" (0..1) to your honest conviction — the runner REJECTS any open below abstention.minConfidence (${spec.abstention.minConfidence}). The decision-level "confidence" is the fallback when an action omits its own.`,
|
|
52
|
+
"Prefer skip when the signal is weak or data is stale.",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
export function buildUserPrompt(obs) {
|
|
56
|
+
return [
|
|
57
|
+
"Decide for THIS cycle using only the observation below (data available now — no look-ahead).",
|
|
58
|
+
"",
|
|
59
|
+
"```json",
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
asOf: obs.asOf,
|
|
62
|
+
cashAvailableMusd: obs.cashAvailableMusd,
|
|
63
|
+
equityMusd: obs.equityMusd,
|
|
64
|
+
openPositions: obs.openPositions,
|
|
65
|
+
openOrders: obs.openOrders,
|
|
66
|
+
pmPositions: obs.pmPositions,
|
|
67
|
+
pmMarkets: obs.pmMarkets,
|
|
68
|
+
watch: obs.watch,
|
|
69
|
+
newClosedTrades: obs.newClosedTrades,
|
|
70
|
+
polledBeforeWrite: obs.polledBeforeWrite,
|
|
71
|
+
}, null, 2),
|
|
72
|
+
"```",
|
|
73
|
+
"",
|
|
74
|
+
"Return ONLY the JSON decision object.",
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AgentSpec } from "./types.js";
|
|
2
|
+
export interface DecideInput {
|
|
3
|
+
system: string;
|
|
4
|
+
user: string;
|
|
5
|
+
maxTokens?: number;
|
|
6
|
+
}
|
|
7
|
+
export type DecideResult = {
|
|
8
|
+
ok: true;
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
error: string;
|
|
13
|
+
};
|
|
14
|
+
export interface Provider {
|
|
15
|
+
label: string;
|
|
16
|
+
decide(input: DecideInput): Promise<DecideResult>;
|
|
17
|
+
}
|
|
18
|
+
export interface ProviderEnv {
|
|
19
|
+
ANTHROPIC_API_KEY?: string;
|
|
20
|
+
OPENAI_API_KEY?: string;
|
|
21
|
+
GROQ_API_KEY?: string;
|
|
22
|
+
NVIDIA_API_KEY?: string;
|
|
23
|
+
MODEL_API_KEY?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch): Provider;
|