From 04c59ee93d4d4dbfa7066509c908c47c34f9a731 Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 19 May 2026 17:06:20 +0200 Subject: [PATCH] Enforce maker min notional Proof: strategy now enforces DB-backed pair min_notional and verifies rounded gross edge remains at or above configured edge before emitting commands; dust exact-out quotes persist as strategy skips instead of relay attempts. Assumptions: 1 USD/EUR-equivalent minimum is an approved strategy policy for active maker pairs; this changes only stricter DB-backed strategy gating and does not loosen edge, max notional, inventory, arming, pair enablement, or response-age behavior. Still fake: venue-native terminal fill ids and fee-complete realized PnL remain unavailable. --- src/apps/operator-dashboard.mjs | 2 + src/apps/strategy-engine.mjs | 1 + src/core/operator-dashboard.mjs | 1 + src/core/strategy.mjs | 25 ++++++- src/lib/postgres.mjs | 17 ++++- .../static/pages/StrategyPage.jsx | 47 +++++++++++- test/operator-dashboard-app-static.test.mjs | 1 + test/operator-dashboard-ui-static.test.mjs | 3 + test/strategy-engine-static.test.mjs | 5 ++ test/strategy.test.mjs | 73 +++++++++++++++++++ test/trading-config.test.mjs | 35 +++++++++ 11 files changed, 204 insertions(+), 6 deletions(-) diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index c4807f0..bbb8da0 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -645,6 +645,7 @@ async function invokeControl(control, body) { pairId: body.pair_id || body.pair, edgeBps: Number(body.edge_bps), maxNotional: body.max_notional, + minNotional: bodyField(body, 'min_notional', 'minNotional'), requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'), requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'), requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'), @@ -677,6 +678,7 @@ async function invokeControl(control, body) { mode: body.mode, edgeBps: body.edge_bps, maxNotional: body.max_notional, + minNotional: bodyField(body, 'min_notional', 'minNotional'), requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'), requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'), requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'), diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index a4f83b0..102a21f 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -302,6 +302,7 @@ const controlApi = startControlApi({ pairId, edgeBps, maxNotional: body.max_notional, + minNotional: body.min_notional, makerMaxQuoteAgeEnabled: body.maker_max_quote_age_enabled, makerMaxQuoteAgeMs: body.maker_max_quote_age_ms, makerLatencyPolicyReason: body.maker_latency_policy_reason, diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 5102f2c..e411056 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1106,6 +1106,7 @@ const HUMAN_REASON_TEXT = { executor_disarmed: 'Executor is disarmed.', executor_paused: 'Executor intake is paused.', inventory_unavailable: 'Inventory unavailable.', + min_notional_not_met: 'Below minimum notional.', maker_quote_age_unavailable: 'Maker quote age is unavailable.', maker_quote_response_policy_invalid: 'Maker response-age policy is invalid.', maker_quote_too_old: 'Maker quote is too old for the configured response-age policy.', diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index 9d781f3..41363b3 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -17,6 +17,8 @@ import { quoteAgeMsAt, } from './maker-timing.mjs'; +const EDGE_EPSILON_PCT = 1e-9; + export function evaluateTradeOpportunity({ demandEvent, priceEvent, @@ -32,6 +34,7 @@ export function evaluateTradeOpportunity({ const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotional }); const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct; const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional; + const effectiveMinNotional = pairRuntime.minNotional ?? 0; const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config }); const decisionId = crypto.randomUUID(); const decisionAt = new Date(now).toISOString(); @@ -53,9 +56,7 @@ export function evaluateTradeOpportunity({ ? null : String(pairRuntime.strategyConfig.edgeBps), max_notional: effectiveMaxNotional == null ? null : String(effectiveMaxNotional), - min_notional: pairRuntime.strategyConfig?.minNotional == null - ? null - : String(pairRuntime.strategyConfig.minNotional), + min_notional: String(effectiveMinNotional), price_route_id: pairRuntime.priceRoute?.routeId || null, direction: pairRuntime.direction, request_kind: payload.request_kind, @@ -135,6 +136,7 @@ export function evaluateTradeOpportunity({ pairRuntime, thresholdPct: effectiveThresholdPct, maxNotional: effectiveMaxNotional, + minNotional: effectiveMinNotional, }); if (!buildResult.ok) { @@ -269,6 +271,7 @@ function buildQuote({ pairRuntime = null, thresholdPct, maxNotional, + minNotional = 0, }) { const direction = pairRuntime?.direction || classifyPairDirection({ assetIn: demand.asset_in, @@ -342,6 +345,8 @@ function buildQuote({ notionalAssetId, notionalSymbol: notionalAsset?.symbol || null, maxNotional, + minNotional, + thresholdPct, legacyEureNotional, proposedAmountOut: proposedOutputUnits, impliedRate, @@ -394,6 +399,8 @@ function buildQuote({ notionalAssetId, notionalSymbol: notionalAsset?.symbol || null, maxNotional, + minNotional, + thresholdPct, legacyEureNotional, proposedAmountIn: proposedInputUnits, proposedAmountOut: demand.amount_out, @@ -423,6 +430,8 @@ function finalizeQuote({ notionalAssetId = null, notionalSymbol = null, maxNotional, + minNotional = 0, + thresholdPct, legacyEureNotional = false, proposedAmountIn = null, proposedAmountOut = null, @@ -469,6 +478,14 @@ function finalizeQuote({ return { ok: false, reason: 'invalid_pricing', details: reasonBase }; } + if (Number.isFinite(thresholdPct) && grossEdgePct + EDGE_EPSILON_PCT < thresholdPct) { + return { ok: false, reason: 'below_edge_threshold', details: reasonBase }; + } + + if (Number.isFinite(minNotional) && quoteNotional < minNotional) { + return { ok: false, reason: 'min_notional_not_met', details: reasonBase }; + } + if (quoteNotional > maxNotional) { return { ok: false, reason: 'max_notional_exceeded', details: reasonBase }; } @@ -626,6 +643,7 @@ function resolvePairRuntime({ assetOut: pair.assetOut, thresholdPct: Number(pair.strategyConfig.edgeBps) / 100, maxNotional: Number(pair.strategyConfig.maxNotional), + minNotional: Number(pair.strategyConfig.minNotional ?? 0), priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs), inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs), }; @@ -649,6 +667,7 @@ function blockedPairRuntime(pair, config, reason) { maxNotional: pair?.strategyConfig ? Number(pair.strategyConfig.maxNotional) : (config.strategyMaxNotional ?? config.strategyMaxNotionalEure), + minNotional: pair?.strategyConfig ? Number(pair.strategyConfig.minNotional ?? 0) : 0, priceMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.priceMaxAgeMs) : config.strategyPriceMaxAgeMs, inventoryMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.inventoryMaxAgeMs) : config.strategyInventoryMaxAgeMs, }; diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 5110a40..1f6cf33 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -768,6 +768,7 @@ export async function createPairStrategyConfigVersion(pool, { throw new Error('edge_bps must be a positive integer'); } const nextMaxNotional = positiveNumberStringOrDefault(maxNotional, active.max_notional, 'max_notional'); + const nextMinNotional = nonNegativeNumberStringOrDefault(minNotional, active.min_notional, 'min_notional'); const nextConfig = { configId: `${resolvedPairId}:v${nextVersion}`, @@ -775,7 +776,7 @@ export async function createPairStrategyConfigVersion(pool, { version: nextVersion, edgeBps: nextEdgeBps, maxNotional: nextMaxNotional, - minNotional: minNotional == null ? String(active.min_notional) : String(minNotional), + minNotional: nextMinNotional, slippageBps: slippageBps == null ? Number(active.slippage_bps) : Number(slippageBps), minDeadlineMs: minDeadlineMs == null ? Number(active.min_deadline_ms) : Number(minDeadlineMs), priceMaxAgeMs: priceMaxAgeMs == null ? Number(active.price_max_age_ms) : Number(priceMaxAgeMs), @@ -808,6 +809,7 @@ export async function createPairStrategyConfigVersion(pool, { createdBy: changedBy, reason, }; + validateStrategyNotionalBounds(nextConfig); validateMakerQuoteAgePolicy(nextConfig); await client.query( @@ -1138,6 +1140,12 @@ function buildTradingConfigSnapshot({ if (strategyConfig && !(Number(strategyConfig.maxNotional) > 0)) { blockReasons.push('max_notional_invalid'); } + if (strategyConfig && !(Number(strategyConfig.minNotional) >= 0)) { + blockReasons.push('min_notional_invalid'); + } + if (strategyConfig && Number(strategyConfig.minNotional) > Number(strategyConfig.maxNotional)) { + blockReasons.push('min_notional_exceeds_max_notional'); + } const observeEnabled = pairCanObserve(pair); const makerEnabled = pairCanMake(pair); @@ -1441,6 +1449,7 @@ function buildInitialPairStrategyConfig(pairId, { ? baseConfig.makerLatencyPolicyReason : nullableString(makerLatencyPolicyReason), }; + validateStrategyNotionalBounds(next); validateMakerQuoteAgePolicy(next); return next; } @@ -1507,6 +1516,12 @@ function nullableString(value) { return normalized || null; } +function validateStrategyNotionalBounds(config) { + if (Number(config.minNotional) > Number(config.maxNotional)) { + throw new Error('min_notional must be less than or equal to max_notional'); + } +} + function validateMakerQuoteAgePolicy(config) { if (config.makerMaxQuoteAgeEnabled !== true) return; if (!Number.isInteger(config.makerMaxQuoteAgeMs) || config.makerMaxQuoteAgeMs <= 0) { diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index c59c05f..a28afa8 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -789,9 +789,11 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { asset_out: '', mode: 'observe_only', edge_bps: '49', + min_notional: '0', max_notional: '150', }); const [edgeDrafts, setEdgeDrafts] = useState({}); + const [minNotionalDrafts, setMinNotionalDrafts] = useState({}); const [maxNotionalDrafts, setMaxNotionalDrafts] = useState({}); const [policyEnabledDrafts, setPolicyEnabledDrafts] = useState({}); const [maxQuoteAgeDrafts, setMaxQuoteAgeDrafts] = useState({}); @@ -818,6 +820,12 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const tradingMode = TRADING_PAIR_MODES.has(pair.mode); return [pairId, String(strategyConfig.max_notional ?? pair.max_notional ?? (tradingMode ? '150' : ''))]; }))); + setMinNotionalDrafts(Object.fromEntries(pairs.map((pair) => { + const pairId = pair.pair_id || pair.pairId; + const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; + const tradingMode = TRADING_PAIR_MODES.has(pair.mode); + return [pairId, String(strategyConfig.min_notional ?? pair.min_notional ?? (tradingMode ? '0' : ''))]; + }))); setPolicyEnabledDrafts(Object.fromEntries(pairs.map((pair) => { const pairId = pair.pair_id || pair.pairId; const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; @@ -840,9 +848,10 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const hasStrategyConfig = Boolean(strategyConfig.config_id || strategyConfig.configId); const edgeBps = edgeDrafts[pairId]; const maxNotional = maxNotionalDrafts[pairId]; + const minNotional = minNotionalDrafts[pairId]; const policyEnabled = policyEnabledDrafts[pairId] === true; const maxQuoteAgeMs = maxQuoteAgeDrafts[pairId]; - if (!edgeBps || !maxNotional) return; + if (!edgeBps || !maxNotional || minNotional === undefined || minNotional === '') return; if (policyEnabled && !maxQuoteAgeMs) return; if (!hasStrategyConfig) { @@ -858,6 +867,7 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { mode, edge_bps: Number(edgeBps), max_notional: maxNotional, + min_notional: minNotional, maker_max_quote_age_enabled: policyEnabled, maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null, maker_latency_policy_reason: policyEnabled ? 'operator dashboard maker response-age policy' : null, @@ -869,6 +879,7 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { pair_id: pairId, edge_bps: Number(edgeBps), max_notional: maxNotional, + min_notional: minNotional, maker_max_quote_age_enabled: policyEnabled, maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null, maker_latency_policy_reason: policyEnabled ? 'operator dashboard maker response-age policy' : null, @@ -890,6 +901,7 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { mode: pairForm.mode, edge_bps: Number(pairForm.edge_bps), max_notional: pairForm.max_notional, + min_notional: pairForm.min_notional, }); } @@ -917,7 +929,7 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { const tradingModeSelected = TRADING_PAIR_MODES.has(pairForm.mode); const pairFormDisabled = !assets.length || pairForm.asset_in === pairForm.asset_out - || (tradingModeSelected && (!pairForm.edge_bps || !pairForm.max_notional)); + || (tradingModeSelected && (!pairForm.edge_bps || !pairForm.max_notional || pairForm.min_notional === '')); return (
@@ -987,6 +999,19 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { value={pairForm.edge_bps} /> +
+ + setPairForm((current) => ({ ...current, min_notional: event.target.value }))} + required={tradingModeSelected} + step="0.00000001" + type="number" + value={pairForm.min_notional} + /> +
{formatConfiguredEdgeBps(strategyConfig.edge_bps, { prefix: false })} +
{strategyConfig.min_notional ?? '0'} min
{strategyConfig.max_notional || 'Unavailable'} max
{strategyConfig.request_max_notional == null @@ -1079,6 +1107,21 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { value={edgeDrafts[pairId] ?? ''} />
+
+ Min + setMinNotionalDrafts((current) => ({ + ...current, + [pairId]: event.target.value, + }))} + step="0.00000001" + style={{ maxWidth: 112 }} + type="number" + value={minNotionalDrafts[pairId] ?? ''} + /> +
Max { + assert.match(source, /path: '\/pair-config\/edge'/); + assert.match(source, /minNotional: body\.min_notional/); +}); diff --git a/test/strategy.test.mjs b/test/strategy.test.mjs index dd4a68b..4dbd994 100644 --- a/test/strategy.test.mjs +++ b/test/strategy.test.mjs @@ -193,6 +193,79 @@ test('strategy emits actionable exact-in BTC -> USDC command from DB price route assert.equal(result.command.asset_out_decimals, 6); }); +test('strategy rejects dust exact-out BTC -> USDC below configured min notional after integer rounding', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { + edgeBps: 20, + minNotional: '1', + maxNotional: '400', + }, + }); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-dust-exact-out', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_out', + amount_out: '2016', + min_deadline_ms: '60000', + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'rejected'); + assert.equal(result.decision.decision_reason, 'min_notional_not_met'); + assert.equal(result.decision.edge_bps, '20'); + assert.equal(result.decision.threshold_pct, '0.2'); + assert.equal(result.decision.notional, '0.002016'); + assert.equal(result.decision.min_notional, '1'); + assert.equal(result.decision.proposed_amount_in, '3'); + assert.ok(Number(result.decision.gross_edge_pct) >= Number(result.decision.threshold_pct)); + assert.equal(result.command, undefined); +}); + +test('strategy emits exact-out BTC -> USDC command at min notional only when rounded edge clears threshold', () => { + const config = makeBtcUsdcDbConfig({ + strategyConfigOverrides: { + edgeBps: 20, + minNotional: '1', + maxNotional: '400', + }, + }); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-usdc-min-exact-out', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingUsdc.assetId, + request_kind: 'exact_out', + amount_out: '1000000', + min_deadline_ms: '60000', + }, + }, + priceEvent: makeBtcUsdcPriceEvent(), + inventoryEvent: makeBtcUsdcInventoryEvent(), + config, + armed: true, + now: Date.parse('2026-04-02T10:00:05.000Z'), + }); + + assert.equal(result.decision.decision, 'actionable'); + assert.equal(result.decision.decision_reason, 'actionable'); + assert.equal(result.decision.notional, '1.000000'); + assert.equal(result.decision.min_notional, '1'); + assert.equal(result.command.quote_output.amount_in, '1253'); + assert.ok(Number(result.decision.gross_edge_pct) >= Number(result.decision.threshold_pct)); +}); + test('strategy blocks BTC -> USDC when route-specific reference price is stale', () => { const config = makeBtcUsdcDbConfig(); const result = evaluateTradeOpportunity({ diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index 93dc535..cd05876 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -202,6 +202,7 @@ test('edge update creates a new active strategy version', async () => { pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, edgeBps: 75, maxNotional: '42', + minNotional: '1', changedBy: 'test', reason: 'test edge update', }); @@ -210,8 +211,10 @@ test('edge update creates a new active strategy version', async () => { .filter((row) => row.pair_id === `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`); assert.equal(next.version, 2); + assert.equal(next.minNotional, '1'); assert.equal(snapshot.pairByKey.get(`${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).strategyConfig.edgeBps, 75); assert.equal(snapshot.pairByKey.get(`${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).strategyConfig.maxNotional, '42'); + assert.equal(snapshot.pairByKey.get(`${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).strategyConfig.minNotional, '1'); assert.equal(versions.find((row) => row.version === 1).active, false); }); @@ -305,6 +308,38 @@ test('strategy config update rejects invalid max notional', async () => { ); }); +test('strategy config update rejects min notional above max notional', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + + await assert.rejects( + createPairStrategyConfigVersion(pool, { + pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, + edgeBps: 75, + maxNotional: '42', + minNotional: '43', + }), + /min_notional must be less than or equal to max_notional/, + ); +}); + +test('pair strategy initialization rejects min notional above max notional', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + + await assert.rejects( + setTradingPairMode(pool, { + assetIn: CURRENT_NBTC_ASSET_ID, + assetOut: CURRENT_USDC_ASSET_ID, + mode: 'maker', + edgeBps: 20, + maxNotional: '1', + minNotional: '2', + }), + /min_notional must be less than or equal to max_notional/, + ); +}); + test('observe-only enable does not downgrade an active trading pair', async () => { const pool = createMemoryPool(); await seedTradingConfig(pool);