@coinrithm/mcp-trading 0.7.7 → 0.7.9
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 +178 -0
- package/README.md +86 -69
- package/dist/agent/act.js +19 -6
- package/dist/agent/capitalSizing.d.ts +32 -0
- package/dist/agent/capitalSizing.js +270 -0
- package/dist/agent/client.d.ts +8 -1
- package/dist/agent/client.js +98 -39
- package/dist/agent/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -0
- package/dist/agent/decisionReceipt.d.ts +48 -0
- package/dist/agent/decisionReceipt.js +619 -0
- package/dist/agent/decisionValidator.d.ts +19 -2
- package/dist/agent/decisionValidator.js +74 -3
- package/dist/agent/engine.d.ts +1 -0
- package/dist/agent/engine.js +1 -0
- package/dist/agent/gate.js +14 -11
- package/dist/agent/observe.js +175 -34
- package/dist/agent/pmContext.d.ts +13 -0
- package/dist/agent/pmContext.js +136 -0
- package/dist/agent/prompt.d.ts +11 -1
- package/dist/agent/prompt.js +135 -7
- package/dist/agent/providerCapabilities.d.ts +3 -0
- package/dist/agent/providerCapabilities.js +41 -3
- package/dist/agent/providers.d.ts +2 -1
- package/dist/agent/providers.js +222 -86
- package/dist/agent/resolve.js +1 -0
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +369 -38
- package/dist/agent/scorecard.js +7 -1
- package/dist/agent/skill.js +17 -0
- package/dist/agent/skillValidator.d.ts +1 -0
- package/dist/agent/skillValidator.js +56 -0
- package/dist/agent/state.js +23 -2
- package/dist/agent/strictLint.js +19 -0
- package/dist/agent/templates.js +4 -0
- package/dist/agent/thesis.d.ts +40 -0
- package/dist/agent/thesis.js +319 -0
- package/dist/agent/types.d.ts +127 -0
- package/dist/client.js +3 -2
- package/dist/http.d.ts +7 -1
- package/dist/http.js +36 -16
- package/dist/httpCompletion.d.ts +33 -0
- package/dist/httpCompletion.js +220 -0
- package/dist/retryAfter.d.ts +1 -0
- package/dist/retryAfter.js +16 -0
- package/dist/tools.js +19 -1
- package/package.json +4 -2
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
// Paper settlement writers use EPS=1e-8 on floating-point frozen balances.
|
|
12
|
+
// Thawing the final position can leave negative dust (observed: -6.82e-13).
|
|
13
|
+
// Normalize only that bounded frozen residue, never spendable cash or debt.
|
|
14
|
+
// This is much tighter than reconciliation tolerance and does not edit balances.
|
|
15
|
+
const FROZEN_RESIDUE_TOLERANCE = 1e-8;
|
|
16
|
+
const centsDown = (n) => Math.floor(n * 100) / 100;
|
|
17
|
+
const positive = (n) => asNum(n) !== undefined && n > 0;
|
|
18
|
+
const nonnegative = (n) => asNum(n) !== undefined && n >= 0;
|
|
19
|
+
/** Reconcile the bounded open-position reads against the wallet's frozen
|
|
20
|
+
* buckets. A full page is not assumed complete: the collateral checksums must
|
|
21
|
+
* agree. No held mark, unknown status, missing bucket, or mismatched book can
|
|
22
|
+
* silently become zero exposure. Lists retain legacy positions on other books
|
|
23
|
+
* for management; only explicitly attributed current-book rows reconcile its
|
|
24
|
+
* cash and marks. Closed history never contributes unrealized. */
|
|
25
|
+
export function deriveCapitalBook(portfolio, wallet, futures, pm) {
|
|
26
|
+
const p = asObj(portfolio), w = asObj(wallet), eq = asObj(p.equity), rawCash = asObj(w.usdt);
|
|
27
|
+
const cash = { ...rawCash };
|
|
28
|
+
const unavailable = (reason) => ({
|
|
29
|
+
status: "unavailable",
|
|
30
|
+
reason,
|
|
31
|
+
});
|
|
32
|
+
if (p.bookScope !== "api_key")
|
|
33
|
+
return unavailable("independent_agent_book_unproven");
|
|
34
|
+
if (!Number.isSafeInteger(p.walletId) ||
|
|
35
|
+
p.walletId <= 0 ||
|
|
36
|
+
p.walletId !== w.walletId)
|
|
37
|
+
return unavailable("portfolio_wallet_identity_mismatch");
|
|
38
|
+
if (eq.valuationBasis !== CAPITAL_VALUATION_BASIS || !positive(eq.totalUsd))
|
|
39
|
+
return unavailable("portfolio_valuation_unavailable");
|
|
40
|
+
if (eq.spotValuationComplete !== true)
|
|
41
|
+
return unavailable("held_spot_valuation_unproven");
|
|
42
|
+
const buckets = ["available", "frozen", "frozenPm", "frozenFutures"];
|
|
43
|
+
for (const key of buckets) {
|
|
44
|
+
const walletValue = asNum(rawCash[key]);
|
|
45
|
+
const portfolioValue = asNum(eq[key]);
|
|
46
|
+
const minimum = key === "available" ? 0 : -FROZEN_RESIDUE_TOLERANCE;
|
|
47
|
+
if (walletValue === undefined ||
|
|
48
|
+
portfolioValue === undefined ||
|
|
49
|
+
walletValue < minimum ||
|
|
50
|
+
portfolioValue < minimum ||
|
|
51
|
+
// Compare raw reads first: normalization must not hide snapshot drift.
|
|
52
|
+
Math.abs(walletValue - portfolioValue) > CENT_TOLERANCE)
|
|
53
|
+
return unavailable("cash_partitions_incomplete_or_changed");
|
|
54
|
+
cash[key] = Math.max(0, walletValue);
|
|
55
|
+
}
|
|
56
|
+
const cashTotal = buckets.reduce((sum, k) => sum + cash[k], 0);
|
|
57
|
+
if (eq.totalUsd + CENT_TOLERANCE < cashTotal)
|
|
58
|
+
return unavailable("wallet_asset_value_incoherent");
|
|
59
|
+
let negativeMarks = 0;
|
|
60
|
+
for (const [raw, bucket, amountKey, markKeys] of [
|
|
61
|
+
[futures, "frozenFutures", "marginMusd", ["unrealizedPnlMusd"]],
|
|
62
|
+
[pm, "frozenPm", "stakeMusd", ["unrealizedPnl", "unrealizedPnlMusd"]],
|
|
63
|
+
]) {
|
|
64
|
+
const rows = asObj(raw).positions;
|
|
65
|
+
if (!Array.isArray(rows)) {
|
|
66
|
+
// PM is not fetched for a futures-only legacy universe. Zero frozen PM
|
|
67
|
+
// proves no tied-up PM collateral; a nonzero bucket must have coverage.
|
|
68
|
+
if (bucket === "frozenPm" && raw === undefined && cash[bucket] === 0)
|
|
69
|
+
continue;
|
|
70
|
+
return unavailable("position_coverage_unavailable");
|
|
71
|
+
}
|
|
72
|
+
let held = 0;
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
const position = asObj(row);
|
|
75
|
+
if (typeof position.status !== "string")
|
|
76
|
+
return unavailable("position_status_unavailable");
|
|
77
|
+
if (position.status !== "open")
|
|
78
|
+
continue;
|
|
79
|
+
if (!Number.isSafeInteger(position.walletId) ||
|
|
80
|
+
position.walletId <= 0)
|
|
81
|
+
return unavailable("held_position_wallet_unavailable");
|
|
82
|
+
// The key-scoped API includes legacy/shared-book positions. Their
|
|
83
|
+
// liabilities settle to their originating wallet, not this active book.
|
|
84
|
+
// Do not filter the management observation or credit their close proceeds.
|
|
85
|
+
if (position.walletId !== p.walletId)
|
|
86
|
+
continue;
|
|
87
|
+
const amount = position[amountKey];
|
|
88
|
+
const mark = markKeys
|
|
89
|
+
.map((key) => asNum(position[key]))
|
|
90
|
+
.find((n) => n !== undefined);
|
|
91
|
+
if (!nonnegative(amount) || mark === undefined)
|
|
92
|
+
return unavailable("held_position_mark_unavailable");
|
|
93
|
+
held += amount;
|
|
94
|
+
negativeMarks += Math.min(0, mark);
|
|
95
|
+
}
|
|
96
|
+
if (Math.abs(held - cash[bucket]) > CENT_TOLERANCE)
|
|
97
|
+
return unavailable("held_collateral_coverage_mismatch");
|
|
98
|
+
}
|
|
99
|
+
const conservativeEquityMusd = eq.totalUsd + negativeMarks;
|
|
100
|
+
if (!positive(conservativeEquityMusd))
|
|
101
|
+
return unavailable("nonpositive_conservative_equity");
|
|
102
|
+
return {
|
|
103
|
+
status: "ready",
|
|
104
|
+
walletId: p.walletId,
|
|
105
|
+
conservativeEquityMusd,
|
|
106
|
+
cashAvailableMusd: Math.min(cash.available, eq.available),
|
|
107
|
+
committedCapitalMusd: Math.max(0, eq.totalUsd - cash.available),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export const usesCapitalSizing = (spec, mechanical = false) => spec.capitalSizing !== undefined &&
|
|
111
|
+
!mechanical &&
|
|
112
|
+
spec.model?.provider !== "mechanical";
|
|
113
|
+
const increases = (a) => a.type === "futures_open" ||
|
|
114
|
+
a.type === "pm_open" ||
|
|
115
|
+
(a.type === "spot_order" && a.side === "buy");
|
|
116
|
+
export function prepareCapitalAction(action, spec, observation, budget) {
|
|
117
|
+
if (!usesCapitalSizing(spec) || !increases(action))
|
|
118
|
+
return { action };
|
|
119
|
+
if (validateCapitalSizingPolicy(spec.capitalSizing).length > 0)
|
|
120
|
+
return { action, rejection: "capital_policy_invalid" };
|
|
121
|
+
const policy = spec.capitalSizing;
|
|
122
|
+
const adjustment = {
|
|
123
|
+
version: policy.version,
|
|
124
|
+
basis: "owned_collateral_spot_marked_negative_position_marks_only",
|
|
125
|
+
};
|
|
126
|
+
const reject = (rejection) => ({ action, adjustment, rejection });
|
|
127
|
+
const book = observation.capitalBook;
|
|
128
|
+
if (!book || book.status !== "ready")
|
|
129
|
+
return reject(book?.reason ?? "capital_book_unavailable");
|
|
130
|
+
const equity = book.conservativeEquityMusd;
|
|
131
|
+
if (!positive(equity) ||
|
|
132
|
+
!nonnegative(budget.cashAvailableMusd) ||
|
|
133
|
+
!nonnegative(budget.committedCapitalMusd) ||
|
|
134
|
+
!nonnegative(budget.openMarginMusd))
|
|
135
|
+
return reject("capital_budget_unavailable");
|
|
136
|
+
adjustment.conservativeEquityMusd = equity;
|
|
137
|
+
const ticket = Math.min(spec.risk.perTradeMarginMusd, (equity * policy.perTicketCapitalPct) / 100);
|
|
138
|
+
const room = Math.min(ticket, (equity * policy.totalCapitalPct) / 100 - budget.committedCapitalMusd, budget.cashAvailableMusd - (equity * policy.cashReservePct) / 100);
|
|
139
|
+
if (!positive(room))
|
|
140
|
+
return reject("capital_allocation_or_reserve_exhausted");
|
|
141
|
+
if (action.type === "spot_order")
|
|
142
|
+
return { action, adjustment }; // retain quantity; quote gate owns its cost
|
|
143
|
+
if (action.type === "pm_open") {
|
|
144
|
+
const stake = centsDown(Math.min(room, (equity * policy.pmMaxLossPct) / 100));
|
|
145
|
+
adjustment.proposedAmountMusd = action.stakeMusd;
|
|
146
|
+
adjustment.sizedAmountMusd = stake;
|
|
147
|
+
adjustment.riskBudgetMusd = (equity * policy.pmMaxLossPct) / 100;
|
|
148
|
+
if (stake < 10)
|
|
149
|
+
return reject("capital_ticket_below_minimum");
|
|
150
|
+
return { action: { ...action, stakeMusd: stake }, adjustment };
|
|
151
|
+
}
|
|
152
|
+
if (action.type !== "futures_open")
|
|
153
|
+
return { action };
|
|
154
|
+
const mark = observation.watch.find((w) => w.symbol.toUpperCase() === action.symbol.toUpperCase())?.priceUsd;
|
|
155
|
+
const stop = action.stopLossPrice;
|
|
156
|
+
if (!positive(mark) ||
|
|
157
|
+
!positive(stop) ||
|
|
158
|
+
!positive(action.leverage) ||
|
|
159
|
+
(action.side === "long" ? stop >= mark : stop <= mark))
|
|
160
|
+
return reject("capital_stop_or_mark_unavailable");
|
|
161
|
+
const distance = Math.abs(mark - stop) / mark;
|
|
162
|
+
const fee = CAPITAL_FEE_BUFFER_BPS / 10_000;
|
|
163
|
+
const riskPerMargin = action.leverage * (distance + fee * (1 + stop / mark));
|
|
164
|
+
const riskBudget = (equity * policy.futuresRiskPct) / 100;
|
|
165
|
+
const margin = centsDown(Math.min(ticket, riskBudget / riskPerMargin, spec.limits.maxOpenMarginMusd - budget.openMarginMusd, room / (1 + action.leverage * fee)));
|
|
166
|
+
Object.assign(adjustment, {
|
|
167
|
+
proposedAmountMusd: action.marginMusd,
|
|
168
|
+
sizedAmountMusd: margin,
|
|
169
|
+
riskBudgetMusd: riskBudget,
|
|
170
|
+
feeBufferBps: CAPITAL_FEE_BUFFER_BPS,
|
|
171
|
+
});
|
|
172
|
+
if (margin < 10)
|
|
173
|
+
return reject("capital_ticket_below_minimum");
|
|
174
|
+
return { action: { ...action, marginMusd: margin }, adjustment };
|
|
175
|
+
}
|
|
176
|
+
/** Quote-bound monetary checks. Drift fails closed; never requote a resized
|
|
177
|
+
* ticket, silently widen a cap, or credit uncertain close/sell proceeds. */
|
|
178
|
+
export function validateCapitalAction(action, spec, observation, budget, quote) {
|
|
179
|
+
if (!usesCapitalSizing(spec) || !increases(action))
|
|
180
|
+
return undefined;
|
|
181
|
+
if (validateCapitalSizingPolicy(spec.capitalSizing).length > 0)
|
|
182
|
+
return "capital_policy_invalid";
|
|
183
|
+
if (observation.capitalBook?.status !== "ready")
|
|
184
|
+
return "capital_book_unavailable";
|
|
185
|
+
const p = spec.capitalSizing, equity = observation.capitalBook.conservativeEquityMusd;
|
|
186
|
+
if (!positive(equity) ||
|
|
187
|
+
!nonnegative(budget.cashAvailableMusd) ||
|
|
188
|
+
!nonnegative(budget.committedCapitalMusd) ||
|
|
189
|
+
!nonnegative(budget.openMarginMusd))
|
|
190
|
+
return "capital_budget_unavailable";
|
|
191
|
+
let cost, allocated;
|
|
192
|
+
if (action.type === "pm_open") {
|
|
193
|
+
cost = allocated = action.stakeMusd;
|
|
194
|
+
if (cost > (equity * p.pmMaxLossPct) / 100 + 1e-8)
|
|
195
|
+
return "capital_pm_max_loss_exceeded";
|
|
196
|
+
}
|
|
197
|
+
else if (action.type === "spot_order") {
|
|
198
|
+
cost = allocated = capitalSpotBuyCost(action, quote);
|
|
199
|
+
// Without a stop model, the entire spot buy is the capital at risk.
|
|
200
|
+
if (positive(cost) && cost > (equity * p.futuresRiskPct) / 100 + 1e-8)
|
|
201
|
+
return "capital_spot_risk_exceeded";
|
|
202
|
+
}
|
|
203
|
+
else if (action.type === "futures_open") {
|
|
204
|
+
const entry = quote?.entryPrice, stop = action.stopLossPrice, target = action.takeProfitPrice;
|
|
205
|
+
const bps = quote?.futuresFeeBps, entryFee = quote?.estimatedEntryFeeMusd;
|
|
206
|
+
cost = quote?.cashRequiredMusd;
|
|
207
|
+
allocated = action.marginMusd;
|
|
208
|
+
if (!positive(entry) ||
|
|
209
|
+
!positive(stop) ||
|
|
210
|
+
!positive(target) ||
|
|
211
|
+
!nonnegative(bps) ||
|
|
212
|
+
!nonnegative(entryFee) ||
|
|
213
|
+
!positive(cost))
|
|
214
|
+
return "capital_quote_cost_evidence_missing";
|
|
215
|
+
const notional = action.marginMusd * action.leverage;
|
|
216
|
+
const feeRate = bps / 10_000;
|
|
217
|
+
if (Math.abs(entryFee - notional * feeRate) > CENT_TOLERANCE ||
|
|
218
|
+
Math.abs(cost - action.marginMusd - entryFee) > CENT_TOLERANCE)
|
|
219
|
+
return "capital_quote_cost_mismatch";
|
|
220
|
+
const adverse = action.side === "long" ? entry - stop : stop - entry;
|
|
221
|
+
const favorable = action.side === "long" ? target - entry : entry - target;
|
|
222
|
+
if (!(adverse > 0) || !(favorable > 0))
|
|
223
|
+
return "capital_stop_target_wrong_side";
|
|
224
|
+
const risk = (notional * adverse) / entry +
|
|
225
|
+
entryFee +
|
|
226
|
+
((notional * stop) / entry) * feeRate;
|
|
227
|
+
const reward = (notional * favorable) / entry -
|
|
228
|
+
entryFee -
|
|
229
|
+
((notional * target) / entry) * feeRate;
|
|
230
|
+
if (risk > (equity * p.futuresRiskPct) / 100 + 1e-8)
|
|
231
|
+
return "capital_quote_stop_risk_exceeded";
|
|
232
|
+
if (reward / risk + 1e-8 < p.minRewardRisk)
|
|
233
|
+
return "capital_quote_reward_risk_too_low";
|
|
234
|
+
}
|
|
235
|
+
if (!positive(cost) || !positive(allocated))
|
|
236
|
+
return "capital_quote_cost_evidence_missing";
|
|
237
|
+
if (cost >
|
|
238
|
+
Math.min(spec.risk.perTradeMarginMusd, (equity * p.perTicketCapitalPct) / 100) +
|
|
239
|
+
1e-8)
|
|
240
|
+
return "capital_ticket_cap_exceeded";
|
|
241
|
+
if (budget.committedCapitalMusd + cost >
|
|
242
|
+
(equity * p.totalCapitalPct) / 100 + 1e-8)
|
|
243
|
+
return "capital_combined_allocation_exceeded";
|
|
244
|
+
if (budget.cashAvailableMusd - cost <
|
|
245
|
+
(equity * p.cashReservePct) / 100 - 1e-8)
|
|
246
|
+
return "capital_cash_reserve_exceeded";
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/** Fee-inclusive opt-in reservation; the legacy gross helper stays unchanged.
|
|
250
|
+
* The API quotes a market fill even for a pending limit/stop order. Reserve at
|
|
251
|
+
* least that fee, scaled up if the proposed price requires more notional.
|
|
252
|
+
* This is conservative captured quote evidence, not a future fill guarantee. */
|
|
253
|
+
export function capitalSpotBuyCost(action, quote) {
|
|
254
|
+
const gross = spotBuyCost(action, quote);
|
|
255
|
+
const quotedGross = quote?.estimatedCostMusd;
|
|
256
|
+
const fee = quote?.estimatedFeeMusd;
|
|
257
|
+
if (!positive(gross) || !positive(quotedGross) || !nonnegative(fee))
|
|
258
|
+
return undefined;
|
|
259
|
+
const cost = gross + fee * Math.max(1, gross / quotedGross);
|
|
260
|
+
return positive(cost) ? cost : undefined;
|
|
261
|
+
}
|
|
262
|
+
export function capitalCashCost(action, quote) {
|
|
263
|
+
if (action.type === "futures_open")
|
|
264
|
+
return quote?.cashRequiredMusd ?? action.marginMusd;
|
|
265
|
+
if (action.type === "pm_open")
|
|
266
|
+
return action.stakeMusd;
|
|
267
|
+
if (action.type === "spot_order" && action.side === "buy")
|
|
268
|
+
return capitalSpotBuyCost(action, quote) ?? Number.NaN;
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
package/dist/agent/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AgentTrace, ApiResult } from "./types.js";
|
|
2
2
|
export declare const DEFAULT_BASE_URL = "https://api.coinrithm.com";
|
|
3
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
3
4
|
export type ProvenanceReport = {
|
|
4
5
|
runtimeKind?: "hosted_scheduler" | "self_host_runner" | "byo_api" | "mcp_tool";
|
|
5
6
|
packageVersion?: string;
|
|
@@ -21,14 +22,20 @@ export interface ClientConfig {
|
|
|
21
22
|
fetchFn?: typeof fetch;
|
|
22
23
|
sleepFn?: (ms: number) => Promise<void>;
|
|
23
24
|
maxRetries?: number;
|
|
25
|
+
/** Total deadline, including response bodies and all 429 retry waits. */
|
|
26
|
+
requestTimeoutMs?: number;
|
|
27
|
+
/** Optional caller cancellation, applied to each request from this client. */
|
|
28
|
+
signal?: AbortSignal;
|
|
24
29
|
extraHeaders?: Record<string, string>;
|
|
25
30
|
}
|
|
26
31
|
export declare class CoinRithmClient {
|
|
27
32
|
private readonly apiKey;
|
|
28
33
|
private readonly baseUrl;
|
|
29
34
|
private readonly fetchFn;
|
|
30
|
-
private readonly sleepFn
|
|
35
|
+
private readonly sleepFn?;
|
|
31
36
|
private readonly maxRetries;
|
|
37
|
+
private readonly requestTimeoutMs;
|
|
38
|
+
private readonly signal?;
|
|
32
39
|
private readonly extraHeaders?;
|
|
33
40
|
rateLimitHits: number;
|
|
34
41
|
constructor(cfg: ClientConfig);
|
package/dist/agent/client.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// from an agent file). 429 backs off on Retry-After; 401/403/409/422 are
|
|
5
5
|
// FAIL-CLOSED cycle outcomes (returned, not retried). fetch + sleep are
|
|
6
6
|
// injectable so tests run with no network and no real waits.
|
|
7
|
-
import {
|
|
7
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
|
+
import { retryAfterSeconds } from "../retryAfter.js";
|
|
8
9
|
export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
|
|
10
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
9
11
|
function traceHeaders(trace) {
|
|
10
12
|
const h = {};
|
|
11
13
|
if (!trace)
|
|
@@ -30,6 +32,8 @@ export class CoinRithmClient {
|
|
|
30
32
|
fetchFn;
|
|
31
33
|
sleepFn;
|
|
32
34
|
maxRetries;
|
|
35
|
+
requestTimeoutMs;
|
|
36
|
+
signal;
|
|
33
37
|
extraHeaders;
|
|
34
38
|
// Every 429 seen this session (read or write, retried or not) — feeds the
|
|
35
39
|
// rate-limit-pressure kill-switch, which a write-only counter would miss.
|
|
@@ -38,8 +42,15 @@ export class CoinRithmClient {
|
|
|
38
42
|
this.apiKey = cfg.apiKey;
|
|
39
43
|
this.baseUrl = (cfg.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
40
44
|
this.fetchFn = cfg.fetchFn ?? fetch;
|
|
41
|
-
this.sleepFn = cfg.sleepFn
|
|
45
|
+
this.sleepFn = cfg.sleepFn;
|
|
42
46
|
this.maxRetries = cfg.maxRetries ?? 3;
|
|
47
|
+
this.requestTimeoutMs = cfg.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
48
|
+
if (!Number.isSafeInteger(this.requestTimeoutMs) ||
|
|
49
|
+
this.requestTimeoutMs < 1 ||
|
|
50
|
+
this.requestTimeoutMs > 2_147_483_647) {
|
|
51
|
+
throw new Error("requestTimeoutMs must be an integer between 1 and 2147483647");
|
|
52
|
+
}
|
|
53
|
+
this.signal = cfg.signal;
|
|
43
54
|
this.extraHeaders = cfg.extraHeaders;
|
|
44
55
|
}
|
|
45
56
|
async request(method, path, opts = {}) {
|
|
@@ -59,53 +70,101 @@ export class CoinRithmClient {
|
|
|
59
70
|
};
|
|
60
71
|
if (opts.body !== undefined)
|
|
61
72
|
headers["Content-Type"] = "application/json";
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
let timedOut = false;
|
|
75
|
+
const cancel = () => controller.abort(new Error("API request cancelled"));
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
timedOut = true;
|
|
78
|
+
controller.abort(new Error("API request deadline exceeded"));
|
|
79
|
+
}, this.requestTimeoutMs);
|
|
80
|
+
this.signal?.addEventListener("abort", cancel, { once: true });
|
|
81
|
+
if (this.signal?.aborted)
|
|
82
|
+
cancel();
|
|
83
|
+
let rejectAborted;
|
|
84
|
+
const aborted = new Promise((_, reject) => {
|
|
85
|
+
rejectAborted = () => reject(controller.signal.reason);
|
|
86
|
+
controller.signal.addEventListener("abort", rejectAborted, {
|
|
87
|
+
once: true,
|
|
88
|
+
});
|
|
89
|
+
if (controller.signal.aborted)
|
|
90
|
+
rejectAborted();
|
|
91
|
+
});
|
|
92
|
+
const perform = async () => {
|
|
93
|
+
for (let attempt = 0;; attempt++) {
|
|
94
|
+
controller.signal.throwIfAborted();
|
|
95
|
+
const res = await this.fetchFn(url.toString(), {
|
|
66
96
|
method,
|
|
67
97
|
headers,
|
|
68
98
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
99
|
+
signal: controller.signal,
|
|
69
100
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
const text = await res.text();
|
|
89
|
-
let data = text;
|
|
90
|
-
if (text) {
|
|
91
|
-
try {
|
|
92
|
-
data = JSON.parse(text);
|
|
101
|
+
controller.signal.throwIfAborted();
|
|
102
|
+
const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
|
|
103
|
+
if (res.status === 429)
|
|
104
|
+
this.rateLimitHits += 1;
|
|
105
|
+
if (res.status === 429 && attempt < this.maxRetries) {
|
|
106
|
+
// Release this response before waiting so retries do not retain sockets.
|
|
107
|
+
void res.body?.cancel().catch(() => { });
|
|
108
|
+
const delayMs = (retryAfter ?? 5) * 1000;
|
|
109
|
+
// Never shorten a provider's Retry-After or overflow a Node timer.
|
|
110
|
+
if (delayMs >= this.requestTimeoutMs)
|
|
111
|
+
await aborted;
|
|
112
|
+
else if (this.sleepFn)
|
|
113
|
+
await this.sleepFn(delayMs);
|
|
114
|
+
else
|
|
115
|
+
await sleep(delayMs, undefined, { signal: controller.signal });
|
|
116
|
+
continue;
|
|
93
117
|
}
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
controller.signal.throwIfAborted();
|
|
120
|
+
let data = text;
|
|
121
|
+
if (text) {
|
|
122
|
+
try {
|
|
123
|
+
data = JSON.parse(text);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* leave as text */
|
|
127
|
+
}
|
|
96
128
|
}
|
|
129
|
+
return {
|
|
130
|
+
ok: res.ok,
|
|
131
|
+
status: res.status,
|
|
132
|
+
data,
|
|
133
|
+
retryAfterSeconds: res.status === 429 ? retryAfter : undefined,
|
|
134
|
+
rateLimitRemaining: Number(res.headers.get("ratelimit-remaining")) || undefined,
|
|
135
|
+
ledgerEventId: res.headers.get("x-coinrithm-ledger-event-id"),
|
|
136
|
+
};
|
|
97
137
|
}
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
// The race also bounds injected transports that do not honor AbortSignal.
|
|
141
|
+
return await Promise.race([perform(), aborted]);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
98
144
|
return {
|
|
99
|
-
ok:
|
|
100
|
-
status:
|
|
101
|
-
data
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
145
|
+
ok: false,
|
|
146
|
+
status: 0,
|
|
147
|
+
data: {
|
|
148
|
+
error: timedOut
|
|
149
|
+
? "request_timeout"
|
|
150
|
+
: controller.signal.aborted
|
|
151
|
+
? "request_aborted"
|
|
152
|
+
: "network_error",
|
|
153
|
+
message: timedOut
|
|
154
|
+
? `API request exceeded ${this.requestTimeoutMs}ms deadline`
|
|
155
|
+
: controller.signal.aborted
|
|
156
|
+
? "API request cancelled"
|
|
157
|
+
: err instanceof Error
|
|
158
|
+
? err.message
|
|
159
|
+
: String(err),
|
|
160
|
+
},
|
|
107
161
|
};
|
|
108
162
|
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
this.signal?.removeEventListener("abort", cancel);
|
|
166
|
+
controller.signal.removeEventListener("abort", rejectAborted);
|
|
167
|
+
}
|
|
109
168
|
}
|
|
110
169
|
// ── reads ──────────────────────────────────────────────────────────────────
|
|
111
170
|
me(trace) {
|