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:
parent
748950a1d8
commit
04c59ee93d
11 changed files with 204 additions and 6 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="panel">
|
||||
|
|
@ -987,6 +999,19 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
|
|||
value={pairForm.edge_bps}
|
||||
/>
|
||||
</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">
|
||||
<label htmlFor="pair-max-notional">Max notional</label>
|
||||
<input
|
||||
|
|
@ -1030,6 +1055,8 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
|
|||
const tradingMode = TRADING_PAIR_MODES.has(pair.mode);
|
||||
const policyEnabled = policyEnabledDrafts[pairId] === true;
|
||||
const configButtonDisabled = !edgeDrafts[pairId]
|
||||
|| minNotionalDrafts[pairId] === undefined
|
||||
|| minNotionalDrafts[pairId] === ''
|
||||
|| !maxNotionalDrafts[pairId]
|
||||
|| (policyEnabled && !maxQuoteAgeDrafts[pairId])
|
||||
|| (!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>{formatConfiguredEdgeBps(strategyConfig.edge_bps, { prefix: false })}</td>
|
||||
<td>
|
||||
<div>{strategyConfig.min_notional ?? '0'} min</div>
|
||||
<div>{strategyConfig.max_notional || 'Unavailable'} max</div>
|
||||
<div className="status-subtle">
|
||||
{strategyConfig.request_max_notional == null
|
||||
|
|
@ -1079,6 +1107,21 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
|
|||
value={edgeDrafts[pairId] ?? ''}
|
||||
/>
|
||||
</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">
|
||||
<span className="status-subtle">Max</span>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -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, /edgeBps: body\.edge_bps/);
|
||||
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, /requestMaxSlippageBps: bodyField\(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'\)/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 \/ activate pair/);
|
||||
assert.match(strategySource, /pair-edge-bps/);
|
||||
assert.match(strategySource, /pair-min-notional/);
|
||||
assert.match(strategySource, /pair-max-notional/);
|
||||
assert.match(strategySource, /Edge bps for/);
|
||||
assert.match(strategySource, /Min 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_ms/);
|
||||
assert.match(strategySource, /Age policy/);
|
||||
|
|
|
|||
|
|
@ -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, /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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue