diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d099a2..01d1d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Changed +- `ExchangeClient` now exposes a conservative pre-trade fee estimate, and canonical fills require an explicit fee. Buy-side risk checks validate order inputs, fee estimates, and the returned fill before portfolio accounting. This is a breaking interface change intended for the next minor release. + ### Added - Initial fork from upstream [Franklin (brcc) 3.21.9](https://github.com/BlockRunAI/Franklin/tree/v3.21.9). - New CLI binary name `franklin-trading`, new npm package name `@blockrun/franklin-trading`. diff --git a/src/trading/engine.ts b/src/trading/engine.ts index 7f974b1..b2d2e56 100644 --- a/src/trading/engine.ts +++ b/src/trading/engine.ts @@ -13,9 +13,31 @@ */ import type { ExchangeClient } from './mock-exchange.js'; -import type { Portfolio } from './portfolio.js'; +import type { Fill, Portfolio } from './portfolio.js'; import type { RiskEngine } from './risk.js'; +const FEE_COMPARISON_TOLERANCE_USD = 1e-9; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function assertAcceptableBuyFill(fill: Fill, symbol: string, estimatedFeeUsd: number): void { + if (fill.symbol !== symbol || fill.side !== 'buy') { + throw new Error( + `Exchange returned mismatched fill: expected buy ${symbol}, received ${fill.side} ${fill.symbol}`, + ); + } + if (!Number.isFinite(fill.feeUsd) || fill.feeUsd < 0) { + throw new Error(`Exchange returned invalid fill fee: ${fill.feeUsd}`); + } + if (fill.feeUsd > estimatedFeeUsd + FEE_COMPARISON_TOLERANCE_USD) { + throw new Error( + `Exchange fill fee $${fill.feeUsd} exceeds the approved estimate $${estimatedFeeUsd}`, + ); + } +} + export interface OpenPositionRequest { symbol: string; qty: number; @@ -43,22 +65,55 @@ export class TradingEngine { async openPosition(req: OpenPositionRequest): Promise { const { portfolio, risk, exchange } = this.deps; - const decision = risk.check(portfolio, { + const order = { symbol: req.symbol, - side: 'buy', + side: 'buy' as const, qty: req.qty, priceUsd: req.priceUsd, + }; + // Reject malformed or obviously over-limit orders before a future + // adapter can spend money or rate-limit capacity on a fee quote. + const localDecision = risk.check(portfolio, { ...order, feeUsd: 0 }); + if (!localDecision.allowed) { + return { status: 'blocked', reason: localDecision.reason ?? 'blocked by risk engine' }; + } + + let feeUsd: number; + try { + feeUsd = await exchange.estimateFee(order); + } catch (error) { + return { + status: 'blocked', + reason: `Unable to estimate exchange fee: ${errorMessage(error)}`, + }; + } + const decision = risk.check(portfolio, { + ...order, + feeUsd, }); if (!decision.allowed) { return { status: 'blocked', reason: decision.reason ?? 'blocked by risk engine' }; } - const fill = await exchange.placeOrder({ - symbol: req.symbol, + const fill = await exchange.placeOrder(order); + assertAcceptableBuyFill(fill, order.symbol, feeUsd); + + // Re-evaluate the canonical fill before mutating accounting. This also + // catches unexpected quantity/price changes that would breach cash or + // exposure limits even when the fee stayed below its ceiling. + const fillDecision = risk.check(portfolio, { + symbol: fill.symbol, side: 'buy', - qty: req.qty, - priceUsd: req.priceUsd, + qty: fill.qty, + priceUsd: fill.priceUsd, + feeUsd: fill.feeUsd, }); + if (!fillDecision.allowed) { + throw new Error( + `Exchange fill violates pre-trade risk: ${fillDecision.reason ?? 'blocked by risk engine'}`, + ); + } + portfolio.applyFill(fill); return { status: 'filled', @@ -66,7 +121,7 @@ export class TradingEngine { symbol: fill.symbol, qty: fill.qty, priceUsd: fill.priceUsd, - feeUsd: fill.feeUsd ?? 0, + feeUsd: fill.feeUsd, }, }; } @@ -95,7 +150,7 @@ export class TradingEngine { symbol: fill.symbol, qty: fill.qty, priceUsd: fill.priceUsd, - feeUsd: fill.feeUsd ?? 0, + feeUsd: fill.feeUsd, }, }; } diff --git a/src/trading/fees.ts b/src/trading/fees.ts new file mode 100644 index 0000000..80990cd --- /dev/null +++ b/src/trading/fees.ts @@ -0,0 +1,14 @@ +/** Calculate a fee from order notional and basis points. */ +export function bpsFee(order: { qty: number; priceUsd: number }, feeBps: number): number { + if (!Number.isFinite(order.qty) || order.qty <= 0) { + throw new RangeError(`Invalid order quantity: ${order.qty}`); + } + if (!Number.isFinite(order.priceUsd) || order.priceUsd <= 0) { + throw new RangeError(`Invalid order price: ${order.priceUsd}`); + } + if (!Number.isFinite(feeBps) || feeBps < 0) { + throw new RangeError(`Invalid fee rate: ${feeBps} bps`); + } + + return (order.qty * order.priceUsd * feeBps) / 10_000; +} diff --git a/src/trading/live-exchange.ts b/src/trading/live-exchange.ts index e5f5945..bba8096 100644 --- a/src/trading/live-exchange.ts +++ b/src/trading/live-exchange.ts @@ -14,8 +14,9 @@ * can validate behavior without hitting CoinGecko. */ -import type { ExchangeClient } from './mock-exchange.js'; -import type { Fill, Side } from './portfolio.js'; +import type { ExchangeClient, ExchangeOrder } from './mock-exchange.js'; +import type { Fill } from './portfolio.js'; +import { bpsFee } from './fees.js'; /** Subset of src/trading/data.ts's PriceData that we actually consume. */ export interface PricingClientResponse { @@ -52,20 +53,17 @@ export class LiveExchange implements ExchangeClient { } } - async placeOrder(order: { - symbol: string; - side: Side; - qty: number; - priceUsd: number; - }): Promise { - const notional = order.qty * order.priceUsd; - const feeUsd = (notional * this.opts.feeBps) / 10_000; + estimateFee(order: ExchangeOrder): number { + return bpsFee(order, this.opts.feeBps); + } + + async placeOrder(order: ExchangeOrder): Promise { return { symbol: order.symbol, side: order.side, qty: order.qty, priceUsd: order.priceUsd, - feeUsd, + feeUsd: this.estimateFee(order), }; } } diff --git a/src/trading/mock-exchange.ts b/src/trading/mock-exchange.ts index 4262beb..e7964ab 100644 --- a/src/trading/mock-exchange.ts +++ b/src/trading/mock-exchange.ts @@ -12,14 +12,20 @@ */ import type { Fill, Side } from './portfolio.js'; +import { bpsFee } from './fees.js'; + +export interface ExchangeOrder { + symbol: string; + side: Side; + qty: number; + priceUsd: number; +} export interface ExchangeClient { - placeOrder(order: { - symbol: string; - side: Side; - qty: number; - priceUsd: number; - }): Promise; + // Return a conservative fee ceiling before an order reaches the venue. + // A fill must never charge more than the estimate returned for its order. + estimateFee(order: ExchangeOrder): number | Promise; + placeOrder(order: ExchangeOrder): Promise; // Live mark-price for portfolio valuation. Real adapters hit the ticker // endpoint; MockExchange reads from its config. getPrice(symbol: string): Promise; @@ -44,23 +50,20 @@ export class MockExchange implements ExchangeClient { this.prices[symbol] = priceUsd; } - async placeOrder(order: { - symbol: string; - side: Side; - qty: number; - priceUsd: number; - }): Promise { + estimateFee(order: ExchangeOrder): number { + return bpsFee(order, this.feeBps); + } + + async placeOrder(order: ExchangeOrder): Promise { if (!(order.symbol in this.prices)) { throw new Error(`MockExchange has no quote for ${order.symbol}`); } - const notional = order.qty * order.priceUsd; - const feeUsd = (notional * this.feeBps) / 10_000; return { symbol: order.symbol, side: order.side, qty: order.qty, priceUsd: order.priceUsd, - feeUsd, + feeUsd: this.estimateFee(order), }; } diff --git a/src/trading/portfolio.ts b/src/trading/portfolio.ts index 30bf3dd..ad58285 100644 --- a/src/trading/portfolio.ts +++ b/src/trading/portfolio.ts @@ -20,7 +20,7 @@ export interface Fill { side: Side; qty: number; priceUsd: number; - feeUsd?: number; + feeUsd: number; } export interface Position { @@ -76,7 +76,17 @@ export class Portfolio { } applyFill(fill: Fill): void { - const fee = fill.feeUsd ?? 0; + if (!Number.isFinite(fill.qty) || fill.qty <= 0) { + throw new RangeError(`Invalid fill quantity: ${fill.qty}`); + } + if (!Number.isFinite(fill.priceUsd) || fill.priceUsd <= 0) { + throw new RangeError(`Invalid fill price: ${fill.priceUsd}`); + } + if (!Number.isFinite(fill.feeUsd) || fill.feeUsd < 0) { + throw new RangeError(`Invalid fill fee: ${fill.feeUsd}`); + } + + const fee = fill.feeUsd; const notional = fill.qty * fill.priceUsd; if (fill.side === 'buy') { diff --git a/src/trading/risk.ts b/src/trading/risk.ts index 365507c..35408c0 100644 --- a/src/trading/risk.ts +++ b/src/trading/risk.ts @@ -13,20 +13,23 @@ * cap could otherwise trap the agent in a losing position it wants to exit. */ -import type { Portfolio, Side } from './portfolio.js'; +import type { Portfolio } from './portfolio.js'; export interface RiskConfig { maxPositionUsd: number; maxTotalExposureUsd: number; } -export interface OrderRequest { +interface BaseOrderRequest { symbol: string; - side: Side; qty: number; priceUsd: number; } +export type OrderRequest = + | (BaseOrderRequest & { side: 'buy'; feeUsd: number }) + | (BaseOrderRequest & { side: 'sell'; feeUsd?: never }); + export interface RiskDecision { allowed: boolean; reason?: string; @@ -36,6 +39,17 @@ export class RiskEngine { constructor(private config: RiskConfig) {} check(portfolio: Portfolio, order: OrderRequest): RiskDecision { + if (!Number.isFinite(order.qty) || order.qty <= 0) { + return { allowed: false, reason: `Invalid order quantity: ${order.qty}` }; + } + if (!Number.isFinite(order.priceUsd) || order.priceUsd <= 0) { + return { allowed: false, reason: `Invalid order price: ${order.priceUsd}` }; + } + const notional = order.qty * order.priceUsd; + if (!Number.isFinite(notional) || notional <= 0) { + return { allowed: false, reason: `Invalid order notional: ${notional}` }; + } + // Sells of existing positions are always permitted; exposure caps are // entry-side only, and Portfolio.applyFill enforces that we don't sell // more than we hold. @@ -47,12 +61,19 @@ export class RiskEngine { return { allowed: true }; } - const notional = order.qty * order.priceUsd; + const feeUsd = order.feeUsd; + if (!Number.isFinite(feeUsd) || feeUsd < 0) { + return { + allowed: false, + reason: `Invalid estimated fee: ${feeUsd}`, + }; + } + const cashRequired = notional + feeUsd; - if (notional > portfolio.cashUsd) { + if (cashRequired > portfolio.cashUsd) { return { allowed: false, - reason: `Insufficient cash: order needs $${notional.toFixed(2)} but only $${portfolio.cashUsd.toFixed(2)} available`, + reason: `Insufficient cash: order needs $${cashRequired.toFixed(2)} including $${feeUsd.toFixed(2)} estimated fee but only $${portfolio.cashUsd.toFixed(2)} available`, }; } diff --git a/test/local.mjs b/test/local.mjs index f7b93b4..a9f71a6 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -3036,8 +3036,8 @@ test('Portfolio: buy fill into empty portfolio opens a position and debits cash' test('Portfolio: sell closing at higher price realizes positive P&L', async () => { const { Portfolio } = await import('../dist/trading/portfolio.js'); const pf = new Portfolio({ startingCashUsd: 1000 }); - pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000 }); - pf.applyFill({ symbol: 'BTC', side: 'sell', qty: 0.01, priceUsd: 72_000 }); + pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000, feeUsd: 0 }); + pf.applyFill({ symbol: 'BTC', side: 'sell', qty: 0.01, priceUsd: 72_000, feeUsd: 0 }); // Cash: 1000 - 700 (buy) + 720 (sell) = 1020 → realized gain of 20. assert.equal(pf.cashUsd, 1020); @@ -3048,9 +3048,9 @@ test('Portfolio: sell closing at higher price realizes positive P&L', async () = test('Portfolio: sell more than held throws', async () => { const { Portfolio } = await import('../dist/trading/portfolio.js'); const pf = new Portfolio({ startingCashUsd: 1000 }); - pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.005, priceUsd: 70_000 }); + pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.005, priceUsd: 70_000, feeUsd: 0 }); assert.throws( - () => pf.applyFill({ symbol: 'BTC', side: 'sell', qty: 0.01, priceUsd: 72_000 }), + () => pf.applyFill({ symbol: 'BTC', side: 'sell', qty: 0.01, priceUsd: 72_000, feeUsd: 0 }), /only 0\.005/, ); }); @@ -3061,7 +3061,7 @@ test('RiskEngine: rejects buy order exceeding per-position cap', async () => { const pf = new Portfolio({ startingCashUsd: 1000 }); const risk = new RiskEngine({ maxPositionUsd: 200, maxTotalExposureUsd: 800 }); - const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000 }); + const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000, feeUsd: 0 }); assert.equal(decision.allowed, false); assert.match(decision.reason ?? '', /position cap/i); }); @@ -3072,7 +3072,7 @@ test('RiskEngine: allows order sized within position cap and remaining cash', as const pf = new Portfolio({ startingCashUsd: 1000 }); const risk = new RiskEngine({ maxPositionUsd: 200, maxTotalExposureUsd: 800 }); - const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.002, priceUsd: 70_000 }); + const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.002, priceUsd: 70_000, feeUsd: 0 }); assert.equal(decision.allowed, true, decision.reason); }); @@ -3080,12 +3080,12 @@ test('RiskEngine: rejects buy when cumulative exposure would exceed total cap', const { RiskEngine } = await import('../dist/trading/risk.js'); const { Portfolio } = await import('../dist/trading/portfolio.js'); const pf = new Portfolio({ startingCashUsd: 1000 }); - pf.applyFill({ symbol: 'ETH', side: 'buy', qty: 0.15, priceUsd: 3_500 }); // 525 exposure - pf.applyFill({ symbol: 'SOL', side: 'buy', qty: 1, priceUsd: 150 }); // 150 exposure + pf.applyFill({ symbol: 'ETH', side: 'buy', qty: 0.15, priceUsd: 3_500, feeUsd: 0 }); // 525 exposure + pf.applyFill({ symbol: 'SOL', side: 'buy', qty: 1, priceUsd: 150, feeUsd: 0 }); // 150 exposure const risk = new RiskEngine({ maxPositionUsd: 300, maxTotalExposureUsd: 800 }); // Proposed BTC buy of 0.003 * 70000 = 210 would push total to 525+150+210 = 885 > 800 cap - const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.003, priceUsd: 70_000 }); + const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.003, priceUsd: 70_000, feeUsd: 0 }); assert.equal(decision.allowed, false); assert.match(decision.reason ?? '', /total exposure/i); }); @@ -3096,16 +3096,94 @@ test('RiskEngine: rejects buy that exceeds available cash regardless of caps', a const pf = new Portfolio({ startingCashUsd: 100 }); const risk = new RiskEngine({ maxPositionUsd: 10_000, maxTotalExposureUsd: 10_000 }); - const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000 }); + const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000, feeUsd: 0 }); assert.equal(decision.allowed, false); assert.match(decision.reason ?? '', /insufficient cash/i); }); +test('RiskEngine: includes the estimated exchange fee in the buy cash check', async () => { + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const pf = new Portfolio({ startingCashUsd: 100 }); + const risk = new RiskEngine({ maxPositionUsd: 10_000, maxTotalExposureUsd: 10_000 }); + + const decision = risk.check(pf, { + symbol: 'BTC', + side: 'buy', + qty: 0.001, + priceUsd: 100_000, + feeUsd: 0.10, + }); + assert.equal(decision.allowed, false); + assert.match(decision.reason ?? '', /insufficient cash/i); + assert.match(decision.reason ?? '', /\$100\.10/); +}); + +test('RiskEngine: rejects invalid estimated fees', async () => { + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const pf = new Portfolio({ startingCashUsd: 1000 }); + const risk = new RiskEngine({ maxPositionUsd: 10_000, maxTotalExposureUsd: 10_000 }); + + for (const feeUsd of [NaN, Infinity, -0.01]) { + const decision = risk.check(pf, { + symbol: 'BTC', side: 'buy', qty: 0.001, priceUsd: 70_000, feeUsd, + }); + assert.equal(decision.allowed, false, `fee ${feeUsd} must be rejected`); + assert.match(decision.reason ?? '', /invalid estimated fee/i); + } +}); + +test('RiskEngine: rejects non-finite and non-positive order inputs', async () => { + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const pf = new Portfolio({ startingCashUsd: 1000 }); + const risk = new RiskEngine({ maxPositionUsd: 10_000, maxTotalExposureUsd: 10_000 }); + + for (const qty of [NaN, Infinity, 0, -0.001]) { + const decision = risk.check(pf, { + symbol: 'BTC', side: 'buy', qty, priceUsd: 70_000, feeUsd: 0, + }); + assert.equal(decision.allowed, false, `quantity ${qty} must be rejected`); + assert.match(decision.reason ?? '', /invalid order quantity/i); + } + for (const priceUsd of [NaN, Infinity, 0, -70_000]) { + const decision = risk.check(pf, { + symbol: 'BTC', side: 'buy', qty: 0.001, priceUsd, feeUsd: 0, + }); + assert.equal(decision.allowed, false, `price ${priceUsd} must be rejected`); + assert.match(decision.reason ?? '', /invalid order price/i); + } + + for (const [qty, priceUsd] of [ + [Number.MAX_VALUE, 2], + [Number.MIN_VALUE, 0.5], + ]) { + const decision = risk.check(pf, { + symbol: 'BTC', side: 'buy', qty, priceUsd, feeUsd: 0, + }); + assert.equal(decision.allowed, false, `${qty} * ${priceUsd} must be rejected`); + assert.match(decision.reason ?? '', /invalid order notional/i); + } +}); + +test('RiskEngine: allows a buy whose notional plus fee exactly fits cash', async () => { + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const pf = new Portfolio({ startingCashUsd: 100.10 }); + const risk = new RiskEngine({ maxPositionUsd: 10_000, maxTotalExposureUsd: 10_000 }); + + const decision = risk.check(pf, { + symbol: 'BTC', side: 'buy', qty: 0.001, priceUsd: 100_000, feeUsd: 0.10, + }); + assert.equal(decision.allowed, true, decision.reason); +}); + test('RiskEngine: sell is allowed even when caps are exceeded, as long as position exists', async () => { const { RiskEngine } = await import('../dist/trading/risk.js'); const { Portfolio } = await import('../dist/trading/portfolio.js'); const pf = new Portfolio({ startingCashUsd: 1000 }); - pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000 }); + pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000, feeUsd: 0 }); const risk = new RiskEngine({ maxPositionUsd: 1, maxTotalExposureUsd: 1 }); // paranoid caps const decision = risk.check(pf, { symbol: 'BTC', side: 'sell', qty: 0.01, priceUsd: 72_000 }); @@ -3267,6 +3345,23 @@ test('LiveExchange: placeOrder charges fee on notional and echoes price', async assert.equal(fill.priceUsd, 70_000); }); +test('ExchangeClient implementations use the quoted fee for their fills', async () => { + const { LiveExchange } = await import('../dist/trading/live-exchange.js'); + const { MockExchange } = await import('../dist/trading/mock-exchange.js'); + const order = { symbol: 'BTC', side: 'buy', qty: 0.005, priceUsd: 70_000 }; + const pricingClient = { async getPrice() { return 'not used for placeOrder'; } }; + const exchanges = [ + new MockExchange({ prices: { BTC: 70_000 }, feeBps: 15 }), + new LiveExchange({ pricing: pricingClient, feeBps: 15 }), + ]; + + for (const exchange of exchanges) { + const estimate = await exchange.estimateFee(order); + const fill = await exchange.placeOrder(order); + assert.equal(fill.feeUsd, estimate); + } +}); + test('createTradingCapabilities: TradingPortfolio reports cash, positions, and P&L in markdown', async () => { const { createTradingCapabilities } = await import('../dist/tools/trading-execute.js'); const { TradingEngine } = await import('../dist/trading/engine.js'); @@ -3387,13 +3482,33 @@ test('TradingEngine: executes a compliant order through risk → exchange → po assert.ok(Math.abs(portfolio.cashUsd - (1000 - 140.14)) < 1e-9); }); +test('TradingEngine: blocks a buy when notional fits cash but notional plus fee does not', async () => { + const { TradingEngine } = await import('../dist/trading/engine.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { MockExchange } = await import('../dist/trading/mock-exchange.js'); + + const portfolio = new Portfolio({ startingCashUsd: 100 }); + const risk = new RiskEngine({ maxPositionUsd: 10_000, maxTotalExposureUsd: 10_000 }); + const exchange = new MockExchange({ prices: { BTC: 100_000 }, feeBps: 10 }); + const engine = new TradingEngine({ portfolio, risk, exchange }); + + const outcome = await engine.openPosition({ symbol: 'BTC', qty: 0.001, priceUsd: 100_000 }); + assert.equal(outcome.status, 'blocked'); + assert.match(outcome.reason ?? '', /insufficient cash/i); + assert.equal(portfolio.getPosition('BTC'), undefined); + assert.equal(portfolio.cashUsd, 100); +}); + test('TradingEngine: blocks order that violates risk and does NOT touch the exchange', async () => { const { TradingEngine } = await import('../dist/trading/engine.js'); const { Portfolio } = await import('../dist/trading/portfolio.js'); const { RiskEngine } = await import('../dist/trading/risk.js'); + let estimated = 0; let placed = 0; const fakeExchange = { + estimateFee() { estimated++; return 0; }, async placeOrder() { placed++; throw new Error('should never be called'); }, async getPrice() { return null; }, }; @@ -3405,10 +3520,80 @@ test('TradingEngine: blocks order that violates risk and does NOT touch the exch const outcome = await engine.openPosition({ symbol: 'BTC', qty: 0.01, priceUsd: 70_000 }); assert.equal(outcome.status, 'blocked'); assert.match(outcome.reason ?? '', /position cap/i); + assert.equal(estimated, 0, 'local risk failures must not request a fee quote'); assert.equal(placed, 0, 'exchange must not be called when risk blocks the trade'); assert.equal(portfolio.cashUsd, 1000, 'portfolio must be untouched on block'); }); +test('TradingEngine: returns blocked when the fee estimate fails', async () => { + const { TradingEngine } = await import('../dist/trading/engine.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const { RiskEngine } = await import('../dist/trading/risk.js'); + + let placed = 0; + const fakeExchange = { + async estimateFee() { throw new Error('quote service unavailable'); }, + async placeOrder() { placed++; throw new Error('should never be called'); }, + async getPrice() { return null; }, + }; + const portfolio = new Portfolio({ startingCashUsd: 1000 }); + const risk = new RiskEngine({ maxPositionUsd: 500, maxTotalExposureUsd: 800 }); + const engine = new TradingEngine({ portfolio, risk, exchange: fakeExchange }); + + const outcome = await engine.openPosition({ symbol: 'BTC', qty: 0.001, priceUsd: 70_000 }); + assert.equal(outcome.status, 'blocked'); + assert.match(outcome.reason ?? '', /unable to estimate exchange fee/i); + assert.match(outcome.reason ?? '', /quote service unavailable/i); + assert.equal(placed, 0); + assert.equal(portfolio.cashUsd, 1000); +}); + +test('TradingEngine: rejects invalid or underestimated fill fees before accounting', async () => { + const { TradingEngine } = await import('../dist/trading/engine.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const { RiskEngine } = await import('../dist/trading/risk.js'); + + for (const feeUsd of [NaN, Infinity, -0.01, 1.01]) { + const portfolio = new Portfolio({ startingCashUsd: 1000 }); + const risk = new RiskEngine({ maxPositionUsd: 500, maxTotalExposureUsd: 800 }); + const fakeExchange = { + estimateFee() { return 1; }, + async placeOrder(order) { return { ...order, feeUsd }; }, + async getPrice() { return null; }, + }; + const engine = new TradingEngine({ portfolio, risk, exchange: fakeExchange }); + + await assert.rejects( + () => engine.openPosition({ symbol: 'BTC', qty: 0.001, priceUsd: 70_000 }), + /invalid fill fee|exceeds the approved estimate/i, + ); + assert.equal(portfolio.getPosition('BTC'), undefined); + assert.equal(portfolio.cashUsd, 1000, `fee ${feeUsd} must not mutate accounting`); + } +}); + +test('TradingEngine: re-checks the actual fill against risk before accounting', async () => { + const { TradingEngine } = await import('../dist/trading/engine.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const { RiskEngine } = await import('../dist/trading/risk.js'); + + const portfolio = new Portfolio({ startingCashUsd: 100 }); + const risk = new RiskEngine({ maxPositionUsd: 1000, maxTotalExposureUsd: 1000 }); + const fakeExchange = { + estimateFee() { return 0; }, + async placeOrder(order) { return { ...order, priceUsd: 101_000, feeUsd: 0 }; }, + async getPrice() { return null; }, + }; + const engine = new TradingEngine({ portfolio, risk, exchange: fakeExchange }); + + await assert.rejects( + () => engine.openPosition({ symbol: 'BTC', qty: 0.001, priceUsd: 100_000 }), + /fill violates pre-trade risk.*insufficient cash/i, + ); + assert.equal(portfolio.getPosition('BTC'), undefined); + assert.equal(portfolio.cashUsd, 100); +}); + test('TradingEngine: closePosition liquidates an open position and realizes P&L', async () => { const { TradingEngine } = await import('../dist/trading/engine.js'); const { Portfolio } = await import('../dist/trading/portfolio.js'); @@ -3442,7 +3627,7 @@ test('portfolio store: save + load roundtrips cash, positions, realized P&L', as try { const pf = new Portfolio({ startingCashUsd: 1000 }); pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000, feeUsd: 0.5 }); - pf.applyFill({ symbol: 'ETH', side: 'buy', qty: 0.1, priceUsd: 3_500 }); + pf.applyFill({ symbol: 'ETH', side: 'buy', qty: 0.1, priceUsd: 3_500, feeUsd: 0 }); savePortfolio(pf, tmpFile); const restored = loadPortfolio(tmpFile); @@ -3490,8 +3675,8 @@ test('MockExchange: rejects order when price table has no quote for symbol', asy test('Portfolio: markToMarket computes unrealized P&L against live price', async () => { const { Portfolio } = await import('../dist/trading/portfolio.js'); const pf = new Portfolio({ startingCashUsd: 1000 }); - pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000 }); - pf.applyFill({ symbol: 'ETH', side: 'buy', qty: 0.1, priceUsd: 3_500 }); + pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000, feeUsd: 0 }); + pf.applyFill({ symbol: 'ETH', side: 'buy', qty: 0.1, priceUsd: 3_500, feeUsd: 0 }); const snap = pf.markToMarket({ BTC: 72_000, ETH: 3_400 }); // BTC: 0.01 * (72000 - 70000) = +20; ETH: 0.1 * (3400 - 3500) = -10