@coinrithm/mcp-trading 0.7.7 → 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.
- package/CHANGELOG.md +83 -0
- package/dist/agent/act.js +19 -6
- package/dist/agent/capitalSizing.d.ts +32 -0
- package/dist/agent/capitalSizing.js +257 -0
- package/dist/agent/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -0
- package/dist/agent/decisionReceipt.d.ts +45 -0
- package/dist/agent/decisionReceipt.js +595 -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/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.js +119 -73
- package/dist/agent/resolve.js +1 -0
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +360 -34
- 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 +6 -0
- package/dist/agent/strictLint.js +19 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,89 @@ 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
|
+
|
|
8
91
|
## 0.7.7
|
|
9
92
|
|
|
10
93
|
Reliability release. Every change here came from a live production failure, not
|
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
|
|
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
|
+
}
|