From a0e7a698a1e563e5743ef7ffa26319d50b983d79 Mon Sep 17 00:00:00 2001 From: philipp Date: Wed, 13 May 2026 13:19:44 +0200 Subject: [PATCH] Initialize pair configs from dashboard Proof: npm test; npm run operator-dashboard:build; focused regressions cover maker/taker activation creating config, invalid initial edge rejection, and invalid max-notional rejection. Assumptions: Adding maker/taker/both mode should create an initial pair strategy config with operator-provided edge and max notional, but pairs without a real price route must remain blocked. Still fake: No BTC/USDC external reference price route or liquidity model exists; non-nBTC/EURe pairs still fail closed on missing price route until that path is built. --- src/apps/operator-dashboard.mjs | 5 +- src/lib/postgres.mjs | 173 +++++++++++++++++- .../static/pages/StrategyPage.jsx | 117 ++++++++++-- test/operator-dashboard-app-static.test.mjs | 2 + test/operator-dashboard-ui-static.test.mjs | 4 + test/trading-config.test.mjs | 39 +++- 6 files changed, 322 insertions(+), 18 deletions(-) diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 259ed0c..7c5866c 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -625,8 +625,9 @@ async function invokeControl(control, body) { const result = await createPairStrategyConfigVersion(pool, { pairId: body.pair_id || body.pair, edgeBps: Number(body.edge_bps), + maxNotional: body.max_notional, changedBy: body.changed_by || 'operator', - reason: body.reason || 'dashboard edge update', + reason: body.reason || 'dashboard pair strategy config update', }); await tradingConfigStore.forceRefresh(); return result; @@ -649,6 +650,8 @@ async function invokeControl(control, body) { assetIn: body.asset_in, assetOut: body.asset_out, mode: body.mode, + edgeBps: body.edge_bps, + maxNotional: body.max_notional, changedBy: body.changed_by || 'operator', reason: body.reason || 'dashboard pair mode update', }); diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index df8c781..3967df9 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -737,13 +737,14 @@ export async function createPairStrategyConfigVersion(pool, { if (!Number.isInteger(nextEdgeBps) || nextEdgeBps <= 0) { throw new Error('edge_bps must be a positive integer'); } + const nextMaxNotional = positiveNumberStringOrDefault(maxNotional, active.max_notional, 'max_notional'); const nextConfig = { configId: `${resolvedPairId}:v${nextVersion}`, pairId: resolvedPairId, version: nextVersion, edgeBps: nextEdgeBps, - maxNotional: maxNotional == null ? String(active.max_notional) : String(maxNotional), + maxNotional: nextMaxNotional, minNotional: minNotional == null ? String(active.min_notional) : String(minNotional), slippageBps: slippageBps == null ? Number(active.slippage_bps) : Number(slippageBps), minDeadlineMs: minDeadlineMs == null ? Number(active.min_deadline_ms) : Number(minDeadlineMs), @@ -855,6 +856,16 @@ export async function setTradingPairMode(pool, { assetIn = null, assetOut = null, mode = 'observe_only', + edgeBps = null, + maxNotional = null, + minNotional = null, + slippageBps = null, + minDeadlineMs = null, + priceMaxAgeMs = null, + inventoryMaxAgeMs = null, + requestDefaultNotional = null, + requestMaxNotional = null, + requestMaxSlippageBps = null, changedBy = 'operator', reason = 'operator pair mode update', } = {}) { @@ -893,6 +904,69 @@ export async function setTradingPairMode(pool, { status: normalizedMode, }; await upsertSeedPair(client, { pair: nextPair, now: new Date().toISOString() }); + + let strategyConfig = null; + if (pairCanMake(nextPair) || pairCanTake(nextPair)) { + const activeConfigResult = await client.query( + ` + SELECT * + FROM ${PAIR_STRATEGY_CONFIGS_TABLE} + WHERE pair_id = $1 AND active = true + ORDER BY version DESC + LIMIT 1 + `, + [resolvedPairId], + ); + strategyConfig = activeConfigResult.rows[0] + ? normalizeStrategyConfigRow(activeConfigResult.rows[0]) + : null; + + if (!strategyConfig) { + const nextConfig = buildInitialPairStrategyConfig(resolvedPairId, { + edgeBps, + maxNotional, + minNotional, + slippageBps, + minDeadlineMs, + priceMaxAgeMs, + inventoryMaxAgeMs, + requestDefaultNotional, + requestMaxNotional, + requestMaxSlippageBps, + changedBy, + reason, + }); + await insertPairStrategyConfig(client, { config: nextConfig, active: true }); + strategyConfig = normalizeStrategyConfigRow({ + config_id: nextConfig.configId, + pair_id: nextConfig.pairId, + version: nextConfig.version, + active: true, + edge_bps: nextConfig.edgeBps, + max_notional: nextConfig.maxNotional, + min_notional: nextConfig.minNotional, + slippage_bps: nextConfig.slippageBps, + min_deadline_ms: nextConfig.minDeadlineMs, + price_max_age_ms: nextConfig.priceMaxAgeMs, + inventory_max_age_ms: nextConfig.inventoryMaxAgeMs, + request_default_notional: nextConfig.requestDefaultNotional, + request_max_notional: nextConfig.requestMaxNotional, + request_max_slippage_bps: nextConfig.requestMaxSlippageBps, + created_by: changedBy, + reason, + }); + await insertConfigAuditLog(client, { + entityType: 'pair_strategy_config', + entityId: resolvedPairId, + action: 'initial_version_created', + oldValue: null, + newValue: strategyConfig, + changedBy, + reason, + }); + } + } + await insertConfigAuditLog(client, { entityType: 'trading_pair', entityId: resolvedPairId, @@ -902,7 +976,10 @@ export async function setTradingPairMode(pool, { changedBy, reason, }); - return nextPair; + return { + ...nextPair, + strategyConfig, + }; }); } @@ -1227,6 +1304,98 @@ function splitPairId(pairId) { return parts; } +function buildInitialPairStrategyConfig(pairId, { + edgeBps = null, + maxNotional = null, + minNotional = null, + slippageBps = null, + minDeadlineMs = null, + priceMaxAgeMs = null, + inventoryMaxAgeMs = null, + requestDefaultNotional = null, + requestMaxNotional = null, + requestMaxSlippageBps = null, + changedBy = 'operator', + reason = 'operator pair strategy config initialization', +} = {}) { + const baseConfig = buildSeedStrategyConfig(pairId, { + createdBy: changedBy, + reason, + }); + + return { + ...baseConfig, + edgeBps: positiveIntegerOrDefault(edgeBps, baseConfig.edgeBps, 'edge_bps'), + maxNotional: positiveNumberStringOrDefault(maxNotional, baseConfig.maxNotional, 'max_notional'), + minNotional: nonNegativeNumberStringOrDefault(minNotional, baseConfig.minNotional, 'min_notional'), + slippageBps: nonNegativeIntegerOrDefault(slippageBps, baseConfig.slippageBps, 'slippage_bps'), + minDeadlineMs: positiveIntegerOrDefault(minDeadlineMs, baseConfig.minDeadlineMs, 'min_deadline_ms'), + priceMaxAgeMs: positiveIntegerOrDefault(priceMaxAgeMs, baseConfig.priceMaxAgeMs, 'price_max_age_ms'), + inventoryMaxAgeMs: + positiveIntegerOrDefault(inventoryMaxAgeMs, baseConfig.inventoryMaxAgeMs, 'inventory_max_age_ms'), + requestDefaultNotional: + nullablePositiveNumberStringOrDefault( + requestDefaultNotional, + baseConfig.requestDefaultNotional, + 'request_default_notional', + ), + requestMaxNotional: + nullablePositiveNumberStringOrDefault( + requestMaxNotional, + baseConfig.requestMaxNotional, + 'request_max_notional', + ), + requestMaxSlippageBps: + nullableNonNegativeIntegerOrDefault( + requestMaxSlippageBps, + baseConfig.requestMaxSlippageBps, + 'request_max_slippage_bps', + ), + }; +} + +function hasConfigOverride(value) { + return value != null && String(value).trim() !== ''; +} + +function positiveIntegerOrDefault(value, fallback, field) { + if (!hasConfigOverride(value)) return Number(fallback); + const next = Number(value); + if (!Number.isInteger(next) || next <= 0) throw new Error(`${field} must be a positive integer`); + return next; +} + +function nonNegativeIntegerOrDefault(value, fallback, field) { + if (!hasConfigOverride(value)) return Number(fallback); + const next = Number(value); + if (!Number.isInteger(next) || next < 0) throw new Error(`${field} must be a non-negative integer`); + return next; +} + +function nullableNonNegativeIntegerOrDefault(value, fallback, field) { + if (!hasConfigOverride(value)) return fallback == null ? null : nonNegativeIntegerOrDefault(fallback, 0, field); + return nonNegativeIntegerOrDefault(value, 0, field); +} + +function positiveNumberStringOrDefault(value, fallback, field) { + if (!hasConfigOverride(value)) return String(fallback); + const next = String(value).trim(); + if (!(Number(next) > 0)) throw new Error(`${field} must be greater than zero`); + return next; +} + +function nonNegativeNumberStringOrDefault(value, fallback, field) { + if (!hasConfigOverride(value)) return String(fallback); + const next = String(value).trim(); + if (!(Number(next) >= 0)) throw new Error(`${field} must be zero or greater`); + return next; +} + +function nullablePositiveNumberStringOrDefault(value, fallback, field) { + if (!hasConfigOverride(value)) return fallback == null ? null : positiveNumberStringOrDefault(fallback, '1', field); + return positiveNumberStringOrDefault(value, '1', field); +} + function normalizeStrategyConfigRow(row) { if (!row) return null; return { diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index c1723f9..35008da 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -402,8 +402,11 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { asset_in: '', asset_out: '', mode: 'observe_only', + edge_bps: '49', + max_notional: '150', }); const [edgeDrafts, setEdgeDrafts] = useState({}); + const [maxNotionalDrafts, setMaxNotionalDrafts] = useState({}); useEffect(() => { if (!assets.length) return; @@ -418,17 +421,46 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { setEdgeDrafts(Object.fromEntries(pairs.map((pair) => { const pairId = pair.pair_id || pair.pairId; const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; - return [pairId, String(strategyConfig.edge_bps ?? pair.edge_bps ?? '')]; + const tradingMode = TRADING_PAIR_MODES.has(pair.mode); + return [pairId, String(strategyConfig.edge_bps ?? pair.edge_bps ?? (tradingMode ? '49' : ''))]; + }))); + setMaxNotionalDrafts(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.max_notional ?? pair.max_notional ?? (tradingMode ? '150' : ''))]; }))); }, [pairs]); - async function updateEdge(pair) { + async function updatePairConfig(pair) { const pairId = pair.pair_id || pair.pairId; - const next = edgeDrafts[pairId]; - if (!next) return; + const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; + const hasStrategyConfig = Boolean(strategyConfig.config_id || strategyConfig.configId); + const edgeBps = edgeDrafts[pairId]; + const maxNotional = maxNotionalDrafts[pairId]; + if (!edgeBps || !maxNotional) return; + + if (!hasStrategyConfig) { + const mode = pair.mode || pair.status || 'observe_only'; + if ( + TRADING_PAIR_MODES.has(mode) + && !window.confirm('Initialize strategy config for this trading pair?') + ) { + return; + } + await onControl?.('operator-dashboard', 'set-pair-mode', { + pair_id: pairId, + mode, + edge_bps: Number(edgeBps), + max_notional: maxNotional, + }); + return; + } + await onControl?.('operator-dashboard', 'update-pair-edge', { pair_id: pairId, - edge_bps: Number(next), + edge_bps: Number(edgeBps), + max_notional: maxNotional, }); } @@ -445,6 +477,8 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { asset_in: pairForm.asset_in, asset_out: pairForm.asset_out, mode: pairForm.mode, + edge_bps: Number(pairForm.edge_bps), + max_notional: pairForm.max_notional, }); } @@ -469,6 +503,11 @@ 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)); + return (
@@ -524,9 +563,35 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
+
+ + setPairForm((current) => ({ ...current, edge_bps: event.target.value }))} + required={tradingModeSelected} + step="1" + type="number" + value={pairForm.edge_bps} + /> +
+
+ + setPairForm((current) => ({ ...current, max_notional: event.target.value }))} + required={tradingModeSelected} + step="0.00000001" + type="number" + value={pairForm.max_notional} + /> +
-
@@ -547,13 +612,19 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) { {pairs.length ? pairs.map((pair) => { + const pairId = pair.pair_id || pair.pairId; const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; const route = pair.priceRoute || pair.price_route || {}; + const hasStrategyConfig = Boolean(strategyConfig.config_id || strategyConfig.configId); + const tradingMode = TRADING_PAIR_MODES.has(pair.mode); + const configButtonDisabled = !edgeDrafts[pairId] + || !maxNotionalDrafts[pairId] + || (!hasStrategyConfig && !tradingMode); return ( - +
{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}
-
{truncateMiddle(pair.pair_id || pair.pairId, 42)}
+
{truncateMiddle(pairId, 42)}
{strategyConfig.edge_bps ?? 'Unavailable'} bps @@ -566,25 +637,43 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
v{strategyConfig.version || 'Unavailable'}
+ Edge setEdgeDrafts((current) => ({ ...current, - [pair.pair_id || pair.pairId]: event.target.value, + [pairId]: event.target.value, }))} step="1" style={{ maxWidth: 92 }} type="number" - value={edgeDrafts[pair.pair_id || pair.pairId] ?? ''} + value={edgeDrafts[pairId] ?? ''} /> +
+
+ Max + setMaxNotionalDrafts((current) => ({ + ...current, + [pairId]: event.target.value, + }))} + step="0.00000001" + style={{ maxWidth: 112 }} + type="number" + value={maxNotionalDrafts[pairId] ?? ''} + /> +
+
diff --git a/test/operator-dashboard-app-static.test.mjs b/test/operator-dashboard-app-static.test.mjs index a708ce9..be5ce2c 100644 --- a/test/operator-dashboard-app-static.test.mjs +++ b/test/operator-dashboard-app-static.test.mjs @@ -25,4 +25,6 @@ test('operator dashboard exposes DB-backed pair activation and pause controls', assert.match(source, /pauseTradingPair/); assert.match(source, /control\.action === 'set-pair-mode'/); assert.match(source, /control\.action === 'pause-pair'/); + assert.match(source, /edgeBps: body\.edge_bps/); + assert.match(source, /maxNotional: body\.max_notional/); }); diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 3746508..672636a 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -68,7 +68,11 @@ test('strategy page exposes pair activation, pause, edge, and deposit address co assert.match(strategySource, /pause-pair/); 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-max-notional/); assert.match(strategySource, /Edge bps for/); + assert.match(strategySource, /Max notional for/); + assert.match(strategySource, /Init/); assert.match(strategySource, /deposit_address/); assert.match(strategySource, /Copy/); }); diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index b35c617..a5b737c 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -123,6 +123,7 @@ test('edge update creates a new active strategy version', async () => { const next = await createPairStrategyConfigVersion(pool, { pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, edgeBps: 75, + maxNotional: '42', changedBy: 'test', reason: 'test edge update', }); @@ -132,9 +133,24 @@ test('edge update creates a new active strategy version', async () => { assert.equal(next.version, 2); 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(versions.find((row) => row.version === 1).active, false); }); +test('strategy config update rejects invalid 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: '0', + }), + /max_notional must be greater than zero/, + ); +}); + test('observe-only enable does not downgrade an active trading pair', async () => { const pool = createMemoryPool(); await seedTradingConfig(pool); @@ -212,6 +228,8 @@ test('pair mode updates activate a directed pair without inventing a price route const updated = await setTradingPairMode(pool, { pairId, mode: 'maker', + edgeBps: 75, + maxNotional: '25', changedBy: 'test', reason: 'operator activation test', }); @@ -219,10 +237,29 @@ test('pair mode updates activate a directed pair without inventing a price route const pair = snapshot.pairByKey.get(pairId); assert.equal(updated.mode, 'maker'); + assert.equal(updated.strategyConfig.edgeBps, 75); + assert.equal(updated.strategyConfig.maxNotional, '25'); assert.equal(pair.enabled, true); assert.equal(pair.makerEnabled, true); + assert.equal(pair.strategyConfig.edgeBps, 75); + assert.equal(pair.strategyConfig.maxNotional, '25'); assert.equal(pair.canTrade, false); - assert.equal(pair.blockReason, 'pair_strategy_config_missing'); + assert.equal(pair.blockReason, 'price_route_missing'); +}); + +test('pair mode activation rejects invalid initial edge config', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + + await assert.rejects( + setTradingPairMode(pool, { + assetIn: LEGACY_OMFT_BTC_ASSET_ID, + assetOut: CURRENT_EURE_ASSET_ID, + mode: 'both', + edgeBps: 0, + }), + /edge_bps must be a positive integer/, + ); }); test('pair pause disables trading without deleting strategy config or seed restoring it', async () => {