diff --git a/README.md b/README.md index b5a7f3a..17b3c8e 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,49 @@ CHARTER only enforces anything if the agent has to go through it. It is a real b What that setup protects against: an agent exceeding its limits, an agent approving its own escalation, replaying or double-executing an approval, acting on a stale approval, and tampering with the record afterward (detectable). What it does not protect against: someone with write access to the audit file who also recomputes every hash, a compromised CHARTER host, or an approver who approves something bad. The CLI runs with local access to the log, so the OS username is the identity it records. The API identity is whatever the holder of the approver key claims in `X-Charter-Approver`. There is still one shared approver key, so approvers are told apart by the name they claim, not by separate credentials. The full list of what is and is not defended against is in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md). +## Rules + +Every proposal is checked against all of these, and every result is returned in the verdict. A `violated` rule is a veto. A `warning` is an escalation. + +| Rule | What it checks | Set in the mandate by | +| --- | --- | --- | +| `killSwitch` | Trading is not halted | `charter halt` | +| `symbolAllowlist` | Symbol and side are permitted | `allowedSymbols`, `blockedSymbols`, `allowedSides` | +| `perTradeMaxUsd` and `dailySpendCapUsd` | Size of this trade, and today's total, counting resting orders | `perTradeMaxUsd`, `dailySpendCapUsd` | +| `perSymbolDailyCapUsd` | Today's total in this one symbol, across all agents | `perSymbolDailyCapUsd` | +| `maxTradesPerHour` | How many trades this agent has executed in the last hour | `maxTradesPerHour` | +| `cooldownSeconds` | Time since this agent last traded this symbol | `cooldownSeconds` | +| `maxLimitDeviationPct` | A limit price is not a likely typo (default 5%) | `maxLimitDeviationPct` | +| `maxOpenOrders` | How many CHARTER limit orders are resting | `maxOpenOrders` | +| `sellWithinHoldings` | A sell is covered by what the account holds | always on for sells | +| `maxSlippageBps` | Projected slippage from the live order book | `maxSlippageBps` | +| `maxLeverage` | No more leverage than allowed | `maxLeverage` | +| `dailyDrawdownHaltPct` | The account is not down more than the limit today | `dailyDrawdownHaltPct` | +| `confirmAboveUsd` | Bigger than this needs a human, so it escalates | `confirmAboveUsd` | + +The rules that depend on history (spend, trade rate, cooldown, per-symbol total) read the audit log rather than keep counters. A rule that needs information it could not get, such as holdings for a sell, refuses the trade instead of assuming it is fine. + +## Order types + +```bash +npx tsx src/index.ts propose BTCUSDT BUY --usd 50 +npx tsx src/index.ts propose BTCUSDT BUY --type LIMIT --qty 0.001 --limit-price 78000 +``` + +A MARKET order is sized in USD. A LIMIT order is sized in units and a price, and is simulated against the book: whatever can fill at that price or better is projected as a fill, and the rest is reported as resting. A resting order is logged as placed rather than filled, and counts against the spend caps because it can still fill. Quantities and prices are rounded down to the exchange's step and tick sizes, so an order is never larger than what was approved, and one below the minimum is refused before it is sent. + +```bash +npx tsx src/index.ts orders +npx tsx src/index.ts cancel BTCUSDT +npx tsx src/index.ts halt --cancel-open +``` + +CHARTER only ever lists or cancels orders it placed itself, identified by their client order id. + +## Portfolio value + +The drawdown halt and the NAV impact in a verdict use the value of the whole account. Every holding is priced through its USDT pair, or through a BTC, ETH, or BNB pair if it has none, and fiat is priced through its inverse pair. Anything with no route to a dollar price is left out of the total and reported, not guessed. On the testnet account this replaced a hand-picked list of eight assets that captured about a third of the account's value. A drawdown baseline recorded under an older method is ignored, so two methods are never compared against each other. + ## Verdict reasons A verdict is never a bare PASS, VETO, or ESCALATE label. Every `Verdict` carries a `reasons` array with one entry per policy rule that ran, each shaped as `{ rule, outcome, detail }` (`outcome` is `"ok"`, `"warning"`, or `"violated"`). This is the structured attribution behind the decision: which specific rule drove it, and why, not just the final outcome. It's produced by `evaluateProposal` in `src/policy/engine.ts`, which runs every rule and always returns the full result set regardless of decision; the shape itself is `RuleResult` in `src/policy/types.ts`. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index fe85717..aa3ea38 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -32,7 +32,10 @@ CHARTER enforces limits only on trades that go through it. For that to mean anyt | Approval replayed, or two approvals race | An approval is granted once; a per-id guard covers simultaneous requests | `src/approval/service.ts` | | Approval acted on after conditions changed | The proposal is evaluated again, with fresh market data, spend, drawdown, and kill switch, at the moment of approval | `src/approval/service.ts` | | Approval acted on after it went stale | Approvals expire | `src/approval/state.ts` | -| Trading must stop now | Kill switch vetoes everything, including an already-approved trade not yet executed | `src/policy/rules/killSwitch.ts` | +| Trading must stop now | Kill switch vetoes everything, including an already-approved trade not yet executed. `--cancel-open` also pulls resting orders off the book | `src/policy/rules/killSwitch.ts`, `src/execution/orders.ts` | +| A limit price typo, or too many resting orders | Limit price must be near the market; the number of resting orders is capped | `src/policy/rules/orders.ts` | +| A runaway agent trading too often | Per-agent hourly trade limit, per-symbol cooldown, and a per-symbol daily cap | `src/policy/rules/activity.ts` | +| Selling something not held | A sell must be covered by holdings, and is refused if holdings cannot be read | `src/policy/rules/orders.ts` | | The record is edited after the fact | Hash-chained log; edits, reordering, and deletions are detected by `audit verify` | `src/audit/log.ts` | | Acting on a tampered record | The chain is verified before an approval is acted on | `src/approval/service.ts` | | Timing attacks on key comparison | Constant-time comparison for every credential | `constantTimeEquals` | @@ -48,7 +51,10 @@ CHARTER enforces limits only on trades that go through it. For that to mean anyt - **Denial of service.** There is no rate limiting on the API beyond what the mandate rules impose on trading. - **Transport security.** The server speaks plain HTTP. Put it behind TLS if it is reachable beyond localhost. - **Testnet only.** Everything here has been exercised against Binance Spot Testnet. The mainnet execution path has not been run. -- **Simulation is an estimate.** It walks a sampled order book and can understate the impact of a large order; it now says so when the depth ran out, but it does not predict moves between simulation and fill. +- **Simulation is an estimate.** It walks a sampled order book and can understate the impact of a large order; it says so when the depth ran out, but it does not predict moves between simulation and fill. +- **A halt does not undo what already happened.** It stops new proposals and, with `--cancel-open`, cancels resting orders. An order that has already filled stays filled. +- **Resting orders are only counted, not watched.** A limit order can fill long after it was approved, at a market the rules never saw. The open-order cap and the spend caps bound this, and it can be cancelled, but nothing re-evaluates it while it rests. +- **Portfolio value depends on prices the exchange reports.** Holdings with no price route are excluded from NAV and listed, so a portfolio with a lot of unpriceable assets is undervalued for the drawdown check. ## Assumptions worth stating diff --git a/skillhub-pr/skills/charter/skill.md b/skillhub-pr/skills/charter/skill.md index 3ad0696..22751ba 100644 --- a/skillhub-pr/skills/charter/skill.md +++ b/skillhub-pr/skills/charter/skill.md @@ -51,7 +51,15 @@ Response: } ``` -Set `"execute": true` to have CHARTER place the real order immediately when the verdict is `PASS`, or when you are supplying human confirmation for an `ESCALATE`. Leave it `false` for a dry-run verdict only. +Set `"execute": true` to have CHARTER place the real order immediately when the verdict is `PASS`. Leave it `false` for a dry-run verdict only. `execute` has no effect on an `ESCALATE`, which always waits for a person to approve it. + +A LIMIT order is sized by `quantity` and `limitPrice` instead of `usd`: + +```json +{ "mandateId": "", "symbol": "BTCUSDT", "side": "BUY", "type": "LIMIT", "quantity": 0.001, "limitPrice": 78000 } +``` + +If your instance issues per-agent keys, send yours as `X-Charter-Api-Key`. Your identity then comes from the key, so `agentId` can be left out, and if you send one it must match. A key only works for the mandates it was registered with. ### charter.status diff --git a/src/api/server.ts b/src/api/server.ts index a1ac2c9..cb0a271 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -22,6 +22,8 @@ import { } from "../approval/service.js"; import type { ApprovalStatus } from "../approval/state.js"; import { authenticateAgent, constantTimeEquals, registryInUse } from "../agents/service.js"; +import { activeVenue } from "../venues/index.js"; +import { cancelAllCharterOrders, cancelCharterOrder, listOpenCharterOrders } from "../execution/orders.js"; import type { AgentRecord } from "../agents/state.js"; const ProposeBodySchema = z.object({ @@ -30,13 +32,27 @@ const ProposeBodySchema = z.object({ mandateId: z.string().uuid(), symbol: z.string().min(1), side: z.enum(["BUY", "SELL"]), - usd: z.number().positive(), + type: z.enum(["MARKET", "LIMIT"]).optional().default("MARKET"), + /** MARKET: the amount in USD. */ + usd: z.number().positive().optional(), + /** LIMIT: how much of the asset, and the price. */ + quantity: z.number().positive().optional(), + limitPrice: z.number().positive().optional(), reason: z.string().optional(), execute: z.boolean().optional().default(false), +}).superRefine((b, ctx) => { + if (b.type === "MARKET" && b.usd === undefined && b.quantity === undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A MARKET proposal needs usd (or quantity)" }); + } + if (b.type === "LIMIT") { + if (b.quantity === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal needs quantity" }); + if (b.limitPrice === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal needs limitPrice" }); + if (b.usd !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal is sized by quantity, not usd" }); + } }); const DecisionBodySchema = z.object({ note: z.string().optional() }); -const HaltBodySchema = z.object({ reason: z.string().optional() }); +const HaltBodySchema = z.object({ reason: z.string().optional(), cancelOpen: z.boolean().optional().default(false) }); /** Routes that decide something (approve, reject, halt, resume) rather than propose it. */ function isApproverRoute(path: string): boolean { @@ -163,6 +179,10 @@ export function startApiServer(): Server { res.status(404).json({ error: err.message }); return; } + if (err instanceof z.ZodError) { + res.status(400).json({ error: "Invalid proposal", issues: err.issues }); + return; + } res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); } }); @@ -258,7 +278,24 @@ export function startApiServer(): Server { res.status(400).json({ error: "Invalid body", issues: body.error.issues }); return; } - res.json({ killSwitch: await engageKillSwitch(approver(req), body.data.reason) }); + const killSwitch = await engageKillSwitch(approver(req), body.data.reason); + // Optionally pull every resting CHARTER order off the book as well, since a + // halt stops new proposals but does not by itself stop an order already resting. + const cancelled = body.data.cancelOpen ? await cancelAllCharterOrders(activeVenue, approver(req), body.data.reason ?? "kill switch") : undefined; + res.json({ killSwitch, ...(cancelled ? { cancelled: cancelled.cancelled.length, failed: cancelled.failed } : {}) }); + }); + + app.get("/control/orders", async (_req: Request, res: Response) => { + res.json(await listOpenCharterOrders(activeVenue)); + }); + + app.post("/control/orders/:symbol/:orderId/cancel", async (req: Request, res: Response) => { + try { + await cancelCharterOrder(activeVenue, req.params.symbol as string, req.params.orderId as string, approver(req)); + res.json({ cancelled: req.params.orderId }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } }); app.post("/control/resume", async (req: Request, res: Response) => { diff --git a/src/audit/log.ts b/src/audit/log.ts index e237fee..0551e93 100644 --- a/src/audit/log.ts +++ b/src/audit/log.ts @@ -13,6 +13,8 @@ export type AuditEventType = | "EXECUTION_ATTEMPTED" | "EXECUTION_CONFIRMED" | "EXECUTION_FILLED" + | "EXECUTION_PLACED" + | "ORDER_CANCELLED" | "EXECUTION_REJECTED_BY_PLATFORM" | "APPROVAL_REQUESTED" | "APPROVAL_GRANTED" diff --git a/src/cli/commands/control.ts b/src/cli/commands/control.ts index b10090b..66271d3 100644 --- a/src/cli/commands/control.ts +++ b/src/cli/commands/control.ts @@ -1,6 +1,8 @@ import { userInfo } from "node:os"; import { approve, engageKillSwitch, getKillSwitch, listApprovals, reject, releaseKillSwitch } from "../../approval/service.js"; import type { ApprovalStatus } from "../../approval/state.js"; +import { activeVenue } from "../../venues/index.js"; +import { cancelAllCharterOrders, cancelCharterOrder, listOpenCharterOrders } from "../../execution/orders.js"; /** The CLI runs with local access to the audit log, so the operator's OS username is the default identity. */ function operator(explicit?: string): string { @@ -39,10 +41,34 @@ export async function rejectCommand(approvalId: string, approver: string | undef console.log(`Rejected by ${who}. Nothing was placed.`); } -export async function haltCommand(by: string | undefined, reason?: string): Promise { +export async function haltCommand(by: string | undefined, reason?: string, cancelOpen = false): Promise { const who = operator(by); await engageKillSwitch(who, reason); console.log(`Kill switch ENGAGED by ${who}. Every proposal is now vetoed until it is released.`); + if (cancelOpen) { + const { cancelled, failed } = await cancelAllCharterOrders(activeVenue, who, reason ?? "kill switch"); + console.log(`Cancelled ${cancelled.length} resting CHARTER order${cancelled.length === 1 ? "" : "s"}.`); + for (const f of failed) console.log(` could not cancel ${f.order.symbol} ${f.order.orderId}: ${f.error}`); + } else { + console.log("Any order already resting on the book stays there. Add --cancel-open to pull them."); + } +} + +export async function ordersCommand(): Promise { + const orders = await listOpenCharterOrders(activeVenue); + if (orders.length === 0) { + console.log("No CHARTER orders are resting on the book."); + return; + } + for (const o of orders) { + console.log(`${o.symbol} ${o.orderId} ${o.side} ${o.origQty} @ ${o.price} ${o.status} filled ${o.executedQty}`); + } +} + +export async function cancelCommand(symbol: string, orderId: string, by: string | undefined, reason?: string): Promise { + const who = operator(by); + await cancelCharterOrder(activeVenue, symbol.toUpperCase(), orderId, who, reason); + console.log(`Cancelled ${symbol.toUpperCase()} order ${orderId}. Recorded as cancelled by ${who}.`); } export async function resumeCommand(by: string | undefined): Promise { diff --git a/src/cli/commands/propose.ts b/src/cli/commands/propose.ts index 185967c..271eba1 100644 --- a/src/cli/commands/propose.ts +++ b/src/cli/commands/propose.ts @@ -3,14 +3,21 @@ import { runProposal } from "../../policy/runProposal.js"; export interface ProposeOptions { symbol: string; side: "BUY" | "SELL"; - usd: number; + type: "MARKET" | "LIMIT"; + usd?: number; + quantity?: number; + limitPrice?: number; mandateId: string; agentId: string; execute: boolean; } export async function proposeCommand(opts: ProposeOptions): Promise { - console.log(`\n${opts.agentId} wants to ${opts.side} $${opts.usd} of ${opts.symbol}`); + if (opts.type === "LIMIT") { + console.log(`\n${opts.agentId} wants a limit ${opts.side} of ${opts.quantity} ${opts.symbol} at ${opts.limitPrice}`); + } else { + console.log(`\n${opts.agentId} wants to ${opts.side} $${opts.usd} of ${opts.symbol}`); + } console.log("Simulating against live order book..."); const { verdict, execution, approval } = await runProposal({ @@ -18,7 +25,10 @@ export async function proposeCommand(opts: ProposeOptions): Promise { mandateId: opts.mandateId, symbol: opts.symbol, side: opts.side, + type: opts.type, usd: opts.usd, + quantity: opts.quantity, + limitPrice: opts.limitPrice, reason: "CLI propose command", execute: opts.execute, }); @@ -28,6 +38,9 @@ export async function proposeCommand(opts: ProposeOptions): Promise { ` reference price: ${s.referencePrice} projected fill: ${s.projectedFillPrice.toFixed(2)} ` + `slippage: ${s.projectedSlippageBps.toFixed(1)}bps NAV impact: ${s.projectedNavImpactPct.toFixed(3)}%` ); + if (s.restingUsd > 0) { + console.log(` $${s.restingUsd.toFixed(2)} would not fill at once and would rest on the book at the limit price.`); + } if (s.liquidityInsufficient) { console.log( ` WARNING: sampled order-book depth could not fully cover this notional (unfilled: $${s.unfilledUsd.toFixed(2)}). ` + @@ -46,6 +59,14 @@ export async function proposeCommand(opts: ProposeOptions): Promise { return; } + if (execution && (execution.status === "NEW" || execution.status === "PARTIALLY_FILLED")) { + console.log( + `\nPlaced and resting: orderId=${execution.orderId} status=${execution.status} executedQty=${execution.executedQty}. ` + + `It can still fill later. Cancel it with: charter cancel ${execution.symbol} ${execution.orderId}` + ); + return; + } + if (execution) { console.log(`\nFilled: orderId=${execution.orderId} status=${execution.status} executedQty=${execution.executedQty} quoteQty=${execution.cummulativeQuoteQty}`); return; diff --git a/src/cli/ui.tsx b/src/cli/ui.tsx index a537b52..3cc2d91 100644 --- a/src/cli/ui.tsx +++ b/src/cli/ui.tsx @@ -27,9 +27,9 @@ function verdictLine(e: AuditEntry): { text: string; color: string } | null { } function fillLine(e: AuditEntry): string | null { - if (e.type !== "EXECUTION_FILLED") return null; + if (e.type !== "EXECUTION_FILLED" && e.type !== "EXECUTION_PLACED") return null; const p = e.payload as { orderId: string; symbol: string; side: string; executedQty: number; notionalUsd: number }; - return `#${e.seq} ${e.timestamp.slice(11, 19)} FILLED ${p.side} ${p.executedQty} ${p.symbol} ($${p.notionalUsd.toFixed(2)}) order ${p.orderId}`; + return `#${e.seq} ${e.timestamp.slice(11, 19)} ${e.type === "EXECUTION_PLACED" ? "RESTING" : "FILLED "} ${p.side} ${p.executedQty} ${p.symbol} ($${p.notionalUsd.toFixed(2)}) order ${p.orderId}`; } const App: React.FC = () => { diff --git a/src/execution/adapter.ts b/src/execution/adapter.ts index 377deb7..4d5bfcf 100644 --- a/src/execution/adapter.ts +++ b/src/execution/adapter.ts @@ -38,6 +38,8 @@ export async function executeProposal( await auditLog.append("EXECUTION_ATTEMPTED", venue.name, { proposalId: proposal.id, verdictId: verdict.id, + agentId: proposal.agentId, + symbol: proposal.symbol, order, notionalUsd: verdict.simulation.notionalUsd, ...(approval ? { approvalId: approval.approvalId, approvedBy: approval.approver } : {}), @@ -55,9 +57,23 @@ export async function executeProposal( throw err; } - await auditLog.append("EXECUTION_FILLED", venue.name, { + // An order that ended with nothing executed (expired, cancelled) committed nothing. + const stillOpen = result.status === "NEW" || result.status === "PARTIALLY_FILLED"; + if (!stillOpen && result.status !== "FILLED" && result.executedQty === 0) { + await auditLog.append("EXECUTION_REJECTED_BY_PLATFORM", venue.name, { + proposalId: proposal.id, + verdictId: verdict.id, + error: `Order ended ${result.status} with nothing executed`, + }); + return result; + } + + // A resting limit order has not filled, but it still can, so it is logged as + // placed and counted against the caps the same way a fill is. + await auditLog.append(stillOpen ? "EXECUTION_PLACED" : "EXECUTION_FILLED", venue.name, { proposalId: proposal.id, verdictId: verdict.id, + agentId: proposal.agentId, orderId: result.orderId, symbol: result.symbol, side: result.side, diff --git a/src/execution/orders.ts b/src/execution/orders.ts new file mode 100644 index 0000000..94e50b7 --- /dev/null +++ b/src/execution/orders.ts @@ -0,0 +1,41 @@ +import { auditLog } from "../audit/log.js"; +import type { ExecutionVenue, OpenOrder } from "../venues/types.js"; + +/** Orders CHARTER placed carry this client id prefix (see execution/adapter.ts), so it never touches an order it did not place. */ +const CHARTER_PREFIX = "charter-"; + +export async function listOpenCharterOrders(venue: ExecutionVenue): Promise { + const open = await venue.getOpenOrders(); + return open.filter((o) => o.clientOrderId.startsWith(CHARTER_PREFIX)); +} + +export async function cancelCharterOrder(venue: ExecutionVenue, symbol: string, orderId: string, by: string, reason?: string): Promise { + const result = await venue.cancelOrder(symbol, orderId); + await auditLog.append("ORDER_CANCELLED", venue.name, { + orderId, + symbol, + by, + reason, + status: result.status, + executedQty: result.executedQty, + }); +} + +/** Cancels every resting CHARTER order. Returns what was cancelled and what failed to cancel. */ +export async function cancelAllCharterOrders( + venue: ExecutionVenue, + by: string, + reason?: string +): Promise<{ cancelled: OpenOrder[]; failed: Array<{ order: OpenOrder; error: string }> }> { + const cancelled: OpenOrder[] = []; + const failed: Array<{ order: OpenOrder; error: string }> = []; + for (const order of await listOpenCharterOrders(venue)) { + try { + await cancelCharterOrder(venue, order.symbol, order.orderId, by, reason); + cancelled.push(order); + } catch (err) { + failed.push({ order, error: err instanceof Error ? err.message : String(err) }); + } + } + return { cancelled, failed }; +} diff --git a/src/index.ts b/src/index.ts index 71c92de..61bf282 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,10 @@ import { agentAddCommand, agentListCommand, agentRevokeCommand, agentRotateComma import { approvalsListCommand, approveCommand, + cancelCommand, controlStatusCommand, haltCommand, + ordersCommand, rejectCommand, resumeCommand, } from "./cli/commands/control.js"; @@ -27,24 +29,53 @@ program await initCommand(); }); +interface ProposeCliOptions { + usd?: number; + type: string; + qty?: number; + limitPrice?: number; + mandate: string; + agent: string; + execute: boolean; +} + program .command("propose") .description("Submit a trade proposal for a PASS/VETO/ESCALATE verdict, optionally executing it") .argument("", "e.g. BTCUSDT") .argument("", "BUY or SELL") - .requiredOption("--usd ", "notional size in USD (quote asset)", parseFloat) + .option("--usd ", "MARKET: how much to trade, in USD", parseFloat) + .option("--type ", "MARKET or LIMIT", "MARKET") + .option("--qty ", "LIMIT: how much of the asset", parseFloat) + .option("--limit-price ", "LIMIT: the price", parseFloat) .option("--mandate ", "mandate id", "b2f1e9a0-1a2b-4c3d-8e4f-000000000001") .option("--agent ", "identifier of the agent making the proposal", "cli-operator") .option("--execute", "place the real order if the verdict is PASS (an ESCALATE always waits for a separate approval)", false) - .action(async (symbol: string, side: string, opts: { usd: number; mandate: string; agent: string; execute: boolean }) => { + .action(async (symbol: string, side: string, opts: ProposeCliOptions) => { if (side !== "BUY" && side !== "SELL") { console.error("side must be BUY or SELL"); process.exit(1); } + const type = opts.type.toUpperCase(); + if (type !== "MARKET" && type !== "LIMIT") { + console.error("--type must be MARKET or LIMIT"); + process.exit(1); + } + if (type === "MARKET" && opts.usd === undefined) { + console.error("A MARKET proposal needs --usd"); + process.exit(1); + } + if (type === "LIMIT" && (opts.qty === undefined || opts.limitPrice === undefined)) { + console.error("A LIMIT proposal needs --qty and --limit-price"); + process.exit(1); + } await proposeCommand({ symbol: symbol.toUpperCase(), side, + type, usd: opts.usd, + quantity: opts.qty, + limitPrice: opts.limitPrice, mandateId: opts.mandate, agentId: opts.agent, execute: opts.execute, @@ -110,8 +141,27 @@ program .description("Engage the kill switch: every proposal is vetoed until it is released") .option("--by ", "who is halting (defaults to your OS username)") .option("--reason ", "why, recorded in the audit log") - .action(async (opts: { by?: string; reason?: string }) => { - await haltCommand(opts.by, opts.reason); + .option("--cancel-open", "also cancel every CHARTER order resting on the book", false) + .action(async (opts: { by?: string; reason?: string; cancelOpen: boolean }) => { + await haltCommand(opts.by, opts.reason, opts.cancelOpen); + }); + +program + .command("orders") + .description("List CHARTER orders resting on the book") + .action(async () => { + await ordersCommand(); + }); + +program + .command("cancel") + .description("Cancel a resting CHARTER order") + .argument("") + .argument("") + .option("--by ", "who is cancelling (defaults to your OS username)") + .option("--reason ") + .action(async (symbol: string, orderId: string, opts: { by?: string; reason?: string }) => { + await cancelCommand(symbol, orderId, opts.by, opts.reason); }); program @@ -185,5 +235,7 @@ audit program.parseAsync(process.argv).catch((err: unknown) => { console.error(`\nError: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); + // Not process.exit(): on Windows that can trip a libuv assertion while + // network handles are still closing. Setting the code lets the process drain. + process.exitCode = 1; }); diff --git a/src/mandate/compiler.ts b/src/mandate/compiler.ts index 14e71dc..5059ec4 100644 --- a/src/mandate/compiler.ts +++ b/src/mandate/compiler.ts @@ -13,14 +13,20 @@ Output ONLY a JSON object (no markdown fences, no commentary) with exactly these "allowedSymbols": string[] | null, // e.g. ["BTCUSDT","ETHUSDT"], or null if unrestricted "blockedSymbols": string[] | null, "allowedSides": ("BUY"|"SELL")[] | null, - "maxSlippageBps": number | null + "maxSlippageBps": number | null, + "perSymbolDailyCapUsd": number | null, // max total USD per symbol per day, across everything + "maxTradesPerHour": number | null, // most trades one agent may make in any rolling hour (whole number) + "cooldownSeconds": number | null, // minimum seconds between two trades by the same agent in the same symbol + "maxOpenOrders": number | null, // most limit orders allowed to rest on the book at once (whole number) + "maxLimitDeviationPct": number | null // a limit price further than this percent from the market is refused } IMPORTANT: confirmAboveUsd must always be strictly less than perTradeMaxUsd. If it were not, no order could ever be large enough to need confirmation without already being blocked by the hard cap, making confirmAboveUsd meaningless. Be conservative: if the covenant doesn't mention a limit, pick a sensible strict default rather than an unlimited one (e.g. if only one of perTradeMaxUsd/confirmAboveUsd is stated, set the other to roughly a third of it, respecting the constraint above; dailyDrawdownHaltPct defaults to 5 if unstated). -Omit optional array fields as null rather than guessing a symbol list that wasn't mentioned.`; +Omit optional array fields as null rather than guessing a symbol list that wasn't mentioned. +Set perSymbolDailyCapUsd, maxTradesPerHour, cooldownSeconds, maxOpenOrders, and maxLimitDeviationPct to null unless the covenant asks for that kind of limit. Do not invent them.`; /** * Pure parse/normalize/validate step, factored out of compileMandateFromText diff --git a/src/mandate/navSnapshot.ts b/src/mandate/navSnapshot.ts index ed6e875..45808b9 100644 --- a/src/mandate/navSnapshot.ts +++ b/src/mandate/navSnapshot.ts @@ -1,6 +1,6 @@ import { auditLog, type AuditEntry } from "../audit/log.js"; import type { ExecutionVenue } from "../venues/types.js"; -import { computeApproxNavUsd } from "../market/nav.js"; +import { NAV_METHOD, computeApproxNavUsd } from "../market/nav.js"; function todayStartIso(): string { const d = new Date(); @@ -13,17 +13,24 @@ function todayStartIso(): string { * NAV_SNAPSHOT has been recorded yet today, computes one from the venue * right now and logs it, so the very first proposal of the day sets the * baseline everything else is measured against. + * + * A snapshot only counts if it was taken with the current NAV method. One + * taken with an older method (or none recorded) is ignored, because + * comparing a number from one method against a baseline from another would + * report a swing that is really just the two methods disagreeing. */ export async function getOrCreateStartOfDayNav(venue: ExecutionVenue, marketBaseUrl: string): Promise { const todayStart = todayStartIso(); const entries = await auditLog.all(); - const todaysSnapshot = entries.find((e: AuditEntry) => e.type === "NAV_SNAPSHOT" && e.timestamp >= todayStart); + const todaysSnapshot = entries.find( + (e: AuditEntry) => e.type === "NAV_SNAPSHOT" && e.timestamp >= todayStart && (e.payload as { method?: string }).method === NAV_METHOD + ); if (todaysSnapshot) { return (todaysSnapshot.payload as { navUsd: number }).navUsd; } const navUsd = await computeApproxNavUsd(venue, marketBaseUrl); - await auditLog.append("NAV_SNAPSHOT", venue.name, { navUsd, reason: "start-of-day baseline" }); + await auditLog.append("NAV_SNAPSHOT", venue.name, { navUsd, method: NAV_METHOD, reason: "start-of-day baseline" }); return navUsd; } diff --git a/src/mandate/schema.ts b/src/mandate/schema.ts index 8481002..fcf9641 100644 --- a/src/mandate/schema.ts +++ b/src/mandate/schema.ts @@ -14,8 +14,17 @@ export const MandateLimitsSchema = z.object({ allowedSymbols: z.array(z.string()).optional(), blockedSymbols: z.array(z.string()).optional(), allowedSides: z.array(z.enum(["BUY", "SELL"])).optional(), - maxOpenPositions: z.number().int().positive().optional(), maxSlippageBps: z.number().positive().optional(), + /** Cap on total notional per symbol per UTC day, across all agents. */ + perSymbolDailyCapUsd: z.number().positive().optional(), + /** Most trades one agent may have executed in any rolling hour. */ + maxTradesPerHour: z.number().int().positive().optional(), + /** Minimum seconds between two executed trades by the same agent in the same symbol. */ + cooldownSeconds: z.number().positive().optional(), + /** Most CHARTER-placed limit orders that may be resting on the book at once. */ + maxOpenOrders: z.number().int().positive().optional(), + /** A limit price further than this from the market is refused as a likely fat-finger. Defaults to 5. */ + maxLimitDeviationPct: z.number().positive().max(100).optional(), }); export const MandateSchema = z.object({ diff --git a/src/market/binanceRest.ts b/src/market/binanceRest.ts index 7c7bd31..c7f887b 100644 --- a/src/market/binanceRest.ts +++ b/src/market/binanceRest.ts @@ -17,6 +17,16 @@ export async function getTickerPrice(baseUrl: string, symbol: string): Promise> { + const res = await fetch(`${baseUrl}/api/v3/ticker/price`); + if (!res.ok) throw new Error(`ticker/price (all symbols) failed: ${res.status} ${res.statusText}`); + const json = (await res.json()) as Array<{ symbol: string; price: string }>; + const prices = new Map(); + for (const t of json) prices.set(t.symbol, parseFloat(t.price)); + return prices; +} + export interface Kline { openTime: number; open: number; diff --git a/src/market/nav.ts b/src/market/nav.ts index 551560b..25426ba 100644 --- a/src/market/nav.ts +++ b/src/market/nav.ts @@ -1,36 +1,89 @@ -import type { ExecutionVenue } from "../venues/types.js"; -import { getTickerPrice } from "./binanceRest.js"; +import type { Balance, ExecutionVenue } from "../venues/types.js"; +import { getAllTickerPrices } from "./binanceRest.js"; + +/** Bump when the way NAV is computed changes, so a baseline from an older method is never compared against a newer number. */ +export const NAV_METHOD = "full-v2"; + +/** Assets treated as one dollar. */ +const STABLES = new Set(["USDT", "USDC", "FDUSD", "TUSD", "USDP", "BFUSD", "USD1", "USDE", "USDS", "RLUSD", "XUSD", "USD"]); + +/** Quote assets tried, in order, to price something with no direct USDT pair. */ +const BRIDGES = ["BTC", "ETH", "BNB"] as const; + +export interface PricedHolding { + asset: string; + quantity: number; + usd: number; + /** How it was priced: "stable", "USDT" for a direct pair, or the bridge asset used. */ + via: string; +} + +export interface UnpricedHolding { + asset: string; + quantity: number; +} + +export interface NavBreakdown { + navUsd: number; + priced: PricedHolding[]; + /** Holdings with no route to a dollar price on this venue. Excluded from navUsd, and listed here rather than hidden. */ + unpriced: UnpricedHolding[]; +} /** - * Approximate NAV in USD, used for dailyDrawdownHaltPct and NAV-impact - * calculations. Deliberately simple for the hackathon timeline: prices a - * curated set of major assets via their live *USDT ticker and treats - * USDT/USDC/BFUSD/FDUSD as $1, good enough to gate real limits, not a - * full portfolio valuation engine. Assets outside this set are ignored - * (documented here rather than hidden) rather than silently mis-priced. + * Values every holding in USD. Stablecoins count as one dollar. Anything + * else is priced through its USDT pair, or, failing that, through a BTC, + * ETH, or BNB pair converted via that asset's own USDT price. What cannot + * be priced is reported in `unpriced` instead of being guessed at or + * dropped silently. */ -const STABLES = new Set(["USDT", "USDC", "BFUSD", "FDUSD", "TUSD", "USD"]); -const PRICED_ASSETS = ["BTC", "ETH", "BNB", "SOL", "XRP", "DOGE", "ADA", "LTC"]; - -export async function computeApproxNavUsd(venue: ExecutionVenue, marketBaseUrl: string): Promise { - const balances = await venue.getSubAccountBalances(); - let navUsd = 0; +export function valueHoldings(balances: Balance[], prices: Map): NavBreakdown { + const priced: PricedHolding[] = []; + const unpriced: UnpricedHolding[] = []; for (const b of balances) { - const total = b.free + b.locked; - if (total <= 0) continue; + const quantity = b.free + b.locked; + if (quantity <= 0) continue; + if (STABLES.has(b.asset)) { - navUsd += total; + priced.push({ asset: b.asset, quantity, usd: quantity, via: "stable" }); continue; } - if (!PRICED_ASSETS.includes(b.asset)) continue; - try { - const ticker = await getTickerPrice(marketBaseUrl, `${b.asset}USDT`); - navUsd += total * ticker.price; - } catch { - // No USDT pair for this asset on this venue, skip rather than guess. + + const direct = prices.get(`${b.asset}USDT`); + if (direct !== undefined && direct > 0) { + priced.push({ asset: b.asset, quantity, usd: quantity * direct, via: "USDT" }); + continue; + } + + // Fiat currencies are quoted the other way round: USDTTRY is lira per dollar. + const inverse = prices.get(`USDT${b.asset}`); + if (inverse !== undefined && inverse > 0) { + priced.push({ asset: b.asset, quantity, usd: quantity / inverse, via: "1/USDT" }); + continue; + } + + let bridged: PricedHolding | undefined; + for (const bridge of BRIDGES) { + const pair = prices.get(`${b.asset}${bridge}`); + const bridgeUsd = prices.get(`${bridge}USDT`); + if (pair !== undefined && pair > 0 && bridgeUsd !== undefined && bridgeUsd > 0) { + bridged = { asset: b.asset, quantity, usd: quantity * pair * bridgeUsd, via: bridge }; + break; + } } + if (bridged) priced.push(bridged); + else unpriced.push({ asset: b.asset, quantity }); } - return navUsd; + return { navUsd: priced.reduce((sum, h) => sum + h.usd, 0), priced, unpriced }; +} + +export async function computeNavBreakdown(venue: ExecutionVenue, marketBaseUrl: string): Promise { + const [balances, prices] = await Promise.all([venue.getSubAccountBalances(), getAllTickerPrices(marketBaseUrl)]); + return valueHoldings(balances, prices); +} + +export async function computeApproxNavUsd(venue: ExecutionVenue, marketBaseUrl: string): Promise { + return (await computeNavBreakdown(venue, marketBaseUrl)).navUsd; } diff --git a/src/market/simulator.ts b/src/market/simulator.ts index 7e16e14..0374a68 100644 --- a/src/market/simulator.ts +++ b/src/market/simulator.ts @@ -6,6 +6,11 @@ import type { Proposal, SimulationResult } from "../policy/types.js"; * (never a different venue's book, that would make projected vs. actual * numbers dishonest) to estimate fill price and slippage for a proposal's * notional size. + * + * MARKET: walks the book until the notional is covered. + * LIMIT: only levels at or inside the limit price are reachable. Whatever + * those cannot cover would rest on the book at the limit price, and is + * reported as `restingUsd` rather than treated as slippage. */ export async function simulateProposal(venue: ExecutionVenue, proposal: Proposal, navUsd: number): Promise { const depth: OrderBook = await venue.getDepth(proposal.symbol, 50); @@ -14,33 +19,62 @@ export async function simulateProposal(venue: ExecutionVenue, proposal: Proposal throw new Error(`No ${proposal.side === "BUY" ? "ask" : "bid"} liquidity available for ${proposal.symbol}`); } + const isLimit = proposal.type === "LIMIT"; const referencePrice = levels[0]!.price; - const notionalUsd = proposal.quoteOrderQty ?? (proposal.quantity ?? 0) * referencePrice; + const limitPrice = proposal.limitPrice ?? referencePrice; - // Walk the book to find the volume-weighted average fill price for this notional. + const notionalUsd = isLimit ? (proposal.quantity ?? 0) * limitPrice : (proposal.quoteOrderQty ?? (proposal.quantity ?? 0) * referencePrice); + + // A limit order can only take liquidity that is at or better than its price. + const reachable = isLimit ? levels.filter((l) => (proposal.side === "BUY" ? l.price <= limitPrice : l.price >= limitPrice)) : levels; + + // Walk the book to find the volume-weighted average fill price. let remainingUsd = notionalUsd; let filledUsd = 0; let filledBaseQty = 0; - for (const level of levels) { - const levelNotional = level.price * level.quantity; - const take = Math.min(remainingUsd, levelNotional); - if (take <= 0) break; - filledUsd += take; - filledBaseQty += take / level.price; - remainingUsd -= take; - if (remainingUsd <= 0) break; + let restingUsd = 0; + + if (isLimit) { + // A limit order is sized in base units, not dollars. Walking it with a + // dollar budget would let it buy more units than were asked for whenever + // the book is cheaper than the limit price. + let remainingQty = proposal.quantity ?? 0; + for (const level of reachable) { + const takeQty = Math.min(remainingQty, level.quantity); + if (takeQty <= 0) break; + filledUsd += takeQty * level.price; + filledBaseQty += takeQty; + remainingQty -= takeQty; + if (remainingQty <= 0) break; + } + restingUsd = Math.max(0, remainingQty) * limitPrice; + remainingUsd = 0; + } else { + for (const level of reachable) { + const levelNotional = level.price * level.quantity; + const take = Math.min(remainingUsd, levelNotional); + if (take <= 0) break; + filledUsd += take; + filledBaseQty += take / level.price; + remainingUsd -= take; + if (remainingUsd <= 0) break; + } } - const projectedFillPrice = filledBaseQty > 0 ? filledUsd / filledBaseQty : referencePrice; - const projectedSlippageBps = referencePrice > 0 ? ((projectedFillPrice - referencePrice) / referencePrice) * 10_000 * (proposal.side === "BUY" ? 1 : -1) : 0; + const projectedFillPrice = filledBaseQty > 0 ? filledUsd / filledBaseQty : isLimit ? limitPrice : referencePrice; + const projectedSlippageBps = + filledBaseQty > 0 && referencePrice > 0 + ? ((projectedFillPrice - referencePrice) / referencePrice) * 10_000 * (proposal.side === "BUY" ? 1 : -1) + : 0; const projectedNavImpactPct = navUsd > 0 ? (notionalUsd / navUsd) * 100 : 0; - // If the sampled depth ran out before covering the full notional, the numbers + // If the sampled depth ran out before covering a MARKET notional, the numbers // above only describe the fillable portion and understate the real impact. // liquidityInsufficient/unfilledUsd are the authoritative signal of that, not - // projectedFillPrice/projectedSlippageBps. - const liquidityInsufficient = remainingUsd > 0; - const unfilledUsd = liquidityInsufficient ? remainingUsd : 0; + // projectedFillPrice/projectedSlippageBps. For a LIMIT the same shortfall is + // not a problem, it just rests on the book, and is reported as restingUsd. + const shortfall = remainingUsd > 0 ? remainingUsd : 0; + const liquidityInsufficient = shortfall > 0; return { venue: venue.name, @@ -51,6 +85,7 @@ export async function simulateProposal(venue: ExecutionVenue, proposal: Proposal projectedNavImpactPct, orderBookDepthSampledAt: depth.sampledAt, liquidityInsufficient, - unfilledUsd, + unfilledUsd: liquidityInsufficient ? shortfall : 0, + restingUsd, }; } diff --git a/src/market/symbols.ts b/src/market/symbols.ts new file mode 100644 index 0000000..7e6be4f --- /dev/null +++ b/src/market/symbols.ts @@ -0,0 +1,11 @@ +/** Quote assets a spot symbol can end in, longest first so USDT is not read as USD plus a stray T. */ +const QUOTES = ["FDUSD", "USDT", "USDC", "TUSD", "BUSD", "USDP", "USD1", "USDE", "USDS", "BTC", "ETH", "BNB", "EUR", "TRY", "BRL", "USD"]; + +/** BTCUSDT -> BTC. Returns undefined if the symbol does not end in a known quote asset. */ +export function baseAssetOf(symbol: string): string | undefined { + const upper = symbol.toUpperCase(); + for (const quote of QUOTES) { + if (upper.endsWith(quote) && upper.length > quote.length) return upper.slice(0, upper.length - quote.length); + } + return undefined; +} diff --git a/src/policy/assess.ts b/src/policy/assess.ts index 8dc79c1..3949a51 100644 --- a/src/policy/assess.ts +++ b/src/policy/assess.ts @@ -2,10 +2,12 @@ import { activeVenue, marketDataBaseUrl } from "../venues/index.js"; import type { Mandate } from "../mandate/schema.js"; import type { Proposal, Verdict } from "./types.js"; import { simulateProposal } from "../market/simulator.js"; -import { computeApproxNavUsd } from "../market/nav.js"; +import { valueHoldings } from "../market/nav.js"; +import { getAllTickerPrices } from "../market/binanceRest.js"; import { getOrCreateStartOfDayNav } from "../mandate/navSnapshot.js"; import { evaluateProposal } from "./engine.js"; import { deriveKillSwitch } from "../approval/state.js"; +import { listOpenCharterOrders } from "../execution/orders.js"; import { auditLog } from "../audit/log.js"; function todayStartIso(): string { @@ -16,20 +18,35 @@ function todayStartIso(): string { /** * Simulates a proposal against the live order book and evaluates it against - * the mandate, the day's spend, the drawdown baseline, and the kill switch, - * then records the verdict. Used for a proposal's first evaluation and again, - * with fresh market data, when a human approval is about to be acted on. + * the mandate, the day's spend, the drawdown baseline, the kill switch, and + * recent activity, then records the verdict. Used for a proposal's first + * evaluation and again, with fresh market data, when a human approval is + * about to be acted on. */ export async function assessProposal(proposal: Proposal, mandate: Mandate, extra: Record = {}): Promise { - const navUsd = await computeApproxNavUsd(activeVenue, marketDataBaseUrl()); + // One balances call and one prices call serve both the NAV figure and the + // holdings a SELL is checked against. + const [balances, prices] = await Promise.all([activeVenue.getSubAccountBalances(), getAllTickerPrices(marketDataBaseUrl())]); + const navUsd = valueHoldings(balances, prices).navUsd; + const holdings = Object.fromEntries(balances.map((b) => [b.asset, b.free])); + const simulation = await simulateProposal(activeVenue, proposal, navUsd); const entries = await auditLog.all(); - const todaysFilled = entries.filter((e) => e.type === "EXECUTION_FILLED" && e.timestamp >= todayStartIso()); + // A resting order can still fill, so placed orders count toward the daily spend alongside filled ones. + const todaysCommitted = entries.filter((e) => (e.type === "EXECUTION_FILLED" || e.type === "EXECUTION_PLACED") && e.timestamp >= todayStartIso()); const startOfDayNavUsd = await getOrCreateStartOfDayNav(activeVenue, marketDataBaseUrl()); const killSwitch = deriveKillSwitch(entries); - const verdict = evaluateProposal(proposal, mandate, simulation, todaysFilled, { currentNavUsd: navUsd, startOfDayNavUsd }, killSwitch); + // Only ask the exchange about open orders when the mandate has a limit that needs the answer. + const needsOpenOrders = mandate.limits.maxOpenOrders !== undefined && proposal.type === "LIMIT"; + const openOrderCount = needsOpenOrders ? (await listOpenCharterOrders(activeVenue)).length : undefined; + + const verdict = evaluateProposal(proposal, mandate, simulation, todaysCommitted, { currentNavUsd: navUsd, startOfDayNavUsd }, killSwitch, { + entries, + holdings, + openOrderCount, + }); await auditLog.append("VERDICT_ISSUED", activeVenue.name, { verdict, ...extra }); return verdict; } diff --git a/src/policy/engine.ts b/src/policy/engine.ts index 937b85d..5532aa6 100644 --- a/src/policy/engine.ts +++ b/src/policy/engine.ts @@ -8,9 +8,22 @@ import { checkMaxSlippage } from "./rules/maxSlippage.js"; import { checkLeverageLimit } from "./rules/leverageLimit.js"; import { checkDrawdownHalt } from "./rules/drawdownHalt.js"; import { checkKillSwitch } from "./rules/killSwitch.js"; +import { checkCooldown, checkPerSymbolDailyCap, checkTradeRate } from "./rules/activity.js"; +import { checkLimitPrice, checkOpenOrders, checkSellWithinHoldings } from "./rules/orders.js"; import type { KillSwitchState } from "../approval/state.js"; import { randomUUID } from "node:crypto"; +/** Extra facts some rules need. Every field is optional; a rule that needs one it was not given fails closed. */ +export interface EvaluationContext { + /** The whole audit log, for the rules that depend on what has already been executed. */ + entries?: AuditEntry[]; + /** Free balance per asset, for checking a SELL is covered. */ + holdings?: Record; + /** How many CHARTER limit orders are resting on the book right now. */ + openOrderCount?: number; + now?: Date; +} + /** * Evaluates a proposal against a mandate using a real simulation. Any * `violated` rule -> VETO (execution never attempted). Otherwise, any @@ -22,12 +35,22 @@ export function evaluateProposal( simulation: SimulationResult, todaysFilledEntries: AuditEntry[], navContext: { currentNavUsd: number; startOfDayNavUsd: number }, - killSwitch?: KillSwitchState + killSwitch?: KillSwitchState, + context: EvaluationContext = {} ): Verdict { + const entries = context.entries ?? []; + const now = context.now ?? new Date(); + const reasons: RuleResult[] = [ checkKillSwitch(killSwitch), checkSymbolAllowlist(proposal, mandate), checkSpendingCap(proposal, mandate, simulation, todaysFilledEntries), + checkPerSymbolDailyCap(proposal, mandate, simulation, entries, now), + checkTradeRate(proposal, mandate, entries, now), + checkCooldown(proposal, mandate, entries, now), + checkLimitPrice(proposal, mandate, simulation), + checkOpenOrders(proposal, mandate, context.openOrderCount), + checkSellWithinHoldings(proposal, simulation, context.holdings), checkMaxSlippage(simulation, mandate), checkLeverageLimit(mandate), checkDrawdownHalt(navContext.currentNavUsd, navContext.startOfDayNavUsd, mandate), diff --git a/src/policy/rules/activity.ts b/src/policy/rules/activity.ts new file mode 100644 index 0000000..c8ab973 --- /dev/null +++ b/src/policy/rules/activity.ts @@ -0,0 +1,91 @@ +import type { Mandate } from "../../mandate/schema.js"; +import type { Proposal, RuleResult, SimulationResult } from "../types.js"; +import type { AuditEntry } from "../../audit/log.js"; + +/** + * Rules that depend on what has already been executed. All of them read the + * audit log rather than keep counters, so they cannot drift from the record. + * A proposal counts as executed once an order was filled or placed on the + * book, since a resting order can still fill later. + */ + +interface Committed { + at: number; + agentId?: string; + symbol?: string; + notionalUsd: number; +} + +function committedExecutions(entries: AuditEntry[]): Committed[] { + const out: Committed[] = []; + for (const e of entries) { + if (e.type !== "EXECUTION_FILLED" && e.type !== "EXECUTION_PLACED") continue; + const p = e.payload as { agentId?: string; symbol?: string; notionalUsd?: number }; + out.push({ at: new Date(e.timestamp).getTime(), agentId: p.agentId, symbol: p.symbol, notionalUsd: p.notionalUsd ?? 0 }); + } + return out; +} + +function startOfUtcDay(now: Date): number { + const d = new Date(now); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); +} + +export function checkPerSymbolDailyCap(proposal: Proposal, mandate: Mandate, simulation: SimulationResult, entries: AuditEntry[], now: Date): RuleResult { + const cap = mandate.limits.perSymbolDailyCapUsd; + if (cap === undefined) return { rule: "perSymbolDailyCapUsd", outcome: "ok", detail: "No per-symbol daily cap configured" }; + + const dayStart = startOfUtcDay(now); + const spent = committedExecutions(entries) + .filter((c) => c.symbol === proposal.symbol && c.at >= dayStart) + .reduce((sum, c) => sum + c.notionalUsd, 0); + const projected = spent + simulation.notionalUsd; + + if (projected > cap) { + return { + rule: "perSymbolDailyCapUsd", + outcome: "violated", + detail: `${proposal.symbol} already has $${spent.toFixed(2)} today, and this $${simulation.notionalUsd.toFixed(2)} would make $${projected.toFixed(2)}, over the $${cap} per-symbol daily cap`, + }; + } + return { rule: "perSymbolDailyCapUsd", outcome: "ok", detail: `${proposal.symbol} would be at $${projected.toFixed(2)} of its $${cap} daily cap` }; +} + +export function checkTradeRate(proposal: Proposal, mandate: Mandate, entries: AuditEntry[], now: Date): RuleResult { + const max = mandate.limits.maxTradesPerHour; + if (max === undefined) return { rule: "maxTradesPerHour", outcome: "ok", detail: "No trade-rate limit configured" }; + + const since = now.getTime() - 3_600_000; + const recent = committedExecutions(entries).filter((c) => c.agentId === proposal.agentId && c.at >= since).length; + + if (recent >= max) { + return { + rule: "maxTradesPerHour", + outcome: "violated", + detail: `${proposal.agentId} has already executed ${recent} trade${recent === 1 ? "" : "s"} in the last hour, at the limit of ${max}`, + }; + } + return { rule: "maxTradesPerHour", outcome: "ok", detail: `${proposal.agentId} has executed ${recent} of ${max} allowed trades in the last hour` }; +} + +export function checkCooldown(proposal: Proposal, mandate: Mandate, entries: AuditEntry[], now: Date): RuleResult { + const seconds = mandate.limits.cooldownSeconds; + if (seconds === undefined) return { rule: "cooldownSeconds", outcome: "ok", detail: "No cooldown configured" }; + + const last = committedExecutions(entries) + .filter((c) => c.agentId === proposal.agentId && c.symbol === proposal.symbol) + .reduce((latest, c) => (latest === undefined || c.at > latest ? c.at : latest), undefined); + + if (last !== undefined) { + const elapsed = (now.getTime() - last) / 1000; + if (elapsed < seconds) { + return { + rule: "cooldownSeconds", + outcome: "violated", + detail: `${proposal.agentId} traded ${proposal.symbol} ${elapsed.toFixed(0)}s ago, and the cooldown is ${seconds}s (${(seconds - elapsed).toFixed(0)}s left)`, + }; + } + } + return { rule: "cooldownSeconds", outcome: "ok", detail: `Past the ${seconds}s cooldown for ${proposal.agentId} in ${proposal.symbol}` }; +} diff --git a/src/policy/rules/orders.ts b/src/policy/rules/orders.ts new file mode 100644 index 0000000..2b473a2 --- /dev/null +++ b/src/policy/rules/orders.ts @@ -0,0 +1,61 @@ +import type { Mandate } from "../../mandate/schema.js"; +import type { Proposal, RuleResult, SimulationResult } from "../types.js"; +import { baseAssetOf } from "../../market/symbols.js"; + +const DEFAULT_MAX_LIMIT_DEVIATION_PCT = 5; + +/** + * A limit price far from the market is far more often a typo than a + * strategy. Refused in either direction, since a BUY priced far above the + * market pays up to that price and one far below rests untouched. + */ +export function checkLimitPrice(proposal: Proposal, mandate: Mandate, simulation: SimulationResult): RuleResult { + if (proposal.type !== "LIMIT" || proposal.limitPrice === undefined) { + return { rule: "maxLimitDeviationPct", outcome: "ok", detail: "Not a limit order" }; + } + const max = mandate.limits.maxLimitDeviationPct ?? DEFAULT_MAX_LIMIT_DEVIATION_PCT; + const deviation = (Math.abs(proposal.limitPrice - simulation.referencePrice) / simulation.referencePrice) * 100; + + if (deviation > max) { + return { + rule: "maxLimitDeviationPct", + outcome: "violated", + detail: `Limit price ${proposal.limitPrice} is ${deviation.toFixed(2)}% from the market price ${simulation.referencePrice}, beyond the ${max}% allowed`, + }; + } + return { rule: "maxLimitDeviationPct", outcome: "ok", detail: `Limit price is ${deviation.toFixed(2)}% from the market, within ${max}%` }; +} + +/** Resting limit orders can fill later, so the number allowed to sit on the book at once is capped. */ +export function checkOpenOrders(proposal: Proposal, mandate: Mandate, openOrderCount: number | undefined): RuleResult { + const max = mandate.limits.maxOpenOrders; + if (max === undefined || proposal.type !== "LIMIT") { + return { rule: "maxOpenOrders", outcome: "ok", detail: "No open-order limit applies" }; + } + if (openOrderCount === undefined) { + return { rule: "maxOpenOrders", outcome: "violated", detail: "Could not read the open orders, so the open-order limit cannot be verified" }; + } + if (openOrderCount >= max) { + return { rule: "maxOpenOrders", outcome: "violated", detail: `${openOrderCount} CHARTER orders are already resting, at the limit of ${max}` }; + } + return { rule: "maxOpenOrders", outcome: "ok", detail: `${openOrderCount} of ${max} allowed open orders in use` }; +} + +/** + * A SELL has to be covered by what is actually held. If holdings could not + * be read, the SELL is refused rather than assumed to be covered. + */ +export function checkSellWithinHoldings(proposal: Proposal, simulation: SimulationResult, holdings: Record | undefined): RuleResult { + if (proposal.side !== "SELL") return { rule: "sellWithinHoldings", outcome: "ok", detail: "Not a sell" }; + + const base = baseAssetOf(proposal.symbol); + if (!base) return { rule: "sellWithinHoldings", outcome: "violated", detail: `Cannot tell which asset ${proposal.symbol} sells, so holdings cannot be checked` }; + if (!holdings) return { rule: "sellWithinHoldings", outcome: "violated", detail: "Could not read holdings, so the sell cannot be checked against them" }; + + const needed = proposal.quantity ?? simulation.notionalUsd / simulation.referencePrice; + const held = holdings[base] ?? 0; + if (needed > held) { + return { rule: "sellWithinHoldings", outcome: "violated", detail: `Selling about ${needed.toFixed(8)} ${base} but only ${held} is free to sell` }; + } + return { rule: "sellWithinHoldings", outcome: "ok", detail: `Selling about ${needed.toFixed(8)} ${base} of ${held} held` }; +} diff --git a/src/policy/runProposal.ts b/src/policy/runProposal.ts index 23d9c31..069bb9c 100644 --- a/src/policy/runProposal.ts +++ b/src/policy/runProposal.ts @@ -13,7 +13,13 @@ export interface RunProposalInput { mandateId: string; symbol: string; side: "BUY" | "SELL"; - usd: number; + /** Order type. Defaults to MARKET. */ + type?: "MARKET" | "LIMIT"; + /** MARKET: the amount to trade, in USD. */ + usd?: number; + /** LIMIT: how much of the asset, and the price. */ + quantity?: number; + limitPrice?: number; reason?: string; /** Place the real order if the verdict is PASS. Has no effect on an ESCALATE, which always waits for a separate human approval. */ execute: boolean; @@ -42,8 +48,10 @@ export async function runProposal(input: RunProposalInput): Promise { + if (p.type === "MARKET" && p.quoteOrderQty === undefined && p.quantity === undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A MARKET proposal needs a USD amount (quoteOrderQty) or a quantity" }); + } + if (p.type === "LIMIT") { + if (p.quantity === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal needs a quantity" }); + if (p.limitPrice === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal needs a limitPrice" }); + if (p.quoteOrderQty !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal is sized by quantity, not a USD amount" }); + } + }); export type Proposal = z.infer; export type RuleOutcome = "ok" | "violated" | "warning"; @@ -35,8 +46,10 @@ export interface SimulationResult { orderBookDepthSampledAt: string; /** True when the sampled order-book depth couldn't fully cover notionalUsd. When true, projectedFillPrice/projectedSlippageBps describe only the fillable portion and understate the real impact. */ liquidityInsufficient: boolean; - /** Notional not coverable by the sampled depth; 0 when fully covered. */ + /** Notional not coverable by the sampled depth; 0 when fully covered. Only meaningful for MARKET. */ unfilledUsd: number; + /** LIMIT only: notional that would not fill at once and would rest on the book at the limit price. */ + restingUsd: number; } export interface Verdict { diff --git a/src/venues/filters.ts b/src/venues/filters.ts new file mode 100644 index 0000000..766d8b0 --- /dev/null +++ b/src/venues/filters.ts @@ -0,0 +1,46 @@ +/** + * Exchange precision rules. Binance rejects an order whose quantity is not + * a multiple of the symbol's step size, or whose price is not a multiple of + * its tick size, so both are rounded down to what the exchange will accept. + */ + +export interface SymbolFilters { + stepSize: string; + tickSize: string; + minQty: number; + minNotional: number; +} + +interface RawFilter { + filterType: string; + stepSize?: string; + tickSize?: string; + minQty?: string; + minNotional?: string; +} + +export function decimalsOf(step: string): number { + const trimmed = step.includes(".") ? step.replace(/0+$/, "") : step; + const dot = trimmed.indexOf("."); + return dot === -1 ? 0 : trimmed.length - dot - 1; +} + +/** Rounds down to a multiple of `step`, never up, so a quantity is never larger than what was approved. */ +export function roundDownToStep(value: number, step: string): number { + const stepNum = parseFloat(step); + if (!(stepNum > 0)) return value; + const units = Math.floor(value / stepNum + 1e-9); + return Number((units * stepNum).toFixed(decimalsOf(step))); +} + +export function parseFilters(filters: RawFilter[]): SymbolFilters { + const lot = filters.find((f) => f.filterType === "LOT_SIZE"); + const price = filters.find((f) => f.filterType === "PRICE_FILTER"); + const notional = filters.find((f) => f.filterType === "NOTIONAL" || f.filterType === "MIN_NOTIONAL"); + return { + stepSize: lot?.stepSize ?? "0", + tickSize: price?.tickSize ?? "0", + minQty: parseFloat(lot?.minQty ?? "0"), + minNotional: parseFloat(notional?.minNotional ?? "0"), + }; +} diff --git a/src/venues/testnetClient.ts b/src/venues/testnetClient.ts index b9fdc2b..3affe9a 100644 --- a/src/venues/testnetClient.ts +++ b/src/venues/testnetClient.ts @@ -2,10 +2,12 @@ import { createHmac } from "node:crypto"; import type { Balance, ExecutionVenue, + OpenOrder, OrderBook, OrderRequest, OrderResult, } from "./types.js"; +import { parseFilters, roundDownToStep, type SymbolFilters } from "./filters.js"; /** * Real, authenticated client against Binance Spot Testnet @@ -23,28 +25,68 @@ export class TestnetClient implements ExecutionVenue { private readonly baseUrl: string ) {} + /** Exchange clock minus local clock, in ms. Signed requests are rejected if their timestamp drifts too far from the exchange's. */ + private timeOffsetMs = 0; + private timeSynced = false; + + private async syncTime(): Promise { + const before = Date.now(); + const res = await fetch(`${this.baseUrl}/api/v3/time`); + const after = Date.now(); + if (!res.ok) throw new Error(`Testnet time fetch failed: ${res.status} ${res.statusText}`); + const { serverTime } = (await res.json()) as { serverTime: number }; + // Assume the server stamped the time halfway through the round trip. + this.timeOffsetMs = serverTime - Math.round((before + after) / 2); + this.timeSynced = true; + } + private sign(params: Record): URLSearchParams { const search = new URLSearchParams(); for (const [k, v] of Object.entries(params)) search.set(k, String(v)); - search.set("timestamp", String(Date.now())); - search.set("recvWindow", "5000"); + search.set("timestamp", String(Date.now() + this.timeOffsetMs)); + search.set("recvWindow", "10000"); const signature = createHmac("sha256", this.apiSecret).update(search.toString()).digest("hex"); search.set("signature", signature); return search; } - private async signedRequest(method: "GET" | "POST", path: string, params: Record = {}): Promise { - const search = this.sign(params); - const url = `${this.baseUrl}${path}?${search.toString()}`; - const res = await fetch(url, { - method, - headers: { "X-MBX-APIKEY": this.apiKey }, - }); - if (!res.ok) { + private filterCache = new Map(); + + private async getFilters(symbol: string): Promise { + const cached = this.filterCache.get(symbol); + if (cached) return cached; + const res = await fetch(`${this.baseUrl}/api/v3/exchangeInfo?symbol=${symbol}`); + if (!res.ok) throw new Error(`Testnet exchangeInfo failed for ${symbol}: ${res.status} ${res.statusText}`); + const json = (await res.json()) as { symbols?: Array<{ filters: Parameters[0] }> }; + const first = json.symbols?.[0]; + if (!first) throw new Error(`Testnet has no symbol ${symbol}`); + const filters = parseFilters(first.filters); + this.filterCache.set(symbol, filters); + return filters; + } + + private async signedRequest(method: "GET" | "POST" | "DELETE", path: string, params: Record = {}): Promise { + if (!this.timeSynced) await this.syncTime(); + + for (let attempt = 0; ; attempt++) { + const search = this.sign(params); + const url = `${this.baseUrl}${path}?${search.toString()}`; + const res = await fetch(url, { + method, + headers: { "X-MBX-APIKEY": this.apiKey }, + }); + if (res.ok) return (await res.json()) as T; + const body = await res.text(); + // -1021: the timestamp fell outside the accepted window. The clocks have + // drifted since the last sync, so resync once and try again. This is + // safe to retry: a rejected request was never executed. + if (attempt === 0 && body.includes('"code":-1021')) { + await this.syncTime(); + continue; + } throw new Error(`Testnet ${method} ${path} failed: ${res.status} ${res.statusText}: ${body}`); } - return (await res.json()) as T; } async getSubAccountBalances(): Promise { @@ -77,14 +119,25 @@ export class TestnetClient implements ExecutionVenue { }; if (order.type === "MARKET") { if (order.quoteOrderQty !== undefined) params.quoteOrderQty = order.quoteOrderQty; - else if (order.quantity !== undefined) params.quantity = order.quantity; + else if (order.quantity !== undefined) params.quantity = roundDownToStep(order.quantity, (await this.getFilters(order.symbol)).stepSize); else throw new Error("MARKET order requires quantity or quoteOrderQty"); } else { if (order.quantity === undefined || order.limitPrice === undefined) { throw new Error("LIMIT order requires quantity and limitPrice"); } - params.quantity = order.quantity; - params.price = order.limitPrice; + // Round to what the exchange accepts. Quantity only ever rounds down, so + // the order is never larger than what was approved. + const filters = await this.getFilters(order.symbol); + const quantity = roundDownToStep(order.quantity, filters.stepSize); + const price = roundDownToStep(order.limitPrice, filters.tickSize); + if (quantity <= 0 || quantity < filters.minQty) { + throw new Error(`Quantity ${order.quantity} rounds to ${quantity}, below the ${order.symbol} minimum of ${filters.minQty}`); + } + if (filters.minNotional > 0 && quantity * price < filters.minNotional) { + throw new Error(`Order value ${(quantity * price).toFixed(2)} is below the ${order.symbol} minimum of ${filters.minNotional}`); + } + params.quantity = quantity; + params.price = price; params.timeInForce = "GTC"; } if (order.clientOrderId) params.newClientOrderId = order.clientOrderId; @@ -119,6 +172,59 @@ export class TestnetClient implements ExecutionVenue { }; } + async getOpenOrders(symbol?: string): Promise { + const raw = await this.signedRequest< + Array<{ + orderId: number; + symbol: string; + side: "BUY" | "SELL"; + type: string; + price: string; + origQty: string; + executedQty: string; + status: string; + clientOrderId: string; + time: number; + }> + >("GET", "/api/v3/openOrders", symbol ? { symbol } : {}); + return raw.map((o) => ({ + orderId: String(o.orderId), + symbol: o.symbol, + side: o.side, + type: o.type, + price: parseFloat(o.price), + origQty: parseFloat(o.origQty), + executedQty: parseFloat(o.executedQty), + status: o.status, + clientOrderId: o.clientOrderId, + time: o.time, + })); + } + + async cancelOrder(symbol: string, orderId: string): Promise { + const raw = await this.signedRequest<{ + orderId: number; + symbol: string; + side: "BUY" | "SELL"; + type: string; + status: string; + executedQty: string; + cummulativeQuoteQty: string; + }>("DELETE", "/api/v3/order", { symbol, orderId }); + return { + venue: this.name, + orderId: String(raw.orderId), + symbol: raw.symbol, + side: raw.side, + type: raw.type as "MARKET" | "LIMIT", + status: raw.status, + executedQty: parseFloat(raw.executedQty), + cummulativeQuoteQty: parseFloat(raw.cummulativeQuoteQty), + fills: [], + raw, + }; + } + async getOrder(symbol: string, orderId: string): Promise { const raw = await this.signedRequest<{ orderId: number; diff --git a/src/venues/types.ts b/src/venues/types.ts index 870970d..eab8707 100644 --- a/src/venues/types.ts +++ b/src/venues/types.ts @@ -50,6 +50,19 @@ export interface OrderResult { raw: unknown; } +export interface OpenOrder { + orderId: string; + symbol: string; + side: OrderSide; + type: string; + price: number; + origQty: number; + executedQty: number; + status: string; + clientOrderId: string; + time: number; +} + export type VenueName = "testnet" | "mainnet-mcp"; export interface ExecutionVenue { @@ -58,4 +71,7 @@ export interface ExecutionVenue { getDepth(symbol: string, limit?: number): Promise; placeOrder(order: OrderRequest): Promise; getOrder(symbol: string, orderId: string): Promise; + /** Orders currently resting on the book for this account, optionally for one symbol. */ + getOpenOrders(symbol?: string): Promise; + cancelOrder(symbol: string, orderId: string): Promise; } diff --git a/tests/api.server.test.ts b/tests/api.server.test.ts index acfdbb2..a4f385a 100644 --- a/tests/api.server.test.ts +++ b/tests/api.server.test.ts @@ -32,6 +32,11 @@ vi.mock("../src/venues/index.js", () => ({ activeVenue: { name: "testnet" }, mar vi.mock("../src/policy/assess.js", () => ({ assessProposal: vi.fn() })); vi.mock("../src/execution/adapter.js", () => ({ executeProposal: vi.fn() })); vi.mock("../src/policy/runProposal.js", () => ({ runProposal: vi.fn() })); +vi.mock("../src/execution/orders.js", () => ({ + listOpenCharterOrders: vi.fn(async () => [{ orderId: "1", symbol: "BTCUSDT" }]), + cancelCharterOrder: vi.fn(), + cancelAllCharterOrders: vi.fn(async () => ({ cancelled: [{}, {}], failed: [] })), +})); vi.mock("../src/approval/service.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -328,3 +333,83 @@ describe("per-agent keys", () => { expect(res.status).toBe(401); }); }); + +describe("order types in requests", () => { + const post = (body: Record) => + fetch(`${base}/propose`, { method: "POST", headers: { ...json, ...AGENT }, body: JSON.stringify({ agentId: "a", mandateId: uuid, symbol: "BTCUSDT", side: "BUY", ...body }) }); + + const passed = () => vi.mocked(runProposal).mockResolvedValue({ proposal: { id: "p1", agentId: "a" }, verdict: { decision: "PASS" } } as never); + + it("accepts a LIMIT proposal sized by quantity and price and passes it through", async () => { + passed(); + const res = await post({ type: "LIMIT", quantity: 0.001, limitPrice: 70000 }); + expect(res.status).toBe(200); + expect(vi.mocked(runProposal).mock.calls[0]![0]).toMatchObject({ type: "LIMIT", quantity: 0.001, limitPrice: 70000 }); + }); + + it("refuses a LIMIT proposal with no price", async () => { + const res = await post({ type: "LIMIT", quantity: 0.001 }); + expect(res.status).toBe(400); + expect(JSON.stringify(await res.json())).toContain("limitPrice"); + expect(runProposal).not.toHaveBeenCalled(); + }); + + it("refuses a LIMIT proposal with no quantity", async () => { + expect((await post({ type: "LIMIT", limitPrice: 70000 })).status).toBe(400); + }); + + it("refuses a LIMIT proposal sized in USD, which would be ambiguous", async () => { + expect((await post({ type: "LIMIT", quantity: 1, limitPrice: 1, usd: 100 })).status).toBe(400); + }); + + it("refuses a MARKET proposal with no size", async () => { + expect((await post({})).status).toBe(400); + }); + + it("defaults to MARKET, sized in USD", async () => { + passed(); + expect((await post({ usd: 15 })).status).toBe(200); + expect(vi.mocked(runProposal).mock.calls[0]![0]).toMatchObject({ type: "MARKET", usd: 15 }); + }); + + it("turns a validation error from the pipeline into a 400, not a 500", async () => { + const { z } = await import("zod"); + vi.mocked(runProposal).mockRejectedValue(new z.ZodError([{ code: "custom", message: "bad", path: [] }])); + expect((await post({ usd: 15 })).status).toBe(400); + }); +}); + +describe("resting-order controls", () => { + it("lists resting orders only with the approver key", async () => { + expect((await fetch(`${base}/control/orders`, { headers: AGENT })).status).toBe(401); + const ok = await fetch(`${base}/control/orders`, { headers: APPROVER }); + expect(ok.status).toBe(200); + expect(((await ok.json()) as unknown[]).length).toBe(1); + }); + + it("halt with cancelOpen also cancels resting orders and reports how many", async () => { + vi.mocked(service.engageKillSwitch).mockResolvedValue({ engaged: true } as never); + const orders = await import("../src/execution/orders.js"); + const res = await fetch(`${base}/control/halt`, { method: "POST", headers: { ...json, ...APPROVER }, body: JSON.stringify({ reason: "drill", cancelOpen: true }) }); + const body = (await res.json()) as { cancelled: number }; + expect(res.status).toBe(200); + expect(body.cancelled).toBe(2); + expect(orders.cancelAllCharterOrders).toHaveBeenCalled(); + }); + + it("halt without cancelOpen leaves resting orders alone", async () => { + vi.mocked(service.engageKillSwitch).mockResolvedValue({ engaged: true } as never); + const orders = await import("../src/execution/orders.js"); + vi.mocked(orders.cancelAllCharterOrders).mockClear(); + await fetch(`${base}/control/halt`, { method: "POST", headers: { ...json, ...APPROVER }, body: JSON.stringify({ reason: "drill" }) }); + expect(orders.cancelAllCharterOrders).not.toHaveBeenCalled(); + }); + + it("cancelling an order needs the approver key and names who did it", async () => { + const orders = await import("../src/execution/orders.js"); + expect((await fetch(`${base}/control/orders/BTCUSDT/9/cancel`, { method: "POST", headers: { ...json, ...AGENT }, body: "{}" })).status).toBe(401); + const res = await fetch(`${base}/control/orders/BTCUSDT/9/cancel`, { method: "POST", headers: { ...json, ...APPROVER }, body: "{}" }); + expect(res.status).toBe(200); + expect(orders.cancelCharterOrder).toHaveBeenCalledWith(expect.anything(), "BTCUSDT", "9", "alice"); + }); +}); diff --git a/tests/execution.adapter.test.ts b/tests/execution.adapter.test.ts index 664fc1c..c315da8 100644 --- a/tests/execution.adapter.test.ts +++ b/tests/execution.adapter.test.ts @@ -35,6 +35,7 @@ function makeSimulation(overrides: Partial = {}): SimulationRe orderBookDepthSampledAt: new Date().toISOString(), liquidityInsufficient: false, unfilledUsd: 0, + restingUsd: 0, ...overrides, }; } @@ -66,6 +67,12 @@ function makeVenue(overrides: Partial = {}): ExecutionVenue { async getOrder(): Promise { throw new Error("not used in this test"); }, + async getOpenOrders() { + return []; + }, + async cancelOrder(): Promise { + throw new Error("not used in this test"); + }, ...overrides, }; } @@ -167,3 +174,32 @@ describe("executeProposal with an approval", () => { expect(mockedAppend.mock.calls[0]![2]).not.toHaveProperty("approvalId"); }); }); + +describe("executeProposal with resting and unfilled orders", () => { + const base = { venue: "testnet" as const, symbol: "BTCUSDT", side: "BUY" as const, type: "LIMIT" as const, cummulativeQuoteQty: 0, fills: [], raw: {} }; + + it("logs a resting order as placed, not filled", async () => { + const venue = makeVenue({ placeOrder: vi.fn().mockResolvedValue({ ...base, orderId: "1", status: "NEW", executedQty: 0 }) }); + await executeProposal(venue, makeProposal(), makeVerdict("PASS")); + expect(mockedAppend.mock.calls.map((c) => c[0])).toEqual(["EXECUTION_ATTEMPTED", "EXECUTION_PLACED"]); + }); + + it("logs a partly filled order as placed, since the rest is still open", async () => { + const venue = makeVenue({ placeOrder: vi.fn().mockResolvedValue({ ...base, orderId: "2", status: "PARTIALLY_FILLED", executedQty: 0.5 }) }); + await executeProposal(venue, makeProposal(), makeVerdict("PASS")); + expect(mockedAppend.mock.calls[1]![0]).toBe("EXECUTION_PLACED"); + }); + + it("records the agent and symbol so activity rules can find the trade later", async () => { + const venue = makeVenue({ placeOrder: vi.fn().mockResolvedValue({ ...base, orderId: "3", status: "FILLED", executedQty: 1 }) }); + await executeProposal(venue, makeProposal(), makeVerdict("PASS")); + expect(mockedAppend.mock.calls[0]![2]).toMatchObject({ agentId: "test-agent", symbol: "BTCUSDT" }); + expect(mockedAppend.mock.calls[1]![2]).toMatchObject({ agentId: "test-agent" }); + }); + + it("does not count an order that ended with nothing executed", async () => { + const venue = makeVenue({ placeOrder: vi.fn().mockResolvedValue({ ...base, orderId: "4", status: "EXPIRED", executedQty: 0 }) }); + await executeProposal(venue, makeProposal(), makeVerdict("PASS")); + expect(mockedAppend.mock.calls.map((c) => c[0])).toEqual(["EXECUTION_ATTEMPTED", "EXECUTION_REJECTED_BY_PLATFORM"]); + }); +}); diff --git a/tests/execution.orders.test.ts b/tests/execution.orders.test.ts new file mode 100644 index 0000000..76e72b5 --- /dev/null +++ b/tests/execution.orders.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/audit/log.js", () => ({ auditLog: { append: vi.fn() } })); + +import { cancelAllCharterOrders, cancelCharterOrder, listOpenCharterOrders } from "../src/execution/orders.js"; +import { auditLog } from "../src/audit/log.js"; +import type { ExecutionVenue, OpenOrder, OrderResult } from "../src/venues/types.js"; + +const mockedAppend = vi.mocked(auditLog.append); + +const order = (orderId: string, clientOrderId: string, symbol = "BTCUSDT"): OpenOrder => ({ + orderId, + symbol, + side: "BUY", + type: "LIMIT", + price: 100, + origQty: 1, + executedQty: 0, + status: "NEW", + clientOrderId, + time: 0, +}); + +function venue(open: OpenOrder[], cancel: (symbol: string, id: string) => Promise): ExecutionVenue { + return { + name: "testnet", + getSubAccountBalances: async () => [], + getDepth: async () => { + throw new Error("unused"); + }, + placeOrder: async () => { + throw new Error("unused"); + }, + getOrder: async () => { + throw new Error("unused"); + }, + getOpenOrders: async () => open, + cancelOrder: cancel, + }; +} + +const cancelled = (id: string): OrderResult => + ({ venue: "testnet", orderId: id, symbol: "BTCUSDT", side: "BUY", type: "LIMIT", status: "CANCELED", executedQty: 0, cummulativeQuoteQty: 0, fills: [], raw: {} }) as OrderResult; + +beforeEach(() => { + mockedAppend.mockReset(); + mockedAppend.mockResolvedValue({} as never); +}); + +describe("listOpenCharterOrders", () => { + it("returns only orders CHARTER placed, never anything else on the account", async () => { + const v = venue([order("1", "charter-abc"), order("2", "web_manual_order"), order("3", "charter-def")], async () => cancelled("x")); + const list = await listOpenCharterOrders(v); + expect(list.map((o) => o.orderId)).toEqual(["1", "3"]); + }); +}); + +describe("cancelCharterOrder", () => { + it("cancels on the venue and records who did it and why", async () => { + const cancel = vi.fn(async (_s: string, id: string) => cancelled(id)); + await cancelCharterOrder(venue([], cancel), "BTCUSDT", "42", "alice", "changed my mind"); + expect(cancel).toHaveBeenCalledWith("BTCUSDT", "42"); + expect(mockedAppend).toHaveBeenCalledWith("ORDER_CANCELLED", "testnet", expect.objectContaining({ orderId: "42", by: "alice", reason: "changed my mind" })); + }); + + it("records nothing if the venue refused the cancel", async () => { + const cancel = vi.fn(async () => { + throw new Error("Unknown order"); + }); + await expect(cancelCharterOrder(venue([], cancel), "BTCUSDT", "42", "alice")).rejects.toThrow("Unknown order"); + expect(mockedAppend).not.toHaveBeenCalled(); + }); +}); + +describe("cancelAllCharterOrders", () => { + it("cancels every CHARTER order and leaves other orders alone", async () => { + const cancel = vi.fn(async (_s: string, id: string) => cancelled(id)); + const v = venue([order("1", "charter-a"), order("2", "manual"), order("3", "charter-b")], cancel); + const result = await cancelAllCharterOrders(v, "alice", "halt"); + expect(result.cancelled.map((o) => o.orderId)).toEqual(["1", "3"]); + expect(cancel).toHaveBeenCalledTimes(2); + expect(cancel).not.toHaveBeenCalledWith(expect.anything(), "2"); + }); + + it("keeps going when one cancel fails, and reports it", async () => { + const cancel = vi.fn(async (_s: string, id: string) => { + if (id === "1") throw new Error("already filled"); + return cancelled(id); + }); + const v = venue([order("1", "charter-a"), order("2", "charter-b")], cancel); + const result = await cancelAllCharterOrders(v, "alice"); + expect(result.cancelled.map((o) => o.orderId)).toEqual(["2"]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.error).toContain("already filled"); + }); +}); diff --git a/tests/mandate.compiler.test.ts b/tests/mandate.compiler.test.ts index 7ed27be..ef2d505 100644 --- a/tests/mandate.compiler.test.ts +++ b/tests/mandate.compiler.test.ts @@ -77,3 +77,25 @@ describe("parseCompilerOutput", () => { expect(result.confirmAboveUsd).toBe(49.99); }); }); + +describe("parseCompilerOutput with the newer limits", () => { + it("accepts and keeps the optional activity and order limits", () => { + const result = parseCompilerOutput( + JSON.stringify({ ...VALID, perSymbolDailyCapUsd: 500, maxTradesPerHour: 4, cooldownSeconds: 60, maxOpenOrders: 2, maxLimitDeviationPct: 3 }) + ); + expect(result).toMatchObject({ perSymbolDailyCapUsd: 500, maxTradesPerHour: 4, cooldownSeconds: 60, maxOpenOrders: 2, maxLimitDeviationPct: 3 }); + }); + + it("treats null for them as not set", () => { + const result = parseCompilerOutput(JSON.stringify({ ...VALID, perSymbolDailyCapUsd: null, maxTradesPerHour: null, cooldownSeconds: null, maxOpenOrders: null, maxLimitDeviationPct: null })); + expect(result.maxTradesPerHour).toBeUndefined(); + expect(result.cooldownSeconds).toBeUndefined(); + }); + + it("refuses values that make no sense", () => { + expect(() => parseCompilerOutput(JSON.stringify({ ...VALID, maxTradesPerHour: 0 }))).toThrow("failed validation"); + expect(() => parseCompilerOutput(JSON.stringify({ ...VALID, maxTradesPerHour: 2.5 }))).toThrow("failed validation"); + expect(() => parseCompilerOutput(JSON.stringify({ ...VALID, maxLimitDeviationPct: 150 }))).toThrow("failed validation"); + expect(() => parseCompilerOutput(JSON.stringify({ ...VALID, cooldownSeconds: -5 }))).toThrow("failed validation"); + }); +}); diff --git a/tests/nav.test.ts b/tests/nav.test.ts index 3ed7977..e3a795a 100644 --- a/tests/nav.test.ts +++ b/tests/nav.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../src/market/binanceRest.js", () => ({ - getTickerPrice: vi.fn(), + getAllTickerPrices: vi.fn(), })); -import { computeApproxNavUsd } from "../src/market/nav.js"; -import { getTickerPrice } from "../src/market/binanceRest.js"; +import { computeApproxNavUsd, computeNavBreakdown, valueHoldings } from "../src/market/nav.js"; +import { getAllTickerPrices } from "../src/market/binanceRest.js"; import type { ExecutionVenue, Balance, OrderBook, OrderRequest, OrderResult } from "../src/venues/types.js"; -const mockedGetTickerPrice = vi.mocked(getTickerPrice); +const mockedPrices = vi.mocked(getAllTickerPrices); function makeVenue(balances: Balance[]): ExecutionVenue { return { @@ -25,68 +25,117 @@ function makeVenue(balances: Balance[]): ExecutionVenue { async getOrder(): Promise { throw new Error("not used in this test"); }, + async getOpenOrders() { + return []; + }, + async cancelOrder(): Promise { + throw new Error("not used in this test"); + }, }; } +const prices = (entries: Record) => new Map(Object.entries(entries)); + beforeEach(() => { - mockedGetTickerPrice.mockReset(); + mockedPrices.mockReset(); }); -describe("computeApproxNavUsd", () => { - it("returns 0 for empty balances", async () => { - const nav = await computeApproxNavUsd(makeVenue([]), "https://example.test"); - expect(nav).toBe(0); - expect(mockedGetTickerPrice).not.toHaveBeenCalled(); +describe("valueHoldings", () => { + it("is zero for an empty account", () => { + expect(valueHoldings([], prices({})).navUsd).toBe(0); }); - it("sums stables at 1:1 without calling the ticker", async () => { - const balances: Balance[] = [ - { asset: "USDT", free: 100, locked: 0 }, - { asset: "USDC", free: 50, locked: 25 }, - ]; - const nav = await computeApproxNavUsd(makeVenue(balances), "https://example.test"); - expect(nav).toBe(175); - expect(mockedGetTickerPrice).not.toHaveBeenCalled(); + it("counts stablecoins at one dollar, free and locked", () => { + const r = valueHoldings( + [ + { asset: "USDT", free: 100, locked: 0 }, + { asset: "USDC", free: 50, locked: 25 }, + ], + prices({}) + ); + expect(r.navUsd).toBe(175); + expect(r.priced.every((h) => h.via === "stable")).toBe(true); }); - it("skips zero-balance entries", async () => { - const balances: Balance[] = [{ asset: "USDT", free: 0, locked: 0 }]; - const nav = await computeApproxNavUsd(makeVenue(balances), "https://example.test"); - expect(nav).toBe(0); + it("skips zero balances", () => { + const r = valueHoldings([{ asset: "USDT", free: 0, locked: 0 }], prices({})); + expect(r.priced).toHaveLength(0); + expect(r.unpriced).toHaveLength(0); }); - it("converts a priced asset via its live ticker", async () => { - mockedGetTickerPrice.mockResolvedValueOnce({ symbol: "BTCUSDT", price: 80000 }); - const balances: Balance[] = [{ asset: "BTC", free: 0.5, locked: 0 }]; - const nav = await computeApproxNavUsd(makeVenue(balances), "https://example.test"); - expect(nav).toBe(40000); - expect(mockedGetTickerPrice).toHaveBeenCalledWith("https://example.test", "BTCUSDT"); + it("prices any asset through its USDT pair, not just a fixed list", () => { + const r = valueHoldings([{ asset: "LINK", free: 10, locked: 0 }], prices({ LINKUSDT: 20 })); + expect(r.navUsd).toBe(200); + expect(r.priced[0]).toMatchObject({ asset: "LINK", via: "USDT" }); }); - it("silently skips an asset outside the stables and priced-asset lists", async () => { - const balances: Balance[] = [{ asset: "SOMERANDOMCOIN", free: 1000, locked: 0 }]; - const nav = await computeApproxNavUsd(makeVenue(balances), "https://example.test"); - expect(nav).toBe(0); - expect(mockedGetTickerPrice).not.toHaveBeenCalled(); + it("falls back to a BTC pair converted through BTCUSDT", () => { + const r = valueHoldings([{ asset: "ODD", free: 1000, locked: 0 }], prices({ ODDBTC: 0.000001, BTCUSDT: 80000 })); + expect(r.navUsd).toBeCloseTo(1000 * 0.000001 * 80000, 6); + expect(r.priced[0]!.via).toBe("BTC"); }); - it("excludes a priced asset whose ticker call rejects, without throwing", async () => { - mockedGetTickerPrice.mockRejectedValueOnce(new Error("no USDT pair")); - const balances: Balance[] = [{ asset: "ETH", free: 1, locked: 0 }]; - await expect(computeApproxNavUsd(makeVenue(balances), "https://example.test")).resolves.toBe(0); + it("tries ETH then BNB bridges when there is no BTC pair", () => { + const viaEth = valueHoldings([{ asset: "ODD", free: 10, locked: 0 }], prices({ ODDETH: 0.01, ETHUSDT: 3000 })); + expect(viaEth.priced[0]!.via).toBe("ETH"); + const viaBnb = valueHoldings([{ asset: "ODD", free: 10, locked: 0 }], prices({ ODDBNB: 0.1, BNBUSDT: 600 })); + expect(viaBnb.priced[0]!.via).toBe("BNB"); + }); + + it("prefers the direct USDT pair over a bridge", () => { + const r = valueHoldings([{ asset: "X", free: 1, locked: 0 }], prices({ XUSDT: 5, XBTC: 1, BTCUSDT: 80000 })); + expect(r.priced[0]).toMatchObject({ usd: 5, via: "USDT" }); + }); + + it("prices a fiat currency through the inverse pair, where USDT is the base", () => { + const r = valueHoldings([{ asset: "TRY", free: 4000, locked: 0 }], prices({ USDTTRY: 40 })); + expect(r.navUsd).toBe(100); + expect(r.priced[0]!.via).toBe("1/USDT"); + }); + + it("reports what it cannot price instead of hiding it", () => { + const r = valueHoldings([{ asset: "NOPAIR", free: 999, locked: 1 }], prices({})); + expect(r.navUsd).toBe(0); + expect(r.unpriced).toEqual([{ asset: "NOPAIR", quantity: 1000 }]); + }); + + it("does not bridge through a pair whose bridge asset has no dollar price", () => { + const r = valueHoldings([{ asset: "ODD", free: 1, locked: 0 }], prices({ ODDBTC: 0.5 })); + expect(r.unpriced.map((u) => u.asset)).toEqual(["ODD"]); + }); + + it("values a realistic mixed portfolio", () => { + const r = valueHoldings( + [ + { asset: "USDT", free: 100, locked: 0 }, + { asset: "BTC", free: 0.1, locked: 0 }, + { asset: "LINK", free: 5, locked: 0 }, + { asset: "GHOST", free: 7, locked: 0 }, + ], + prices({ BTCUSDT: 80000, LINKUSDT: 20 }) + ); + expect(r.navUsd).toBe(100 + 0.1 * 80000 + 5 * 20); + expect(r.unpriced).toHaveLength(1); + }); +}); + +describe("computeNavBreakdown and computeApproxNavUsd", () => { + it("fetches all prices once rather than once per asset", async () => { + mockedPrices.mockResolvedValue(prices({ BTCUSDT: 80000, ETHUSDT: 3000, LINKUSDT: 20 })); + const venue = makeVenue([ + { asset: "BTC", free: 1, locked: 0 }, + { asset: "ETH", free: 1, locked: 0 }, + { asset: "LINK", free: 1, locked: 0 }, + ]); + const nav = await computeApproxNavUsd(venue, "https://example.test"); + expect(nav).toBe(80000 + 3000 + 20); + expect(mockedPrices).toHaveBeenCalledTimes(1); + expect(mockedPrices).toHaveBeenCalledWith("https://example.test"); }); - it("computes a realistic mixed portfolio", async () => { - mockedGetTickerPrice.mockImplementation(async (_base: string, symbol: string) => { - if (symbol === "BTCUSDT") return { symbol, price: 80000 }; - throw new Error("unexpected symbol"); - }); - const balances: Balance[] = [ - { asset: "USDT", free: 100, locked: 0 }, - { asset: "BTC", free: 0.1, locked: 0 }, - { asset: "SOMERANDOMCOIN", free: 999, locked: 0 }, - ]; - const nav = await computeApproxNavUsd(makeVenue(balances), "https://example.test"); - expect(nav).toBe(100 + 0.1 * 80000); + it("exposes the unpriced holdings", async () => { + mockedPrices.mockResolvedValue(prices({})); + const breakdown = await computeNavBreakdown(makeVenue([{ asset: "GHOST", free: 3, locked: 0 }]), "https://example.test"); + expect(breakdown.unpriced).toEqual([{ asset: "GHOST", quantity: 3 }]); }); }); diff --git a/tests/navSnapshot.test.ts b/tests/navSnapshot.test.ts new file mode 100644 index 0000000..ddaf8f5 --- /dev/null +++ b/tests/navSnapshot.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const store = vi.hoisted(() => ({ + entries: [] as Array<{ seq: number; timestamp: string; type: string; venue: string; payload: unknown; prevHash: string; hash: string }>, +})); + +vi.mock("../src/audit/log.js", () => ({ + auditLog: { + all: vi.fn(async () => [...store.entries]), + append: vi.fn(async (type: string, venue: string, payload: unknown) => { + store.entries.push({ seq: store.entries.length, timestamp: new Date().toISOString(), type, venue, payload, prevHash: "x", hash: "y" }); + }), + }, +})); +vi.mock("../src/market/nav.js", () => ({ NAV_METHOD: "full-v2", computeApproxNavUsd: vi.fn() })); + +import { getOrCreateStartOfDayNav } from "../src/mandate/navSnapshot.js"; +import { computeApproxNavUsd } from "../src/market/nav.js"; +import type { ExecutionVenue } from "../src/venues/types.js"; + +const venue = { name: "testnet" } as ExecutionVenue; +const mockedNav = vi.mocked(computeApproxNavUsd); + +const snapshot = (navUsd: number, method?: string) => { + store.entries.push({ seq: store.entries.length, timestamp: new Date().toISOString(), type: "NAV_SNAPSHOT", venue: "testnet", payload: { navUsd, ...(method ? { method } : {}) }, prevHash: "x", hash: "y" }); +}; + +beforeEach(() => { + store.entries.length = 0; + mockedNav.mockReset(); +}); + +describe("getOrCreateStartOfDayNav", () => { + it("records today's baseline on first use, tagged with the NAV method", async () => { + mockedNav.mockResolvedValue(1000); + expect(await getOrCreateStartOfDayNav(venue, "x")).toBe(1000); + expect(store.entries).toHaveLength(1); + expect(store.entries[0]!.payload).toMatchObject({ navUsd: 1000, method: "full-v2" }); + }); + + it("reuses today's baseline instead of recomputing it", async () => { + snapshot(500, "full-v2"); + expect(await getOrCreateStartOfDayNav(venue, "x")).toBe(500); + expect(mockedNav).not.toHaveBeenCalled(); + expect(store.entries).toHaveLength(1); + }); + + it("ignores a baseline taken with an older NAV method, so two methods are never compared", async () => { + snapshot(138_000); // recorded before the method was versioned + mockedNav.mockResolvedValue(436_000); + expect(await getOrCreateStartOfDayNav(venue, "x")).toBe(436_000); + expect(store.entries).toHaveLength(2); + }); + + it("ignores a baseline tagged with some other method", async () => { + snapshot(1, "curated-v1"); + mockedNav.mockResolvedValue(2000); + expect(await getOrCreateStartOfDayNav(venue, "x")).toBe(2000); + }); +}); diff --git a/tests/policy.engine.test.ts b/tests/policy.engine.test.ts index 5a44898..a36958c 100644 --- a/tests/policy.engine.test.ts +++ b/tests/policy.engine.test.ts @@ -52,6 +52,7 @@ function makeSimulation(overrides: Partial = {}): SimulationRe orderBookDepthSampledAt: new Date().toISOString(), liquidityInsufficient: false, unfilledUsd: 0, + restingUsd: 0, ...overrides, }; } @@ -157,3 +158,78 @@ describe("kill switch", () => { expect(absent.decision).toBe("PASS"); }); }); + +describe("evaluateProposal with the new rules", () => { + const NOW = new Date("2026-09-21T12:00:00.000Z"); + const filled = (agentId: string, symbol: string, notionalUsd: number, minutesAgo: number): AuditEntry => ({ + seq: 0, + timestamp: new Date(NOW.getTime() - minutesAgo * 60_000).toISOString(), + type: "EXECUTION_FILLED", + venue: "testnet", + payload: { agentId, symbol, notionalUsd }, + prevHash: "x", + hash: "y", + }); + + it("a trade-rate limit vetoes an otherwise clean proposal, naming the rule", () => { + const mandate = makeMandate({ maxTradesPerHour: 1 }); + const verdict = evaluateProposal(makeProposal({ agentId: "alpha" }), mandate, makeSimulation(), noFills, flatNav, undefined, { + entries: [filled("alpha", "BTCUSDT", 15, 5)], + now: NOW, + }); + expect(verdict.decision).toBe("VETO"); + expect(verdict.reasons.find((r) => r.rule === "maxTradesPerHour")?.outcome).toBe("violated"); + }); + + it("a cooldown and a per-symbol cap can each veto on their own", () => { + const entries = [filled("alpha", "BTCUSDT", 100, 1)]; + const cool = evaluateProposal(makeProposal({ agentId: "alpha" }), makeMandate({ cooldownSeconds: 600 }), makeSimulation(), noFills, flatNav, undefined, { entries, now: NOW }); + const cap = evaluateProposal(makeProposal({ agentId: "alpha" }), makeMandate({ perSymbolDailyCapUsd: 110 }), makeSimulation(), noFills, flatNav, undefined, { entries, now: NOW }); + expect(cool.reasons.find((r) => r.rule === "cooldownSeconds")?.outcome).toBe("violated"); + expect(cap.reasons.find((r) => r.rule === "perSymbolDailyCapUsd")?.outcome).toBe("violated"); + }); + + it("a SELL with no holdings supplied is vetoed rather than assumed covered", () => { + const mandate = makeMandate({ allowedSides: ["BUY", "SELL"] }); + const verdict = evaluateProposal(makeProposal({ side: "SELL" }), mandate, makeSimulation(), noFills, flatNav); + expect(verdict.decision).toBe("VETO"); + expect(verdict.reasons.find((r) => r.rule === "sellWithinHoldings")?.outcome).toBe("violated"); + }); + + it("a SELL covered by holdings passes", () => { + const mandate = makeMandate({ allowedSides: ["BUY", "SELL"] }); + const verdict = evaluateProposal(makeProposal({ side: "SELL" }), mandate, makeSimulation({ notionalUsd: 15, referencePrice: 60000 }), noFills, flatNav, undefined, { + holdings: { BTC: 1 }, + }); + expect(verdict.decision).toBe("PASS"); + }); + + it("a fat-fingered limit price is vetoed", () => { + const verdict = evaluateProposal( + makeProposal({ type: "LIMIT", limitPrice: 30000, quantity: 0.0005, quoteOrderQty: undefined }), + makeMandate(), + makeSimulation({ referencePrice: 60000, notionalUsd: 15 }), + noFills, + flatNav + ); + expect(verdict.decision).toBe("VETO"); + expect(verdict.reasons.find((r) => r.rule === "maxLimitDeviationPct")?.outcome).toBe("violated"); + }); + + it("a limit order is refused when the open-order limit could not be verified", () => { + const verdict = evaluateProposal( + makeProposal({ type: "LIMIT", limitPrice: 60000, quantity: 0.00025, quoteOrderQty: undefined }), + makeMandate({ maxOpenOrders: 2 }), + makeSimulation({ referencePrice: 60000, notionalUsd: 15 }), + noFills, + flatNav + ); + expect(verdict.decision).toBe("VETO"); + expect(verdict.reasons.find((r) => r.rule === "maxOpenOrders")?.outcome).toBe("violated"); + }); + + it("with none of the new limits set, a clean proposal still passes", () => { + const verdict = evaluateProposal(makeProposal(), makeMandate(), makeSimulation(), noFills, flatNav); + expect(verdict.decision).toBe("PASS"); + }); +}); diff --git a/tests/policy.types.test.ts b/tests/policy.types.test.ts new file mode 100644 index 0000000..d7221f8 --- /dev/null +++ b/tests/policy.types.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { ProposalSchema } from "../src/policy/types.js"; + +const base = { + id: "00000000-0000-0000-0000-000000000002", + agentId: "a", + mandateId: "00000000-0000-0000-0000-000000000001", + symbol: "BTCUSDT", + side: "BUY", + submittedAt: new Date().toISOString(), +}; + +const messages = (input: unknown): string[] => { + const r = ProposalSchema.safeParse(input); + return r.success ? [] : r.error.issues.map((i) => i.message); +}; + +describe("ProposalSchema", () => { + it("accepts a MARKET proposal sized in USD", () => { + expect(ProposalSchema.safeParse({ ...base, type: "MARKET", quoteOrderQty: 15 }).success).toBe(true); + }); + + it("accepts a MARKET proposal sized by quantity", () => { + expect(ProposalSchema.safeParse({ ...base, type: "MARKET", quantity: 0.001 }).success).toBe(true); + }); + + it("refuses a MARKET proposal with no size", () => { + expect(messages({ ...base, type: "MARKET" }).join(" ")).toContain("USD amount"); + }); + + it("accepts a LIMIT proposal with quantity and price", () => { + expect(ProposalSchema.safeParse({ ...base, type: "LIMIT", quantity: 0.001, limitPrice: 70000 }).success).toBe(true); + }); + + it("refuses a LIMIT proposal missing its price or its quantity", () => { + expect(messages({ ...base, type: "LIMIT", quantity: 1 }).join(" ")).toContain("limitPrice"); + expect(messages({ ...base, type: "LIMIT", limitPrice: 1 }).join(" ")).toContain("quantity"); + }); + + it("refuses a LIMIT proposal sized in USD, which would be ambiguous", () => { + expect(messages({ ...base, type: "LIMIT", quantity: 1, limitPrice: 1, quoteOrderQty: 100 }).join(" ")).toContain("not a USD amount"); + }); + + it("refuses a non-positive size or price", () => { + expect(ProposalSchema.safeParse({ ...base, type: "LIMIT", quantity: 0, limitPrice: 1 }).success).toBe(false); + expect(ProposalSchema.safeParse({ ...base, type: "LIMIT", quantity: 1, limitPrice: -5 }).success).toBe(false); + }); +}); diff --git a/tests/rules.activity.test.ts b/tests/rules.activity.test.ts new file mode 100644 index 0000000..29fbdad --- /dev/null +++ b/tests/rules.activity.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { checkCooldown, checkPerSymbolDailyCap, checkTradeRate } from "../src/policy/rules/activity.js"; +import type { Mandate } from "../src/mandate/schema.js"; +import type { Proposal, SimulationResult } from "../src/policy/types.js"; +import type { AuditEntry, AuditEventType } from "../src/audit/log.js"; + +const NOW = new Date("2026-09-21T12:00:00.000Z"); + +function mandate(limits: Partial): Mandate { + return { + id: "00000000-0000-0000-0000-000000000001", + version: 1, + owner: "t", + createdAt: NOW.toISOString(), + subAccountId: "t", + naturalLanguageSource: "t", + status: "active", + limits: { perTradeMaxUsd: 500, dailySpendCapUsd: 5000, maxLeverage: 1, dailyDrawdownHaltPct: 5, confirmAboveUsd: 400, ...limits }, + }; +} + +const proposal = (over: Partial = {}): Proposal => + ({ id: "p", agentId: "alpha", mandateId: "m", symbol: "BTCUSDT", side: "BUY", type: "MARKET", quoteOrderQty: 100, submittedAt: NOW.toISOString(), ...over }) as Proposal; + +const sim = (notionalUsd: number): SimulationResult => ({ notionalUsd }) as SimulationResult; + +let seq = 0; +function exec(type: AuditEventType, agentId: string | undefined, symbol: string, notionalUsd: number, minutesAgo: number): AuditEntry { + return { + seq: seq++, + timestamp: new Date(NOW.getTime() - minutesAgo * 60_000).toISOString(), + type, + venue: "testnet", + payload: { agentId, symbol, notionalUsd }, + prevHash: "x", + hash: "y", + }; +} + +describe("checkPerSymbolDailyCap", () => { + it("does nothing when no cap is set", () => { + expect(checkPerSymbolDailyCap(proposal(), mandate({}), sim(100), [], NOW).outcome).toBe("ok"); + }); + + it("allows a trade that keeps the symbol within its cap", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 100, 30)]; + expect(checkPerSymbolDailyCap(proposal(), mandate({ perSymbolDailyCapUsd: 250 }), sim(100), entries, NOW).outcome).toBe("ok"); + }); + + it("vetoes a trade that would push the symbol over its cap", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 200, 30)]; + const r = checkPerSymbolDailyCap(proposal(), mandate({ perSymbolDailyCapUsd: 250 }), sim(100), entries, NOW); + expect(r.outcome).toBe("violated"); + expect(r.detail).toContain("BTCUSDT"); + }); + + it("counts every agent, since it is a cap on the symbol", () => { + const entries = [exec("EXECUTION_FILLED", "someone-else", "BTCUSDT", 200, 30)]; + expect(checkPerSymbolDailyCap(proposal(), mandate({ perSymbolDailyCapUsd: 250 }), sim(100), entries, NOW).outcome).toBe("violated"); + }); + + it("ignores other symbols and earlier days", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "ETHUSDT", 900, 30), exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 900, 60 * 24)]; + expect(checkPerSymbolDailyCap(proposal(), mandate({ perSymbolDailyCapUsd: 250 }), sim(100), entries, NOW).outcome).toBe("ok"); + }); + + it("counts a resting placed order, because it can still fill", () => { + const entries = [exec("EXECUTION_PLACED", "alpha", "BTCUSDT", 200, 30)]; + expect(checkPerSymbolDailyCap(proposal(), mandate({ perSymbolDailyCapUsd: 250 }), sim(100), entries, NOW).outcome).toBe("violated"); + }); +}); + +describe("checkTradeRate", () => { + it("does nothing when no limit is set", () => { + expect(checkTradeRate(proposal(), mandate({}), [], NOW).outcome).toBe("ok"); + }); + + it("allows an agent under its hourly limit", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 10)]; + expect(checkTradeRate(proposal(), mandate({ maxTradesPerHour: 2 }), entries, NOW).outcome).toBe("ok"); + }); + + it("vetoes an agent that has reached its hourly limit", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 10), exec("EXECUTION_FILLED", "alpha", "ETHUSDT", 10, 20)]; + const r = checkTradeRate(proposal(), mandate({ maxTradesPerHour: 2 }), entries, NOW); + expect(r.outcome).toBe("violated"); + expect(r.detail).toContain("alpha"); + }); + + it("counts only that agent's trades", () => { + const entries = [exec("EXECUTION_FILLED", "beta", "BTCUSDT", 10, 10), exec("EXECUTION_FILLED", "beta", "ETHUSDT", 10, 20)]; + expect(checkTradeRate(proposal(), mandate({ maxTradesPerHour: 2 }), entries, NOW).outcome).toBe("ok"); + }); + + it("only looks at the last hour", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 61), exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 90)]; + expect(checkTradeRate(proposal(), mandate({ maxTradesPerHour: 2 }), entries, NOW).outcome).toBe("ok"); + }); + + it("ignores older entries that carry no agent id rather than crashing", () => { + const entries = [exec("EXECUTION_FILLED", undefined, "BTCUSDT", 10, 5)]; + expect(checkTradeRate(proposal(), mandate({ maxTradesPerHour: 1 }), entries, NOW).outcome).toBe("ok"); + }); +}); + +describe("checkCooldown", () => { + it("does nothing when no cooldown is set", () => { + expect(checkCooldown(proposal(), mandate({}), [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 0)], NOW).outcome).toBe("ok"); + }); + + it("vetoes a repeat trade inside the cooldown, and says how long is left", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 1)]; + const r = checkCooldown(proposal(), mandate({ cooldownSeconds: 300 }), entries, NOW); + expect(r.outcome).toBe("violated"); + expect(r.detail).toContain("left"); + }); + + it("allows it once the cooldown has passed", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 10)]; + expect(checkCooldown(proposal(), mandate({ cooldownSeconds: 300 }), entries, NOW).outcome).toBe("ok"); + }); + + it("applies per agent and per symbol", () => { + const entries = [exec("EXECUTION_FILLED", "beta", "BTCUSDT", 10, 1), exec("EXECUTION_FILLED", "alpha", "ETHUSDT", 10, 1)]; + expect(checkCooldown(proposal(), mandate({ cooldownSeconds: 300 }), entries, NOW).outcome).toBe("ok"); + }); + + it("measures from the most recent trade, not the first", () => { + const entries = [exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 60), exec("EXECUTION_FILLED", "alpha", "BTCUSDT", 10, 1)]; + expect(checkCooldown(proposal(), mandate({ cooldownSeconds: 300 }), entries, NOW).outcome).toBe("violated"); + }); +}); diff --git a/tests/rules.orders.test.ts b/tests/rules.orders.test.ts new file mode 100644 index 0000000..c24cf33 --- /dev/null +++ b/tests/rules.orders.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { checkLimitPrice, checkOpenOrders, checkSellWithinHoldings } from "../src/policy/rules/orders.js"; +import type { Mandate } from "../src/mandate/schema.js"; +import type { Proposal, SimulationResult } from "../src/policy/types.js"; + +function mandate(limits: Partial = {}): Mandate { + return { + id: "00000000-0000-0000-0000-000000000001", + version: 1, + owner: "t", + createdAt: new Date().toISOString(), + subAccountId: "t", + naturalLanguageSource: "t", + status: "active", + limits: { perTradeMaxUsd: 500, dailySpendCapUsd: 5000, maxLeverage: 1, dailyDrawdownHaltPct: 5, confirmAboveUsd: 400, ...limits }, + }; +} + +const proposal = (over: Partial = {}): Proposal => + ({ id: "p", agentId: "a", mandateId: "m", symbol: "BTCUSDT", side: "BUY", type: "MARKET", quoteOrderQty: 100, submittedAt: "t", ...over }) as Proposal; + +const sim = (over: Partial = {}): SimulationResult => ({ referencePrice: 100, notionalUsd: 100, ...over }) as SimulationResult; + +describe("checkLimitPrice", () => { + it("does not apply to a market order", () => { + expect(checkLimitPrice(proposal(), mandate(), sim()).outcome).toBe("ok"); + }); + + it("allows a limit price near the market", () => { + expect(checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 101, quantity: 1 }), mandate(), sim()).outcome).toBe("ok"); + }); + + it("refuses a price far below the market as a likely typo", () => { + const r = checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 50, quantity: 1 }), mandate(), sim()); + expect(r.outcome).toBe("violated"); + expect(r.detail).toContain("50.00%"); + }); + + it("refuses a price far above the market too", () => { + expect(checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 150, quantity: 1 }), mandate(), sim()).outcome).toBe("violated"); + }); + + it("defaults to five percent", () => { + expect(checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 105, quantity: 1 }), mandate(), sim()).outcome).toBe("ok"); + expect(checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 106, quantity: 1 }), mandate(), sim()).outcome).toBe("violated"); + }); + + it("uses the mandate's own limit when set", () => { + expect(checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 108, quantity: 1 }), mandate({ maxLimitDeviationPct: 10 }), sim()).outcome).toBe("ok"); + expect(checkLimitPrice(proposal({ type: "LIMIT", limitPrice: 108, quantity: 1 }), mandate({ maxLimitDeviationPct: 2 }), sim()).outcome).toBe("violated"); + }); +}); + +describe("checkOpenOrders", () => { + const limitProposal = proposal({ type: "LIMIT", limitPrice: 100, quantity: 1 }); + + it("does nothing without a limit, or for a market order", () => { + expect(checkOpenOrders(limitProposal, mandate(), 99).outcome).toBe("ok"); + expect(checkOpenOrders(proposal(), mandate({ maxOpenOrders: 1 }), 99).outcome).toBe("ok"); + }); + + it("allows a new order under the limit", () => { + expect(checkOpenOrders(limitProposal, mandate({ maxOpenOrders: 3 }), 2).outcome).toBe("ok"); + }); + + it("vetoes once the limit is reached", () => { + expect(checkOpenOrders(limitProposal, mandate({ maxOpenOrders: 3 }), 3).outcome).toBe("violated"); + }); + + it("fails closed when the open orders could not be read", () => { + expect(checkOpenOrders(limitProposal, mandate({ maxOpenOrders: 3 }), undefined).outcome).toBe("violated"); + }); +}); + +describe("checkSellWithinHoldings", () => { + const sell = (over: Partial = {}) => proposal({ side: "SELL", ...over }); + + it("does not apply to a buy", () => { + expect(checkSellWithinHoldings(proposal(), sim(), undefined).outcome).toBe("ok"); + }); + + it("allows a sell covered by what is held, sized by notional", () => { + expect(checkSellWithinHoldings(sell(), sim({ notionalUsd: 100, referencePrice: 100 }), { BTC: 2 }).outcome).toBe("ok"); + }); + + it("vetoes a sell larger than what is held", () => { + const r = checkSellWithinHoldings(sell(), sim({ notionalUsd: 1000, referencePrice: 100 }), { BTC: 2 }); + expect(r.outcome).toBe("violated"); + expect(r.detail).toContain("BTC"); + }); + + it("uses the explicit quantity for a limit sell", () => { + expect(checkSellWithinHoldings(sell({ type: "LIMIT", quantity: 3, limitPrice: 100 }), sim(), { BTC: 2 }).outcome).toBe("violated"); + expect(checkSellWithinHoldings(sell({ type: "LIMIT", quantity: 1.5, limitPrice: 100 }), sim(), { BTC: 2 }).outcome).toBe("ok"); + }); + + it("vetoes selling an asset that is not held at all", () => { + expect(checkSellWithinHoldings(sell(), sim(), { ETH: 5 }).outcome).toBe("violated"); + }); + + it("fails closed when holdings could not be read", () => { + expect(checkSellWithinHoldings(sell(), sim(), undefined).outcome).toBe("violated"); + }); + + it("fails closed when the asset cannot be worked out from the symbol", () => { + expect(checkSellWithinHoldings(sell({ symbol: "WEIRD" }), sim(), { WEIRD: 1 }).outcome).toBe("violated"); + }); +}); diff --git a/tests/simulator.test.ts b/tests/simulator.test.ts index 8ba507f..7f5b7f7 100644 --- a/tests/simulator.test.ts +++ b/tests/simulator.test.ts @@ -25,6 +25,12 @@ function makeVenue(depth: Partial): ExecutionVenue { async getOrder(): Promise { throw new Error("not used in this test"); }, + async getOpenOrders() { + return []; + }, + async cancelOrder(): Promise { + throw new Error("not used in this test"); + }, }; } @@ -107,3 +113,82 @@ describe("simulateProposal", () => { expect(result.projectedNavImpactPct).toBe(0); }); }); + +describe("simulateProposal with LIMIT orders", () => { + const limitBuy = (limitPrice: number, quantity: number) => makeProposal({ type: "LIMIT", limitPrice, quantity, quoteOrderQty: undefined }); + + it("a limit BUY below the best ask rests entirely, with nothing filled at once", async () => { + const venue = makeVenue({ asks: [{ price: 100, quantity: 10 }] }); + const r = await simulateProposal(venue, limitBuy(95, 2), 10000); + expect(r.notionalUsd).toBe(190); + expect(r.restingUsd).toBe(190); + expect(r.projectedFillPrice).toBe(95); + expect(r.projectedSlippageBps).toBe(0); + expect(r.liquidityInsufficient).toBe(false); + }); + + it("a marketable limit BUY fills at the touch and rests nothing", async () => { + const venue = makeVenue({ asks: [{ price: 100, quantity: 10 }] }); + const r = await simulateProposal(venue, limitBuy(101, 1), 10000); + expect(r.projectedFillPrice).toBe(100); + expect(r.restingUsd).toBe(0); + }); + + it("only takes levels at or inside the limit, and rests the remainder", async () => { + const venue = makeVenue({ + asks: [ + { price: 100, quantity: 1 }, + { price: 105, quantity: 1 }, + { price: 120, quantity: 5 }, + ], + }); + // Limit 106 for 3 units: can take the 100 and 105 levels ($205), 1 unit remains at 106. + const r = await simulateProposal(venue, limitBuy(106, 3), 10000); + expect(r.notionalUsd).toBeCloseTo(318, 6); + expect(r.restingUsd).toBeCloseTo(106, 6); // the one unit left, valued at the limit price + expect(r.projectedFillPrice).toBeCloseTo(102.5, 6); + expect(r.liquidityInsufficient).toBe(false); + }); + + it("never fills more units than asked for, even when the book is cheaper than the limit", async () => { + // A dollar budget of 3 x 106 = $318 would buy 3.18 units at $100. The order is for exactly 3. + const venue = makeVenue({ asks: [{ price: 100, quantity: 10 }] }); + const r = await simulateProposal(venue, limitBuy(106, 3), 10000); + expect(r.projectedFillPrice).toBe(100); + expect(r.restingUsd).toBe(0); + expect(r.liquidityInsufficient).toBe(false); + }); + + it("rests the unfilled units when the reachable book is too thin", async () => { + const venue = makeVenue({ asks: [{ price: 100, quantity: 1 }] }); + const r = await simulateProposal(venue, limitBuy(101, 4), 10000); + expect(r.restingUsd).toBe(3 * 101); + expect(r.liquidityInsufficient).toBe(false); + }); + + it("a limit SELL above the best bid rests", async () => { + const venue = makeVenue({ bids: [{ price: 100, quantity: 10 }] }); + const r = await simulateProposal(venue, makeProposal({ side: "SELL", type: "LIMIT", limitPrice: 110, quantity: 1, quoteOrderQty: undefined }), 10000); + expect(r.restingUsd).toBe(110); + expect(r.projectedFillPrice).toBe(110); + }); + + it("a marketable limit SELL walks bids at or above the limit", async () => { + const venue = makeVenue({ + bids: [ + { price: 100, quantity: 1 }, + { price: 90, quantity: 5 }, + ], + }); + const r = await simulateProposal(venue, makeProposal({ side: "SELL", type: "LIMIT", limitPrice: 95, quantity: 2, quoteOrderQty: undefined }), 10000); + // Takes the 100 level (1 unit, $100); the other unit ($95) rests. + expect(r.projectedFillPrice).toBe(100); + expect(r.restingUsd).toBe(95); + }); + + it("a MARKET order reports no resting notional", async () => { + const venue = makeVenue({ asks: [{ price: 100, quantity: 10 }] }); + const r = await simulateProposal(venue, makeProposal({ quoteOrderQty: 50 }), 10000); + expect(r.restingUsd).toBe(0); + }); +}); diff --git a/tests/venues.filters.test.ts b/tests/venues.filters.test.ts new file mode 100644 index 0000000..e01c599 --- /dev/null +++ b/tests/venues.filters.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { decimalsOf, parseFilters, roundDownToStep } from "../src/venues/filters.js"; +import { baseAssetOf } from "../src/market/symbols.js"; + +describe("decimalsOf", () => { + it("counts the meaningful decimals of a step", () => { + expect(decimalsOf("0.00001000")).toBe(5); + expect(decimalsOf("0.01000000")).toBe(2); + expect(decimalsOf("1.00000000")).toBe(0); + expect(decimalsOf("1")).toBe(0); + expect(decimalsOf("0.1")).toBe(1); + }); +}); + +describe("roundDownToStep", () => { + it("rounds down to a multiple of the step", () => { + expect(roundDownToStep(0.123456789, "0.00001000")).toBe(0.12345); + expect(roundDownToStep(1.999, "0.01000000")).toBe(1.99); + }); + + it("never rounds up", () => { + expect(roundDownToStep(0.00019999, "0.00001000")).toBe(0.00019); + }); + + it("leaves an already-valid value alone, despite floating point noise", () => { + expect(roundDownToStep(0.3, "0.1")).toBe(0.3); + expect(roundDownToStep(0.00018, "0.00001000")).toBe(0.00018); + expect(roundDownToStep(79980.01, "0.01000000")).toBe(79980.01); + }); + + it("handles whole-number steps", () => { + expect(roundDownToStep(12.9, "1.00000000")).toBe(12); + }); + + it("returns the value unchanged when the step is unusable", () => { + expect(roundDownToStep(1.23456, "0")).toBe(1.23456); + }); + + it("can round to zero, which the caller must treat as below the minimum", () => { + expect(roundDownToStep(0.000001, "0.00001000")).toBe(0); + }); +}); + +describe("parseFilters", () => { + it("reads the lot size, tick size, and minimum notional", () => { + const f = parseFilters([ + { filterType: "PRICE_FILTER", tickSize: "0.01000000" }, + { filterType: "LOT_SIZE", stepSize: "0.00001000", minQty: "0.00001000" }, + { filterType: "NOTIONAL", minNotional: "5.00000000" }, + ]); + expect(f).toEqual({ stepSize: "0.00001000", tickSize: "0.01000000", minQty: 0.00001, minNotional: 5 }); + }); + + it("accepts the older MIN_NOTIONAL name", () => { + expect(parseFilters([{ filterType: "MIN_NOTIONAL", minNotional: "10" }]).minNotional).toBe(10); + }); + + it("falls back to permissive values when filters are missing", () => { + expect(parseFilters([])).toEqual({ stepSize: "0", tickSize: "0", minQty: 0, minNotional: 0 }); + }); +}); + +describe("baseAssetOf", () => { + it("splits common pairs", () => { + expect(baseAssetOf("BTCUSDT")).toBe("BTC"); + expect(baseAssetOf("ETHUSDC")).toBe("ETH"); + expect(baseAssetOf("SOLBTC")).toBe("SOL"); + expect(baseAssetOf("BNBFDUSD")).toBe("BNB"); + }); + + it("does not read USDT as USD plus a stray letter", () => { + expect(baseAssetOf("LINKUSDT")).toBe("LINK"); + }); + + it("is case-insensitive", () => { + expect(baseAssetOf("btcusdt")).toBe("BTC"); + }); + + it("returns undefined for something it does not recognise, or a bare quote asset", () => { + expect(baseAssetOf("WEIRD")).toBeUndefined(); + expect(baseAssetOf("USDT")).toBeUndefined(); + }); +}); diff --git a/tests/venues.testnetClient.test.ts b/tests/venues.testnetClient.test.ts new file mode 100644 index 0000000..c77fadb --- /dev/null +++ b/tests/venues.testnetClient.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TestnetClient } from "../src/venues/testnetClient.js"; + +const BASE = "https://testnet.example"; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const timeReply = (serverTime: number) => json({ serverTime }); + +let calls: Array<{ url: string; method: string }>; +let responder: (url: string, method: string) => Response; + +beforeEach(() => { + calls = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + return responder(url, method); + }) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const client = () => new TestnetClient("key", "secret", BASE); +const accountBody = { balances: [{ asset: "USDT", free: "10.5", locked: "0" }] }; + +describe("TestnetClient clock handling", () => { + it("syncs to the exchange clock before its first signed request", async () => { + responder = (url) => (url.includes("/api/v3/time") ? timeReply(Date.now()) : json(accountBody)); + await client().getSubAccountBalances(); + expect(calls[0]!.url).toContain("/api/v3/time"); + expect(calls[1]!.url).toContain("/api/v3/account"); + }); + + it("stamps requests with the exchange's time, not the local clock", async () => { + const skew = 60_000; + responder = (url) => (url.includes("/api/v3/time") ? timeReply(Date.now() + skew) : json(accountBody)); + await client().getSubAccountBalances(); + const stamp = Number(new URL(calls[1]!.url).searchParams.get("timestamp")); + expect(Math.abs(stamp - (Date.now() + skew))).toBeLessThan(5_000); + }); + + it("syncs only once across several requests", async () => { + responder = (url) => (url.includes("/api/v3/time") ? timeReply(Date.now()) : json(accountBody)); + const c = client(); + await c.getSubAccountBalances(); + await c.getSubAccountBalances(); + expect(calls.filter((x) => x.url.includes("/api/v3/time"))).toHaveLength(1); + }); + + it("on a timestamp rejection (-1021) resyncs and retries once, then succeeds", async () => { + let accountCalls = 0; + responder = (url) => { + if (url.includes("/api/v3/time")) return timeReply(Date.now()); + accountCalls++; + return accountCalls === 1 ? json({ code: -1021, msg: "Timestamp for this request is outside of the recvWindow." }, 400) : json(accountBody); + }; + const balances = await client().getSubAccountBalances(); + expect(balances[0]!.asset).toBe("USDT"); + expect(accountCalls).toBe(2); + expect(calls.filter((x) => x.url.includes("/api/v3/time"))).toHaveLength(2); + }); + + it("gives up after one retry rather than looping forever", async () => { + responder = (url) => (url.includes("/api/v3/time") ? timeReply(Date.now()) : json({ code: -1021, msg: "outside of the recvWindow" }, 400)); + await expect(client().getSubAccountBalances()).rejects.toThrow("-1021"); + expect(calls.filter((x) => x.url.includes("/api/v3/account"))).toHaveLength(2); + }); + + it("does not retry an unrelated error", async () => { + responder = (url) => (url.includes("/api/v3/time") ? timeReply(Date.now()) : json({ code: -2010, msg: "Account has insufficient balance" }, 400)); + await expect(client().getSubAccountBalances()).rejects.toThrow("insufficient balance"); + expect(calls.filter((x) => x.url.includes("/api/v3/account"))).toHaveLength(1); + }); +}); + +describe("TestnetClient limit orders", () => { + const exchangeInfo = { + symbols: [ + { + filters: [ + { filterType: "PRICE_FILTER", tickSize: "0.01000000" }, + { filterType: "LOT_SIZE", stepSize: "0.00001000", minQty: "0.00001000" }, + { filterType: "NOTIONAL", minNotional: "5.00000000" }, + ], + }, + ], + }; + const orderReply = { orderId: 7, symbol: "BTCUSDT", side: "BUY", type: "LIMIT", status: "NEW", executedQty: "0", cummulativeQuoteQty: "0" }; + + function orderResponder() { + responder = (url) => { + if (url.includes("/api/v3/time")) return timeReply(Date.now()); + if (url.includes("/api/v3/exchangeInfo")) return json(exchangeInfo); + return json(orderReply); + }; + } + + const sentParams = () => new URL(calls.find((c) => c.url.includes("/api/v3/order"))!.url).searchParams; + + it("rounds quantity down and price to the exchange's step and tick sizes", async () => { + orderResponder(); + await client().placeOrder({ symbol: "BTCUSDT", side: "BUY", type: "LIMIT", quantity: 0.000219999, limitPrice: 80030.129 }); + const p = sentParams(); + expect(p.get("quantity")).toBe("0.00021"); + expect(p.get("price")).toBe("80030.12"); + expect(p.get("timeInForce")).toBe("GTC"); + }); + + it("refuses a quantity that rounds below the minimum, instead of sending something the exchange will reject", async () => { + orderResponder(); + await expect(client().placeOrder({ symbol: "BTCUSDT", side: "BUY", type: "LIMIT", quantity: 0.000001, limitPrice: 80000 })).rejects.toThrow("minimum"); + expect(calls.some((c) => c.url.includes("/api/v3/order"))).toBe(false); + }); + + it("refuses an order below the minimum notional", async () => { + orderResponder(); + await expect(client().placeOrder({ symbol: "BTCUSDT", side: "BUY", type: "LIMIT", quantity: 0.00002, limitPrice: 80000 })).rejects.toThrow("below the BTCUSDT minimum"); + }); + + it("looks the symbol's filters up once and reuses them", async () => { + orderResponder(); + const c = client(); + await c.placeOrder({ symbol: "BTCUSDT", side: "BUY", type: "LIMIT", quantity: 0.0002, limitPrice: 80000 }); + await c.placeOrder({ symbol: "BTCUSDT", side: "BUY", type: "LIMIT", quantity: 0.0002, limitPrice: 80000 }); + expect(calls.filter((x) => x.url.includes("/api/v3/exchangeInfo"))).toHaveLength(1); + }); + + it("cancels with DELETE", async () => { + orderResponder(); + await client().cancelOrder("BTCUSDT", "7"); + const del = calls.find((c) => c.method === "DELETE"); + expect(del?.url).toContain("/api/v3/order"); + expect(del?.url).toContain("orderId=7"); + }); + + it("reads open orders", async () => { + responder = (url) => + url.includes("/api/v3/time") + ? timeReply(Date.now()) + : json([{ orderId: 7, symbol: "BTCUSDT", side: "BUY", type: "LIMIT", price: "80000", origQty: "0.0002", executedQty: "0", status: "NEW", clientOrderId: "charter-abc", time: 1 }]); + const open = await client().getOpenOrders(); + expect(open[0]).toMatchObject({ orderId: "7", price: 80000, origQty: 0.0002, clientOrderId: "charter-abc" }); + }); +});