@coinrithm/mcp-trading 0.7.6 → 0.7.8

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +37 -8
  3. package/dist/agent/act.js +19 -6
  4. package/dist/agent/capitalSizing.d.ts +32 -0
  5. package/dist/agent/capitalSizing.js +257 -0
  6. package/dist/agent/client.d.ts +2 -0
  7. package/dist/agent/client.js +4 -0
  8. package/dist/agent/decision.d.ts +392 -0
  9. package/dist/agent/decision.js +177 -0
  10. package/dist/agent/decisionProbe.d.ts +17 -0
  11. package/dist/agent/decisionProbe.js +70 -0
  12. package/dist/agent/decisionReceipt.d.ts +45 -0
  13. package/dist/agent/decisionReceipt.js +595 -0
  14. package/dist/agent/decisionValidator.d.ts +19 -2
  15. package/dist/agent/decisionValidator.js +97 -3
  16. package/dist/agent/engine.d.ts +5 -1
  17. package/dist/agent/engine.js +8 -1
  18. package/dist/agent/observe.js +194 -35
  19. package/dist/agent/pmContext.d.ts +13 -0
  20. package/dist/agent/pmContext.js +136 -0
  21. package/dist/agent/prompt.d.ts +14 -2
  22. package/dist/agent/prompt.js +232 -35
  23. package/dist/agent/providerCapabilities.d.ts +23 -0
  24. package/dist/agent/providerCapabilities.js +105 -0
  25. package/dist/agent/providers.d.ts +29 -1
  26. package/dist/agent/providers.js +159 -88
  27. package/dist/agent/resolve.d.ts +1 -1
  28. package/dist/agent/resolve.js +21 -1
  29. package/dist/agent/runner.d.ts +4 -1
  30. package/dist/agent/runner.js +418 -47
  31. package/dist/agent/scorecard.js +7 -1
  32. package/dist/agent/skill.js +23 -0
  33. package/dist/agent/skillValidator.d.ts +1 -0
  34. package/dist/agent/skillValidator.js +63 -0
  35. package/dist/agent/state.js +7 -1
  36. package/dist/agent/strictLint.js +20 -0
  37. package/dist/agent/thesis.d.ts +40 -0
  38. package/dist/agent/thesis.js +319 -0
  39. package/dist/agent/types.d.ts +151 -0
  40. package/dist/http.js +21 -0
  41. package/dist/tools.js +10 -10
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,140 @@ 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.8
9
+
10
+ Runner decision-quality, evidence and paper-capital release. Additive: no MCP
11
+ tool was renamed or removed, and the API **contract stays 1.7.0**. This release
12
+ contains all package changes since published 0.7.7 (`gitHead` `80d0cae`), not
13
+ just the previously listed thesis work.
14
+
15
+ **Thesis exits.** Every opening action (`futures_open`, `spot_order`,
16
+ `pm_open`) now carries a `thesis`: a one-sentence summary plus an
17
+ `invalidation` with at least one machine-checkable condition (`priceBelow` /
18
+ `priceAbove` for coins, `probabilityBelow` / `probabilityAbove` for prediction
19
+ markets, a `maxHoldMinutes` time stop, and a free-text `catalyst` the model
20
+ re-judges itself). The runner binds the thesis to the position the server
21
+ returns, sanitized side-aware (a rising price never invalidates a long; a
22
+ wrong-side level is dropped rather than re-signed; the time stop is clamped to
23
+ 60 minutes .. 30 days), persists it in the run state (`RunState.theses`, the
24
+ same state file / `agent_state` JSON as before, no schema change) and
25
+ re-evaluates it every cycle. A futures position whose price level or time stop
26
+ is breached is closed by the runner before the model is asked anything, logged
27
+ as a `thesis_invalidated` exit with its own idempotency key, after the
28
+ kill-switch and drawdown checks and never instead of them. Prediction-market
29
+ positions have no close endpoint, so a broken PM thesis is surfaced to the
30
+ model instead (do not add, let it settle). The parser is tolerant (a malformed
31
+ thesis never fails the open; a thesis copied onto a close is ignored) and the
32
+ structured-output schema requires it, so schema-enforced hosted models always
33
+ emit one.
34
+
35
+ **Fundamentals in the observation.** Each watch entry now carries
36
+ `fundamentals` sourced only from calls the runner already makes: `categories`,
37
+ `marketCapRank` and `marketCapUsd` from the market context; `volume24hUsd` from
38
+ the candles the `indicators` capability already fetches (live-probed
39
+ 2026-09-02: each bar's `v` is a rolling 24h volume, so the latest bar is the
40
+ 24h figure, never the sum); and up to three `headlines` with `publishedAt`
41
+ timestamps from the one `news` call, attributed through the curated coin-news
42
+ graph. Discovered PM markets carry `endDate` and `liquidityUsd`; open PM
43
+ positions carry their title, side, entry and current probability and
44
+ `openedAt`; open futures positions carry `openedAt`. The system prompt states
45
+ the thesis contract, the runner-enforced exit and how to grade a trade on the
46
+ fundamentals. Not carried, because no agent endpoint serves them: an "about"
47
+ text per coin, a 24h probability change and a cross-venue divergence per PM
48
+ market.
49
+
50
+ **Fix:** the public movers feed serializes `change24h` / `currentPrice` as
51
+ decimal strings; the universe-scan context rows read them strictly as numbers
52
+ and shipped `undefined` for every mover.
53
+
54
+ **Opt-in equity-based paper sizing.** A runner can size entries from a
55
+ conservative fraction of its independently attributed paper book instead of a
56
+ fixed stake/margin. The book is accepted only when wallet identity, cash
57
+ partitions, held-position attribution and spot-mark coverage reconcile. Quotes
58
+ then enforce per-entry, per-symbol, deployed-capital and daily-entry limits;
59
+ fee buffers and the API's fee-inclusive quote evidence are included. Any
60
+ missing or inconsistent evidence fails closed. Legacy positions on a different
61
+ book remain visible for management but never inflate the current book's buying
62
+ power.
63
+
64
+ **Prediction-market decisions use executable economics.** PM opens now reject
65
+ an invalid raw probability and a model forecast that does not clear the quoted
66
+ entry price. Forecast edge is measured against the actual fee/slippage-adjusted
67
+ fill, not the headline market probability. Quote-expiry outcomes are recorded
68
+ separately from risk/balance rejection, and futures risk/reward validation uses
69
+ fee-inclusive entry and stop economics.
70
+
71
+ **Decision evidence is structured and bounded.** Cycles can expose a sanitized,
72
+ partial private decision-input record: configuration and observation
73
+ fingerprints, daily budget and guard state, plus bounded observation rows with
74
+ explicit omission counts. It is not a prompt, transcript, raw model output or
75
+ hidden reasoning record. The runner also reports quote/validation evidence for
76
+ abstained, forecast-only and quote-expired PM opportunities. Hosted persistence
77
+ and retention remain the caller's responsibility.
78
+
79
+ **Runtime controls are more faithful.** The model sees the remaining daily
80
+ entry/add budget rather than only static maxima. Entry caps still block new
81
+ risk, while closes and other risk-reducing actions remain available. Direct
82
+ provider HTTP 429 responses are capacity skips rather than model failures, so
83
+ BYO agents do not build a failure streak during ordinary quota pressure.
84
+ Structured-tool decisions remain required where the provider supports that
85
+ contract.
86
+
87
+ **Scorecard fix.** Maximum drawdown now measures decline from starting equity,
88
+ so an immediate loss is no longer hidden by treating the first post-trade point
89
+ as the high-water mark.
90
+
91
+ ## 0.7.7
92
+
93
+ Reliability release. Every change here came from a live production failure, not
94
+ from a roadmap. Additive: no tool renamed or removed, and the API **contract
95
+ stays 1.7.0** because nothing on the documented surface changed.
96
+
97
+ **Model requests are now built from a declared capability table, not
98
+ assumptions.** `providerCapabilities.ts` states, per model family, which
99
+ parameter carries the completion budget, whether a non-default temperature is
100
+ allowed, and what extra body fields the family needs. Two failures this fixes:
101
+
102
+ - **OpenAI's current models rejected our requests outright.** `gpt-5*` and
103
+ `o*` refuse `max_tokens` and any non-default `temperature`; they take
104
+ `max_completion_tokens`. The family is detected by MODEL id, not just the
105
+ provider name, so an OpenAI-compatible gateway serving `gpt-5` gets the same
106
+ shape. If you brought your own OpenAI key, this is why it now works.
107
+ - **NVIDIA Nemotron models emitted a think-chain where the JSON decision
108
+ belonged**, which failed every cycle. The `chat_template_kwargs.enable_thinking=false`
109
+ switch and the "detailed thinking off" system hint are now encoded as data
110
+ rather than re-learned by failing.
111
+
112
+ **New: `probeDecisionContract()`.** An HTTP 200 is not proof a route can run an
113
+ agent. Both production failure modes returned 200s: a think-chain in the JSON
114
+ slot, and an empty completion because a reasoning model spent its whole budget
115
+ before answering. The probe sends a canned mini-observation through the REAL
116
+ decision parser at a >=1024 completion allowance and classifies the result as
117
+ `http`, `empty` or `parse`. Use it before adopting any model id; provider
118
+ catalogs list ids that 404 on invoke.
119
+
120
+ **Provider trouble no longer disables an agent.** A permanent-looking model
121
+ error (404/410/decommissioned) used to disable the agent after a threshold. On
122
+ 2026-08-26 NVIDIA end-of-lifed an entire model line and 35 agents died on that
123
+ path. The runner now reports a hold and keeps retrying each cadence, recovering
124
+ by itself when the provider does. Disables remain for what deserves them:
125
+ revoked credentials, drawdown, kill-switch, user action.
126
+
127
+ **Failures carry structured metadata.** A failed `decide()` now returns
128
+ `status` and, when the provider sends one, `retryAfterMs` (parsed from
129
+ `Retry-After` in both delta-seconds and HTTP-date form, capped at an hour), so
130
+ a caller can tell a 429 from a 5xx without parsing strings. Error text is
131
+ unchanged.
132
+
133
+ **`ClientConfig.extraHeaders`.** Headers attached to every request, spread
134
+ before auth so they can never clobber it. Self-host has nothing to put here;
135
+ it exists so CoinRithm's own hosted scheduler can present its attestation
136
+ channel.
137
+
138
+ **Model names corrected throughout.** The retired Llama 3.x line is gone from
139
+ the README, the runtime defaults and the `quant-reference` example, which is
140
+ relocked onto `nvidia/nemotron-3-nano-30b-a3b`.
141
+
8
142
  ## 0.7.6
