Enforce maker min notional
All checks were successful
deploy / deploy (push) Successful in 56s

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.
This commit is contained in:
philipp 2026-05-19 17:06:20 +02:00
parent 748950a1d8
commit 04c59ee93d
11 changed files with 204 additions and 6 deletions

View file

@ -645,6 +645,7 @@ async function invokeControl(control, body) {
pairId: body.pair_id || body.pair, pairId: body.pair_id || body.pair,
edgeBps: Number(body.edge_bps), edgeBps: Number(body.edge_bps),
maxNotional: body.max_notional, maxNotional: body.max_notional,
minNotional: bodyField(body, 'min_notional', 'minNotional'),
requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'), requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'),
requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'), requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'),
requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'), requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'),
@ -677,6 +678,7 @@ async function invokeControl(control, body) {
mode: body.mode, mode: body.mode,
edgeBps: body.edge_bps, edgeBps: body.edge_bps,
maxNotional: body.max_notional, maxNotional: body.max_notional,
minNotional: bodyField(body, 'min_notional', 'minNotional'),
requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'), requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'),
requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'), requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'),
requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'), requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'),

View file

@ -302,6 +302,7 @@ const controlApi = startControlApi({
pairId, pairId,
edgeBps, edgeBps,
maxNotional: body.max_notional, maxNotional: body.max_notional,
minNotional: body.min_notional,
makerMaxQuoteAgeEnabled: body.maker_max_quote_age_enabled, makerMaxQuoteAgeEnabled: body.maker_max_quote_age_enabled,
makerMaxQuoteAgeMs: body.maker_max_quote_age_ms, makerMaxQuoteAgeMs: body.maker_max_quote_age_ms,
makerLatencyPolicyReason: body.maker_latency_policy_reason, makerLatencyPolicyReason: body.maker_latency_policy_reason,

View file

@ -1106,6 +1106,7 @@ const HUMAN_REASON_TEXT = {
executor_disarmed: 'Executor is disarmed.', executor_disarmed: 'Executor is disarmed.',
executor_paused: 'Executor intake is paused.', executor_paused: 'Executor intake is paused.',
inventory_unavailable: 'Inventory unavailable.', inventory_unavailable: 'Inventory unavailable.',
min_notional_not_met: 'Below minimum notional.',
maker_quote_age_unavailable: 'Maker quote age is unavailable.', maker_quote_age_unavailable: 'Maker quote age is unavailable.',
maker_quote_response_policy_invalid: 'Maker response-age policy is invalid.', 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.', maker_quote_too_old: 'Maker quote is too old for the configured response-age policy.',

View file

@ -17,6 +17,8 @@ import {
quoteAgeMsAt, quoteAgeMsAt,
} from './maker-timing.mjs'; } from './maker-timing.mjs';
const EDGE_EPSILON_PCT = 1e-9;
export function evaluateTradeOpportunity({ export function evaluateTradeOpportunity({
demandEvent, demandEvent,
priceEvent, priceEvent,
@ -32,6 +34,7 @@ export function evaluateTradeOpportunity({
const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotional }); const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotional });
const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct; const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct;
const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional; const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional;
const effectiveMinNotional = pairRuntime.minNotional ?? 0;
const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config }); const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config });
const decisionId = crypto.randomUUID(); const decisionId = crypto.randomUUID();
const decisionAt = new Date(now).toISOString(); const decisionAt = new Date(now).toISOString();
@ -53,9 +56,7 @@ export function evaluateTradeOpportunity({
? null ? null
: String(pairRuntime.strategyConfig.edgeBps), : String(pairRuntime.strategyConfig.edgeBps),
max_notional: effectiveMaxNotional == null ? null : String(effectiveMaxNotional), max_notional: effectiveMaxNotional == null ? null : String(effectiveMaxNotional),
min_notional: pairRuntime.strategyConfig?.minNotional == null min_notional: String(effectiveMinNotional),
? null
: String(pairRuntime.strategyConfig.minNotional),
price_route_id: pairRuntime.priceRoute?.routeId || null, price_route_id: pairRuntime.priceRoute?.routeId || null,
direction: pairRuntime.direction, direction: pairRuntime.direction,
request_kind: payload.request_kind, request_kind: payload.request_kind,
@ -135,6 +136,7 @@ export function evaluateTradeOpportunity({
pairRuntime, pairRuntime,
thresholdPct: effectiveThresholdPct, thresholdPct: effectiveThresholdPct,
maxNotional: effectiveMaxNotional, maxNotional: effectiveMaxNotional,
minNotional: effectiveMinNotional,
}); });
if (!buildResult.ok) { if (!buildResult.ok) {
@ -269,6 +271,7 @@ function buildQuote({
pairRuntime = null, pairRuntime = null,
thresholdPct, thresholdPct,
maxNotional, maxNotional,
minNotional = 0,
}) { }) {
const direction = pairRuntime?.direction || classifyPairDirection({ const direction = pairRuntime?.direction || classifyPairDirection({
assetIn: demand.asset_in, assetIn: demand.asset_in,
@ -342,6 +345,8 @@ function buildQuote({
notionalAssetId, notionalAssetId,
notionalSymbol: notionalAsset?.symbol || null, notionalSymbol: notionalAsset?.symbol || null,
maxNotional, maxNotional,
minNotional,
thresholdPct,
legacyEureNotional, legacyEureNotional,
proposedAmountOut: proposedOutputUnits, proposedAmountOut: proposedOutputUnits,
impliedRate, impliedRate,
@ -394,6 +399,8 @@ function buildQuote({
notionalAssetId, notionalAssetId,
notionalSymbol: notionalAsset?.symbol || null, notionalSymbol: notionalAsset?.symbol || null,
maxNotional, maxNotional,
minNotional,
thresholdPct,
legacyEureNotional, legacyEureNotional,
proposedAmountIn: proposedInputUnits, proposedAmountIn: proposedInputUnits,
proposedAmountOut: demand.amount_out, proposedAmountOut: demand.amount_out,
@ -423,6 +430,8 @@ function finalizeQuote({
notionalAssetId = null, notionalAssetId = null,
notionalSymbol = null, notionalSymbol = null,
maxNotional, maxNotional,
minNotional = 0,
thresholdPct,
legacyEureNotional = false, legacyEureNotional = false,
proposedAmountIn = null, proposedAmountIn = null,
proposedAmountOut = null, proposedAmountOut = null,
@ -469,6 +478,14 @@ function finalizeQuote({
return { ok: false, reason: 'invalid_pricing', details: reasonBase }; 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) { if (quoteNotional > maxNotional) {
return { ok: false, reason: 'max_notional_exceeded', details: reasonBase }; return { ok: false, reason: 'max_notional_exceeded', details: reasonBase };
} }
@ -626,6 +643,7 @@ function resolvePairRuntime({
assetOut: pair.assetOut, assetOut: pair.assetOut,
thresholdPct: Number(pair.strategyConfig.edgeBps) / 100, thresholdPct: Number(pair.strategyConfig.edgeBps) / 100,
maxNotional: Number(pair.strategyConfig.maxNotional), maxNotional: Number(pair.strategyConfig.maxNotional),
minNotional: Number(pair.strategyConfig.minNotional ?? 0),
priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs), priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs),
inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs), inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs),
}; };
@ -649,6 +667,7 @@ function blockedPairRuntime(pair, config, reason) {
maxNotional: pair?.strategyConfig maxNotional: pair?.strategyConfig
? Number(pair.strategyConfig.maxNotional) ? Number(pair.strategyConfig.maxNotional)
: (config.strategyMaxNotional ?? config.strategyMaxNotionalEure), : (config.strategyMaxNotional ?? config.strategyMaxNotionalEure),
minNotional: pair?.strategyConfig ? Number(pair.strategyConfig.minNotional ?? 0) : 0,
priceMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.priceMaxAgeMs) : config.strategyPriceMaxAgeMs, priceMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.priceMaxAgeMs) : config.strategyPriceMaxAgeMs,
inventoryMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.inventoryMaxAgeMs) : config.strategyInventoryMaxAgeMs, inventoryMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.inventoryMaxAgeMs) : config.strategyInventoryMaxAgeMs,
}; };

View file

@ -768,6 +768,7 @@ export async function createPairStrategyConfigVersion(pool, {
throw new Error('edge_bps must be a positive integer'); throw new Error('edge_bps must be a positive integer');
} }
const nextMaxNotional = positiveNumberStringOrDefault(maxNotional, active.max_notional, 'max_notional'); const nextMaxNotional = positiveNumberStringOrDefault(maxNotional, active.max_notional, 'max_notional');
const nextMinNotional = nonNegativeNumberStringOrDefault(minNotional, active.min_notional, 'min_notional');
const nextConfig = { const nextConfig = {
configId: `${resolvedPairId}:v${nextVersion}`, configId: `${resolvedPairId}:v${nextVersion}`,
@ -775,7 +776,7 @@ export async function createPairStrategyConfigVersion(pool, {
version: nextVersion, version: nextVersion,
edgeBps: nextEdgeBps, edgeBps: nextEdgeBps,
maxNotional: nextMaxNotional, maxNotional: nextMaxNotional,
minNotional: minNotional == null ? String(active.min_notional) : String(minNotional), minNotional: nextMinNotional,
slippageBps: slippageBps == null ? Number(active.slippage_bps) : Number(slippageBps), slippageBps: slippageBps == null ? Number(active.slippage_bps) : Number(slippageBps),
minDeadlineMs: minDeadlineMs == null ? Number(active.min_deadline_ms) : Number(minDeadlineMs), minDeadlineMs: minDeadlineMs == null ? Number(active.min_deadline_ms) : Number(minDeadlineMs),
priceMaxAgeMs: priceMaxAgeMs == null ? Number(active.price_max_age_ms) : Number(priceMaxAgeMs), priceMaxAgeMs: priceMaxAgeMs == null ? Number(active.price_max_age_ms) : Number(priceMaxAgeMs),
@ -808,6 +809,7 @@ export async function createPairStrategyConfigVersion(pool, {
createdBy: changedBy, createdBy: changedBy,
reason, reason,
}; };
validateStrategyNotionalBounds(nextConfig);
validateMakerQuoteAgePolicy(nextConfig); validateMakerQuoteAgePolicy(nextConfig);
await client.query( await client.query(
@ -1138,6 +1140,12 @@ function buildTradingConfigSnapshot({
if (strategyConfig && !(Number(strategyConfig.maxNotional) > 0)) { if (strategyConfig && !(Number(strategyConfig.maxNotional) > 0)) {
blockReasons.push('max_notional_invalid'); 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 observeEnabled = pairCanObserve(pair);
const makerEnabled = pairCanMake(pair); const makerEnabled = pairCanMake(pair);
@ -1441,6 +1449,7 @@ function buildInitialPairStrategyConfig(pairId, {
? baseConfig.makerLatencyPolicyReason ? baseConfig.makerLatencyPolicyReason
: nullableString(makerLatencyPolicyReason), : nullableString(makerLatencyPolicyReason),
}; };
validateStrategyNotionalBounds(next);
validateMakerQuoteAgePolicy(next); validateMakerQuoteAgePolicy(next);
return next; return next;
} }
@ -1507,6 +1516,12 @@ function nullableString(value) {
return normalized || null; 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) { function validateMakerQuoteAgePolicy(config) {
if (config.makerMaxQuoteAgeEnabled !== true) return; if (config.makerMaxQuoteAgeEnabled !== true) return;
if (!Number.isInteger(config.makerMaxQuoteAgeMs) || config.makerMaxQuoteAgeMs <= 0) { if (!Number.isInteger(config.makerMaxQuoteAgeMs) || config.makerMaxQuoteAgeMs <= 0) {

View file

@ -789,9 +789,11 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
asset_out: '', asset_out: '',
mode: 'observe_only', mode: 'observe_only',
edge_bps: '49', edge_bps: '49',
min_notional: '0',
max_notional: '150', max_notional: '150',
}); });
const [edgeDrafts, setEdgeDrafts] = useState({}); const [edgeDrafts, setEdgeDrafts] = useState({});
const [minNotionalDrafts, setMinNotionalDrafts] = useState({});
const [maxNotionalDrafts, setMaxNotionalDrafts] = useState({}); const [maxNotionalDrafts, setMaxNotionalDrafts] = useState({});
const [policyEnabledDrafts, setPolicyEnabledDrafts] = useState({}); const [policyEnabledDrafts, setPolicyEnabledDrafts] = useState({});
const [maxQuoteAgeDrafts, setMaxQuoteAgeDrafts] = useState({}); const [maxQuoteAgeDrafts, setMaxQuoteAgeDrafts] = useState({});
@ -818,6 +820,12 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
const tradingMode = TRADING_PAIR_MODES.has(pair.mode); const tradingMode = TRADING_PAIR_MODES.has(pair.mode);
return [pairId, String(strategyConfig.max_notional ?? pair.max_notional ?? (tradingMode ? '150' : ''))]; 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) => { setPolicyEnabledDrafts(Object.fromEntries(pairs.map((pair) => {
const pairId = pair.pair_id || pair.pairId; const pairId = pair.pair_id || pair.pairId;
const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; 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 hasStrategyConfig = Boolean(strategyConfig.config_id || strategyConfig.configId);
const edgeBps = edgeDrafts[pairId]; const edgeBps = edgeDrafts[pairId];
const maxNotional = maxNotionalDrafts[pairId]; const maxNotional = maxNotionalDrafts[pairId];
const minNotional = minNotionalDrafts[pairId];
const policyEnabled = policyEnabledDrafts[pairId] === true; const policyEnabled = policyEnabledDrafts[pairId] === true;
const maxQuoteAgeMs = maxQuoteAgeDrafts[pairId]; const maxQuoteAgeMs = maxQuoteAgeDrafts[pairId];
if (!edgeBps || !maxNotional) return; if (!edgeBps || !maxNotional || minNotional === undefined || minNotional === '') return;
if (policyEnabled && !maxQuoteAgeMs) return; if (policyEnabled && !maxQuoteAgeMs) return;
if (!hasStrategyConfig) { if (!hasStrategyConfig) {
@ -858,6 +867,7 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
mode, mode,
edge_bps: Number(edgeBps), edge_bps: Number(edgeBps),
max_notional: maxNotional, max_notional: maxNotional,
min_notional: minNotional,
maker_max_quote_age_enabled: policyEnabled, maker_max_quote_age_enabled: policyEnabled,
maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null, maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null,
maker_latency_policy_reason: policyEnabled ? 'operator dashboard maker response-age policy' : 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, pair_id: pairId,
edge_bps: Number(edgeBps), edge_bps: Number(edgeBps),
max_notional: maxNotional, max_notional: maxNotional,
min_notional: minNotional,
maker_max_quote_age_enabled: policyEnabled, maker_max_quote_age_enabled: policyEnabled,
maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null, maker_max_quote_age_ms: policyEnabled ? Number(maxQuoteAgeMs) : null,
maker_latency_policy_reason: policyEnabled ? 'operator dashboard maker response-age policy' : 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, mode: pairForm.mode,
edge_bps: Number(pairForm.edge_bps), edge_bps: Number(pairForm.edge_bps),
max_notional: pairForm.max_notional, 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 tradingModeSelected = TRADING_PAIR_MODES.has(pairForm.mode);
const pairFormDisabled = !assets.length const pairFormDisabled = !assets.length
|| pairForm.asset_in === pairForm.asset_out || pairForm.asset_in === pairForm.asset_out
|| (tradingModeSelected && (!pairForm.edge_bps || !pairForm.max_notional)); || (tradingModeSelected && (!pairForm.edge_bps || !pairForm.max_notional || pairForm.min_notional === ''));
return ( return (
<section className="panel"> <section className="panel">
@ -987,6 +999,19 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
value={pairForm.edge_bps} value={pairForm.edge_bps}
/> />
</div> </div>
<div className="field">
<label htmlFor="pair-min-notional">Min notional</label>
<input
disabled={!tradingModeSelected}
id="pair-min-notional"
min="0"
onChange={(event) => setPairForm((current) => ({ ...current, min_notional: event.target.value }))}
required={tradingModeSelected}
step="0.00000001"
type="number"
value={pairForm.min_notional}
/>
</div>
<div className="field"> <div className="field">
<label htmlFor="pair-max-notional">Max notional</label> <label htmlFor="pair-max-notional">Max notional</label>
<input <input
@ -1030,6 +1055,8 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
const tradingMode = TRADING_PAIR_MODES.has(pair.mode); const tradingMode = TRADING_PAIR_MODES.has(pair.mode);
const policyEnabled = policyEnabledDrafts[pairId] === true; const policyEnabled = policyEnabledDrafts[pairId] === true;
const configButtonDisabled = !edgeDrafts[pairId] const configButtonDisabled = !edgeDrafts[pairId]
|| minNotionalDrafts[pairId] === undefined
|| minNotionalDrafts[pairId] === ''
|| !maxNotionalDrafts[pairId] || !maxNotionalDrafts[pairId]
|| (policyEnabled && !maxQuoteAgeDrafts[pairId]) || (policyEnabled && !maxQuoteAgeDrafts[pairId])
|| (!hasStrategyConfig && !tradingMode); || (!hasStrategyConfig && !tradingMode);
@ -1042,6 +1069,7 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
<td><Pill label={pair.mode || pair.status} stateLabel={pair.canTrade || pair.can_trade ? 'healthy' : 'warning'} /></td> <td><Pill label={pair.mode || pair.status} stateLabel={pair.canTrade || pair.can_trade ? 'healthy' : 'warning'} /></td>
<td>{formatConfiguredEdgeBps(strategyConfig.edge_bps, { prefix: false })}</td> <td>{formatConfiguredEdgeBps(strategyConfig.edge_bps, { prefix: false })}</td>
<td> <td>
<div>{strategyConfig.min_notional ?? '0'} min</div>
<div>{strategyConfig.max_notional || 'Unavailable'} max</div> <div>{strategyConfig.max_notional || 'Unavailable'} max</div>
<div className="status-subtle"> <div className="status-subtle">
{strategyConfig.request_max_notional == null {strategyConfig.request_max_notional == null
@ -1079,6 +1107,21 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
value={edgeDrafts[pairId] ?? ''} value={edgeDrafts[pairId] ?? ''}
/> />
</div> </div>
<div className="trace-row">
<span className="status-subtle">Min</span>
<input
aria-label={`Min notional for ${pairId}`}
min="0"
onChange={(event) => setMinNotionalDrafts((current) => ({
...current,
[pairId]: event.target.value,
}))}
step="0.00000001"
style={{ maxWidth: 112 }}
type="number"
value={minNotionalDrafts[pairId] ?? ''}
/>
</div>
<div className="trace-row"> <div className="trace-row">
<span className="status-subtle">Max</span> <span className="status-subtle">Max</span>
<input <input

View file

@ -27,6 +27,7 @@ test('operator dashboard exposes DB-backed pair activation and pause controls',
assert.match(source, /control\.action === 'pause-pair'/); assert.match(source, /control\.action === 'pause-pair'/);
assert.match(source, /edgeBps: body\.edge_bps/); assert.match(source, /edgeBps: body\.edge_bps/);
assert.match(source, /maxNotional: body\.max_notional/); assert.match(source, /maxNotional: body\.max_notional/);
assert.match(source, /minNotional: bodyField\(body, 'min_notional', 'minNotional'\)/);
assert.match(source, /requestMaxNotional: bodyField\(body, 'request_max_notional', 'requestMaxNotional'\)/); assert.match(source, /requestMaxNotional: bodyField\(body, 'request_max_notional', 'requestMaxNotional'\)/);
assert.match(source, /requestMaxSlippageBps: bodyField\(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'\)/); assert.match(source, /requestMaxSlippageBps: bodyField\(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'\)/);
}); });

View file

@ -108,9 +108,12 @@ test('strategy page exposes pair activation, pause, edge, and deposit address co
assert.match(strategySource, /Add, pause, and tune directed pairs/); assert.match(strategySource, /Add, pause, and tune directed pairs/);
assert.match(strategySource, /Add \/ activate pair/); assert.match(strategySource, /Add \/ activate pair/);
assert.match(strategySource, /pair-edge-bps/); assert.match(strategySource, /pair-edge-bps/);
assert.match(strategySource, /pair-min-notional/);
assert.match(strategySource, /pair-max-notional/); assert.match(strategySource, /pair-max-notional/);
assert.match(strategySource, /Edge bps for/); assert.match(strategySource, /Edge bps for/);
assert.match(strategySource, /Min notional for/);
assert.match(strategySource, /Max notional for/); assert.match(strategySource, /Max notional for/);
assert.match(strategySource, /min_notional/);
assert.match(strategySource, /maker_max_quote_age_enabled/); assert.match(strategySource, /maker_max_quote_age_enabled/);
assert.match(strategySource, /maker_max_quote_age_ms/); assert.match(strategySource, /maker_max_quote_age_ms/);
assert.match(strategySource, /Age policy/); assert.match(strategySource, /Age policy/);

View file

@ -20,3 +20,8 @@ test('strategy execute commands stamp command publish time into durable observed
assert.match(source, /quote_age_at_command_ms: makerTiming\.quote_age_at_command_ms/); assert.match(source, /quote_age_at_command_ms: makerTiming\.quote_age_at_command_ms/);
assert.match(source, /observedAt:\s*commandPublishedAt/); assert.match(source, /observedAt:\s*commandPublishedAt/);
}); });
test('strategy control edge updates preserve DB-backed min notional policy', () => {
assert.match(source, /path: '\/pair-config\/edge'/);
assert.match(source, /minNotional: body\.min_notional/);
});

View file

@ -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); 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', () => { test('strategy blocks BTC -> USDC when route-specific reference price is stale', () => {
const config = makeBtcUsdcDbConfig(); const config = makeBtcUsdcDbConfig();
const result = evaluateTradeOpportunity({ const result = evaluateTradeOpportunity({

View file

@ -202,6 +202,7 @@ test('edge update creates a new active strategy version', async () => {
pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`,
edgeBps: 75, edgeBps: 75,
maxNotional: '42', maxNotional: '42',
minNotional: '1',
changedBy: 'test', changedBy: 'test',
reason: 'test edge update', 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}`); .filter((row) => row.pair_id === `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`);
assert.equal(next.version, 2); 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.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.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); 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 () => { test('observe-only enable does not downgrade an active trading pair', async () => {
const pool = createMemoryPool(); const pool = createMemoryPool();
await seedTradingConfig(pool); await seedTradingConfig(pool);