@coinrithm/mcp-trading 0.7.9 → 0.7.10

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 CHANGED
@@ -5,10 +5,36 @@ 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.9
9
-
10
- This entry describes the package contents. Check npm for publication status;
11
- a source version or hosted deployment does not confirm npm delivery.
8
+ ## 0.7.10 — 2026-09-15
9
+
10
+ Source changes following the published 0.7.9 release. The TypeScript SDK remains
11
+ 0.3.1 and Python remains 1.8.1; their runtime source is unchanged.
12
+
13
+ - Persist file-backed run identity before execution so a first-cycle process
14
+ crash cannot discard the idempotency identity. Transport uncertainty is still
15
+ not automatically replayed or recorded as a completed trade.
16
+ - Fix runner startup on Node 18: use the imported Node crypto API instead of
17
+ depending on a global crypto object. The installed-package matrix reproduced
18
+ this failure on Linux, Windows and macOS.
19
+ - Add the supported `@coinrithm/mcp-trading/engine` entry point, preserving
20
+ existing deep imports, and separate observation accounting and opportunity
21
+ reporting from cycle ordering.
22
+ - Add opt-in, machine-checked crypto return predicates and visible warnings for
23
+ inactive/reserved configuration. Existing agents are not automatically opted in.
24
+ - Serialize scheduler migrations in a bounded transaction; add an offline
25
+ credential-rotation helper and interruption/recovery rehearsal.
26
+ - Pin workflow actions and verify release-tool checksums. Add installed-package
27
+ compatibility and restart smoke checks across operating systems and runtimes.
28
+
29
+ Source/CI checks, registry publication and production deployment are separate.
30
+ This entry does not claim that 0.7.10 is published or deployed.
31
+
32
+ ## 0.7.9 - 2026-09-15
33
+
34
+ Published on npm and verified on 2026-09-15: the registry archive matches the
35
+ reviewed artifact and passes fresh-install checks. See the
36
+ [combined release notes](https://github.com/CoinRithm/coinrithm-agent-trading/releases/tag/mcp-trading-v0.7.9)
37
+ for source provenance, verification and community acknowledgments.
12
38
 
13
39
  Public market-data fidelity and runner reliability release. No MCP tool was
14
40
  renamed or removed, and the API **contract stays 1.7.0**.
package/README.md CHANGED
@@ -42,10 +42,12 @@ This package ships two binaries:
42
42
 
43
43
  > **Paper trading only** — virtual funds (50,000 mUSD). Not financial advice.
44
44
 
45
- ## Version 0.7.9
45
+ ## Version 0.7.10
46
46
 
47
- This version includes the market-data fidelity and runner reliability fixes
48
- listed in [CHANGELOG.md](./CHANGELOG.md). Check `npm view @coinrithm/mcp-trading
47
+ This source version adds restart identity persistence, a supported engine
48
+ import, opt-in entry predicates and configuration warnings. It also fixes
49
+ runner startup on Node 18 by importing the Node crypto API explicitly. See
50
+ [CHANGELOG.md](./CHANGELOG.md). Check `npm view @coinrithm/mcp-trading
49
51
  version` for the latest published version. Hosted deployments and npm releases
50
52
  are separate.
51
53
 
@@ -53,6 +55,11 @@ Runner API operations have a 30-second total deadline, including response
53
55
  bodies and 429 retry waits. Timeout and cancellation results remain unconfirmed;
54
56
  the client does not automatically replay an uncertain trading write.
55
57
 
58
+ Embedding the runner? Import from `@coinrithm/mcp-trading/engine` for the
59
+ supported engine and state helpers. Existing `dist/agent/engine.js` imports
60
+ remain compatible. See the [entry conditions and engine guide](https://github.com/CoinRithm/coinrithm-agent-trading/blob/main/docs/agent-runner.md#binding-entry-conditions-and-strategy-prose)
61
+ for the exact opt-in policy and persistence contract.
62
+
56
63
  ## Quick start
57
64
 
58
65
  ```bash
package/dist/agent/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { configurationWarnings } from "./configurationWarnings.js";
1
2
  // coinrithm-agent — the public scaffolder/inspector CLI.
2
3
  //
3
4
  // Authors, validates, ejects, locks, and inspects agent DEFINITIONS. It does
@@ -101,6 +102,7 @@ export function cmdValidate(path, mode = "self-host") {
101
102
  const spec = buildSpec(raw);
102
103
  const lint = [...strictLint(raw), ...checkCapabilityDrift(resolved, spec)];
103
104
  const v = validateSkill({ spec, body: resolved.mergedProse, raw }, mode);
105
+ const warnings = configurationWarnings(raw);
104
106
  // Hosted-only: the managed deploy/edit API caps the merged strategy prose at
105
107
  // HOSTED_PROSE_MAX_CHARS and REVERTS the save when it is exceeded, so a
106
108
  // bundle that resolves and lints perfectly can still be undeployable through
@@ -129,10 +131,16 @@ export function cmdValidate(path, mode = "self-host") {
129
131
  }
130
132
  for (const i of v.issues)
131
133
  lines.push(`✗ ${i.code}: ${i.reason}`);
134
+ lines.push(...warnings.map((warning) => `⚠ ${warning}`));
132
135
  lines.push(...pinWarnings(path));
133
136
  const ok = v.valid && (!lintFatal || lint.length === 0);
134
137
  lines.unshift(ok ? `✓ valid (${mode})` : `✗ invalid (${mode})`);
135
- return { ok, code: ok ? 0 : 1, lines, data: { lint, validation: v } };
138
+ return {
139
+ ok,
140
+ code: ok ? 0 : 1,
141
+ lines,
142
+ data: { lint, validation: v, warnings },
143
+ };
136
144
  }
137
145
  export function cmdLock(path) {
138
146
  const v = cmdValidate(path, "self-host");
@@ -215,6 +223,7 @@ export function cmdInspect(path, json = false) {
215
223
  ];
216
224
  const v = validateSkill({ spec, body: resolved.mergedProse, raw: resolved.rawFrontmatter }, "self-host");
217
225
  const output = {
226
+ warnings: configurationWarnings(resolved.rawFrontmatter),
218
227
  resolvedConfig: resolved.rawFrontmatter,
219
228
  provenance: resolved.provenance,
220
229
  contentHashes: resolved.contentHashes,
@@ -236,6 +245,7 @@ export function cmdInspect(path, json = false) {
236
245
  `risk: maxLeverage=${spec.risk.maxLeverage} perTradeMargin=${spec.risk.perTradeMarginMusd} requireStopLoss=${spec.risk.requireStopLoss}`,
237
246
  `sources: ${Object.keys(resolved.contentHashes).length} file(s)`,
238
247
  `validation: ${v.valid ? "valid" : "INVALID"}${lint.length ? ` (+${lint.length} lint note(s))` : ""}`,
248
+ ...output.warnings.map((warning) => `⚠ ${warning}`),
239
249
  ];
240
250
  return { ok: v.valid, code: 0, lines, data: output };
241
251
  }
@@ -0,0 +1 @@
1
+ export declare function configurationWarnings(raw: Record<string, unknown>): string[];
@@ -0,0 +1,21 @@
1
+ // Advisory only: these fields remain loadable for backwards compatibility.
2
+ export function configurationWarnings(raw) {
3
+ const warnings = [];
4
+ if (Array.isArray(raw.capabilities) &&
5
+ raw.capabilities.includes("websearch")) {
6
+ warnings.push("capabilities.websearch is reserved: the runner does not perform web searches");
7
+ }
8
+ if (raw.abstention && typeof raw.abstention === "object") {
9
+ for (const key of [
10
+ "onStaleData",
11
+ "onWeakSignal",
12
+ "onMissingQuote",
13
+ "onInsufficientBalance",
14
+ ]) {
15
+ if (Object.prototype.hasOwnProperty.call(raw.abstention, key)) {
16
+ warnings.push(`abstention.${key} is inactive: changing it does not change execution; freshness, quote and balance checks remain mandatory`);
17
+ }
18
+ }
19
+ }
20
+ return warnings;
21
+ }
@@ -3,6 +3,7 @@
3
3
  // come from the spec (not the observation or the model), a prompt-injection in
4
4
  // market text cannot widen a limit or force a trade. Covers futures + spot + PM.
5
5
  import { ok, fail, actionVenue, spotBuyCost, } from "./types.js";
6
+ import { checkEntryPredicates } from "./entryPredicates.js";
6
7
  const SERVER_MAX_LEVERAGE = 20;
7
8
  // Minimum edge, in probability POINTS, between the model's own forecast for
8
9
  // the outcome it is backing and what that outcome currently costs. Live
@@ -51,6 +52,9 @@ export function validateAction(action, ctx) {
51
52
  return fail("no_poll_before_write", "must successfully poll /trades before writing");
52
53
  }
53
54
  const increasesRisk = isRiskIncreasingAction(action);
55
+ const entryCondition = checkEntryPredicates(action, spec, observation);
56
+ if (!entryCondition.valid)
57
+ return entryCondition;
54
58
  if (increasesRisk &&
55
59
  ctx.riskIncreasesThisCycle >= spec.limits.maxWritesPerCycle) {
56
60
  return fail("write_budget_exceeded", `maxWritesPerCycle ${spec.limits.maxWritesPerCycle} reached`);
@@ -8,7 +8,7 @@ export { CoinRithmClient } from "./client.js";
8
8
  export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
9
9
  export { resolveAgent } from "./resolve.js";
10
10
  export { validateSkill, type SkillValidationMode } from "./skillValidator.js";
11
- export { newState, rollDay } from "./state.js";
11
+ export { newState, rollDay, loadState, saveState } from "./state.js";
12
12
  export { makeRunId } from "./runEvidence.js";
13
13
  export { parseCadenceMs } from "./util.js";
14
14
  export { BENCHMARK_AGENTS, BENCHMARK_STRATEGIES, decideMechanical, isBenchmarkStrategy, type BenchmarkStrategy, type BenchmarkAgentDefinition, } from "./mechanical.js";
@@ -18,7 +18,7 @@ export { CoinRithmClient } from "./client.js";
18
18
  export { loadAgent, buildSpec } from "./skill.js";
19
19
  export { resolveAgent } from "./resolve.js";
20
20
  export { validateSkill } from "./skillValidator.js";
21
- export { newState, rollDay } from "./state.js";
21
+ export { newState, rollDay, loadState, saveState } from "./state.js";
22
22
  export { makeRunId } from "./runEvidence.js";
23
23
  export { parseCadenceMs } from "./util.js";
24
24
  // Mechanical BENCHMARK baseline agents (sol #7): the deterministic, non-LLM
@@ -0,0 +1,10 @@
1
+ import type { AgentSpec, Observation, ProposedAction, ValidationResult } from "./types.js";
2
+ export interface EntryPredicate {
3
+ side: "long" | "short";
4
+ metric: "change1h" | "change24h";
5
+ operator: "gte" | "lte";
6
+ threshold: number;
7
+ maxAgeSeconds: number;
8
+ }
9
+ export declare function entryPredicateIssues(value: unknown): ValidationResult[];
10
+ export declare function checkEntryPredicates(action: ProposedAction, spec: AgentSpec, observation: Observation): ValidationResult;
@@ -0,0 +1,67 @@
1
+ import { fail, ok } from "./types.js";
2
+ export function entryPredicateIssues(value) {
3
+ if (!Array.isArray(value) || value.length === 0 || value.length > 16)
4
+ return [
5
+ fail("entry_predicate_config", "risk.entryPredicates must contain 1–16 conditions"),
6
+ ];
7
+ const keys = ["side", "metric", "operator", "threshold", "maxAgeSeconds"];
8
+ const issues = [];
9
+ for (const [i, item] of value.entries()) {
10
+ const p = item;
11
+ if (!p ||
12
+ typeof p !== "object" ||
13
+ Array.isArray(p) ||
14
+ Object.keys(p).some((key) => !keys.includes(key)) ||
15
+ !["long", "short"].includes(p.side) ||
16
+ !["change1h", "change24h"].includes(p.metric) ||
17
+ !["gte", "lte"].includes(p.operator) ||
18
+ typeof p.threshold !== "number" ||
19
+ !Number.isFinite(p.threshold) ||
20
+ typeof p.maxAgeSeconds !== "number" ||
21
+ !Number.isFinite(p.maxAgeSeconds) ||
22
+ p.maxAgeSeconds <= 0) {
23
+ issues.push(fail("entry_predicate_config", `risk.entryPredicates[${i}] has an invalid condition`));
24
+ }
25
+ }
26
+ return issues;
27
+ }
28
+ export function checkEntryPredicates(action, spec, observation) {
29
+ const rules = spec.risk.entryPredicates;
30
+ if (rules === undefined)
31
+ return ok();
32
+ if (action.type !== "futures_open" &&
33
+ !(action.type === "spot_order" && action.side === "buy"))
34
+ return ok();
35
+ const invalid = entryPredicateIssues(rules);
36
+ if (invalid.length)
37
+ return invalid[0];
38
+ const side = action.type === "futures_open" ? action.side : "long";
39
+ const applicable = rules.filter((rule) => rule.side === side);
40
+ if (!applicable.length)
41
+ return ok();
42
+ const entry = observation.watch.find((item) => item.symbol.toUpperCase() === action.symbol.toUpperCase());
43
+ const observedAt = Date.parse(observation.asOf);
44
+ const age = entry?.freshness?.ageSeconds;
45
+ for (const rule of applicable) {
46
+ const value = entry?.[rule.metric];
47
+ // Missing evidence never becomes a passing predicate. Include time since
48
+ // observation so a long model call cannot freeze the freshness clock.
49
+ if (entry?.freshness?.status !== "fresh" ||
50
+ typeof age !== "number" ||
51
+ !Number.isFinite(age) ||
52
+ age < 0 ||
53
+ !Number.isFinite(observedAt) ||
54
+ age + Math.max(0, (Date.now() - observedAt) / 1000) >
55
+ rule.maxAgeSeconds ||
56
+ typeof value !== "number" ||
57
+ !Number.isFinite(value)) {
58
+ return fail("entry_predicate_evidence", `fresh ${rule.metric} evidence required for ${action.symbol}`);
59
+ }
60
+ if (!(rule.operator === "gte"
61
+ ? value >= rule.threshold
62
+ : value <= rule.threshold)) {
63
+ return fail("entry_predicate_false", `${action.symbol} ${rule.metric}=${value} does not satisfy ${rule.operator} ${rule.threshold}`);
64
+ }
65
+ }
66
+ return ok();
67
+ }
@@ -0,0 +1,16 @@
1
+ import type { CoinRithmClient, ProvenanceReport } from "./client.js";
2
+ import type { AgentSpec, AgentTrace, PostedOpportunity } from "./types.js";
3
+ export declare function createOpportunityReporter({ client, spec, live, enabled, runId, decisionId, provenance, baseTrace, log, }: {
4
+ client: CoinRithmClient;
5
+ spec: AgentSpec;
6
+ live: boolean;
7
+ enabled: boolean;
8
+ runId: string;
9
+ decisionId: string;
10
+ provenance: ProvenanceReport;
11
+ baseTrace: AgentTrace;
12
+ log: (line: string) => void;
13
+ }): {
14
+ post: (o: PostedOpportunity) => Promise<void>;
15
+ readonly posted: PostedOpportunity | undefined;
16
+ };
@@ -0,0 +1,38 @@
1
+ export function createOpportunityReporter({ client, spec, live, enabled, runId, decisionId, provenance, baseTrace, log, }) {
2
+ let opportunityPosted = false;
3
+ let postedOpportunity;
4
+ const post = async (o) => {
5
+ if (!enabled || !live || opportunityPosted)
6
+ return;
7
+ opportunityPosted = true;
8
+ postedOpportunity = o;
9
+ try {
10
+ await client.reportPmOpportunity({
11
+ kind: o.kind,
12
+ source: o.source,
13
+ slug: o.slug,
14
+ outcomeExternalMarketId: o.outcomeExternalMarketId,
15
+ forecastProbability: o.forecastProbability,
16
+ marketProbability: o.marketProbability,
17
+ reasonCode: o.reasonCode,
18
+ cohort: {
19
+ universeSize: o.universeSize,
20
+ horizon: spec.objective?.horizon,
21
+ },
22
+ decisionId,
23
+ runId,
24
+ provenance,
25
+ }, baseTrace);
26
+ log(`reported ${o.kind} opportunity (universe ${o.universeSize ?? "?"})`);
27
+ }
28
+ catch (err) {
29
+ log(`opportunity post failed: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ };
32
+ return {
33
+ post,
34
+ get posted() {
35
+ return postedOpportunity;
36
+ },
37
+ };
38
+ }
@@ -117,6 +117,11 @@ opts = {}) {
117
117
  : '- DIRECTION: LONG ONLY — every futures_open MUST be side:"long". A short is REJECTED by the runner no matter how strong the setup looks.',
118
118
  ]
119
119
  : []),
120
+ ...(r.entryPredicates
121
+ ? [
122
+ `- Binding crypto entry conditions (all conditions matching the entry side must pass on fresh observed data): ${JSON.stringify(r.entryPredicates)}. Missing evidence is a rejection. These conditions never authorize exceeding another cap.`,
123
+ ]
124
+ : []),
120
125
  // With universe_scan, the validator's gate is WATCH-membership (manual
121
126
  // watchlist ∪ this cycle's discovered entries) — saying "ONLY these" here
122
127
  // while the universe-scan section below calls discovered movers tradable
@@ -0,0 +1,2 @@
1
+ import type { RunState, Observation } from "./types.js";
2
+ export declare function reconcileObservation(state: RunState, observation: Observation): void;
@@ -0,0 +1,33 @@
1
+ import { accrueRealized } from "./state.js";
2
+ import { asObj, asNum, asStr } from "./extract.js";
3
+ export function reconcileObservation(state, observation) {
4
+ accrueRealized(state, observation.newClosedTrades);
5
+ state.cursor = observation.syncCursor;
6
+ for (const t of observation.newClosedTrades) {
7
+ state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
8
+ }
9
+ state.seen = state.seen.slice(-500);
10
+ // Slice-3 reflection: journal closed-trade OUTCOMES (not just opens) so the agent
11
+ // remembers how its theses RESOLVED — a stop-out it should not revenge-trade, a
12
+ // winner its style works on. Defensive field reads (the /trades shape varies);
13
+ // a partial entry is harmless, a missing one is skipped.
14
+ for (const t of observation.newClosedTrades.slice(-5)) {
15
+ const o = asObj(t);
16
+ const sym = asStr(o.symbol) ?? asStr(o.coinSymbol) ?? asStr(o.coinId);
17
+ const pnl = asNum(o.realizedPnlMusd) ??
18
+ asNum(o.pnlMusd) ??
19
+ asNum(o.realizedPnl) ??
20
+ asNum(o.pnl);
21
+ const side = asStr(o.side);
22
+ if (sym || pnl != null) {
23
+ const did = `closed ${side ?? ""} ${sym ?? "position"}`.trim() +
24
+ (pnl != null
25
+ ? `: ${pnl >= 0 ? "+" : ""}${Math.round(pnl)}mUSD ${pnl >= 0 ? "WIN" : "LOSS"}`
26
+ : "");
27
+ state.journal = [
28
+ ...(state.journal ?? []),
29
+ { at: observation.asOf, did },
30
+ ].slice(-12);
31
+ }
32
+ }
33
+ }
@@ -8,6 +8,8 @@ import { usesCapitalSizing, prepareCapitalAction, validateCapitalAction, capital
8
8
  import { decideMechanical } from "./mechanical.js";
9
9
  import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
10
10
  import { baseSymbol, scanSetups } from "./setups.js";
11
+ import { reconcileObservation } from "./reconcileObservation.js";
12
+ import { createOpportunityReporter } from "./opportunityReporter.js";
11
13
  import { observe } from "./observe.js";
12
14
  import { buildDailyRiskBudget, buildSystemPrompt, buildUserPrompt, } from "./prompt.js";
13
15
  import { parseDecision } from "./decision.js";
@@ -15,7 +17,7 @@ import { validateAction, isRiskIncreasingAction, } from "./decisionValidator.js"
15
17
  import { resolvePmRef } from "./resolvePm.js";
16
18
  import { fetchQuote, executeAction } from "./act.js";
17
19
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
18
- import { rollDay, checkKillSwitch, accrueRealized, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
20
+ import { rollDay, checkKillSwitch, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
19
21
  import { asObj, asNum, asStr } from "./extract.js";
20
22
  import { parseCadenceMs, sleep } from "./util.js";
21
23
  import { buildObservationReceipt } from "./observationReceipt.js";
@@ -441,43 +443,22 @@ async function runCycleCore(deps, capture) {
441
443
  live,
442
444
  };
443
445
  }
446
+ // Establish durable run identity before the first possible write. A process
447
+ // killed before its first response must not restart with a new idempotency key.
448
+ if (live)
449
+ saveState(stateFile, state);
444
450
  const baseTrace = makeTrace(runId, decisionId, spec);
445
- // Opportunity capture (kills evaluation selection bias). Post at most ONE
446
- // non-opened opportunity per cycle, LIVE only (dry-run never writes), best-effort
447
- // — a failed post never affects the cycle result. The latch is set BEFORE the
448
- // await so a failure never retries within the cycle (respects the write budget);
449
- // the cohort/universe field carries the breadth, so we never post per-market.
450
- const captureOpportunity = agentOpportunityCaptureEnabled();
451
- let opportunityPosted = false;
452
- let postedOpportunity;
453
- const postOpportunity = async (o) => {
454
- if (!captureOpportunity || !live || opportunityPosted)
455
- return;
456
- opportunityPosted = true;
457
- postedOpportunity = o;
458
- try {
459
- await client.reportPmOpportunity({
460
- kind: o.kind,
461
- source: o.source,
462
- slug: o.slug,
463
- outcomeExternalMarketId: o.outcomeExternalMarketId,
464
- forecastProbability: o.forecastProbability,
465
- marketProbability: o.marketProbability,
466
- reasonCode: o.reasonCode,
467
- cohort: {
468
- universeSize: o.universeSize,
469
- horizon: spec.objective?.horizon,
470
- },
471
- decisionId,
472
- runId,
473
- provenance,
474
- }, baseTrace);
475
- log(`reported ${o.kind} opportunity (universe ${o.universeSize ?? "?"})`);
476
- }
477
- catch (err) {
478
- log(`opportunity post failed: ${err instanceof Error ? err.message : String(err)}`);
479
- }
480
- };
451
+ const opportunities = createOpportunityReporter({
452
+ client,
453
+ spec,
454
+ live,
455
+ enabled: agentOpportunityCaptureEnabled(),
456
+ runId,
457
+ decisionId,
458
+ provenance,
459
+ baseTrace,
460
+ log,
461
+ });
481
462
  // OBSERVE
482
463
  const obs = await observe(client, spec, state, baseTrace);
483
464
  const observation = obs.observation;
@@ -496,35 +477,7 @@ async function runCycleCore(deps, capture) {
496
477
  // Reads build the observation, so its hash cannot exist before they finish.
497
478
  // From this point every durable write carries the exact decision-input receipt.
498
479
  Object.assign(baseTrace, observationReceipt);
499
- accrueRealized(state, observation.newClosedTrades);
500
- state.cursor = observation.syncCursor;
501
- for (const t of observation.newClosedTrades) {
502
- state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
503
- }
504
- state.seen = state.seen.slice(-500);
505
- // Slice-3 reflection: journal closed-trade OUTCOMES (not just opens) so the agent
506
- // remembers how its theses RESOLVED — a stop-out it should not revenge-trade, a
507
- // winner its style works on. Defensive field reads (the /trades shape varies);
508
- // a partial entry is harmless, a missing one is skipped.
509
- for (const t of observation.newClosedTrades.slice(-5)) {
510
- const o = asObj(t);
511
- const sym = asStr(o.symbol) ?? asStr(o.coinSymbol) ?? asStr(o.coinId);
512
- const pnl = asNum(o.realizedPnlMusd) ??
513
- asNum(o.pnlMusd) ??
514
- asNum(o.realizedPnl) ??
515
- asNum(o.pnl);
516
- const side = asStr(o.side);
517
- if (sym || pnl != null) {
518
- const did = `closed ${side ?? ""} ${sym ?? "position"}`.trim() +
519
- (pnl != null
520
- ? `: ${pnl >= 0 ? "+" : ""}${Math.round(pnl)}mUSD ${pnl >= 0 ? "WIN" : "LOSS"}`
521
- : "");
522
- state.journal = [
523
- ...(state.journal ?? []),
524
- { at: observation.asOf, did },
525
- ].slice(-12);
526
- }
527
- }
480
+ reconcileObservation(state, observation);
528
481
  capture({
529
482
  ...captureBase,
530
483
  phase: "observed",
@@ -897,7 +850,7 @@ async function runCycleCore(deps, capture) {
897
850
  // cohort via universeSize.
898
851
  const skipOpp = buildSkipOpportunity(decision, observation.pmMarkets, forecastEnabled);
899
852
  if (skipOpp)
900
- await postOpportunity(skipOpp);
853
+ await opportunities.post(skipOpp);
901
854
  saveState(stateFile, state);
902
855
  log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
903
856
  return {
@@ -912,7 +865,7 @@ async function runCycleCore(deps, capture) {
912
865
  decisionType: "skip",
913
866
  writeAttempted: decision.actions.length,
914
867
  writeAccepted: 0,
915
- ...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
868
+ ...(opportunities.posted ? { opportunity: opportunities.posted } : {}),
916
869
  ...observationReceipt,
917
870
  };
918
871
  }
@@ -1265,7 +1218,7 @@ async function runCycleCore(deps, capture) {
1265
1218
  // (once-per-cycle; carries the universe breadth in the cohort field).
1266
1219
  if (action.type === "pm_open" && isQuoteExpiredResult(r.status, r.data)) {
1267
1220
  const pm = action;
1268
- await postOpportunity({
1221
+ await opportunities.post({
1269
1222
  kind: "quote_expired",
1270
1223
  source: pm.source,
1271
1224
  slug: pm.slug,
@@ -1322,7 +1275,7 @@ async function runCycleCore(deps, capture) {
1322
1275
  decisionType: "act",
1323
1276
  writeAttempted: decision.actions.length,
1324
1277
  writeAccepted: planned.filter((p) => p.accepted).length,
1325
- ...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
1278
+ ...(opportunities.posted ? { opportunity: opportunities.posted } : {}),
1326
1279
  ...observationReceipt,
1327
1280
  };
1328
1281
  }
@@ -103,6 +103,12 @@ export function buildSpec(raw) {
103
103
  ? { capitalSizing: buildCapitalSizing(raw.capitalSizing) }
104
104
  : {}),
105
105
  risk: {
106
+ // Preserve malformed explicit policies for fail-closed validation.
107
+ ...(risk.entryPredicates !== undefined
108
+ ? {
109
+ entryPredicates: risk.entryPredicates,
110
+ }
111
+ : {}),
106
112
  maxLeverage: num(risk.maxLeverage, 1),
107
113
  perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
108
114
  maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
@@ -1,5 +1,6 @@
1
1
  import { fail, VENUES, PROVIDERS, SPEC_VERSION, OBJECTIVE_PRIMARIES, ALLOWED_CAPABILITIES, } from "./types.js";
2
2
  import { parseCadenceMs, scanForSecrets } from "./util.js";
3
+ import { entryPredicateIssues } from "./entryPredicates.js";
3
4
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
5
  const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5
6
  // maxTradesPerDay accepts 0 as the explicit "unlimited daily trades" sentinel.
@@ -93,6 +94,8 @@ export function validateSkill(parsed, mode = "self-host") {
93
94
  }
94
95
  else {
95
96
  const r = raw.risk;
97
+ if (r.entryPredicates !== undefined)
98
+ issues.push(...entryPredicateIssues(r.entryPredicates));
96
99
  if (!isPosNum(r.maxLeverage))
97
100
  add("skill_risk_leverage", "risk.maxLeverage must be a positive number");
98
101
  else if (r.maxLeverage > 20)
@@ -49,6 +49,7 @@ const ALLOWED_KEYS = {
49
49
  "watchlist",
50
50
  "blocklist",
51
51
  "direction",
52
+ "entryPredicates",
52
53
  ],
53
54
  sizing: null,
54
55
  capitalSizing: [
@@ -50,37 +50,37 @@ const PRESETS = {
50
50
  modelFail: 5,
51
51
  },
52
52
  };
53
- const THESIS_BODY = `# Momentum Futures — strategy
54
-
55
- You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
- Everything here is simulated; it is not financial advice and never touches real
57
- money. Edit this prose freely (any language) — it is your agent's borders.
58
-
59
- ## Each cycle
60
-
61
- 1. Ground yourself: read your portfolio and open positions first. Never assume
62
- balances or what is already open.
63
- 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
- agree (both up, or both down) and is not already an open position.
65
- 3. Pick the strongest candidate and commit when the read is clear — even a
66
- moderate-confidence one — sized small with a stop. Skip only when the signals
67
- contradict or the data is stale; a quiet tape where your edge is still real
68
- is an act, not a skip.
69
- 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
- the quote is not eligible, relay the reason and stop.
71
- 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
- open. Place the take-profit a touch wider than the stop.
73
- 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
- fired while you were not looking, and react to what actually happened.
75
-
76
- The hard caps (leverage, margin, watchlist) live in the config blocks above and
53
+ const THESIS_BODY = `# Momentum Futures — strategy
54
+
55
+ You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
+ Everything here is simulated; it is not financial advice and never touches real
57
+ money. Edit this prose freely (any language) — it is your agent's borders.
58
+
59
+ ## Each cycle
60
+
61
+ 1. Ground yourself: read your portfolio and open positions first. Never assume
62
+ balances or what is already open.
63
+ 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
+ agree (both up, or both down) and is not already an open position.
65
+ 3. Pick the strongest candidate and commit when the read is clear — even a
66
+ moderate-confidence one — sized small with a stop. Skip only when the signals
67
+ contradict or the data is stale; a quiet tape where your edge is still real
68
+ is an act, not a skip.
69
+ 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
+ the quote is not eligible, relay the reason and stop.
71
+ 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
+ open. Place the take-profit a touch wider than the stop.
73
+ 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
+ fired while you were not looking, and react to what actually happened.
75
+
76
+ The hard caps (leverage, margin, watchlist) live in the config blocks above and
77
77
  are enforced by the runner — change them there, not in this prose.`;
78
- const PERSONA_STUB = `# Persona
79
-
80
- Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
- — sized small and protected with a stop, and skips only when the read is
82
- contradictory or the data is stale. States its reasoning plainly in its own
83
- voice, and never frames paper results as real-money advice.
78
+ const PERSONA_STUB = `# Persona
79
+
80
+ Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
+ — sized small and protected with a stop, and skips only when the read is
82
+ contradictory or the data is stale. States its reasoning plainly in its own
83
+ voice, and never frames paper results as real-money advice.
84
84
  `;
85
85
  export function buildAgentObject(name, preset) {
86
86
  const p = PRESETS[preset];
@@ -26,6 +26,7 @@ export interface TriggerPolicy {
26
26
  }
27
27
  export declare const DEFAULT_TRIGGER_POLICY: TriggerPolicy;
28
28
  export interface RiskConfig {
29
+ entryPredicates?: import("./entryPredicates.js").EntryPredicate[];
29
30
  maxLeverage: number;
30
31
  perTradeMarginMusd: number;
31
32
  maxConcurrentPositions: number;
@@ -1,5 +1,5 @@
1
1
  // Small dependency-free helpers shared across the runner.
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { resolve as resolvePath, relative as relativePath, isAbsolute, } from "node:path";
4
4
  export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
5
  // Parse a cadence string ("30s", "15m", "1h", "4h", "1d") to milliseconds.
@@ -63,8 +63,8 @@ export function scanForSecrets(value, pathPrefix = "", findings = []) {
63
63
  }
64
64
  // Short random id (for runId/decisionId/idempotencyKey suffixes).
65
65
  export function shortId() {
66
- // randomUUID is available in Node 18+ (globalThis.crypto).
67
- return crypto.randomUUID().slice(0, 8);
66
+ // Import the Node API: globalThis.crypto is not enabled by default in Node 18.
67
+ return randomUUID().slice(0, 8);
68
68
  }
69
69
  // ── Resolver helpers (cross-platform-deterministic) ──────────────────────────
70
70
  // Normalize file content for hashing: CRLF/CR -> LF, strip trailing whitespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinrithm/mcp-trading",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
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",
@@ -84,5 +84,14 @@
84
84
  "typescript": "^5.5.0",
85
85
  "typescript-eslint": "^8.11.0",
86
86
  "vitest": "4.1.11"
87
+ },
88
+ "exports": {
89
+ ".": "./dist/index.js",
90
+ "./engine": {
91
+ "types": "./dist/agent/engine.d.ts",
92
+ "import": "./dist/agent/engine.js"
93
+ },
94
+ "./package.json": "./package.json",
95
+ "./dist/*": "./dist/*"
87
96
  }
88
97
  }