@unhedged/mcp 0.1.3 → 0.2.0
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/README.md +25 -1
- package/dist/client/errors.js +8 -0
- package/dist/client/types.d.ts +17 -0
- package/dist/safety/spend-cap.d.ts +7 -5
- package/dist/safety/spend-cap.js +19 -14
- package/dist/tools/orders.js +542 -28
- package/package.json +2 -1
- package/skills/market-maker-workflow.md +13 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Model Context Protocol server for the [Unhedged](https://unhedged.gg) prediction
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
- Exposes
|
|
7
|
+
- Exposes MCP **tools** over the Unhedged v1 REST API: search markets, read balances and portfolio, place bets and orders (including batch place/replace), cancel orders, request withdrawals, and read a market-maker profile.
|
|
8
8
|
- Ships 6 MCP **prompts** ("skills") — opinionated markdown workflows the agent can execute end-to-end (e.g. `place-hedged-position`, `cash-out`, `portfolio-review`).
|
|
9
9
|
- Auto-generates an `Idempotency-Key` for every state-changing call. Each new call uses a fresh key, so ambiguous failures are not retried automatically. Check the account's transaction history before repeating a mutation; open orders and balances cannot rule out a filled order or completed withdrawal. Ask the user if the outcome remains uncertain.
|
|
10
10
|
- Declares MCP tool annotations (`readOnlyHint`, `destructiveHint`); bets, orders, cancellations, and withdrawals are marked destructive. Confirmation behavior depends on the client.
|
|
@@ -20,6 +20,27 @@ The init wizard asks which client to wire up (Claude Desktop, Cursor, Claude CLI
|
|
|
20
20
|
|
|
21
21
|
Mint an API key at <https://unhedged.gg/account/api-keys>. See the [API key minting walkthrough](#api-key-minting) below for recommended scope sets.
|
|
22
22
|
|
|
23
|
+
## Upgrading from 0.1.3 to 0.2.0
|
|
24
|
+
|
|
25
|
+
Version 0.2.0 adds centi-share support. MCP order quantities now use **two-decimal strings** in human shares, with a minimum of `"0.01"`. This is a breaking change from 0.1.3's integer quantity inputs.
|
|
26
|
+
|
|
27
|
+
| Tool | Quantity format |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `place_order` | `quantity: "1.00"` |
|
|
30
|
+
| `batch_place_orders` | `quantity: "1.00"` in each order |
|
|
31
|
+
| `replace_order` | `quantity: "1.00"` |
|
|
32
|
+
| `batch_replace_orders` | `quantity: "1.00"` in each replacement |
|
|
33
|
+
|
|
34
|
+
Use `"0.01"` for one hundredth of a share, `"1.00"` for one share, or `"1.25"` for one and a quarter shares. Order responses also return share quantities as decimal strings. REST's support for whole-number numeric inputs does not apply to these MCP tool schemas.
|
|
35
|
+
|
|
36
|
+
To pin the release, configure your MCP client to launch:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx -y @unhedged/mcp@0.2.0
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Keep the existing API-key environment setting, then restart the MCP server or client to load the new version and tool schemas. See the [order API reference](https://unhedged.gg/docs/api#place-order) for REST request and response examples.
|
|
43
|
+
|
|
23
44
|
## Environment variables
|
|
24
45
|
|
|
25
46
|
| Variable | Required | Description |
|
|
@@ -61,7 +82,10 @@ Tool descriptions are available in your MCP client. Quick reference:
|
|
|
61
82
|
| `place_bet` | `bet:place` | yes |
|
|
62
83
|
| `list_orders` | `order:read` | n/a |
|
|
63
84
|
| `place_order` | `order:place` | yes |
|
|
85
|
+
| `batch_place_orders` | `order:place` | yes |
|
|
64
86
|
| `cancel_order` | `order:cancel` | yes |
|
|
87
|
+
| `replace_order` | `order:place` + `order:cancel` | yes |
|
|
88
|
+
| `batch_replace_orders` | `order:place` + `order:cancel` | yes |
|
|
65
89
|
| `request_withdrawal` | `balance:withdraw` | yes |
|
|
66
90
|
| `get_market_maker_profile` | `portfolio:read` | n/a |
|
|
67
91
|
|
package/dist/client/errors.js
CHANGED
|
@@ -18,6 +18,14 @@ export class UnhedgedApiError extends Error {
|
|
|
18
18
|
this.retryable = args.status >= 500 || args.status === 429 || args.status === 0;
|
|
19
19
|
}
|
|
20
20
|
toAgentMessage(stateChanging = false) {
|
|
21
|
+
if (stateChanging && this.status === 400 &&
|
|
22
|
+
this.responseBody != null && typeof this.responseBody === 'object' &&
|
|
23
|
+
'results' in this.responseBody && Array.isArray(this.responseBody.results)) {
|
|
24
|
+
return (`Unhedged API returned 400: ${this.message}. The batch transaction committed. ` +
|
|
25
|
+
`Successful legs remain committed; failed replacements may have cancelled their source orders. ` +
|
|
26
|
+
`Do not repeat successful legs. Inspect each result before retrying or replacing orders.\n` +
|
|
27
|
+
JSON.stringify({ results: this.responseBody.results }, null, 2));
|
|
28
|
+
}
|
|
21
29
|
if (this.missingScope) {
|
|
22
30
|
return (`Unhedged API rejected the call (${this.status}): the API key is missing scope ` +
|
|
23
31
|
`"${this.missingScope}". Mint a new key including this scope at ` +
|
package/dist/client/types.d.ts
CHANGED
|
@@ -97,6 +97,7 @@ export interface PlaceBetResponse {
|
|
|
97
97
|
}
|
|
98
98
|
export interface MEOrder {
|
|
99
99
|
id?: string | number;
|
|
100
|
+
client_order_id?: string | null;
|
|
100
101
|
marketId: string;
|
|
101
102
|
outcome: string;
|
|
102
103
|
side: 'bid' | 'ask';
|
|
@@ -105,6 +106,10 @@ export interface MEOrder {
|
|
|
105
106
|
triggerPrice?: string;
|
|
106
107
|
quantity?: number;
|
|
107
108
|
quoteQuantity?: string;
|
|
109
|
+
/** Filled contracts; remaining size is `quantity - cum_quantity`. */
|
|
110
|
+
cum_quantity?: number;
|
|
111
|
+
/** Filled quote notional string; remaining quote is `quote_quantity - cum_quote_quantity`. */
|
|
112
|
+
cum_quote_quantity?: string;
|
|
108
113
|
status?: string;
|
|
109
114
|
[key: string]: unknown;
|
|
110
115
|
}
|
|
@@ -117,9 +122,21 @@ export interface ListOrdersResponse {
|
|
|
117
122
|
export interface PlaceOrderResponse {
|
|
118
123
|
order: MEOrder;
|
|
119
124
|
}
|
|
125
|
+
export interface BatchPlaceOrderResponse {
|
|
126
|
+
results: Array<{
|
|
127
|
+
order?: MEOrder;
|
|
128
|
+
err?: unknown;
|
|
129
|
+
}>;
|
|
130
|
+
}
|
|
120
131
|
export interface CancelOrderResponse {
|
|
121
132
|
result: unknown;
|
|
122
133
|
}
|
|
134
|
+
export interface ReplaceOrderResponse {
|
|
135
|
+
result: unknown;
|
|
136
|
+
}
|
|
137
|
+
export interface BatchReplaceOrderResponse {
|
|
138
|
+
results: unknown[];
|
|
139
|
+
}
|
|
123
140
|
export interface EthereumWithdrawQuoteResponse {
|
|
124
141
|
quote: {
|
|
125
142
|
quoteId: string;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
1
2
|
/**
|
|
2
3
|
* In-process session spend cap. Tracks how much an agent has committed across
|
|
3
|
-
* `place_bet`
|
|
4
|
-
*
|
|
4
|
+
* `place_bet`, `place_order` / `batch_place_orders` (bid side), and incremental
|
|
5
|
+
* bid notional from `replace_order` / `batch_replace_orders` within a single
|
|
6
|
+
* server lifetime, and refuses further spend once the configured cap is reached.
|
|
5
7
|
*
|
|
6
8
|
* Independent of (additive to) any rate or balance limits the backend enforces.
|
|
7
9
|
* Resets when the process restarts.
|
|
@@ -16,15 +18,15 @@ export declare class SpendCapTracker {
|
|
|
16
18
|
effectiveCap(): number | null;
|
|
17
19
|
/**
|
|
18
20
|
* Throws SpendCapExceededError if `amount` would push cumulative spend over
|
|
19
|
-
* the cap. If the cap is null, this is a no-op.
|
|
21
|
+
* the cap. If the cap is null, this is a no-op.
|
|
20
22
|
*/
|
|
21
|
-
reserve(amount:
|
|
23
|
+
reserve(amount: BigNumber.Value, toolName: string): void;
|
|
22
24
|
/**
|
|
23
25
|
* Return a previously reserved amount to the cap, e.g. when the backend
|
|
24
26
|
* definitively rejected the placement (4xx) so nothing was actually spent.
|
|
25
27
|
* Ambiguous failures (network errors, 5xx) should NOT be released.
|
|
26
28
|
*/
|
|
27
|
-
release(amount:
|
|
29
|
+
release(amount: BigNumber.Value): void;
|
|
28
30
|
}
|
|
29
31
|
export declare class SpendCapExceededError extends Error {
|
|
30
32
|
readonly spent: number;
|
package/dist/safety/spend-cap.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
1
2
|
/**
|
|
2
3
|
* In-process session spend cap. Tracks how much an agent has committed across
|
|
3
|
-
* `place_bet`
|
|
4
|
-
*
|
|
4
|
+
* `place_bet`, `place_order` / `batch_place_orders` (bid side), and incremental
|
|
5
|
+
* bid notional from `replace_order` / `batch_replace_orders` within a single
|
|
6
|
+
* server lifetime, and refuses further spend once the configured cap is reached.
|
|
5
7
|
*
|
|
6
8
|
* Independent of (additive to) any rate or balance limits the backend enforces.
|
|
7
9
|
* Resets when the process restarts.
|
|
8
10
|
*/
|
|
9
11
|
export class SpendCapTracker {
|
|
10
12
|
cap;
|
|
11
|
-
spent = 0;
|
|
13
|
+
spent = new BigNumber(0);
|
|
12
14
|
constructor(cap) {
|
|
13
15
|
this.cap = cap;
|
|
14
16
|
}
|
|
15
17
|
/** How much has been committed so far (USD). */
|
|
16
18
|
totalSpent() {
|
|
17
|
-
return this.spent;
|
|
19
|
+
return this.spent.toNumber();
|
|
18
20
|
}
|
|
19
21
|
/** Effective cap, or `null` if unset. */
|
|
20
22
|
effectiveCap() {
|
|
@@ -22,22 +24,24 @@ export class SpendCapTracker {
|
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Throws SpendCapExceededError if `amount` would push cumulative spend over
|
|
25
|
-
* the cap. If the cap is null, this is a no-op.
|
|
27
|
+
* the cap. If the cap is null, this is a no-op.
|
|
26
28
|
*/
|
|
27
29
|
reserve(amount, toolName) {
|
|
28
30
|
if (this.cap === null)
|
|
29
31
|
return;
|
|
30
|
-
|
|
32
|
+
const value = new BigNumber(amount);
|
|
33
|
+
if (!value.isFinite() || value.lt(0)) {
|
|
31
34
|
throw new SpendCapExceededError(`Tool ${toolName} attempted to reserve a negative or non-finite amount (${amount}). ` +
|
|
32
|
-
`Refusing as a safety measure.`, this.
|
|
35
|
+
`Refusing as a safety measure.`, this.totalSpent(), this.cap);
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const next = this.spent.plus(value);
|
|
38
|
+
if (next.gt(this.cap)) {
|
|
39
|
+
throw new SpendCapExceededError(`Refusing ${toolName}: this call would commit ${value.toFixed(2)} USD, bringing the ` +
|
|
40
|
+
`session total to ${next.toFixed(2)} USD and exceeding the configured ` +
|
|
37
41
|
`cap of ${this.cap.toFixed(2)} USD (UNHEDGED_MAX_SPEND_PER_SESSION). ` +
|
|
38
|
-
`Restart the MCP server or raise the cap to continue.`, this.
|
|
42
|
+
`Restart the MCP server or raise the cap to continue.`, this.totalSpent(), this.cap);
|
|
39
43
|
}
|
|
40
|
-
this.spent
|
|
44
|
+
this.spent = next;
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
47
|
* Return a previously reserved amount to the cap, e.g. when the backend
|
|
@@ -47,9 +51,10 @@ export class SpendCapTracker {
|
|
|
47
51
|
release(amount) {
|
|
48
52
|
if (this.cap === null)
|
|
49
53
|
return;
|
|
50
|
-
|
|
54
|
+
const value = new BigNumber(amount);
|
|
55
|
+
if (!value.isFinite() || value.lte(0))
|
|
51
56
|
return;
|
|
52
|
-
this.spent =
|
|
57
|
+
this.spent = BigNumber.maximum(0, this.spent.minus(value));
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
export class SpendCapExceededError extends Error {
|
package/dist/tools/orders.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import BigNumber from 'bignumber.js';
|
|
2
3
|
import { defineTool, READ_ONLY } from './types.js';
|
|
3
4
|
import { UnhedgedApiError } from '../client/errors.js';
|
|
4
5
|
import { SpendCapExceededError } from '../safety/spend-cap.js';
|
|
@@ -13,7 +14,7 @@ Filters:
|
|
|
13
14
|
- **includeTotal**: include an exact account-wide total.
|
|
14
15
|
- **includeMarketDetails**: include compact market metadata for account-wide rows.
|
|
15
16
|
|
|
16
|
-
Returns \`{ orders, nextCursor }\` and, when requested, \`total\` / \`markets\`. Follow \`nextCursor\` until it is null when a complete account-wide inventory is required. Each order has \`id\`, \`
|
|
17
|
+
Returns \`{ orders, nextCursor }\` and, when requested, \`total\` / \`markets\`. Follow \`nextCursor\` until it is null when a complete account-wide inventory is required. Each order has \`id\`, optional \`client_order_id\`, \`market_id\`, \`outcome\`, \`side\` (\`bid\` or \`ask\`), \`type\` (\`Limit | Market | StopLoss | TakeProfit\`), \`price\`, \`quantity\` or \`quote_quantity\`, and status. Pending conditional orders (StopLoss/TakeProfit) are included while open.
|
|
17
18
|
|
|
18
19
|
Only works on CLOB markets — parimutuel markets won't have any orders to return.
|
|
19
20
|
|
|
@@ -43,15 +44,20 @@ Required parameters:
|
|
|
43
44
|
- **outcome**: outcome label, e.g. \`"YES"\`, \`"NO"\`, \`"$95k - $100k"\`. Match exactly the labels from \`get_market\`.
|
|
44
45
|
- **side**: \`"bid"\` to buy a contract on this outcome, \`"ask"\` to sell.
|
|
45
46
|
- **type**: \`"Limit"\`, \`"Market"\`, \`"StopLoss"\`, or \`"TakeProfit"\`.
|
|
46
|
-
- **One of** \`quantity\` (
|
|
47
|
+
- **One of** \`quantity\` (contracts as a 2-decimal string, min \`"0.01"\`, e.g. \`"5.00"\`) **or** \`quoteQuantity\` (decimal USD notional string with exactly 2 decimals). Not both.
|
|
47
48
|
|
|
48
49
|
Conditional parameters:
|
|
49
50
|
- **price**: required for \`Limit\`. Decimal string with exactly 2 decimals, strictly between 0 and 1 (probability).
|
|
50
51
|
- **triggerPrice**: required for \`StopLoss\` and \`TakeProfit\`. Same bounds as price.
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
Optional:
|
|
54
|
+
- **clientOrderId**: your own id for this open order (1–64 chars, \`[A-Za-z0-9._:-]\`, case preserved). Unique among **open** orders for this balance in this market (all outcomes and API keys share that namespace). Echoed on the order as \`client_order_id\`. After the order closes the alias may be reused — that does **not** make a timed-out placement safe to retry with the same id.
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
Returns \`{ order: ... }\` with the engine-assigned ID, optional \`client_order_id\`, status, and any immediate fills.
|
|
57
|
+
|
|
58
|
+
The MCP server automatically adds a fresh \`Idempotency-Key\` on each call. It
|
|
59
|
+
does not retry automatically, so calling this tool again creates a new request
|
|
60
|
+
rather than replaying the first.
|
|
55
61
|
|
|
56
62
|
Requires scope: \`order:place\`.
|
|
57
63
|
|
|
@@ -68,7 +74,8 @@ Buy 5 contracts of YES at probability 0.55:
|
|
|
68
74
|
"side": "bid",
|
|
69
75
|
"type": "Limit",
|
|
70
76
|
"price": "0.55",
|
|
71
|
-
"quantity": 5
|
|
77
|
+
"quantity": "5.00",
|
|
78
|
+
"clientOrderId": "bot-42"
|
|
72
79
|
}
|
|
73
80
|
\`\`\`
|
|
74
81
|
|
|
@@ -88,22 +95,36 @@ Cancel one, several, or all of the user's open orders on a single CLOB market.
|
|
|
88
95
|
|
|
89
96
|
Required:
|
|
90
97
|
- **marketId**: market to cancel on.
|
|
91
|
-
- **orderIds**: array of order IDs (as strings)
|
|
98
|
+
- **orderIds**: array of numeric order IDs (as strings). May be \`[]\` when cancelling only by \`clientOrderIds\`, or when cancelling all.
|
|
99
|
+
|
|
100
|
+
Optional:
|
|
101
|
+
- **clientOrderIds**: array of client order IDs (same charset/length as placement). Targets are the **union** of \`orderIds\` and resolved \`clientOrderIds\`. Unresolved client IDs are soft failures (continue cancelling the rest; first failure in \`err\`). Case is preserved.
|
|
102
|
+
|
|
103
|
+
**Cancel-all** (every open order for this balance on the market) runs only when **both** \`orderIds\` and \`clientOrderIds\` are empty (omit \`clientOrderIds\` or pass \`[]\`). A nonempty \`clientOrderIds\` list with \`orderIds: []\` is a **targeted** cancel, not cancel-all.
|
|
104
|
+
|
|
105
|
+
Numeric IDs and client IDs are best-effort (\`dropped\` plus \`err\` on partial success). An alias can be reused after an order closes, so a later cancel by the same string may hit a **different** open order — do not treat repeat cancel as a durable receipt for the original intent.
|
|
92
106
|
|
|
93
|
-
Returns \`{ result }\` with engine-side
|
|
107
|
+
Returns \`{ result }\` with engine-side outcomes (typically \`dropped\` order ids).
|
|
94
108
|
|
|
95
|
-
|
|
109
|
+
If the market is not ACTIVE or the trading cutoff has passed, REST returns HTTP
|
|
110
|
+
200 with an empty \`dropped\` list without calling the engine.
|
|
96
111
|
|
|
97
|
-
The MCP server adds
|
|
112
|
+
The MCP server adds a fresh \`Idempotency-Key\` on each call. The API response
|
|
113
|
+
cache excludes \`DELETE\`, so cancel requests are not replay-protected.
|
|
98
114
|
|
|
99
115
|
Requires scope: \`order:cancel\`.
|
|
100
116
|
|
|
101
117
|
### Examples
|
|
102
|
-
Cancel two specific orders:
|
|
118
|
+
Cancel two specific orders by numeric id:
|
|
103
119
|
\`\`\`json
|
|
104
120
|
{ "marketId": "clxabc123...", "orderIds": ["1042", "1051"] }
|
|
105
121
|
\`\`\`
|
|
106
122
|
|
|
123
|
+
Cancel by client id only:
|
|
124
|
+
\`\`\`json
|
|
125
|
+
{ "marketId": "clxabc123...", "orderIds": [], "clientOrderIds": ["bot-42"] }
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
107
128
|
Cancel **all** open orders on this market:
|
|
108
129
|
\`\`\`json
|
|
109
130
|
{ "marketId": "clxabc123...", "orderIds": [] }
|
|
@@ -131,6 +152,13 @@ const positiveDecimalStringSchema = z
|
|
|
131
152
|
const n = Number(value);
|
|
132
153
|
return Number.isFinite(n) && n > 0;
|
|
133
154
|
});
|
|
155
|
+
/** Matches REST `clientOrderId` / `clientOrderIds`: 1–64 of [A-Za-z0-9._:-], case preserved. */
|
|
156
|
+
const clientOrderIdSchema = z
|
|
157
|
+
.string()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(64)
|
|
160
|
+
.regex(/^[A-Za-z0-9._:-]+$/)
|
|
161
|
+
.describe('Client order id (1–64 chars, letters/digits/._:-). Unique among open orders per balance+market.');
|
|
134
162
|
const placeSchema = {
|
|
135
163
|
marketId: z.string().min(1),
|
|
136
164
|
outcome: z.string().min(1).describe('Outcome label, e.g. "YES" or "$95k - $100k".'),
|
|
@@ -142,21 +170,23 @@ const placeSchema = {
|
|
|
142
170
|
triggerPrice: probabilityStringSchema
|
|
143
171
|
.optional()
|
|
144
172
|
.describe('Trigger probability decimal string between 0 and 1 with exactly 2 decimals. Required for StopLoss/TakeProfit.'),
|
|
145
|
-
quantity:
|
|
146
|
-
.number()
|
|
147
|
-
.int()
|
|
148
|
-
.positive()
|
|
173
|
+
quantity: positiveDecimalStringSchema
|
|
149
174
|
.optional()
|
|
150
|
-
.describe('Number of contracts as a
|
|
175
|
+
.describe('Number of contracts as a 2-decimal string (min "0.01").'),
|
|
151
176
|
quoteQuantity: positiveDecimalStringSchema
|
|
152
177
|
.optional()
|
|
153
178
|
.describe('USD notional string with exactly 2 decimals. Use INSTEAD of quantity.'),
|
|
179
|
+
clientOrderId: clientOrderIdSchema.optional(),
|
|
154
180
|
};
|
|
155
181
|
const cancelSchema = {
|
|
156
182
|
marketId: z.string().min(1),
|
|
157
183
|
orderIds: z
|
|
158
184
|
.array(z.string())
|
|
159
|
-
.describe('
|
|
185
|
+
.describe('Numeric order IDs to cancel. Pass [] with clientOrderIds for client-only cancel, or [] alone (and empty/omitted clientOrderIds) for cancel-all.'),
|
|
186
|
+
clientOrderIds: z
|
|
187
|
+
.array(clientOrderIdSchema)
|
|
188
|
+
.optional()
|
|
189
|
+
.describe('Optional client order IDs to cancel (union with orderIds). Unknown ids reject the whole request. Both lists empty = cancel-all.'),
|
|
160
190
|
};
|
|
161
191
|
/**
|
|
162
192
|
* Best-effort client-side notional estimate for the spend cap.
|
|
@@ -164,17 +194,175 @@ const cancelSchema = {
|
|
|
164
194
|
*/
|
|
165
195
|
function estimateBidNotional(args) {
|
|
166
196
|
if (args.side !== 'bid')
|
|
167
|
-
return 0;
|
|
197
|
+
return new BigNumber(0);
|
|
168
198
|
if (typeof args.quoteQuantity === 'string')
|
|
169
|
-
return
|
|
170
|
-
if (args.type === 'Limit' && typeof args.price === 'string' &&
|
|
171
|
-
const price =
|
|
172
|
-
|
|
199
|
+
return finiteDecimal(args.quoteQuantity) ?? null;
|
|
200
|
+
if (args.type === 'Limit' && typeof args.price === 'string' && args.quantity != null) {
|
|
201
|
+
const price = new BigNumber(args.price);
|
|
202
|
+
const qty = new BigNumber(args.quantity);
|
|
203
|
+
if (!price.isFinite() || !qty.isFinite())
|
|
173
204
|
return null;
|
|
174
|
-
return price
|
|
205
|
+
return price.times(qty);
|
|
175
206
|
}
|
|
176
207
|
return null;
|
|
177
208
|
}
|
|
209
|
+
function isRecord(value) {
|
|
210
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
211
|
+
}
|
|
212
|
+
function isInternalCancelStatus(status) {
|
|
213
|
+
if (status === 'InternalCancel')
|
|
214
|
+
return true;
|
|
215
|
+
return isRecord(status) && Object.prototype.hasOwnProperty.call(status, 'InternalCancel');
|
|
216
|
+
}
|
|
217
|
+
function positiveNumericField(record, field) {
|
|
218
|
+
const value = record[field];
|
|
219
|
+
if (typeof value === 'number')
|
|
220
|
+
return Number.isFinite(value) && value > 0;
|
|
221
|
+
if (typeof value === 'string') {
|
|
222
|
+
const parsed = Number(value);
|
|
223
|
+
return Number.isFinite(parsed) && parsed > 0;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
/** Match backend `hasExecutedFill`: either base or quote cumulative fill counts. */
|
|
228
|
+
function orderHasExecutedFill(order) {
|
|
229
|
+
return (positiveNumericField(order, 'cum_quantity') ||
|
|
230
|
+
positiveNumericField(order, 'cumQuantity') ||
|
|
231
|
+
positiveNumericField(order, 'cum_quote_quantity') ||
|
|
232
|
+
positiveNumericField(order, 'cumQuoteQuantity'));
|
|
233
|
+
}
|
|
234
|
+
/** Per-leg outcomes from a committed batch returned with HTTP 400. */
|
|
235
|
+
function resultsFromApiError(err) {
|
|
236
|
+
if (!isRecord(err.responseBody) || !Array.isArray(err.responseBody.results))
|
|
237
|
+
return null;
|
|
238
|
+
return err.responseBody.results;
|
|
239
|
+
}
|
|
240
|
+
/** True when a batch-place leg committed buy exposure we should keep reserved. */
|
|
241
|
+
function batchPlaceLegConsumedSpend(result) {
|
|
242
|
+
if (result == null || result.order == null)
|
|
243
|
+
return false;
|
|
244
|
+
if (!isRecord(result.order))
|
|
245
|
+
return false;
|
|
246
|
+
// Soft InternalCancel with no fills: nothing locked/spent — release like `{ err }`.
|
|
247
|
+
if (isInternalCancelStatus(result.order.status)) {
|
|
248
|
+
return orderHasExecutedFill(result.order);
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* True when a replace outcome kept incremental buy exposure.
|
|
254
|
+
* Used for single `replace_order` results and batch-replace legs.
|
|
255
|
+
* Releases `{ err }`, `unchanged`, and zero-fill InternalCancel replacements.
|
|
256
|
+
*/
|
|
257
|
+
function batchReplaceLegConsumedSpend(result) {
|
|
258
|
+
if (!isRecord(result))
|
|
259
|
+
return false;
|
|
260
|
+
if ('err' in result)
|
|
261
|
+
return false;
|
|
262
|
+
if (result.status === 'unchanged')
|
|
263
|
+
return false;
|
|
264
|
+
const replacement = result.replacementOrder ?? result.replacement_order;
|
|
265
|
+
if (isRecord(replacement) && isInternalCancelStatus(replacement.status)) {
|
|
266
|
+
return orderHasExecutedFill(replacement);
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
function orderIdOf(order) {
|
|
271
|
+
if (order.id == null)
|
|
272
|
+
return undefined;
|
|
273
|
+
return String(order.id);
|
|
274
|
+
}
|
|
275
|
+
function clientOrderIdOf(order) {
|
|
276
|
+
const value = order.client_order_id ?? order.clientOrderId;
|
|
277
|
+
return typeof value === 'string' ? value : undefined;
|
|
278
|
+
}
|
|
279
|
+
function sideOf(order) {
|
|
280
|
+
return order.side === 'bid' || order.side === 'ask' ? order.side : null;
|
|
281
|
+
}
|
|
282
|
+
function quoteQuantityOf(order) {
|
|
283
|
+
const value = order.quoteQuantity ?? order.quote_quantity;
|
|
284
|
+
return typeof value === 'string' ? value : undefined;
|
|
285
|
+
}
|
|
286
|
+
function finiteDecimal(value) {
|
|
287
|
+
if (typeof value !== 'number' && typeof value !== 'string')
|
|
288
|
+
return undefined;
|
|
289
|
+
const parsed = new BigNumber(value);
|
|
290
|
+
return parsed.isFinite() ? parsed : undefined;
|
|
291
|
+
}
|
|
292
|
+
function orderTypeOf(order) {
|
|
293
|
+
const value = order.type;
|
|
294
|
+
if (value === 'Limit' || value === 'Market' || value === 'StopLoss' || value === 'TakeProfit') {
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
return 'Limit';
|
|
298
|
+
}
|
|
299
|
+
function findOpenOrder(orders, identity) {
|
|
300
|
+
if (identity.orderId != null) {
|
|
301
|
+
return orders.find((order) => orderIdOf(order) === identity.orderId);
|
|
302
|
+
}
|
|
303
|
+
if (identity.clientOrderId != null) {
|
|
304
|
+
return orders.find((order) => clientOrderIdOf(order) === identity.clientOrderId);
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Estimated buy notional still locked by an open source bid (remaining size).
|
|
310
|
+
* Partially filled quantity orders use `quantity - cum_quantity`; quote-budget
|
|
311
|
+
* orders use leftover `quote_quantity - cum_quote_quantity`.
|
|
312
|
+
*/
|
|
313
|
+
function estimateSourceBidLockNotional(source) {
|
|
314
|
+
const quoteQuantity = quoteQuantityOf(source);
|
|
315
|
+
if (typeof quoteQuantity === 'string') {
|
|
316
|
+
const remainingQuote = new BigNumber(quoteQuantity).minus(finiteDecimal(source.cum_quote_quantity ?? source.cumQuoteQuantity) ?? 0);
|
|
317
|
+
if (!remainingQuote.isFinite())
|
|
318
|
+
return null;
|
|
319
|
+
return BigNumber.maximum(0, remainingQuote);
|
|
320
|
+
}
|
|
321
|
+
const quantity = finiteDecimal(source.quantity);
|
|
322
|
+
if (quantity === undefined)
|
|
323
|
+
return null;
|
|
324
|
+
const remainingQuantity = BigNumber.maximum(0, quantity.minus(finiteDecimal(source.cum_quantity ?? source.cumQuantity) ?? 0));
|
|
325
|
+
return estimateBidNotional({
|
|
326
|
+
side: 'bid',
|
|
327
|
+
type: orderTypeOf(source),
|
|
328
|
+
price: typeof source.price === 'string' ? source.price : undefined,
|
|
329
|
+
quantity: remainingQuantity,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Incremental buy notional a replace would add beyond the source order's
|
|
334
|
+
* current remaining estimated lock. Asks are 0. Returns `null` when
|
|
335
|
+
* side/exposure can't be determined from the open-order snapshot.
|
|
336
|
+
*/
|
|
337
|
+
function estimateReplacementSpendDelta(source, replacement) {
|
|
338
|
+
if (source == null)
|
|
339
|
+
return null;
|
|
340
|
+
const side = sideOf(source);
|
|
341
|
+
if (side == null)
|
|
342
|
+
return null;
|
|
343
|
+
if (side === 'ask')
|
|
344
|
+
return new BigNumber(0);
|
|
345
|
+
const oldNotional = estimateSourceBidLockNotional(source);
|
|
346
|
+
if (oldNotional === null)
|
|
347
|
+
return null;
|
|
348
|
+
const newNotional = estimateBidNotional({
|
|
349
|
+
side: 'bid',
|
|
350
|
+
type: 'Limit',
|
|
351
|
+
price: replacement.price,
|
|
352
|
+
quantity: replacement.quantity,
|
|
353
|
+
});
|
|
354
|
+
if (newNotional === null)
|
|
355
|
+
return null;
|
|
356
|
+
return BigNumber.maximum(0, newNotional.minus(oldNotional));
|
|
357
|
+
}
|
|
358
|
+
async function loadOpenOrdersForSpendCap(ctx, marketId) {
|
|
359
|
+
const response = await ctx.client.request({
|
|
360
|
+
method: 'GET',
|
|
361
|
+
path: '/api/v1/orders',
|
|
362
|
+
query: { marketId, limit: 500 },
|
|
363
|
+
});
|
|
364
|
+
return Array.isArray(response.orders) ? response.orders : [];
|
|
365
|
+
}
|
|
178
366
|
export const orderTools = [
|
|
179
367
|
defineTool({
|
|
180
368
|
name: 'list_orders',
|
|
@@ -211,7 +399,7 @@ export const orderTools = [
|
|
|
211
399
|
if ((args.type === 'StopLoss' || args.type === 'TakeProfit') && args.triggerPrice === undefined) {
|
|
212
400
|
throw new Error(`triggerPrice is required for ${args.type} orders.`);
|
|
213
401
|
}
|
|
214
|
-
let reserved = 0;
|
|
402
|
+
let reserved = new BigNumber(0);
|
|
215
403
|
if (ctx.spendCap.effectiveCap() !== null) {
|
|
216
404
|
const notional = estimateBidNotional(args);
|
|
217
405
|
if (notional === null) {
|
|
@@ -222,16 +410,32 @@ export const orderTools = [
|
|
|
222
410
|
reserved = notional;
|
|
223
411
|
}
|
|
224
412
|
try {
|
|
413
|
+
const body = {
|
|
414
|
+
marketId: args.marketId,
|
|
415
|
+
outcome: args.outcome,
|
|
416
|
+
side: args.side,
|
|
417
|
+
type: args.type,
|
|
418
|
+
};
|
|
419
|
+
if (args.price !== undefined)
|
|
420
|
+
body.price = args.price;
|
|
421
|
+
if (args.triggerPrice !== undefined)
|
|
422
|
+
body.triggerPrice = args.triggerPrice;
|
|
423
|
+
if (args.quantity !== undefined)
|
|
424
|
+
body.quantity = args.quantity;
|
|
425
|
+
if (args.quoteQuantity !== undefined)
|
|
426
|
+
body.quoteQuantity = args.quoteQuantity;
|
|
427
|
+
if (args.clientOrderId !== undefined)
|
|
428
|
+
body.clientOrderId = args.clientOrderId;
|
|
225
429
|
return await ctx.client.request({
|
|
226
430
|
method: 'POST',
|
|
227
431
|
path: '/api/v1/orders',
|
|
228
|
-
body
|
|
432
|
+
body,
|
|
229
433
|
});
|
|
230
434
|
}
|
|
231
435
|
catch (err) {
|
|
232
436
|
// Definitive server-side rejection (4xx) — nothing was spent, so give
|
|
233
437
|
// the reservation back. Ambiguous outcomes keep it.
|
|
234
|
-
if (reserved
|
|
438
|
+
if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
|
|
235
439
|
ctx.spendCap.release(reserved);
|
|
236
440
|
}
|
|
237
441
|
throw err;
|
|
@@ -243,14 +447,324 @@ export const orderTools = [
|
|
|
243
447
|
title: 'Cancel one or more open orders',
|
|
244
448
|
description: CANCEL_DESCRIPTION,
|
|
245
449
|
inputSchema: cancelSchema,
|
|
246
|
-
// Destructive:
|
|
247
|
-
|
|
450
|
+
// Destructive: both target lists empty cancels EVERY open order on the market.
|
|
451
|
+
// Not durable-idempotent for client aliases: after an order closes the same string
|
|
452
|
+
// can name a later open order.
|
|
453
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
248
454
|
handler: async (args, ctx) => {
|
|
455
|
+
const body = {
|
|
456
|
+
marketId: args.marketId,
|
|
457
|
+
orderIds: args.orderIds,
|
|
458
|
+
};
|
|
459
|
+
if (args.clientOrderIds !== undefined) {
|
|
460
|
+
body.clientOrderIds = args.clientOrderIds;
|
|
461
|
+
}
|
|
249
462
|
return ctx.client.request({
|
|
250
463
|
method: 'DELETE',
|
|
251
464
|
path: '/api/v1/orders',
|
|
252
|
-
body
|
|
465
|
+
body,
|
|
253
466
|
});
|
|
254
467
|
},
|
|
255
468
|
}),
|
|
469
|
+
defineTool({
|
|
470
|
+
name: 'batch_place_orders',
|
|
471
|
+
title: 'Batch-place order-book orders',
|
|
472
|
+
description: `
|
|
473
|
+
Place 1–32 orders on one CLOB market in a single request (\`POST /api/v1/orders/batch\`).
|
|
474
|
+
|
|
475
|
+
Required: **marketId**, **orders** (array of legs). Each leg matches \`place_order\` fields except marketId. **Market legs are not allowed**; use \`place_order\` for Market. Legs may fill. Self-trade follows the market setting.
|
|
476
|
+
|
|
477
|
+
The engine transaction commits even if a leg fails. HTTP **400** with \`{ results }\` when any leg failed (insufficient balance, invalid leg); HTTP **201** when every leg succeeded. Invalid legs are rejected by HTTP before the engine runs.
|
|
478
|
+
|
|
479
|
+
If a **session spend cap** is configured, estimated bid notional across all legs is reserved before the request (same rules as \`place_order\`). Ask legs do not count. HTTP 400 with results releases only failed legs; successful legs keep their reservation. A definitive HTTP 4xx without results releases the whole reservation.
|
|
480
|
+
|
|
481
|
+
Requires scope: \`order:place\`. Counts as one write against rate limits.
|
|
482
|
+
`.trim(),
|
|
483
|
+
inputSchema: {
|
|
484
|
+
marketId: z.string().min(1),
|
|
485
|
+
orders: z
|
|
486
|
+
.array(z.object({
|
|
487
|
+
outcome: placeSchema.outcome,
|
|
488
|
+
side: placeSchema.side,
|
|
489
|
+
type: z.enum(['Limit', 'StopLoss', 'TakeProfit']),
|
|
490
|
+
price: placeSchema.price,
|
|
491
|
+
triggerPrice: placeSchema.triggerPrice,
|
|
492
|
+
quantity: placeSchema.quantity,
|
|
493
|
+
quoteQuantity: placeSchema.quoteQuantity,
|
|
494
|
+
clientOrderId: placeSchema.clientOrderId,
|
|
495
|
+
}))
|
|
496
|
+
.min(1)
|
|
497
|
+
.max(32),
|
|
498
|
+
},
|
|
499
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
500
|
+
handler: async (args, ctx) => {
|
|
501
|
+
for (const [i, leg] of args.orders.entries()) {
|
|
502
|
+
if ((leg.quantity === undefined) === (leg.quoteQuantity === undefined)) {
|
|
503
|
+
throw new Error(`orders[${i}]: provide exactly one of quantity or quoteQuantity.`);
|
|
504
|
+
}
|
|
505
|
+
if (leg.type === 'Limit' && leg.price === undefined) {
|
|
506
|
+
throw new Error(`orders[${i}]: price is required for Limit legs.`);
|
|
507
|
+
}
|
|
508
|
+
if ((leg.type === 'StopLoss' || leg.type === 'TakeProfit') &&
|
|
509
|
+
leg.triggerPrice === undefined) {
|
|
510
|
+
throw new Error(`orders[${i}]: triggerPrice is required for ${leg.type} legs.`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Parallel to args.orders: per-leg estimated bid notional (0 for asks).
|
|
514
|
+
// Used to release only failed legs after a committed partial batch.
|
|
515
|
+
let legNotionals = null;
|
|
516
|
+
let reserved = new BigNumber(0);
|
|
517
|
+
if (ctx.spendCap.effectiveCap() !== null) {
|
|
518
|
+
legNotionals = [];
|
|
519
|
+
for (const [i, leg] of args.orders.entries()) {
|
|
520
|
+
const notional = estimateBidNotional(leg);
|
|
521
|
+
if (notional === null) {
|
|
522
|
+
throw new SpendCapExceededError(`Refusing batch_place_orders: a session spend cap is configured but orders[${i}] notional can't be estimated client-side ` +
|
|
523
|
+
`(${leg.type} ${leg.side} without quoteQuantity). Set quoteQuantity, or use a Limit leg with price+quantity.`, ctx.spendCap.totalSpent(), ctx.spendCap.effectiveCap());
|
|
524
|
+
}
|
|
525
|
+
legNotionals.push(notional);
|
|
526
|
+
}
|
|
527
|
+
const total = legNotionals.reduce((sum, n) => sum.plus(n), new BigNumber(0));
|
|
528
|
+
if (total.gt(0)) {
|
|
529
|
+
ctx.spendCap.reserve(total, 'batch_place_orders');
|
|
530
|
+
reserved = total;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const response = await ctx.client.request({
|
|
535
|
+
method: 'POST',
|
|
536
|
+
path: '/api/v1/orders/batch',
|
|
537
|
+
body: {
|
|
538
|
+
marketId: args.marketId,
|
|
539
|
+
orders: args.orders,
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
// Also support older API versions that return partial results with HTTP 201.
|
|
543
|
+
if (legNotionals !== null && Array.isArray(response.results)) {
|
|
544
|
+
let toRelease = new BigNumber(0);
|
|
545
|
+
const n = Math.min(legNotionals.length, response.results.length);
|
|
546
|
+
for (let i = 0; i < n; i++) {
|
|
547
|
+
if (!batchPlaceLegConsumedSpend(response.results[i])) {
|
|
548
|
+
toRelease = toRelease.plus(legNotionals[i]);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (toRelease.gt(0))
|
|
552
|
+
ctx.spendCap.release(toRelease);
|
|
553
|
+
}
|
|
554
|
+
return response;
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
|
|
558
|
+
const results = resultsFromApiError(err);
|
|
559
|
+
if (legNotionals !== null && results) {
|
|
560
|
+
let toRelease = new BigNumber(0);
|
|
561
|
+
const n = Math.min(legNotionals.length, results.length);
|
|
562
|
+
for (let i = 0; i < n; i++) {
|
|
563
|
+
if (!batchPlaceLegConsumedSpend(results[i])) {
|
|
564
|
+
toRelease = toRelease.plus(legNotionals[i]);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (toRelease.gt(0))
|
|
568
|
+
ctx.spendCap.release(toRelease);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
ctx.spendCap.release(reserved);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
}),
|
|
578
|
+
defineTool({
|
|
579
|
+
name: 'replace_order',
|
|
580
|
+
title: 'Replace an open Limit order',
|
|
581
|
+
description: `
|
|
582
|
+
Atomically cancel one open Limit order and place a replacement (\`POST /api/v1/orders/replace\`).
|
|
583
|
+
|
|
584
|
+
Required: **marketId**, **price**, **quantity**, and exactly one of **orderId** / **clientOrderId**.
|
|
585
|
+
Optional: **replacementClientOrderId**.
|
|
586
|
+
|
|
587
|
+
If a **session spend cap** is configured, the tool loads the source open order first. Ask replacements do not count. Bid replacements reserve only the **incremental** estimated notional (\`max(0, new − remaining old)\`), where the old lock uses remaining size after \`cum_quantity\` / \`cum_quote_quantity\`. If the source side or exposure cannot be determined (missing from \`list_orders\`, unknown side, or unsized), the call is refused before submit. A definitive HTTP 4xx releases the reservation. On success, the reservation is also released for \`unchanged\` and zero-fill \`InternalCancel\` outcomes (nothing incremental was locked).
|
|
588
|
+
|
|
589
|
+
Requires scopes: \`order:place\` and \`order:cancel\` (plus \`order:read\` when a spend cap is set, to resolve the source).
|
|
590
|
+
`.trim(),
|
|
591
|
+
inputSchema: {
|
|
592
|
+
marketId: z.string().min(1),
|
|
593
|
+
orderId: z.string().regex(/^[0-9]+$/).optional(),
|
|
594
|
+
clientOrderId: clientOrderIdSchema.optional(),
|
|
595
|
+
replacementClientOrderId: clientOrderIdSchema.optional(),
|
|
596
|
+
price: probabilityStringSchema,
|
|
597
|
+
quantity: positiveDecimalStringSchema,
|
|
598
|
+
},
|
|
599
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
600
|
+
handler: async (args, ctx) => {
|
|
601
|
+
const hasOrderId = args.orderId != null;
|
|
602
|
+
const hasClientOrderId = args.clientOrderId != null;
|
|
603
|
+
if (hasOrderId === hasClientOrderId) {
|
|
604
|
+
throw new Error('Specify exactly one of orderId or clientOrderId.');
|
|
605
|
+
}
|
|
606
|
+
let reserved = new BigNumber(0);
|
|
607
|
+
if (ctx.spendCap.effectiveCap() !== null) {
|
|
608
|
+
const openOrders = await loadOpenOrdersForSpendCap(ctx, args.marketId);
|
|
609
|
+
const source = findOpenOrder(openOrders, {
|
|
610
|
+
orderId: args.orderId,
|
|
611
|
+
clientOrderId: args.clientOrderId,
|
|
612
|
+
});
|
|
613
|
+
const delta = estimateReplacementSpendDelta(source, {
|
|
614
|
+
price: args.price,
|
|
615
|
+
quantity: args.quantity,
|
|
616
|
+
});
|
|
617
|
+
if (delta === null) {
|
|
618
|
+
const target = args.orderId != null ? `orderId=${args.orderId}` : `clientOrderId=${args.clientOrderId}`;
|
|
619
|
+
throw new SpendCapExceededError(`Refusing replace_order: a session spend cap is configured but the source side/exposure for ${target} ` +
|
|
620
|
+
`can't be determined from open orders on this market. Ensure the order is still open and the API key ` +
|
|
621
|
+
`has order:read, then retry.`, ctx.spendCap.totalSpent(), ctx.spendCap.effectiveCap());
|
|
622
|
+
}
|
|
623
|
+
if (delta.gt(0)) {
|
|
624
|
+
ctx.spendCap.reserve(delta, 'replace_order');
|
|
625
|
+
reserved = delta;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const body = {
|
|
629
|
+
marketId: args.marketId,
|
|
630
|
+
price: args.price,
|
|
631
|
+
quantity: args.quantity,
|
|
632
|
+
};
|
|
633
|
+
if (args.orderId !== undefined)
|
|
634
|
+
body.orderId = args.orderId;
|
|
635
|
+
if (args.clientOrderId !== undefined)
|
|
636
|
+
body.clientOrderId = args.clientOrderId;
|
|
637
|
+
if (args.replacementClientOrderId !== undefined) {
|
|
638
|
+
body.replacementClientOrderId = args.replacementClientOrderId;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
const response = await ctx.client.request({
|
|
642
|
+
method: 'POST',
|
|
643
|
+
path: '/api/v1/orders/replace',
|
|
644
|
+
body,
|
|
645
|
+
});
|
|
646
|
+
// Same no-spend outcomes as a batch-replace leg (`unchanged`, zero-fill IC).
|
|
647
|
+
if (reserved.gt(0) && !batchReplaceLegConsumedSpend(response.result)) {
|
|
648
|
+
ctx.spendCap.release(reserved);
|
|
649
|
+
}
|
|
650
|
+
return response;
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
|
|
654
|
+
ctx.spendCap.release(reserved);
|
|
655
|
+
}
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
}),
|
|
660
|
+
defineTool({
|
|
661
|
+
name: 'batch_replace_orders',
|
|
662
|
+
title: 'Batch-replace open Limit orders',
|
|
663
|
+
description: `
|
|
664
|
+
Replace 1–32 open Limit orders on one market (\`POST /api/v1/orders/replace/batch\`).
|
|
665
|
+
|
|
666
|
+
The engine transaction commits even if a leg fails. A replacement may fill. Self-trade follows the market setting: disabled, it will not trade against the user's own orders. HTTP **400** with \`{ results }\` when any leg failed.
|
|
667
|
+
|
|
668
|
+
If a **session spend cap** is configured, open orders on the market are loaded first. Each bid leg reserves incremental estimated notional (\`max(0, new − remaining old)\`, using remaining size after fills); asks are 0. Bids spanning different outcomes reserve full new notional because another replacement may fill a source before its leg executes. Any leg whose source side/exposure cannot be determined refuses the whole batch before submit. HTTP 400 with results releases failed or unchanged legs; successful replacements keep their reservation. A definitive HTTP 4xx without results releases the whole reservation.
|
|
669
|
+
|
|
670
|
+
Requires scopes: \`order:place\` and \`order:cancel\` (plus \`order:read\` when a spend cap is set). One write rate-limit unit per call.
|
|
671
|
+
`.trim(),
|
|
672
|
+
inputSchema: {
|
|
673
|
+
marketId: z.string().min(1),
|
|
674
|
+
replaces: z
|
|
675
|
+
.array(z.object({
|
|
676
|
+
orderId: z.string().regex(/^[0-9]+$/).optional(),
|
|
677
|
+
clientOrderId: clientOrderIdSchema.optional(),
|
|
678
|
+
replacementClientOrderId: clientOrderIdSchema.optional(),
|
|
679
|
+
price: probabilityStringSchema,
|
|
680
|
+
quantity: positiveDecimalStringSchema,
|
|
681
|
+
}))
|
|
682
|
+
.min(1)
|
|
683
|
+
.max(32),
|
|
684
|
+
},
|
|
685
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
686
|
+
handler: async (args, ctx) => {
|
|
687
|
+
for (const [i, leg] of args.replaces.entries()) {
|
|
688
|
+
const hasOrderId = leg.orderId != null;
|
|
689
|
+
const hasClientOrderId = leg.clientOrderId != null;
|
|
690
|
+
if (hasOrderId === hasClientOrderId) {
|
|
691
|
+
throw new Error(`replaces[${i}]: specify exactly one of orderId or clientOrderId.`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
let legDeltas = null;
|
|
695
|
+
let reserved = new BigNumber(0);
|
|
696
|
+
if (ctx.spendCap.effectiveCap() !== null) {
|
|
697
|
+
const openOrders = await loadOpenOrdersForSpendCap(ctx, args.marketId);
|
|
698
|
+
const sources = args.replaces.map((leg) => findOpenOrder(openOrders, leg));
|
|
699
|
+
legDeltas = [];
|
|
700
|
+
for (const [i, leg] of args.replaces.entries()) {
|
|
701
|
+
const source = sources[i];
|
|
702
|
+
let delta = estimateReplacementSpendDelta(source, {
|
|
703
|
+
price: leg.price,
|
|
704
|
+
quantity: leg.quantity,
|
|
705
|
+
});
|
|
706
|
+
if (delta === null) {
|
|
707
|
+
const target = leg.orderId != null ? `orderId=${leg.orderId}` : `clientOrderId=${leg.clientOrderId}`;
|
|
708
|
+
throw new SpendCapExceededError(`Refusing batch_replace_orders: a session spend cap is configured but replaces[${i}] source side/exposure ` +
|
|
709
|
+
`for ${target} can't be determined from open orders on this market. Ensure the order is still open and ` +
|
|
710
|
+
`the API key has order:read, then retry.`, ctx.spendCap.totalSpent(), ctx.spendCap.effectiveCap());
|
|
711
|
+
}
|
|
712
|
+
// Cross-outcome bid matches can spend a source's lock before its leg
|
|
713
|
+
// executes. Do not credit that speculative lock toward the new order.
|
|
714
|
+
if (source && sideOf(source) === 'bid' && sources.some((other, j) => j !== i && other && sideOf(other) === 'bid' &&
|
|
715
|
+
(typeof source.outcome !== 'string' || typeof other.outcome !== 'string' ||
|
|
716
|
+
source.outcome !== other.outcome))) {
|
|
717
|
+
delta = new BigNumber(leg.price).times(leg.quantity);
|
|
718
|
+
}
|
|
719
|
+
legDeltas.push(delta);
|
|
720
|
+
}
|
|
721
|
+
const total = legDeltas.reduce((sum, n) => sum.plus(n), new BigNumber(0));
|
|
722
|
+
if (total.gt(0)) {
|
|
723
|
+
ctx.spendCap.reserve(total, 'batch_replace_orders');
|
|
724
|
+
reserved = total;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
const response = await ctx.client.request({
|
|
729
|
+
method: 'POST',
|
|
730
|
+
path: '/api/v1/orders/replace/batch',
|
|
731
|
+
body: {
|
|
732
|
+
marketId: args.marketId,
|
|
733
|
+
replaces: args.replaces,
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
if (legDeltas !== null && Array.isArray(response.results)) {
|
|
737
|
+
let toRelease = new BigNumber(0);
|
|
738
|
+
const n = Math.min(legDeltas.length, response.results.length);
|
|
739
|
+
for (let i = 0; i < n; i++) {
|
|
740
|
+
if (!batchReplaceLegConsumedSpend(response.results[i])) {
|
|
741
|
+
toRelease = toRelease.plus(legDeltas[i]);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (toRelease.gt(0))
|
|
745
|
+
ctx.spendCap.release(toRelease);
|
|
746
|
+
}
|
|
747
|
+
return response;
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
if (reserved.gt(0) && err instanceof UnhedgedApiError && err.status >= 400 && err.status < 500) {
|
|
751
|
+
const results = resultsFromApiError(err);
|
|
752
|
+
if (legDeltas !== null && results) {
|
|
753
|
+
let toRelease = new BigNumber(0);
|
|
754
|
+
const n = Math.min(legDeltas.length, results.length);
|
|
755
|
+
for (let i = 0; i < n; i++) {
|
|
756
|
+
if (!batchReplaceLegConsumedSpend(results[i]))
|
|
757
|
+
toRelease = toRelease.plus(legDeltas[i]);
|
|
758
|
+
}
|
|
759
|
+
if (toRelease.gt(0))
|
|
760
|
+
ctx.spendCap.release(toRelease);
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
ctx.spendCap.release(reserved);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
throw err;
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
}),
|
|
256
770
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unhedged/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Model Context Protocol server for Unhedged. Connect Claude, Codex, Cursor, and other MCP clients to markets, balances, trading, and withdrawals through a scoped API key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
32
|
+
"bignumber.js": "9.3.1",
|
|
32
33
|
"zod": "^4.3.6"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
@@ -37,7 +37,7 @@ The user is quoting both sides of market **{{market_id}}**, outcome **{{outcome}
|
|
|
37
37
|
- `price: fair_value - spread` (clamp to (0, 1) and round to 2 decimals)
|
|
38
38
|
- `quoteQuantity: {{size_per_side}}` — must be a string with exactly two decimals, e.g. `"5.00"`
|
|
39
39
|
|
|
40
|
-
5. **Place an ask** with `place_order
|
|
40
|
+
5. **Place an ask** with `place_order` (or combine steps 4–5 into one **`batch_place_orders`** call with both bid and ask legs — legs may fill, including each other when self-trade is enabled; one write rate-limit unit):
|
|
41
41
|
- `side: "ask"`
|
|
42
42
|
- `type: "Limit"`
|
|
43
43
|
- `price: fair_value + spread` (same clamp + rounding)
|
|
@@ -47,12 +47,22 @@ The user is quoting both sides of market **{{market_id}}**, outcome **{{outcome}
|
|
|
47
47
|
|
|
48
48
|
## Refresh loop
|
|
49
49
|
|
|
50
|
-
If the user asks to "refresh" or "re-quote"
|
|
50
|
+
If the user asks to "refresh" or "re-quote":
|
|
51
|
+
|
|
52
|
+
- Prefer **`batch_replace_orders`** when both sides already have open Limit orders with known `orderId` / `clientOrderId` values: one call can move bid and ask together (replacements may fill; self-trade follows the market setting). The transaction commits even if a leg fails; HTTP 400 when any leg failed.
|
|
53
|
+
- Otherwise repeat steps 2–6 (`cancel_order` then place). Each tool invocation receives a fresh idempotency key. If a placement fails ambiguously, check the account's transaction history before trying again: a filled order will be absent from open orders. Stop and ask the user if the outcome remains uncertain.
|
|
51
54
|
|
|
52
55
|
## Guardrails
|
|
53
56
|
|
|
54
57
|
- This is single-outcome, single-pair quoting. If the user wants to quote multiple outcomes simultaneously, run this skill once per outcome.
|
|
55
|
-
- The session spend cap counts
|
|
58
|
+
- The session spend cap counts bid-side place notional and **incremental** bid
|
|
59
|
+
notional on replace against the source's **remaining** lock (asks are free).
|
|
60
|
+
Same-size re-quotes of an unfilled order do not consume more of the cap;
|
|
61
|
+
size/price increases and replaces that rebuild after partial fills do.
|
|
62
|
+
Soft no-spend outcomes (`{ err }`, unchanged replace, zero-fill
|
|
63
|
+
`InternalCancel`) release their reservation so later quotes can reuse it.
|
|
64
|
+
Make sure `UNHEDGED_MAX_SPEND_PER_SESSION` covers the planned cycle of bid
|
|
65
|
+
upsizes.
|
|
56
66
|
- Do **not** widen the spread silently to chase a fill. Tell the user what spread you're using and what the bid/ask resolve to.
|
|
57
67
|
- A 403 with "missing required scope: `order:place`" means the API key wasn't minted with that scope. Tell the user to mint a new key with `order:place` and `order:cancel`.
|
|
58
68
|
- **Cancel-on-disconnect (bots):** if an admin enabled `cancelOnDisconnect` on the account, long-running agents should call `POST /api/v1/orders/session/connect` at startup (UUID `sessionId`), `POST .../session/heartbeat` every ~30s, and `POST .../session/disconnect` on shutdown. API-key auth with `order:cancel` is required because the session lifecycle can cancel open CLOB orders when the last session ends.
|