@coinrithm/mcp-trading 0.7.13 → 0.7.14
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 +40 -0
- package/README.md +10 -8
- package/dist/agent/act.d.ts +7 -2
- package/dist/agent/act.js +10 -3
- package/dist/agent/cli.d.ts +1 -0
- package/dist/agent/cli.js +25 -4
- package/dist/agent/decisionReceipt.js +43 -0
- package/dist/agent/decisionValidator.js +74 -0
- package/dist/agent/definitionSnapshot.d.ts +22 -0
- package/dist/agent/definitionSnapshot.js +28 -0
- package/dist/agent/engine.d.ts +1 -0
- package/dist/agent/engine.js +1 -0
- package/dist/agent/mergeRules.d.ts +1 -1
- package/dist/agent/mergeRules.js +17 -2
- package/dist/agent/observe.js +64 -3
- package/dist/agent/prompt.js +7 -1
- package/dist/agent/resolve.js +8 -1
- package/dist/agent/runner.js +40 -7
- package/dist/agent/skill.js +9 -0
- package/dist/agent/skillValidator.js +11 -2
- package/dist/agent/strictLint.js +1 -0
- package/dist/agent/types.d.ts +21 -0
- package/dist/client.d.ts +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ ships two binaries — `coinrithm-mcp` (the MCP server) and `coinrithm-agent` (t
|
|
|
5
5
|
self-host agent runner) — versioned together. The CoinRithm **API contract** is
|
|
6
6
|
versioned separately (see `openapi.yaml` `info.version`, currently `1.7.0`).
|
|
7
7
|
|
|
8
|
+
## 0.7.14
|
|
9
|
+
|
|
10
|
+
- Scope permanent model-error streaks to the attempted provider/model. Discard
|
|
11
|
+
legacy unattributed streaks and reset availability failures on a successful
|
|
12
|
+
provider response, including malformed decisions. Old-route failures cannot
|
|
13
|
+
request an early hold on a replacement model; agent risk limits are unchanged.
|
|
14
|
+
- Attribute a routed permanent-error hold to the attempt that produced the error,
|
|
15
|
+
even when a later fallback is rate-limited; keep actual-call metering unchanged.
|
|
16
|
+
- Preflight futures protection updates against observed side, mark and liquidation
|
|
17
|
+
prices, including retained triggers. Reject known invalid end-states with an
|
|
18
|
+
actionable reason before a write; the API remains authoritative as prices move.
|
|
19
|
+
- Add the optional `risk.pmMinEntryProbabilityPct` policy (0..100 points): the
|
|
20
|
+
runner rejects `pm_open` when the chosen outcome's raw market probability at
|
|
21
|
+
entry is below the floor, with a reason that names both numbers. Fees stay
|
|
22
|
+
in the forecast-edge check, so a 19-point outcome fails a 20-point floor even
|
|
23
|
+
when fees lift its cost above 20. Absent keeps today's behaviour; a set floor
|
|
24
|
+
with no quoted probability fails closed. The floor is rendered in the prompt's
|
|
25
|
+
hard caps.
|
|
26
|
+
- Retain candle timestamps and compact coverage/interval evidence alongside
|
|
27
|
+
indicators, separately from market-price freshness. Missing timestamps stay
|
|
28
|
+
unknown; nominal five-minute cadence does not imply current or regular bars.
|
|
29
|
+
Model input and private decision evidence retain the same context.
|
|
30
|
+
- Load optional `character/entries.md`, `exits.md`, `sizing.md` and `research.md`
|
|
31
|
+
from local bundles so Studio strategy sections reach the model, manifest and
|
|
32
|
+
compiled definition. Existing bundles without these files are unchanged.
|
|
33
|
+
- Include the exact compiled strategy definition and its digest in local
|
|
34
|
+
inspection; expose the same snapshot builder to engine consumers.
|
|
35
|
+
- Add `run --expect-definition` to reject a changed baseline before model or
|
|
36
|
+
account access. The digest is not a full replay record or broker adapter.
|
|
37
|
+
- Hosted validation accepts `limits.maxDailyLossMusd: 0` as no daily loss cap,
|
|
38
|
+
matching the runner and the hosted API; negative and non-finite values are
|
|
39
|
+
still rejected.
|
|
40
|
+
- Tactic cap merges treat 0 as unlimited for `maxTradesPerDay` and
|
|
41
|
+
`maxDailyLossMusd`: a tactic may tighten 0 to a positive cap but can no
|
|
42
|
+
longer turn a positive cap into 0. Writes and margins keep lower-is-tighter.
|
|
43
|
+
|
|
44
|
+
Publication and hosted deployment are verified separately. See the repository's
|
|
45
|
+
[release status](https://github.com/CoinRithm/coinrithm-agent-trading#version-clarity)
|
|
46
|
+
for registry availability and the hosted runtime evidence.
|
|
47
|
+
|
|
8
48
|
## 0.7.13
|
|
9
49
|
|
|
10
50
|
- Preserve optional per-outcome venue terms in compact prediction-market output,
|
package/README.md
CHANGED
|
@@ -43,14 +43,16 @@ This package ships two binaries:
|
|
|
43
43
|
|
|
44
44
|
> **Paper trading only** — virtual funds (50,000 mUSD). Not financial advice.
|
|
45
45
|
|
|
46
|
-
## Version 0.7.
|
|
47
|
-
|
|
48
|
-
This
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
## Version 0.7.14
|
|
47
|
+
|
|
48
|
+
This release makes a configured prediction-market entry floor executable
|
|
49
|
+
(`risk.pmMinEntryProbabilityPct`: runner preflight plus the API's own re-check
|
|
50
|
+
at execution), preflights futures stop/target updates against observed prices,
|
|
51
|
+
loads the optional strategy sections from local bundles, pins compiled strategy
|
|
52
|
+
definitions (`run --expect-definition`), retains candle timing evidence, and
|
|
53
|
+
scopes permanent model-error streaks to the attempted provider/model. Trading
|
|
54
|
+
limits and retry counts are otherwise unchanged. See [CHANGELOG.md](./CHANGELOG.md).
|
|
55
|
+
Check `npm view @coinrithm/mcp-trading version` and the
|
|
54
56
|
[release status](https://github.com/CoinRithm/coinrithm-agent-trading#version-clarity)
|
|
55
57
|
for registry availability; hosted deployments and npm releases are separate.
|
|
56
58
|
|
package/dist/agent/act.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { CoinRithmClient, ProvenanceReport } from "./client.js";
|
|
2
2
|
import { ProposedAction, AgentTrace, ApiResult, Observation, QuoteEvidence } from "./types.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** Configured policy values the server enforces independently of the model's
|
|
4
|
+
* action fields (2026-09-24: the PM entry floor). */
|
|
5
|
+
export interface ExecutionPolicy {
|
|
6
|
+
pmMinEntryProbabilityPct?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function fetchQuote(client: CoinRithmClient, action: ProposedAction, observation: Observation, trace?: AgentTrace, policy?: ExecutionPolicy): Promise<QuoteEvidence | undefined>;
|
|
9
|
+
export declare function executeAction(client: CoinRithmClient, action: ProposedAction, observation: Observation, trace: AgentTrace, idempotencyKey: string, provenance?: ProvenanceReport, policy?: ExecutionPolicy): Promise<ApiResult>;
|
package/dist/agent/act.js
CHANGED
|
@@ -35,8 +35,11 @@ function fundingQuoteEvidence(value) {
|
|
|
35
35
|
function coinIdFor(observation, symbol) {
|
|
36
36
|
return (observation.watch.find((w) => w.symbol.toUpperCase() === symbol.toUpperCase())?.coinId ?? undefined);
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const pmFloorBody = (policy) => typeof policy?.pmMinEntryProbabilityPct === "number" &&
|
|
39
|
+
Number.isFinite(policy.pmMinEntryProbabilityPct)
|
|
40
|
+
? { minEntryProbabilityPct: policy.pmMinEntryProbabilityPct }
|
|
41
|
+
: {};
|
|
42
|
+
export async function fetchQuote(client, action, observation, trace, policy) {
|
|
40
43
|
let r;
|
|
41
44
|
if (action.type === "futures_open") {
|
|
42
45
|
const coinId = coinIdFor(observation, action.symbol);
|
|
@@ -64,6 +67,7 @@ export async function fetchQuote(client, action, observation, trace) {
|
|
|
64
67
|
...(action.forecastProbability != null
|
|
65
68
|
? { forecastProbability: action.forecastProbability }
|
|
66
69
|
: {}),
|
|
70
|
+
...pmFloorBody(policy),
|
|
67
71
|
}, trace);
|
|
68
72
|
}
|
|
69
73
|
else {
|
|
@@ -111,7 +115,7 @@ export async function executeAction(client, action, observation, trace, idempote
|
|
|
111
115
|
// Optional SELF-REPORTED runner provenance, attached to a pm_open only (the one
|
|
112
116
|
// durable-artifact write path here). Absent => the request is byte-identical to
|
|
113
117
|
// pre-provenance behavior.
|
|
114
|
-
provenance) {
|
|
118
|
+
provenance, policy) {
|
|
115
119
|
if (action.type === "futures_open") {
|
|
116
120
|
const coinId = coinIdFor(observation, action.symbol);
|
|
117
121
|
if (!coinId)
|
|
@@ -182,6 +186,9 @@ provenance) {
|
|
|
182
186
|
? { forecastProbability: action.forecastProbability }
|
|
183
187
|
: {}),
|
|
184
188
|
...(action.thesis?.summary ? { thesis: action.thesis.summary } : {}),
|
|
189
|
+
// The CONFIGURED entry floor rides along so the server re-checks it inside
|
|
190
|
+
// its locked open transaction (absent => byte-identical request).
|
|
191
|
+
...pmFloorBody(policy),
|
|
185
192
|
// Attach runner provenance only when present (byte-identical to before when absent).
|
|
186
193
|
...(provenance ? { provenance } : {}),
|
|
187
194
|
agentTrace: trace,
|
package/dist/agent/cli.d.ts
CHANGED
package/dist/agent/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolve as resolvePath, dirname, join, basename } from "node:path";
|
|
|
10
10
|
import { parse as parseYaml } from "yaml";
|
|
11
11
|
import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource, hostedProseBudget, HOSTED_PROSE_MAX_CHARS, } from "./resolve.js";
|
|
12
12
|
import { buildSpec, loadAgent } from "./skill.js";
|
|
13
|
+
import { buildAgentDefinitionSnapshot } from "./definitionSnapshot.js";
|
|
13
14
|
import { validateSkill } from "./skillValidator.js";
|
|
14
15
|
import { strictLint } from "./strictLint.js";
|
|
15
16
|
import { checkCapabilityDrift } from "./capabilityGuard.js";
|
|
@@ -227,6 +228,9 @@ export function cmdInspect(path, json = false) {
|
|
|
227
228
|
resolvedConfig: resolved.rawFrontmatter,
|
|
228
229
|
provenance: resolved.provenance,
|
|
229
230
|
contentHashes: resolved.contentHashes,
|
|
231
|
+
// The local runtime consumes these exact compiled inputs. In particular,
|
|
232
|
+
// skill ablation changes prose, not the source manifest or hard caps.
|
|
233
|
+
compiledDefinition: buildAgentDefinitionSnapshot(spec, runtimeProse(resolved)),
|
|
230
234
|
validation: { valid: v.valid, issues: v.issues, lint },
|
|
231
235
|
};
|
|
232
236
|
if (json) {
|
|
@@ -244,11 +248,17 @@ export function cmdInspect(path, json = false) {
|
|
|
244
248
|
`model: ${spec.model ? `${spec.model.provider}/${spec.model.name}` : "(host free-tier)"}`,
|
|
245
249
|
`risk: maxLeverage=${spec.risk.maxLeverage} perTradeMargin=${spec.risk.perTradeMarginMusd} requireStopLoss=${spec.risk.requireStopLoss}`,
|
|
246
250
|
`sources: ${Object.keys(resolved.contentHashes).length} file(s)`,
|
|
251
|
+
`definition: ${output.compiledDefinition.definitionHash}`,
|
|
247
252
|
`validation: ${v.valid ? "valid" : "INVALID"}${lint.length ? ` (+${lint.length} lint note(s))` : ""}`,
|
|
248
253
|
...output.warnings.map((warning) => `⚠ ${warning}`),
|
|
249
254
|
];
|
|
250
255
|
return { ok: v.valid, code: 0, lines, data: output };
|
|
251
256
|
}
|
|
257
|
+
function runtimeProse(resolved) {
|
|
258
|
+
return envFlag(process.env.COINRITHM_AGENT_DISABLE_SKILLS)
|
|
259
|
+
? mergeProseParts(resolved.proseParts.filter((part) => !isSkillProseSource(part.source)))
|
|
260
|
+
: resolved.mergedProse;
|
|
261
|
+
}
|
|
252
262
|
// Is a process still alive? signal 0 probes without sending — ESRCH means it's
|
|
253
263
|
// gone, EPERM means it exists but we can't signal it (still alive). Unknown PIDs
|
|
254
264
|
// (NaN / non-positive) are treated as alive so we never reclaim a malformed lock.
|
|
@@ -333,6 +343,16 @@ export async function cmdRun(path, opts = {}) {
|
|
|
333
343
|
return issuesResult(e.issues, "resolve failed");
|
|
334
344
|
throw e;
|
|
335
345
|
}
|
|
346
|
+
const mergedProse = runtimeProse(loaded.resolved);
|
|
347
|
+
const definition = buildAgentDefinitionSnapshot(loaded.spec, mergedProse);
|
|
348
|
+
if (opts.expectDefinition !== undefined &&
|
|
349
|
+
opts.expectDefinition !== definition.definitionHash) {
|
|
350
|
+
return fail([
|
|
351
|
+
"compiled agent definition does not match --expect-definition; no model or account call was made",
|
|
352
|
+
`current definition: ${definition.definitionHash}`,
|
|
353
|
+
"inspect the change before choosing a new baseline",
|
|
354
|
+
]);
|
|
355
|
+
}
|
|
336
356
|
const apiKey = process.env.COINRITHM_API_KEY;
|
|
337
357
|
if (!apiKey)
|
|
338
358
|
return fail([
|
|
@@ -386,14 +406,12 @@ export async function cmdRun(path, opts = {}) {
|
|
|
386
406
|
const live = !!opts.live;
|
|
387
407
|
const lines = [
|
|
388
408
|
`run ${live ? "LIVE (paper trades WILL be placed)" : "DRY-RUN (no writes; set --live or LIVE=1)"} — ${loaded.spec.name}`,
|
|
409
|
+
`definition: ${definition.definitionHash}`,
|
|
389
410
|
];
|
|
390
411
|
// Skills ablation kill-switch: drop tactic-skill prose from the prompt for
|
|
391
412
|
// token-cost control or A/B testing. Affects ONLY the run-time prompt — the
|
|
392
413
|
// resolver, manifest, and caps are untouched (the spec is still enforced).
|
|
393
414
|
const disableSkills = envFlag(process.env.COINRITHM_AGENT_DISABLE_SKILLS);
|
|
394
|
-
const mergedProse = disableSkills
|
|
395
|
-
? mergeProseParts(loaded.resolved.proseParts.filter((p) => !isSkillProseSource(p.source)))
|
|
396
|
-
: loaded.body;
|
|
397
415
|
if (disableSkills) {
|
|
398
416
|
const dropped = loaded.resolved.proseParts.filter((p) => isSkillProseSource(p.source)).length;
|
|
399
417
|
lines.push(`skills DISABLED via COINRITHM_AGENT_DISABLE_SKILLS — ${dropped} tactic skill(s) dropped from the prompt (caps unchanged)`);
|
|
@@ -442,6 +460,8 @@ function parseFlags(args) {
|
|
|
442
460
|
out.preset = args[++i];
|
|
443
461
|
else if (a === "--state")
|
|
444
462
|
out.state = args[++i];
|
|
463
|
+
else if (a === "--expect-definition")
|
|
464
|
+
out.expectDefinition = args[++i] ?? "";
|
|
445
465
|
else
|
|
446
466
|
out._.push(a);
|
|
447
467
|
}
|
|
@@ -455,7 +475,7 @@ function usageLines() {
|
|
|
455
475
|
" inspect <path> [--json]",
|
|
456
476
|
" eject <agent.md | dir>",
|
|
457
477
|
" lock <path>",
|
|
458
|
-
" run <path> [--once] [--live] [--dry-run] [--state <file>] (dry-run by default)",
|
|
478
|
+
" run <path> [--once] [--live] [--dry-run] [--state <file>] [--expect-definition sha256:...] (dry-run by default)",
|
|
459
479
|
];
|
|
460
480
|
}
|
|
461
481
|
export async function main(argv) {
|
|
@@ -489,6 +509,7 @@ export async function main(argv) {
|
|
|
489
509
|
once: flags.once,
|
|
490
510
|
live,
|
|
491
511
|
stateFile: flags.state,
|
|
512
|
+
expectDefinition: flags.expectDefinition,
|
|
492
513
|
});
|
|
493
514
|
break;
|
|
494
515
|
}
|
|
@@ -138,6 +138,31 @@ export function buildDecisionInputRecord(input) {
|
|
|
138
138
|
"sentimentBullishPct",
|
|
139
139
|
]),
|
|
140
140
|
freshness: freshness(r.freshness),
|
|
141
|
+
...(r.indicatorContext
|
|
142
|
+
? {
|
|
143
|
+
indicatorContext: {
|
|
144
|
+
range: code(obj(r.indicatorContext).range, ["1D"]),
|
|
145
|
+
asOf: sourceTimestamp(obj(r.indicatorContext).asOf) ?? null,
|
|
146
|
+
intervalStatus: code(obj(r.indicatorContext).intervalStatus, [
|
|
147
|
+
"regular",
|
|
148
|
+
"irregular",
|
|
149
|
+
"unknown",
|
|
150
|
+
]),
|
|
151
|
+
...numeric(obj(r.indicatorContext), [
|
|
152
|
+
"nominalIntervalSeconds",
|
|
153
|
+
"barCount",
|
|
154
|
+
"timestampedBarCount",
|
|
155
|
+
"checkedIntervalCount",
|
|
156
|
+
"irregularIntervalCount",
|
|
157
|
+
"maxGapSeconds",
|
|
158
|
+
]),
|
|
159
|
+
recent15: {
|
|
160
|
+
...numeric(obj(obj(r.indicatorContext).recent15), ["barCount"]),
|
|
161
|
+
intervalStatus: code(obj(obj(r.indicatorContext).recent15).intervalStatus, ["regular", "irregular", "unknown"]),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
141
166
|
indicators: {
|
|
142
167
|
...numeric(obj(r.indicators), [
|
|
143
168
|
"asOfClose",
|
|
@@ -334,6 +359,7 @@ const LIST_KEYS = {
|
|
|
334
359
|
"sentimentBullishPct",
|
|
335
360
|
"freshness",
|
|
336
361
|
"indicators",
|
|
362
|
+
"indicatorContext",
|
|
337
363
|
"fundamentals",
|
|
338
364
|
],
|
|
339
365
|
futuresPositions: [
|
|
@@ -379,6 +405,19 @@ const LIST_KEYS = {
|
|
|
379
405
|
universeMovers: ["symbol", "change24hPct", "priceUsd"],
|
|
380
406
|
};
|
|
381
407
|
const NESTED_KEYS = {
|
|
408
|
+
indicatorContext: [
|
|
409
|
+
"range",
|
|
410
|
+
"asOf",
|
|
411
|
+
"nominalIntervalSeconds",
|
|
412
|
+
"barCount",
|
|
413
|
+
"timestampedBarCount",
|
|
414
|
+
"checkedIntervalCount",
|
|
415
|
+
"irregularIntervalCount",
|
|
416
|
+
"maxGapSeconds",
|
|
417
|
+
"intervalStatus",
|
|
418
|
+
"recent15",
|
|
419
|
+
],
|
|
420
|
+
recent15: ["barCount", "intervalStatus"],
|
|
382
421
|
freshness: ["status", "ageSeconds", "asOf", "basis"],
|
|
383
422
|
quality: [
|
|
384
423
|
"decisionEligible",
|
|
@@ -432,6 +471,10 @@ function validRow(value, keys) {
|
|
|
432
471
|
return sourceTimestamp(v) === v;
|
|
433
472
|
if (key === "basis")
|
|
434
473
|
return code(v, FRESHNESS_BASES) !== null;
|
|
474
|
+
if (key === "range")
|
|
475
|
+
return code(v, ["1D"]) !== null;
|
|
476
|
+
if (key === "intervalStatus")
|
|
477
|
+
return code(v, ["regular", "irregular", "unknown"]) !== null;
|
|
435
478
|
if (key === "policyVersion")
|
|
436
479
|
return typeof v === "string" && /^pm-quality-\d{1,3}$/.test(v);
|
|
437
480
|
if (key === "warningReasons" || key === "blockReasons") {
|
|
@@ -17,6 +17,44 @@ const PM_MIN_FORECAST_EDGE_POINTS = (() => {
|
|
|
17
17
|
return Number.isFinite(raw) && raw >= 0 ? raw : 2;
|
|
18
18
|
})();
|
|
19
19
|
const PM_MIN_STAKE_MUSD = 10; // server minimum prediction-market stake
|
|
20
|
+
// Match backend-v2 validateSlTpPrices, including its boundary tolerance. This
|
|
21
|
+
// preflight uses observed evidence only; the API still checks a fresh mark and
|
|
22
|
+
// locked position at execution. Older observations can omit these prices.
|
|
23
|
+
const TRIGGER_PRICE_EPS = 1e-8;
|
|
24
|
+
function validateTriggerUpdate(action, pos) {
|
|
25
|
+
// executeAction omits null as well as undefined; neither clears a trigger in
|
|
26
|
+
// this runner. Check the resulting state, including retained protection.
|
|
27
|
+
const sl = action.stopLossPrice ?? pos.stopLossPrice;
|
|
28
|
+
const tp = action.takeProfitPrice ?? pos.takeProfitPrice;
|
|
29
|
+
const positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
30
|
+
if (sl != null && !positive(sl))
|
|
31
|
+
return fail("invalid_stop_loss_price", `position ${pos.id}: stop loss must be finite and positive`);
|
|
32
|
+
if (tp != null && !positive(tp))
|
|
33
|
+
return fail("invalid_take_profit_price", `position ${pos.id}: take profit must be finite and positive`);
|
|
34
|
+
if (pos.side !== "long" && pos.side !== "short")
|
|
35
|
+
return ok();
|
|
36
|
+
const long = pos.side === "long";
|
|
37
|
+
const mark = pos.markPrice;
|
|
38
|
+
const liq = pos.liquidationPrice;
|
|
39
|
+
if (sl != null) {
|
|
40
|
+
if (positive(mark) &&
|
|
41
|
+
(long ? sl >= mark - TRIGGER_PRICE_EPS : sl <= mark + TRIGGER_PRICE_EPS)) {
|
|
42
|
+
return fail(long ? "stop_loss_not_below_mark" : "stop_loss_not_above_mark", `position ${pos.id}: ${pos.side} effective stop ${sl} must be ${long ? "below" : "above"} observed mark ${mark}; omitted triggers keep their existing values`);
|
|
43
|
+
}
|
|
44
|
+
if (positive(liq) &&
|
|
45
|
+
(long ? sl <= liq + TRIGGER_PRICE_EPS : sl >= liq - TRIGGER_PRICE_EPS)) {
|
|
46
|
+
return fail(long
|
|
47
|
+
? "stop_loss_not_above_liquidation"
|
|
48
|
+
: "stop_loss_not_below_liquidation", `position ${pos.id}: ${pos.side} effective stop ${sl} must be ${long ? "above" : "below"} observed liquidation ${liq}; omitted triggers keep their existing values`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (tp != null &&
|
|
52
|
+
positive(mark) &&
|
|
53
|
+
(long ? tp <= mark + TRIGGER_PRICE_EPS : tp >= mark - TRIGGER_PRICE_EPS)) {
|
|
54
|
+
return fail(long ? "take_profit_not_above_mark" : "take_profit_not_below_mark", `position ${pos.id}: ${pos.side} effective take profit ${tp} must be ${long ? "above" : "below"} observed mark ${mark}; omitted triggers keep their existing values`);
|
|
55
|
+
}
|
|
56
|
+
return ok();
|
|
57
|
+
}
|
|
20
58
|
// Entry budgets are exposure budgets, not emergency-action budgets. Closing a
|
|
21
59
|
// futures position, updating its protection, cancelling an order, or selling
|
|
22
60
|
// spot reduces/contains risk and must remain available after an entry cap.
|
|
@@ -219,6 +257,7 @@ export function validateAction(action, ctx) {
|
|
|
219
257
|
if (!hasTrigger) {
|
|
220
258
|
return fail("sltp_no_op", "futures_set_sltp must set at least one positive stopLossPrice or takeProfitPrice");
|
|
221
259
|
}
|
|
260
|
+
return validateTriggerUpdate(action, pos);
|
|
222
261
|
}
|
|
223
262
|
return ok();
|
|
224
263
|
}
|
|
@@ -316,6 +355,41 @@ export function validateAction(action, ctx) {
|
|
|
316
355
|
if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
|
|
317
356
|
return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
|
|
318
357
|
}
|
|
358
|
+
// Hard entry-price floor (opt-in, 2026-09-24): compares the CHOSEN
|
|
359
|
+
// outcome's raw market probability at entry (the quote's entryProbability,
|
|
360
|
+
// points) with risk.pmMinEntryProbabilityPct. Fees are deliberately left
|
|
361
|
+
// out: a 19-point outcome fails a 20-point floor even when fees push its
|
|
362
|
+
// all-in cost above 20 (fees belong to the edge check below). No
|
|
363
|
+
// discovery-price fallback: with a floor set and no finite quoted
|
|
364
|
+
// probability the open is rejected, never waved through.
|
|
365
|
+
const entryFloor = spec.risk.pmMinEntryProbabilityPct;
|
|
366
|
+
if (entryFloor !== undefined) {
|
|
367
|
+
// A present-but-invalid floor never silently means "no floor".
|
|
368
|
+
if (typeof entryFloor !== "number" ||
|
|
369
|
+
!Number.isFinite(entryFloor) ||
|
|
370
|
+
entryFloor < 0 ||
|
|
371
|
+
entryFloor > 100) {
|
|
372
|
+
return fail("pm_entry_floor_invalid", `risk.pmMinEntryProbabilityPct ${JSON.stringify(entryFloor)} is not a number between 0 and 100`);
|
|
373
|
+
}
|
|
374
|
+
const market = ctx.quote.entryProbability;
|
|
375
|
+
if (typeof market !== "number" ||
|
|
376
|
+
!Number.isFinite(market) ||
|
|
377
|
+
market < 0 ||
|
|
378
|
+
market > 100) {
|
|
379
|
+
return fail("pm_entry_price_unavailable", `entry floor ${entryFloor}pt is set but the quote carries no usable market probability for the chosen outcome (${JSON.stringify(market ?? null)})`);
|
|
380
|
+
}
|
|
381
|
+
if (market + 1e-9 < entryFloor) {
|
|
382
|
+
const stake = ctx.quote.stakeMusd;
|
|
383
|
+
const shares = ctx.quote.sharesEstimate;
|
|
384
|
+
const cost = typeof stake === "number" &&
|
|
385
|
+
typeof shares === "number" &&
|
|
386
|
+
stake > 0 &&
|
|
387
|
+
shares > 0
|
|
388
|
+
? ` (fee-inclusive cost ${((stake / shares) * 100).toFixed(1)}pt)`
|
|
389
|
+
: "";
|
|
390
|
+
return fail("pm_entry_below_floor", `market ${market.toFixed(1)}pt is below the ${entryFloor}pt entry floor for the chosen outcome${cost}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
319
393
|
// Forecast consistency. By prompt contract forecastProbability is the
|
|
320
394
|
// model's own probability (1-99) that the outcome IT IS BACKING wins, so
|
|
321
395
|
// buying that outcome only makes sense when the forecast clears what the
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentSpec } from "./types.js";
|
|
2
|
+
/** Private, portable definition; deliberately excludes mutable account state. */
|
|
3
|
+
export interface AgentDefinitionSnapshot {
|
|
4
|
+
schema: "coinrithm.agent-definition.v1";
|
|
5
|
+
engine: {
|
|
6
|
+
packageVersion: string;
|
|
7
|
+
resolverVersion: string;
|
|
8
|
+
indicatorVersion: string;
|
|
9
|
+
apiContractVersion: string;
|
|
10
|
+
};
|
|
11
|
+
/** Pass the actual post-overlay spec, without rebuilding it from frontmatter. */
|
|
12
|
+
spec: AgentSpec;
|
|
13
|
+
/** Exact strategy prose supplied to runCycle, after any skills ablation. */
|
|
14
|
+
mergedProse: string;
|
|
15
|
+
definitionHash: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Binds the compiled definition and declared engine versions, not the original
|
|
19
|
+
* source folder alone. This is an integrity/comparison hash, not a signature,
|
|
20
|
+
* complete replay record, or attestation of a particular deployed binary.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildAgentDefinitionSnapshot(spec: AgentSpec, mergedProse: string): AgentDefinitionSnapshot;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { INDICATOR_VERSION } from "./indicators.js";
|
|
2
|
+
import { COINRITHM_API, RESOLVER_VERSION } from "./version.js";
|
|
3
|
+
import { sha256, stableStringify } from "./util.js";
|
|
4
|
+
/**
|
|
5
|
+
* Binds the compiled definition and declared engine versions, not the original
|
|
6
|
+
* source folder alone. This is an integrity/comparison hash, not a signature,
|
|
7
|
+
* complete replay record, or attestation of a particular deployed binary.
|
|
8
|
+
*/
|
|
9
|
+
export function buildAgentDefinitionSnapshot(spec, mergedProse) {
|
|
10
|
+
const definition = {
|
|
11
|
+
schema: "coinrithm.agent-definition.v1",
|
|
12
|
+
engine: {
|
|
13
|
+
packageVersion: COINRITHM_API.mcpVersion,
|
|
14
|
+
resolverVersion: RESOLVER_VERSION,
|
|
15
|
+
indicatorVersion: INDICATOR_VERSION,
|
|
16
|
+
apiContractVersion: COINRITHM_API.openapiVersion,
|
|
17
|
+
},
|
|
18
|
+
spec,
|
|
19
|
+
mergedProse,
|
|
20
|
+
};
|
|
21
|
+
// Detach from caller-owned spec: later mutations must not invalidate a saved
|
|
22
|
+
// artifact while leaving its old hash attached to it.
|
|
23
|
+
const serialized = stableStringify(definition);
|
|
24
|
+
return {
|
|
25
|
+
...JSON.parse(serialized),
|
|
26
|
+
definitionHash: sha256(serialized),
|
|
27
|
+
};
|
|
28
|
+
}
|
package/dist/agent/engine.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { runCycle, type RunnerDeps } from "./runner.js";
|
|
2
|
+
export { buildAgentDefinitionSnapshot, type AgentDefinitionSnapshot, } from "./definitionSnapshot.js";
|
|
2
3
|
export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
|
|
3
4
|
export { selectProvider, providerForRoute, classifyProviderFailure, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
|
|
4
5
|
export { parseDecision } from "./decision.js";
|
package/dist/agent/engine.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// This barrel is the ONE import a host scheduler needs; it re-exports only the
|
|
7
7
|
// stable engine pieces, never the CLI.
|
|
8
8
|
export { runCycle } from "./runner.js";
|
|
9
|
+
export { buildAgentDefinitionSnapshot, } from "./definitionSnapshot.js";
|
|
9
10
|
export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
|
|
10
11
|
export { selectProvider, providerForRoute, classifyProviderFailure, } from "./providers.js";
|
|
11
12
|
export { parseDecision } from "./decision.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ResolveIssue } from "./types.js";
|
|
2
|
-
export type CapDirection = "lower" | "higher" | "true";
|
|
2
|
+
export type CapDirection = "lower" | "lower_zero_off" | "higher" | "true";
|
|
3
3
|
export declare const RISK_CAPS: Record<string, CapDirection>;
|
|
4
4
|
export declare const LIMIT_CAPS: Record<string, CapDirection>;
|
|
5
5
|
export declare function isAtLeastAsRestrictive(dir: CapDirection, candidate: unknown, base: unknown): boolean;
|
package/dist/agent/mergeRules.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Normal (non-cap) config uses simple precedence (inline > $ref > defaults),
|
|
10
10
|
// handled in the resolver; this module owns only the cap arithmetic.
|
|
11
|
+
// 0 is "no cap" for lower_zero_off; every other value compares as itself.
|
|
12
|
+
const zeroOffLimit = (n) => (n === 0 ? Infinity : n);
|
|
13
|
+
const isZeroOffValue = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
11
14
|
// risk.* hard caps a tactic module may tighten.
|
|
12
15
|
export const RISK_CAPS = {
|
|
13
16
|
maxLeverage: "lower",
|
|
@@ -17,9 +20,9 @@ export const RISK_CAPS = {
|
|
|
17
20
|
};
|
|
18
21
|
// limits.* throughput/spend caps a tactic module may tighten.
|
|
19
22
|
export const LIMIT_CAPS = {
|
|
20
|
-
maxTradesPerDay: "
|
|
23
|
+
maxTradesPerDay: "lower_zero_off",
|
|
21
24
|
maxWritesPerCycle: "lower",
|
|
22
|
-
maxDailyLossMusd: "
|
|
25
|
+
maxDailyLossMusd: "lower_zero_off",
|
|
23
26
|
maxOpenMarginMusd: "lower",
|
|
24
27
|
};
|
|
25
28
|
// Is `candidate` at least as restrictive as `base` (i.e. a legal tightening)?
|
|
@@ -29,6 +32,11 @@ export function isAtLeastAsRestrictive(dir, candidate, base) {
|
|
|
29
32
|
// legal: base=false→candidate any; base=true→candidate must be true.
|
|
30
33
|
return base === true ? candidate === true : true;
|
|
31
34
|
}
|
|
35
|
+
if (dir === "lower_zero_off") {
|
|
36
|
+
if (!isZeroOffValue(candidate) || !isZeroOffValue(base))
|
|
37
|
+
return false;
|
|
38
|
+
return zeroOffLimit(candidate) <= zeroOffLimit(base);
|
|
39
|
+
}
|
|
32
40
|
if (typeof candidate !== "number" || typeof base !== "number") {
|
|
33
41
|
// a non-numeric cap candidate where a number is expected is not a legal
|
|
34
42
|
// tightening (caller will surface a type issue separately).
|
|
@@ -40,6 +48,13 @@ export function isAtLeastAsRestrictive(dir, candidate, base) {
|
|
|
40
48
|
export function mostRestrictive(dir, a, b) {
|
|
41
49
|
if (dir === "true")
|
|
42
50
|
return a === true || b === true ? true : false;
|
|
51
|
+
if (dir === "lower_zero_off") {
|
|
52
|
+
if (!isZeroOffValue(a))
|
|
53
|
+
return b;
|
|
54
|
+
if (!isZeroOffValue(b))
|
|
55
|
+
return a;
|
|
56
|
+
return zeroOffLimit(a) <= zeroOffLimit(b) ? a : b;
|
|
57
|
+
}
|
|
43
58
|
if (typeof a !== "number")
|
|
44
59
|
return b;
|
|
45
60
|
if (typeof b !== "number")
|
package/dist/agent/observe.js
CHANGED
|
@@ -6,10 +6,10 @@ import { computeIndicators } from "./indicators.js";
|
|
|
6
6
|
import { scanSetups } from "./setups.js";
|
|
7
7
|
import { freshnessOf, pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
|
|
8
8
|
import { deriveCapitalBook, usesCapitalSizing } from "./capitalSizing.js";
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// short cadence the hosted house agents run on. Probe-verified 2026-06-17.
|
|
9
|
+
// The 1D endpoint nominally returns 288 five-minute bars. Timestamps and gaps
|
|
10
|
+
// are checked separately; requesting this range does not establish freshness.
|
|
12
11
|
const INDICATOR_RANGE = "1D";
|
|
12
|
+
const INDICATOR_INTERVAL_SECONDS = 300;
|
|
13
13
|
// `universe_scan` bounds: how many top movers to pull, and how many of those
|
|
14
14
|
// to fully resolve into tradable watch entries (each resolved row costs a
|
|
15
15
|
// resolve + market [+ candles] call).
|
|
@@ -73,6 +73,49 @@ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)
|
|
|
73
73
|
export function isCalibrationChurnMarket(market) {
|
|
74
74
|
return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
|
|
75
75
|
}
|
|
76
|
+
// The live endpoint's t is Unix seconds. Missing/malformed values (including
|
|
77
|
+
// millisecond epochs) stay unknown; never substitute retrieval time.
|
|
78
|
+
function candleTimestamp(value) {
|
|
79
|
+
return typeof value === "number" &&
|
|
80
|
+
Number.isInteger(value) &&
|
|
81
|
+
value > 0 &&
|
|
82
|
+
value <= 253_402_300_799
|
|
83
|
+
? value
|
|
84
|
+
: undefined;
|
|
85
|
+
}
|
|
86
|
+
function candleIntervals(times) {
|
|
87
|
+
let timestampedBarCount = 0;
|
|
88
|
+
let checkedIntervalCount = 0;
|
|
89
|
+
let irregularIntervalCount = 0;
|
|
90
|
+
let maxGapSeconds;
|
|
91
|
+
for (let i = 0; i < times.length; i++) {
|
|
92
|
+
const current = times[i];
|
|
93
|
+
if (current === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
timestampedBarCount++;
|
|
96
|
+
const previous = times[i - 1];
|
|
97
|
+
if (previous === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
const gap = current - previous;
|
|
100
|
+
checkedIntervalCount++;
|
|
101
|
+
if (gap !== INDICATOR_INTERVAL_SECONDS)
|
|
102
|
+
irregularIntervalCount++;
|
|
103
|
+
maxGapSeconds = Math.max(maxGapSeconds ?? 0, gap);
|
|
104
|
+
}
|
|
105
|
+
const intervalStatus = irregularIntervalCount > 0
|
|
106
|
+
? "irregular"
|
|
107
|
+
: times.length < 2 || timestampedBarCount !== times.length
|
|
108
|
+
? "unknown"
|
|
109
|
+
: "regular";
|
|
110
|
+
return {
|
|
111
|
+
barCount: times.length,
|
|
112
|
+
timestampedBarCount,
|
|
113
|
+
checkedIntervalCount,
|
|
114
|
+
irregularIntervalCount,
|
|
115
|
+
intervalStatus,
|
|
116
|
+
...(maxGapSeconds === undefined ? {} : { maxGapSeconds }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
76
119
|
// Fetch candles for one coin and reduce them to a compact indicator bundle plus
|
|
77
120
|
// the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
78
121
|
// candles) yields null indicators so the cycle proceeds with price-only context
|
|
@@ -92,6 +135,7 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
92
135
|
return { indicators: null };
|
|
93
136
|
// Endpoint shape: { candles: [{ t, o, h, l, c, v, vm }] } ascending (oldest first).
|
|
94
137
|
const candles = [];
|
|
138
|
+
const times = [];
|
|
95
139
|
let latestVolume;
|
|
96
140
|
let latestVolumeMissingVenues;
|
|
97
141
|
for (const raw of asArr(asObj(cr.data).candles)) {
|
|
@@ -104,6 +148,7 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
104
148
|
continue;
|
|
105
149
|
const volume = asNum(c.v);
|
|
106
150
|
candles.push({ open, high, low, close, volume: volume ?? undefined });
|
|
151
|
+
times.push(candleTimestamp(c.t));
|
|
107
152
|
latestVolume = volume != null && volume >= 0 ? volume : undefined;
|
|
108
153
|
const coverage = asNum(c.vm);
|
|
109
154
|
latestVolumeMissingVenues =
|
|
@@ -114,8 +159,22 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
114
159
|
? coverage
|
|
115
160
|
: undefined;
|
|
116
161
|
}
|
|
162
|
+
const latestTime = times.at(-1);
|
|
163
|
+
const recent = candleIntervals(times.slice(-15));
|
|
117
164
|
return {
|
|
118
165
|
indicators: computeIndicators(candles),
|
|
166
|
+
indicatorContext: {
|
|
167
|
+
range: INDICATOR_RANGE,
|
|
168
|
+
nominalIntervalSeconds: INDICATOR_INTERVAL_SECONDS,
|
|
169
|
+
...candleIntervals(times),
|
|
170
|
+
...(latestTime === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: { asOf: new Date(latestTime * 1000).toISOString() }),
|
|
173
|
+
recent15: {
|
|
174
|
+
barCount: recent.barCount,
|
|
175
|
+
intervalStatus: recent.intervalStatus,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
119
178
|
volume24hUsd: latestVolume,
|
|
120
179
|
volumeMissingVenues: latestVolumeMissingVenues,
|
|
121
180
|
};
|
|
@@ -147,6 +206,8 @@ async function enrichFromCandles(client, entry, coinId, trace) {
|
|
|
147
206
|
const cc = await fetchCandleContext(client, coinId, trace);
|
|
148
207
|
if (cc.indicators)
|
|
149
208
|
entry.indicators = cc.indicators;
|
|
209
|
+
if (cc.indicatorContext)
|
|
210
|
+
entry.indicatorContext = cc.indicatorContext;
|
|
150
211
|
if (cc.volume24hUsd != null) {
|
|
151
212
|
entry.fundamentals = {
|
|
152
213
|
...(entry.fundamentals ?? {}),
|
package/dist/agent/prompt.js
CHANGED
|
@@ -147,6 +147,11 @@ opts = {}) {
|
|
|
147
147
|
"- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a LISTED crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open by its `ref` — treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. ESCAPE HATCH — only the markets actually listed in observation.pmMarkets THIS cycle (pm1..pmN) are bettable: if NONE of them matches the coin or view you formed, that is a legitimate SKIP for PM (say so in one clause and move on) — do NOT invent, guess, or increment a ref for a market you wish existed, because a made-up ref is rejected (pm_ref_unknown) and wastes the whole cycle exactly like a rejected open. The mistake to avoid is leaving a LISTED, clearly mispriced crypto market untraded — a mispricing that is NOT on this cycle's board is simply not actionable now, not a miss. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
|
|
148
148
|
]
|
|
149
149
|
: []),
|
|
150
|
+
...(hasPm && typeof r.pmMinEntryProbabilityPct === "number"
|
|
151
|
+
? [
|
|
152
|
+
`- PM ENTRY FLOOR: pm_open on an outcome whose market probability is below ${r.pmMinEntryProbabilityPct} points is REJECTED by the runner (the chosen outcome's own price; fees are not counted). Do not propose cheaper longshots; look for edge on outcomes priced at or above the floor.`,
|
|
153
|
+
]
|
|
154
|
+
: []),
|
|
150
155
|
...(includeForecast
|
|
151
156
|
? [
|
|
152
157
|
"- FORECAST RULE (pm_open forecastProbability): before you look at what the market is pricing, decide YOUR OWN probability the outcome you are backing actually WINS — reason ONLY from the question, its resolution criteria, and the deadline. Put that number (1-99, whole or one decimal) in `forecastProbability`. This is graded against reality as your PUBLIC calibration record, so it must be YOUR judgement, NOT the market's: do NOT copy, round, or anchor it to the observation.pmMarkets `prob`. It is FINE if your honest forecast happens to land on the market's number — but reaching that by echoing the price defeats the point. If you genuinely cannot form an independent view, OMIT the field rather than parroting the market (an absent forecast is better than a fake one, and it never blocks the bet). A forecast you DO give is enforced: if it is not above what the outcome currently costs, the open is rejected, because buying something you price below the market is a losing trade by your own numbers.",
|
|
@@ -156,7 +161,8 @@ opts = {}) {
|
|
|
156
161
|
...(spec.capabilities.includes("indicators")
|
|
157
162
|
? [
|
|
158
163
|
"",
|
|
159
|
-
"## Signals — each watch entry may carry `indicators` (
|
|
164
|
+
"## Signals — each watch entry may carry `indicators` (nominal five-minute candles)",
|
|
165
|
+
"- `indicatorContext` reports accepted candle counts, source `asOf` and intervalStatus (regular/irregular/unknown); compare its asOf with observation.asOf for age. /market freshness is separate. nominalIntervalSeconds=300 does not prove fresh, continuous candles. Missing timestamps are unknown; stale, future-dated or irregular candles do not establish a current five-minute signal. `recent15` describes only recent spacing; Wilder atr14 also retains earlier history, so recent regularity does not erase older gaps.",
|
|
160
166
|
"- rsi14: momentum (>70 overbought, <30 oversold); ema20 & ema50: trend; atr14: volatility (size stops off it); bollinger {upper,mid,lower}; recent20 {high,low}: breakout levels.",
|
|
161
167
|
"- boolean reads: aboveEma20, ema20AboveEma50 (uptrend when both true), brokeRecentHigh (breakout), brokeRecentLow (breakdown).",
|
|
162
168
|
"- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
|
package/dist/agent/resolve.js
CHANGED
|
@@ -47,7 +47,14 @@ const CONFIG_BLOCKS = [
|
|
|
47
47
|
const JOURNAL_MAX_LINES = 200;
|
|
48
48
|
const JOURNAL_MAX_BYTES = 8_000;
|
|
49
49
|
// Optional prose files (markdown the LLM reads), in assembly order.
|
|
50
|
-
const PROSE_FILES = [
|
|
50
|
+
const PROSE_FILES = [
|
|
51
|
+
"character/thesis.md",
|
|
52
|
+
"character/persona.md",
|
|
53
|
+
"character/entries.md",
|
|
54
|
+
"character/exits.md",
|
|
55
|
+
"character/sizing.md",
|
|
56
|
+
"character/research.md",
|
|
57
|
+
];
|
|
51
58
|
// Prose files carry an OPTIONAL YAML frontmatter block (type/title/description/
|
|
52
59
|
// tags) that is authoring metadata, not doctrine — the model gains nothing from
|
|
53
60
|
// `tags: [agent, persona, mean-reversion]`. It was being merged verbatim into
|
package/dist/agent/runner.js
CHANGED
|
@@ -425,6 +425,12 @@ async function runCycleCore(deps, capture) {
|
|
|
425
425
|
const provenance = buildRunnerProvenance(spec);
|
|
426
426
|
state.cyclesRun += 1;
|
|
427
427
|
rollDay(state);
|
|
428
|
+
// Old state did not identify which route failed. Carrying that count into
|
|
429
|
+
// a replacement model can request a hold on its very first error.
|
|
430
|
+
if (state.consecutivePermanentModelErrors &&
|
|
431
|
+
!state.permanentModelErrorRoute) {
|
|
432
|
+
state.consecutivePermanentModelErrors = 0;
|
|
433
|
+
}
|
|
428
434
|
const runId = state.runId;
|
|
429
435
|
const decisionId = makeDecisionId(state.cyclesRun);
|
|
430
436
|
const captureBase = { runId, decisionId, spec, mergedProse, state };
|
|
@@ -581,7 +587,7 @@ async function runCycleCore(deps, capture) {
|
|
|
581
587
|
...makeTrace(runId, decisionId, spec, undefined, `Thesis exit: ${why}`),
|
|
582
588
|
...observationReceipt,
|
|
583
589
|
};
|
|
584
|
-
const r = await executeAction(client, action, observation, trace, idem, provenance);
|
|
590
|
+
const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
|
|
585
591
|
exitPlanned.push({
|
|
586
592
|
action,
|
|
587
593
|
accepted: true,
|
|
@@ -764,13 +770,34 @@ async function runCycleCore(deps, capture) {
|
|
|
764
770
|
// for what deserves them: revoked credentials, drawdown, kill-switch,
|
|
765
771
|
// user action. Transient errors reset the permanent streak.
|
|
766
772
|
if (isPermanentModelError(res.error)) {
|
|
773
|
+
// The router retains the last non-capacity failure even when a later
|
|
774
|
+
// fallback returns 429. Its effective route still describes the last
|
|
775
|
+
// actual call for metering, not necessarily the error returned here.
|
|
776
|
+
const failureAttempt = route?.attempts
|
|
777
|
+
.slice()
|
|
778
|
+
.reverse()
|
|
779
|
+
.find((attempt) => attempt.outcome === "failed" &&
|
|
780
|
+
attempt.failureClass !== "capacity" &&
|
|
781
|
+
attempt.error === res.error);
|
|
782
|
+
const failureRoute = {
|
|
783
|
+
provider: failureAttempt?.provider ??
|
|
784
|
+
route?.effectiveProvider ??
|
|
785
|
+
providerName,
|
|
786
|
+
model: failureAttempt?.model ??
|
|
787
|
+
route?.effectiveModel ??
|
|
788
|
+
spec.model?.name ??
|
|
789
|
+
"unknown",
|
|
790
|
+
};
|
|
791
|
+
const previousRoute = state.permanentModelErrorRoute;
|
|
792
|
+
const sameRoute = previousRoute?.provider === failureRoute.provider &&
|
|
793
|
+
previousRoute?.model === failureRoute.model;
|
|
767
794
|
state.consecutivePermanentModelErrors =
|
|
768
|
-
(state.consecutivePermanentModelErrors ?? 0) + 1;
|
|
795
|
+
(sameRoute ? (state.consecutivePermanentModelErrors ?? 0) : 0) + 1;
|
|
796
|
+
state.permanentModelErrorRoute = failureRoute;
|
|
769
797
|
if (state.consecutivePermanentModelErrors >=
|
|
770
798
|
PERMANENT_MODEL_ERROR_THRESHOLD) {
|
|
771
799
|
const hold = {
|
|
772
|
-
|
|
773
|
-
model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
|
|
800
|
+
...failureRoute,
|
|
774
801
|
error: res.error.slice(0, 200),
|
|
775
802
|
};
|
|
776
803
|
saveState(stateFile, state);
|
|
@@ -792,6 +819,7 @@ async function runCycleCore(deps, capture) {
|
|
|
792
819
|
}
|
|
793
820
|
else {
|
|
794
821
|
state.consecutivePermanentModelErrors = 0;
|
|
822
|
+
delete state.permanentModelErrorRoute;
|
|
795
823
|
}
|
|
796
824
|
saveState(stateFile, state);
|
|
797
825
|
log(`model error: ${res.error}`);
|
|
@@ -808,6 +836,10 @@ async function runCycleCore(deps, capture) {
|
|
|
808
836
|
...observationReceipt,
|
|
809
837
|
};
|
|
810
838
|
}
|
|
839
|
+
// A returned model response proves availability even if its decision JSON
|
|
840
|
+
// is invalid. Output validation keeps its own failure counter below.
|
|
841
|
+
state.consecutivePermanentModelErrors = 0;
|
|
842
|
+
delete state.permanentModelErrorRoute;
|
|
811
843
|
const parsed = parseDecision(res.text);
|
|
812
844
|
if (!parsed.ok) {
|
|
813
845
|
state.consecutiveModelFailures += 1;
|
|
@@ -830,7 +862,6 @@ async function runCycleCore(deps, capture) {
|
|
|
830
862
|
};
|
|
831
863
|
}
|
|
832
864
|
state.consecutiveModelFailures = 0;
|
|
833
|
-
state.consecutivePermanentModelErrors = 0;
|
|
834
865
|
decision = parsed.decision;
|
|
835
866
|
}
|
|
836
867
|
// Reasoning captured for the Arena terminal (keystone transparency): the
|
|
@@ -1027,7 +1058,9 @@ async function runCycleCore(deps, capture) {
|
|
|
1027
1058
|
}
|
|
1028
1059
|
}
|
|
1029
1060
|
}
|
|
1030
|
-
const quote = await fetchQuote(client, action, observation, baseTrace
|
|
1061
|
+
const quote = await fetchQuote(client, action, observation, baseTrace, {
|
|
1062
|
+
pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct,
|
|
1063
|
+
});
|
|
1031
1064
|
// Early PM skip: the quote's openBlocked preview tells us a pm/open right now
|
|
1032
1065
|
// would be rejected 422 by the open-time quality gate (distinct from the
|
|
1033
1066
|
// eligible/blockReasons SHAPE gate the validator checks). Bail here with a clear
|
|
@@ -1150,7 +1183,7 @@ async function runCycleCore(deps, capture) {
|
|
|
1150
1183
|
rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length)),
|
|
1151
1184
|
...observationReceipt,
|
|
1152
1185
|
};
|
|
1153
|
-
const r = await executeAction(client, action, observation, trace, idem, provenance);
|
|
1186
|
+
const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
|
|
1154
1187
|
planned.push({
|
|
1155
1188
|
action,
|
|
1156
1189
|
accepted: true,
|
package/dist/agent/skill.js
CHANGED
|
@@ -109,6 +109,15 @@ export function buildSpec(raw) {
|
|
|
109
109
|
entryPredicates: risk.entryPredicates,
|
|
110
110
|
}
|
|
111
111
|
: {}),
|
|
112
|
+
// Optional PM entry floor: an absent key stays absent (no floor); an
|
|
113
|
+
// explicit value is carried AS WRITTEN, malformed or not, so hosted
|
|
114
|
+
// validation fails closed instead of a typo silently meaning "no floor"
|
|
115
|
+
// (the same rule entryPredicates follow).
|
|
116
|
+
...(risk.pmMinEntryProbabilityPct !== undefined
|
|
117
|
+
? {
|
|
118
|
+
pmMinEntryProbabilityPct: risk.pmMinEntryProbabilityPct,
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
112
121
|
maxLeverage: num(risk.maxLeverage, 1),
|
|
113
122
|
perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
|
|
114
123
|
maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
|
|
@@ -108,6 +108,13 @@ export function validateSkill(parsed, mode = "self-host") {
|
|
|
108
108
|
add("skill_risk_sl", "risk.requireStopLoss must be true or false");
|
|
109
109
|
if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
|
|
110
110
|
add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
|
|
111
|
+
// Optional PM entry floor: absent means no floor; present must be a finite
|
|
112
|
+
// number of probability points in [0, 100].
|
|
113
|
+
if (r.pmMinEntryProbabilityPct !== undefined) {
|
|
114
|
+
const f = r.pmMinEntryProbabilityPct;
|
|
115
|
+
if (typeof f !== "number" || !Number.isFinite(f) || f < 0 || f > 100)
|
|
116
|
+
add("skill_risk_pm_entry_floor", "risk.pmMinEntryProbabilityPct must be a number between 0 and 100 (points; omit for no floor)");
|
|
117
|
+
}
|
|
111
118
|
// Fail-closed on the side restriction: a typo ("shorts_only") must never
|
|
112
119
|
// silently mean "unrestricted" — that is exactly how a prose-only
|
|
113
120
|
// constraint failed live on 2026-08-24.
|
|
@@ -171,8 +178,10 @@ export function validateSkill(parsed, mode = "self-host") {
|
|
|
171
178
|
add("skill_limits_trades", "limits.maxTradesPerDay must be a number >= 0 (0 = unlimited daily trades)");
|
|
172
179
|
if (!isPosNum(l.maxWritesPerCycle))
|
|
173
180
|
add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
// 0 = no daily loss cap: the runner only enforces a positive value and
|
|
182
|
+
// the hosted API already accepts 0..50,000, so hosted validation must too.
|
|
183
|
+
if (!isNonNegNum(l.maxDailyLossMusd))
|
|
184
|
+
add("skill_limits_loss", "limits.maxDailyLossMusd must be a number >= 0 (0 = no daily loss cap)");
|
|
176
185
|
if (!isPosNum(l.maxOpenMarginMusd))
|
|
177
186
|
add("skill_limits_open", "limits.maxOpenMarginMusd must be a positive number");
|
|
178
187
|
}
|
package/dist/agent/strictLint.js
CHANGED
package/dist/agent/types.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface RiskConfig {
|
|
|
34
34
|
watchlist: string[];
|
|
35
35
|
blocklist?: string[];
|
|
36
36
|
direction?: "long_only" | "short_only";
|
|
37
|
+
pmMinEntryProbabilityPct?: number;
|
|
37
38
|
}
|
|
38
39
|
export interface LimitsConfig {
|
|
39
40
|
maxTradesPerDay: number;
|
|
@@ -133,6 +134,21 @@ export interface Freshness {
|
|
|
133
134
|
asOf?: string;
|
|
134
135
|
basis?: string;
|
|
135
136
|
}
|
|
137
|
+
export interface IndicatorContext {
|
|
138
|
+
range: "1D";
|
|
139
|
+
nominalIntervalSeconds: 300;
|
|
140
|
+
barCount: number;
|
|
141
|
+
timestampedBarCount: number;
|
|
142
|
+
checkedIntervalCount: number;
|
|
143
|
+
irregularIntervalCount: number;
|
|
144
|
+
intervalStatus: "regular" | "irregular" | "unknown";
|
|
145
|
+
maxGapSeconds?: number;
|
|
146
|
+
asOf?: string;
|
|
147
|
+
recent15: {
|
|
148
|
+
barCount: number;
|
|
149
|
+
intervalStatus: "regular" | "irregular" | "unknown";
|
|
150
|
+
};
|
|
151
|
+
}
|
|
136
152
|
export interface WatchEntry {
|
|
137
153
|
symbol: string;
|
|
138
154
|
coinId: string | null;
|
|
@@ -144,6 +160,7 @@ export interface WatchEntry {
|
|
|
144
160
|
sentimentBullishPct?: number;
|
|
145
161
|
freshness?: Freshness;
|
|
146
162
|
indicators?: IndicatorSet;
|
|
163
|
+
indicatorContext?: IndicatorContext;
|
|
147
164
|
discovered?: boolean;
|
|
148
165
|
slug?: string;
|
|
149
166
|
fundamentals?: CoinFundamentals;
|
|
@@ -437,6 +454,10 @@ export interface RunState {
|
|
|
437
454
|
lastLlmCallAt?: number;
|
|
438
455
|
lastTriggerFingerprint?: string;
|
|
439
456
|
consecutivePermanentModelErrors?: number;
|
|
457
|
+
permanentModelErrorRoute?: {
|
|
458
|
+
provider: string;
|
|
459
|
+
model: string;
|
|
460
|
+
};
|
|
440
461
|
consecutiveAuthFailures?: number;
|
|
441
462
|
journal?: Array<{
|
|
442
463
|
at: string;
|
package/dist/client.d.ts
CHANGED
|
@@ -174,6 +174,7 @@ export declare class CoinRithmClient {
|
|
|
174
174
|
stakeMusd: number;
|
|
175
175
|
forecastProbability?: number;
|
|
176
176
|
bankrollMusd?: number;
|
|
177
|
+
minEntryProbabilityPct?: number;
|
|
177
178
|
} & {
|
|
178
179
|
agentTrace?: AgentTrace;
|
|
179
180
|
}, apiKey?: string): Promise<ApiResult>;
|
|
@@ -221,6 +222,7 @@ export declare class CoinRithmClient {
|
|
|
221
222
|
stakeMusd: number;
|
|
222
223
|
idempotencyKey: string;
|
|
223
224
|
forecastProbability?: number;
|
|
225
|
+
minEntryProbabilityPct?: number;
|
|
224
226
|
thesis?: string;
|
|
225
227
|
provenance?: ProvenanceReport;
|
|
226
228
|
}>, apiKey?: string): Promise<ApiResult>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coinrithm/mcp-trading",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.14",
|
|
4
4
|
"mcpName": "io.github.CoinRithm/mcp-trading",
|
|
5
5
|
"description": "CoinRithm paper-trading toolkit: an MCP server (coinrithm-mcp) AND a self-host agent runner (coinrithm-agent) for spot, futures, and prediction markets with a user-minted API key.",
|
|
6
6
|
"type": "module",
|