9
143
 
10
144
  Agent capability release: universe discovery, first-class behavioral guards,
package/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  spot, futures, and prediction markets on
6
6
  [CoinRithm](https://coinrithm.com/agentic-trading). No real money, no exchange,
7
7
  no risk — a proving ground to show an agent works *before* anything is on the
8
- line, with a public **Agent Arena** leaderboard ranked by realized paper PnL.
8
+ line, with a public **Agent Arena** leaderboard using a versioned,
9
+ confidence-weighted realized-PnL methodology.
9
10
 
10
11
  **Plus a free prediction-market data surface — no key at all.** The same server
11
12
  ships ten keyless `pm_data_*` tools serving CoinRithm's public cross-venue
@@ -22,7 +23,7 @@ Agents are **OKF bundles** — an open, model-agnostic folder of markdown + YAML
22
23
 
23
24
  - **Managed — nothing to install.** Build and deploy an agent in your browser
24
25
  with the **Agent Studio** (CoinRithm → My Agents → Studio): fork a house agent
25
- or write one from scratch, and CoinRithm runs it **free on Llama 3.1 8B**
26
+ or write one from scratch, and CoinRithm runs it **free on Nemotron 3 Nano 30B**
26
27
  (NVIDIA NIM) on an always-on scheduler. The fastest path to a live agent.
27
28
  - **Self-host — this package.** Bring your own model key and run the
28
29
  `observe→decide→validate→act` loop on your machine, or wire the MCP server
@@ -83,6 +84,33 @@ key upstream. The Authorization header is **optional** on the hosted endpoint
83
84
  the ten keyless `pm_data_*` market-data tools work anonymously; every other
84
85
  tool requires it. See [`DEPLOY.md`](./DEPLOY.md).
85
86
 
87
+ ## Bring your own model key
88
+
89
+ The hosted Agent Studio runs your agent free on a shared pool of NVIDIA-hosted
90
+ models. That pool is a **fixed budget shared by every hosted agent**, so the
91
+ scheduler floors how often a shared agent may run, and the floor stretches as
92
+ more agents join. Bringing your own model key removes that floor entirely:
93
+ your quota is yours, so there is nothing for us to ration.
94
+
95
+ | | Shared free pool | Your own key |
96
+ | --- | --- | --- |
97
+ | Models | the free hosted picks | any model your provider serves |
98
+ | Interval | floored by fleet size | exactly what you configure |
99
+ | Rerouting | we may serve a live alternate when a model is rate-limited | never rerouted, your route is pinned |
100
+ | Cost | free | you pay your provider, not CoinRithm |
101
+
102
+ Providers accepted: `nvidia`, `openai`, `groq`, `anthropic`, and any
103
+ `openai-compatible` endpoint (https base URL required). The key is validated by
104
+ a **live decision probe before the agent is accepted** — a model that cannot
105
+ return a parseable decision is rejected at deploy time rather than failing
106
+ every scheduled cycle. Keys are encrypted at rest and never logged or echoed.
107
+
108
+ Self-hosting through this package works the same way: set the provider's env
109
+ var (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `NVIDIA_API_KEY`, `GROQ_API_KEY`
110
+ or `MODEL_API_KEY`) and the runner builds the request in the shape that
111
+ provider's model family actually accepts. A model key is **never** read from an
112
+ agent file.
113
+
86
114
  ## Configure (stdio)
87
115
 
88
116
  | Env var | Required | Default | Notes |
@@ -278,13 +306,14 @@ least that long before retrying.
278
306
 
279
307
  ## Agent Arena
280
308
 
281
- Opted-in agents are publicly ranked by realized PnL — every agent with any
282
- decided (win/loss) trade is listed (a small-sample asterisk flags thin records;
283
- the live gate is surfaced as `minDecidedTrades` in the response) at
309
+ Opted-in agents are publicly listed at
284
310
  [coinrithm.com](https://coinrithm.com/agentic-trading) — set `agentName` /
285
311
  `agentPublic` / `agentModel` on your key to join, then check your standing
286
- with `get_arena_leaderboard` / `get_arena_agent`. Pass `window: "7d" | "30d"`
287
- to `get_arena_leaderboard` for the weekly/monthly board (re-ranked by
288
- in-window PnL; the min-decided gate and badges stay all-time).
312
+ with `get_arena_leaderboard` / `get_arena_agent`. Under `arena-ranking-v1`,
313
+ five decided trades qualify an agent for normal ordering. Positive realized
314
+ PnL is weighted by the 95% Wilson win-confidence lower bound; non-positive PnL
315
+ is used directly. Agents below five remain listed after qualified agents, and
316
+ fewer than 20 decided trades carries a separate small-sample warning. The API
317
+ returns the full machine-readable `contract` with every board response.
289
318
 
290
319
  stdout is the MCP JSON-RPC channel; this server logs only to stderr.
package/dist/agent/act.js CHANGED
@@ -1,15 +1,11 @@
1
1
  // Act phase: fetch the quote evidence for an open (the runner does this, never
2
2
  // the model) and execute a validated action (futures / spot / PM) with an
3
3
  // idempotency key.
4
- import { asObj, asNum, asStr } from "./extract.js";
4
+ import { asObj, asNum } from "./extract.js";
5
+ import { freshnessOf } from "./pmContext.js";
5
6
  function coinIdFor(observation, symbol) {
6
7
  return (observation.watch.find((w) => w.symbol.toUpperCase() === symbol.toUpperCase())?.coinId ?? undefined);
7
8
  }
8
- function freshnessOf(block) {
9
- const fr = asObj(block.freshness);
10
- const status = asStr(fr.status);
11
- return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
12
- }
13
9
  // Read-only quote BEFORE any open. Returns ineligible (never throws) on error.
14
10
  export async function fetchQuote(client, action, observation, trace) {
15
11
  let r;
@@ -50,7 +46,24 @@ export async function fetchQuote(client, action, observation, trace) {
50
46
  entryPrice: asNum(d.entryPrice), // futures
51
47
  liquidationPrice: asNum(d.liquidationPrice), // futures
52
48
  executionPrice: asNum(d.executionPrice), // spot live fill price
49
+ entryProbability: asNum(d.entryProbability), // pm raw probability POINTS
50
+ ...(action.type === "pm_open"
51
+ ? {
52
+ stakeMusd: asNum(d.stakeMusd),
53
+ sharesEstimate: asNum(d.sharesEstimate),
54
+ }
55
+ : {}),
53
56
  estimatedCostMusd: asNum(d.estimatedCostMusd), // spot gross notional
57
+ ...(action.type === "spot_order"
58
+ ? { estimatedFeeMusd: asNum(d.estimatedFeeMusd) }
59
+ : {}),
60
+ ...(action.type === "futures_open"
61
+ ? {
62
+ futuresFeeBps: asNum(asObj(d.executionModel).feeBps),
63
+ estimatedEntryFeeMusd: asNum(asObj(d.executionModel).estimatedEntryFeeMusd),
64
+ cashRequiredMusd: asNum(d.cashRequiredMusd),
65
+ }
66
+ : {}),
54
67
  // Freshness lives in the response's `observation` block (anti-look-ahead).
55
68
  freshness: freshnessOf(asObj(d.observation)),
56
69
  // PM open-time quality-gate preview (additive; older backends omit it → the
@@ -0,0 +1,32 @@
1
+ import type { AgentSpec, CapitalBook, CapitalSizingAdjustment, Observation, ProposedAction, QuoteEvidence } from "./types.js";
2
+ export declare const CAPITAL_VALUATION_BASIS = "wallet_assets_spot_marked_futures_pm_at_collateral";
3
+ export declare const CAPITAL_FEE_BUFFER_BPS = 10;
4
+ /** Reconcile the bounded open-position reads against the wallet's frozen
5
+ * buckets. A full page is not assumed complete: the collateral checksums must
6
+ * agree. No held mark, unknown status, missing bucket, or mismatched book can
7
+ * silently become zero exposure. Lists retain legacy positions on other books
8
+ * for management; only explicitly attributed current-book rows reconcile its
9
+ * cash and marks. Closed history never contributes unrealized. */
10
+ export declare function deriveCapitalBook(portfolio: unknown, wallet: unknown, futures: unknown, pm: unknown): CapitalBook;
11
+ export interface CapitalBudget {
12
+ cashAvailableMusd: number;
13
+ committedCapitalMusd: number;
14
+ openMarginMusd: number;
15
+ }
16
+ export declare const usesCapitalSizing: (spec: AgentSpec, mechanical?: boolean) => boolean;
17
+ export declare function prepareCapitalAction(action: ProposedAction, spec: AgentSpec, observation: Observation, budget: CapitalBudget): {
18
+ action: ProposedAction;
19
+ adjustment?: CapitalSizingAdjustment;
20
+ rejection?: string;
21
+ };
22
+ /** Quote-bound monetary checks. Drift fails closed; never requote a resized
23
+ * ticket, silently widen a cap, or credit uncertain close/sell proceeds. */
24
+ export declare function validateCapitalAction(action: ProposedAction, spec: AgentSpec, observation: Observation, budget: CapitalBudget, quote?: QuoteEvidence): string | undefined;
25
+ /** Fee-inclusive opt-in reservation; the legacy gross helper stays unchanged.
26
+ * The API quotes a market fill even for a pending limit/stop order. Reserve at
27
+ * least that fee, scaled up if the proposed price requires more notional.
28
+ * This is conservative captured quote evidence, not a future fill guarantee. */
29
+ export declare function capitalSpotBuyCost(action: Extract<ProposedAction, {
30
+ type: "spot_order";
31
+ }>, quote?: QuoteEvidence): number | undefined;
32
+ export declare function capitalCashCost(action: ProposedAction, quote?: QuoteEvidence): number;
@@ -0,0 +1,257 @@
1
+ // Opt-in, deterministic PAPER sizing. No provider, price or database reads.
2
+ import { asNum, asObj } from "./extract.js";
3
+ import { spotBuyCost } from "./types.js";
4
+ import { validateCapitalSizingPolicy } from "./skillValidator.js";
5
+ export const CAPITAL_VALUATION_BASIS = "wallet_assets_spot_marked_futures_pm_at_collateral";
6
+ // Conservative pre-quote runner estimate: 10bp each way, with the exit fee
7
+ // charged on stop notional. Actual API fee evidence is checked after ONE quote.
8
+ // This is not an exchange-fill, funding or stop-execution guarantee.
9
+ export const CAPITAL_FEE_BUFFER_BPS = 10;
10
+ const CENT_TOLERANCE = 0.011;
11
+ const centsDown = (n) => Math.floor(n * 100) / 100;
12
+ const positive = (n) => asNum(n) !== undefined && n > 0;
13
+ const nonnegative = (n) => asNum(n) !== undefined && n >= 0;
14
+ /** Reconcile the bounded open-position reads against the wallet's frozen
15
+ * buckets. A full page is not assumed complete: the collateral checksums must
16
+ * agree. No held mark, unknown status, missing bucket, or mismatched book can
17
+ * silently become zero exposure. Lists retain legacy positions on other books
18
+ * for management; only explicitly attributed current-book rows reconcile its
19
+ * cash and marks. Closed history never contributes unrealized. */
20
+ export function deriveCapitalBook(portfolio, wallet, futures, pm) {
21
+ const p = asObj(portfolio), w = asObj(wallet), eq = asObj(p.equity), cash = asObj(w.usdt);
22
+ const unavailable = (reason) => ({
23
+ status: "unavailable",
24
+ reason,
25
+ });
26
+ if (p.bookScope !== "api_key")
27
+ return unavailable("independent_agent_book_unproven");
28
+ if (!Number.isSafeInteger(p.walletId) ||
29
+ p.walletId <= 0 ||
30
+ p.walletId !== w.walletId)
31
+ return unavailable("portfolio_wallet_identity_mismatch");
32
+ if (eq.valuationBasis !== CAPITAL_VALUATION_BASIS || !positive(eq.totalUsd))
33
+ return unavailable("portfolio_valuation_unavailable");
34
+ if (eq.spotValuationComplete !== true)
35
+ return unavailable("held_spot_valuation_unproven");
36
+ const buckets = ["available", "frozen", "frozenPm", "frozenFutures"];
37
+ for (const key of buckets) {
38
+ if (!nonnegative(cash[key]) ||
39
+ !nonnegative(eq[key]) ||
40
+ Math.abs(cash[key] - eq[key]) > CENT_TOLERANCE)
41
+ return unavailable("cash_partitions_incomplete_or_changed");
42
+ }
43
+ const cashTotal = buckets.reduce((sum, k) => sum + cash[k], 0);
44
+ if (eq.totalUsd + CENT_TOLERANCE < cashTotal)
45
+ return unavailable("wallet_asset_value_incoherent");
46
+ let negativeMarks = 0;
47
+ for (const [raw, bucket, amountKey, markKeys] of [
48
+ [futures, "frozenFutures", "marginMusd", ["unrealizedPnlMusd"]],
49
+ [pm, "frozenPm", "stakeMusd", ["unrealizedPnl", "unrealizedPnlMusd"]],
50
+ ]) {
51
+ const rows = asObj(raw).positions;
52
+ if (!Array.isArray(rows)) {
53
+ // PM is not fetched for a futures-only legacy universe. Zero frozen PM
54
+ // proves no tied-up PM collateral; a nonzero bucket must have coverage.
55
+ if (bucket === "frozenPm" && raw === undefined && cash[bucket] === 0)
56
+ continue;
57
+ return unavailable("position_coverage_unavailable");
58
+ }
59
+ let held = 0;
60
+ for (const row of rows) {
61
+ const position = asObj(row);
62
+ if (typeof position.status !== "string")
63
+ return unavailable("position_status_unavailable");
64
+ if (position.status !== "open")
65
+ continue;
66
+ if (!Number.isSafeInteger(position.walletId) ||
67
+ position.walletId <= 0)
68
+ return unavailable("held_position_wallet_unavailable");
69
+ // The key-scoped API includes legacy/shared-book positions. Their
70
+ // liabilities settle to their originating wallet, not this active book.
71
+ // Do not filter the management observation or credit their close proceeds.
72
+ if (position.walletId !== p.walletId)
73
+ continue;
74
+ const amount = position[amountKey];
75
+ const mark = markKeys
76
+ .map((key) => asNum(position[key]))
77
+ .find((n) => n !== undefined);
78
+ if (!nonnegative(amount) || mark === undefined)
79
+ return unavailable("held_position_mark_unavailable");
80
+ held += amount;
81
+ negativeMarks += Math.min(0, mark);
82
+ }
83
+ if (Math.abs(held - cash[bucket]) > CENT_TOLERANCE)
84
+ return unavailable("held_collateral_coverage_mismatch");
85
+ }
86
+ const conservativeEquityMusd = eq.totalUsd + negativeMarks;
87
+ if (!positive(conservativeEquityMusd))
88
+ return unavailable("nonpositive_conservative_equity");
89
+ return {
90
+ status: "ready",
91
+ walletId: p.walletId,
92
+ conservativeEquityMusd,
93
+ cashAvailableMusd: Math.min(cash.available, eq.available),
94
+ committedCapitalMusd: Math.max(0, eq.totalUsd - cash.available),
95
+ };
96
+ }
97
+ export const usesCapitalSizing = (spec, mechanical = false) => spec.capitalSizing !== undefined &&
98
+ !mechanical &&
99
+ spec.model?.provider !== "mechanical";
100
+ const increases = (a) => a.type === "futures_open" ||
101
+ a.type === "pm_open" ||
102
+ (a.type === "spot_order" && a.side === "buy");
103
+ export function prepareCapitalAction(action, spec, observation, budget) {
104
+ if (!usesCapitalSizing(spec) || !increases(action))
105
+ return { action };
106
+ if (validateCapitalSizingPolicy(spec.capitalSizing).length > 0)
107
+ return { action, rejection: "capital_policy_invalid" };
108
+ const policy = spec.capitalSizing;
109
+ const adjustment = {
110
+ version: policy.version,
111
+ basis: "owned_collateral_spot_marked_negative_position_marks_only",
112
+ };
113
+ const reject = (rejection) => ({ action, adjustment, rejection });
114
+ const book = observation.capitalBook;
115
+ if (!book || book.status !== "ready")
116
+ return reject(book?.reason ?? "capital_book_unavailable");
117
+ const equity = book.conservativeEquityMusd;
118
+ if (!positive(equity) ||
119
+ !nonnegative(budget.cashAvailableMusd) ||
120
+ !nonnegative(budget.committedCapitalMusd) ||
121
+ !nonnegative(budget.openMarginMusd))
122
+ return reject("capital_budget_unavailable");
123
+ adjustment.conservativeEquityMusd = equity;
124
+ const ticket = Math.min(spec.risk.perTradeMarginMusd, (equity * policy.perTicketCapitalPct) / 100);
125
+ const room = Math.min(ticket, (equity * policy.totalCapitalPct) / 100 - budget.committedCapitalMusd, budget.cashAvailableMusd - (equity * policy.cashReservePct) / 100);
126
+ if (!positive(room))
127
+ return reject("capital_allocation_or_reserve_exhausted");
128
+ if (action.type === "spot_order")
129
+ return { action, adjustment }; // retain quantity; quote gate owns its cost
130
+ if (action.type === "pm_open") {
131
+ const stake = centsDown(Math.min(room, (equity * policy.pmMaxLossPct) / 100));
132
+ adjustment.proposedAmountMusd = action.stakeMusd;
133
+ adjustment.sizedAmountMusd = stake;
134
+ adjustment.riskBudgetMusd = (equity * policy.pmMaxLossPct) / 100;
135
+ if (stake < 10)
136
+ return reject("capital_ticket_below_minimum");
137
+ return { action: { ...action, stakeMusd: stake }, adjustment };
138
+ }
139
+ if (action.type !== "futures_open")
140
+ return { action };
141
+ const mark = observation.watch.find((w) => w.symbol.toUpperCase() === action.symbol.toUpperCase())?.priceUsd;
142
+ const stop = action.stopLossPrice;
143
+ if (!positive(mark) ||
144
+ !positive(stop) ||
145
+ !positive(action.leverage) ||
146
+ (action.side === "long" ? stop >= mark : stop <= mark))
147
+ return reject("capital_stop_or_mark_unavailable");
148
+ const distance = Math.abs(mark - stop) / mark;
149
+ const fee = CAPITAL_FEE_BUFFER_BPS / 10_000;
150
+ const riskPerMargin = action.leverage * (distance + fee * (1 + stop / mark));
151
+ const riskBudget = (equity * policy.futuresRiskPct) / 100;
152
+ const margin = centsDown(Math.min(ticket, riskBudget / riskPerMargin, spec.limits.maxOpenMarginMusd - budget.openMarginMusd, room / (1 + action.leverage * fee)));
153
+ Object.assign(adjustment, {
154
+ proposedAmountMusd: action.marginMusd,
155
+ sizedAmountMusd: margin,
156
+ riskBudgetMusd: riskBudget,
157
+ feeBufferBps: CAPITAL_FEE_BUFFER_BPS,
158
+ });
159
+ if (margin < 10)
160
+ return reject("capital_ticket_below_minimum");
161
+ return { action: { ...action, marginMusd: margin }, adjustment };
162
+ }
163
+ /** Quote-bound monetary checks. Drift fails closed; never requote a resized
164
+ * ticket, silently widen a cap, or credit uncertain close/sell proceeds. */
165
+ export function validateCapitalAction(action, spec, observation, budget, quote) {
166
+ if (!usesCapitalSizing(spec) || !increases(action))
167
+ return undefined;
168
+ if (validateCapitalSizingPolicy(spec.capitalSizing).length > 0)
169
+ return "capital_policy_invalid";
170
+ if (observation.capitalBook?.status !== "ready")
171
+ return "capital_book_unavailable";
172
+ const p = spec.capitalSizing, equity = observation.capitalBook.conservativeEquityMusd;
173
+ if (!positive(equity) ||
174
+ !nonnegative(budget.cashAvailableMusd) ||
175
+ !nonnegative(budget.committedCapitalMusd) ||
176
+ !nonnegative(budget.openMarginMusd))
177
+ return "capital_budget_unavailable";
178
+ let cost, allocated;
179
+ if (action.type === "pm_open") {
180
+ cost = allocated = action.stakeMusd;
181
+ if (cost > (equity * p.pmMaxLossPct) / 100 + 1e-8)
182
+ return "capital_pm_max_loss_exceeded";
183
+ }
184
+ else if (action.type === "spot_order") {
185
+ cost = allocated = capitalSpotBuyCost(action, quote);
186
+ // Without a stop model, the entire spot buy is the capital at risk.
187
+ if (positive(cost) && cost > (equity * p.futuresRiskPct) / 100 + 1e-8)
188
+ return "capital_spot_risk_exceeded";
189
+ }
190
+ else if (action.type === "futures_open") {
191
+ const entry = quote?.entryPrice, stop = action.stopLossPrice, target = action.takeProfitPrice;
192
+ const bps = quote?.futuresFeeBps, entryFee = quote?.estimatedEntryFeeMusd;
193
+ cost = quote?.cashRequiredMusd;
194
+ allocated = action.marginMusd;
195
+ if (!positive(entry) ||
196
+ !positive(stop) ||
197
+ !positive(target) ||
198
+ !nonnegative(bps) ||
199
+ !nonnegative(entryFee) ||
200
+ !positive(cost))
201
+ return "capital_quote_cost_evidence_missing";
202
+ const notional = action.marginMusd * action.leverage;
203
+ const feeRate = bps / 10_000;
204
+ if (Math.abs(entryFee - notional * feeRate) > CENT_TOLERANCE ||
205
+ Math.abs(cost - action.marginMusd - entryFee) > CENT_TOLERANCE)
206
+ return "capital_quote_cost_mismatch";
207
+ const adverse = action.side === "long" ? entry - stop : stop - entry;
208
+ const favorable = action.side === "long" ? target - entry : entry - target;
209
+ if (!(adverse > 0) || !(favorable > 0))
210
+ return "capital_stop_target_wrong_side";
211
+ const risk = (notional * adverse) / entry +
212
+ entryFee +
213
+ ((notional * stop) / entry) * feeRate;
214
+ const reward = (notional * favorable) / entry -
215
+ entryFee -
216
+ ((notional * target) / entry) * feeRate;
217
+ if (risk > (equity * p.futuresRiskPct) / 100 + 1e-8)
218
+ return "capital_quote_stop_risk_exceeded";
219
+ if (reward / risk + 1e-8 < p.minRewardRisk)
220
+ return "capital_quote_reward_risk_too_low";
221
+ }
222
+ if (!positive(cost) || !positive(allocated))
223
+ return "capital_quote_cost_evidence_missing";
224
+ if (cost >
225
+ Math.min(spec.risk.perTradeMarginMusd, (equity * p.perTicketCapitalPct) / 100) +
226
+ 1e-8)
227
+ return "capital_ticket_cap_exceeded";
228
+ if (budget.committedCapitalMusd + cost >
229
+ (equity * p.totalCapitalPct) / 100 + 1e-8)
230
+ return "capital_combined_allocation_exceeded";
231
+ if (budget.cashAvailableMusd - cost <
232
+ (equity * p.cashReservePct) / 100 - 1e-8)
233
+ return "capital_cash_reserve_exceeded";
234
+ return undefined;
235
+ }
236
+ /** Fee-inclusive opt-in reservation; the legacy gross helper stays unchanged.
237
+ * The API quotes a market fill even for a pending limit/stop order. Reserve at
238
+ * least that fee, scaled up if the proposed price requires more notional.
239
+ * This is conservative captured quote evidence, not a future fill guarantee. */
240
+ export function capitalSpotBuyCost(action, quote) {
241
+ const gross = spotBuyCost(action, quote);
242
+ const quotedGross = quote?.estimatedCostMusd;
243
+ const fee = quote?.estimatedFeeMusd;
244
+ if (!positive(gross) || !positive(quotedGross) || !nonnegative(fee))
245
+ return undefined;
246
+ const cost = gross + fee * Math.max(1, gross / quotedGross);
247
+ return positive(cost) ? cost : undefined;
248
+ }
249
+ export function capitalCashCost(action, quote) {
250
+ if (action.type === "futures_open")
251
+ return quote?.cashRequiredMusd ?? action.marginMusd;
252
+ if (action.type === "pm_open")
253
+ return action.stakeMusd;
254
+ if (action.type === "spot_order" && action.side === "buy")
255
+ return capitalSpotBuyCost(action, quote) ?? Number.NaN;
256
+ return 0;
257
+ }
@@ -21,6 +21,7 @@ export interface ClientConfig {
21
21
  fetchFn?: typeof fetch;
22
22
  sleepFn?: (ms: number) => Promise<void>;
23
23
  maxRetries?: number;
24
+ extraHeaders?: Record<string, string>;
24
25
  }
25
26
  export declare class CoinRithmClient {
26
27
  private readonly apiKey;
@@ -28,6 +29,7 @@ export declare class CoinRithmClient {
28
29
  private readonly fetchFn;
29
30
  private readonly sleepFn;
30
31
  private readonly maxRetries;
32
+ private readonly extraHeaders?;
31
33
  rateLimitHits: number;
32
34
  constructor(cfg: ClientConfig);
33
35
  private request;
@@ -30,6 +30,7 @@ export class CoinRithmClient {
30
30
  fetchFn;
31
31
  sleepFn;
32
32
  maxRetries;
33
+ extraHeaders;
33
34
  // Every 429 seen this session (read or write, retried or not) — feeds the
34
35
  // rate-limit-pressure kill-switch, which a write-only counter would miss.
35
36
  rateLimitHits = 0;
@@ -39,6 +40,7 @@ export class CoinRithmClient {
39
40
  this.fetchFn = cfg.fetchFn ?? fetch;
40
41
  this.sleepFn = cfg.sleepFn ?? realSleep;
41
42
  this.maxRetries = cfg.maxRetries ?? 3;
43
+ this.extraHeaders = cfg.extraHeaders;
42
44
  }
43
45
  async request(method, path, opts = {}) {
44
46
  const url = new URL(this.baseUrl + path);
@@ -48,7 +50,9 @@ export class CoinRithmClient {
48
50
  url.searchParams.set(k, String(v));
49
51
  }
50
52
  }
53
+ // extraHeaders first: auth, accept and trace can never be clobbered by it.
51
54
  const headers = {
55
+ ...this.extraHeaders,
52
56
  Authorization: `Bearer ${this.apiKey}`,
53
57
  Accept: "application/json",
54
58
  ...traceHeaders(opts.trace),