Initialize pair configs from dashboard
Some checks failed
deploy / deploy (push) Failing after 30s

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.
This commit is contained in:
philipp 2026-05-13 13:19:44 +02:00
parent acdc3c061e
commit a0e7a698a1
6 changed files with 322 additions and 18 deletions

View file

@ -625,8 +625,9 @@ async function invokeControl(control, body) {
const result = await createPairStrategyConfigVersion(pool, { const result = await createPairStrategyConfigVersion(pool, {
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,
changedBy: body.changed_by || 'operator', changedBy: body.changed_by || 'operator',
reason: body.reason || 'dashboard edge update', reason: body.reason || 'dashboard pair strategy config update',
}); });
await tradingConfigStore.forceRefresh(); await tradingConfigStore.forceRefresh();
return result; return result;
@ -649,6 +650,8 @@ async function invokeControl(control, body) {
assetIn: body.asset_in, assetIn: body.asset_in,
assetOut: body.asset_out, assetOut: body.asset_out,
mode: body.mode, mode: body.mode,
edgeBps: body.edge_bps,
maxNotional: body.max_notional,
changedBy: body.changed_by || 'operator', changedBy: body.changed_by || 'operator',
reason: body.reason || 'dashboard pair mode update', reason: body.reason || 'dashboard pair mode update',
}); });

View file

@ -737,13 +737,14 @@ export async function createPairStrategyConfigVersion(pool, {
if (!Number.isInteger(nextEdgeBps) || nextEdgeBps <= 0) { if (!Number.isInteger(nextEdgeBps) || nextEdgeBps <= 0) {
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 nextConfig = { const nextConfig = {
configId: `${resolvedPairId}:v${nextVersion}`, configId: `${resolvedPairId}:v${nextVersion}`,
pairId: resolvedPairId, pairId: resolvedPairId,
version: nextVersion, version: nextVersion,
edgeBps: nextEdgeBps, edgeBps: nextEdgeBps,
maxNotional: maxNotional == null ? String(active.max_notional) : String(maxNotional), maxNotional: nextMaxNotional,
minNotional: minNotional == null ? String(active.min_notional) : String(minNotional), minNotional: minNotional == null ? String(active.min_notional) : String(minNotional),
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),
@ -855,6 +856,16 @@ export async function setTradingPairMode(pool, {
assetIn = null, assetIn = null,
assetOut = null, assetOut = null,
mode = 'observe_only', 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', changedBy = 'operator',
reason = 'operator pair mode update', reason = 'operator pair mode update',
} = {}) { } = {}) {
@ -893,6 +904,69 @@ export async function setTradingPairMode(pool, {
status: normalizedMode, status: normalizedMode,
}; };
await upsertSeedPair(client, { pair: nextPair, now: new Date().toISOString() }); 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, { await insertConfigAuditLog(client, {
entityType: 'trading_pair', entityType: 'trading_pair',
entityId: resolvedPairId, entityId: resolvedPairId,
@ -902,7 +976,10 @@ export async function setTradingPairMode(pool, {
changedBy, changedBy,
reason, reason,
}); });
return nextPair; return {
...nextPair,
strategyConfig,
};
}); });
} }
@ -1227,6 +1304,98 @@ function splitPairId(pairId) {
return parts; 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) { function normalizeStrategyConfigRow(row) {
if (!row) return null; if (!row) return null;
return { return {

View file

@ -402,8 +402,11 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
asset_in: '', asset_in: '',
asset_out: '', asset_out: '',
mode: 'observe_only', mode: 'observe_only',
edge_bps: '49',
max_notional: '150',
}); });
const [edgeDrafts, setEdgeDrafts] = useState({}); const [edgeDrafts, setEdgeDrafts] = useState({});
const [maxNotionalDrafts, setMaxNotionalDrafts] = useState({});
useEffect(() => { useEffect(() => {
if (!assets.length) return; if (!assets.length) return;
@ -418,17 +421,46 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
setEdgeDrafts(Object.fromEntries(pairs.map((pair) => { setEdgeDrafts(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 || {};
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]); }, [pairs]);
async function updateEdge(pair) { async function updatePairConfig(pair) {
const pairId = pair.pair_id || pair.pairId; const pairId = pair.pair_id || pair.pairId;
const next = edgeDrafts[pairId]; const strategyConfig = pair.strategyConfig || pair.strategy_config || {};
if (!next) return; 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', { await onControl?.('operator-dashboard', 'update-pair-edge', {
pair_id: pairId, 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_in: pairForm.asset_in,
asset_out: pairForm.asset_out, asset_out: pairForm.asset_out,
mode: pairForm.mode, 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 ( return (
<section className="panel"> <section className="panel">
<div className="panel-head"> <div className="panel-head">
@ -524,9 +563,35 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
<option value="both">Maker and taker</option> <option value="both">Maker and taker</option>
</select> </select>
</div> </div>
<div className="field">
<label htmlFor="pair-edge-bps">Edge bps</label>
<input
disabled={!tradingModeSelected}
id="pair-edge-bps"
min="1"
onChange={(event) => setPairForm((current) => ({ ...current, edge_bps: event.target.value }))}
required={tradingModeSelected}
step="1"
type="number"
value={pairForm.edge_bps}
/>
</div>
<div className="field">
<label htmlFor="pair-max-notional">Max notional</label>
<input
disabled={!tradingModeSelected}
id="pair-max-notional"
min="0.00000001"
onChange={(event) => setPairForm((current) => ({ ...current, max_notional: event.target.value }))}
required={tradingModeSelected}
step="0.00000001"
type="number"
value={pairForm.max_notional}
/>
</div>
</div> </div>
<div className="button-row"> <div className="button-row">
<button className="button" disabled={!assets.length || pairForm.asset_in === pairForm.asset_out} type="submit"> <button className="button" disabled={pairFormDisabled} type="submit">
Add / activate pair Add / activate pair
</button> </button>
</div> </div>
@ -547,13 +612,19 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
</thead> </thead>
<tbody> <tbody>
{pairs.length ? pairs.map((pair) => { {pairs.length ? pairs.map((pair) => {
const pairId = pair.pair_id || pair.pairId;
const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; const strategyConfig = pair.strategyConfig || pair.strategy_config || {};
const route = pair.priceRoute || pair.price_route || {}; 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 ( return (
<tr key={pair.pair_id || pair.pairId}> <tr key={pairId}>
<td> <td>
<div>{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}</div> <div>{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}</div>
<div className="status-subtle mono">{truncateMiddle(pair.pair_id || pair.pairId, 42)}</div> <div className="status-subtle mono">{truncateMiddle(pairId, 42)}</div>
</td> </td>
<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>{strategyConfig.edge_bps ?? 'Unavailable'} bps</td> <td>{strategyConfig.edge_bps ?? 'Unavailable'} bps</td>
@ -566,25 +637,43 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
<td> <td>
<div>v{strategyConfig.version || 'Unavailable'}</div> <div>v{strategyConfig.version || 'Unavailable'}</div>
<div className="trace-row"> <div className="trace-row">
<span className="status-subtle">Edge</span>
<input <input
aria-label={`Edge bps for ${pair.pair_id || pair.pairId}`} aria-label={`Edge bps for ${pairId}`}
min="1" min="1"
onChange={(event) => setEdgeDrafts((current) => ({ onChange={(event) => setEdgeDrafts((current) => ({
...current, ...current,
[pair.pair_id || pair.pairId]: event.target.value, [pairId]: event.target.value,
}))} }))}
step="1" step="1"
style={{ maxWidth: 92 }} style={{ maxWidth: 92 }}
type="number" type="number"
value={edgeDrafts[pair.pair_id || pair.pairId] ?? ''} value={edgeDrafts[pairId] ?? ''}
/> />
</div>
<div className="trace-row">
<span className="status-subtle">Max</span>
<input
aria-label={`Max notional for ${pairId}`}
min="0.00000001"
onChange={(event) => setMaxNotionalDrafts((current) => ({
...current,
[pairId]: event.target.value,
}))}
step="0.00000001"
style={{ maxWidth: 112 }}
type="number"
value={maxNotionalDrafts[pairId] ?? ''}
/>
</div>
<div className="trace-row">
<button <button
className="button secondary trace-copy-button" className="button secondary trace-copy-button"
disabled={!strategyConfig.config_id && !strategyConfig.configId} disabled={configButtonDisabled}
onClick={() => updateEdge(pair)} onClick={() => updatePairConfig(pair)}
type="button" type="button"
> >
Save {hasStrategyConfig ? 'Save' : 'Init'}
</button> </button>
</div> </div>
</td> </td>

View file

@ -25,4 +25,6 @@ test('operator dashboard exposes DB-backed pair activation and pause controls',
assert.match(source, /pauseTradingPair/); assert.match(source, /pauseTradingPair/);
assert.match(source, /control\.action === 'set-pair-mode'/); assert.match(source, /control\.action === 'set-pair-mode'/);
assert.match(source, /control\.action === 'pause-pair'/); assert.match(source, /control\.action === 'pause-pair'/);
assert.match(source, /edgeBps: body\.edge_bps/);
assert.match(source, /maxNotional: body\.max_notional/);
}); });

View file

@ -68,7 +68,11 @@ test('strategy page exposes pair activation, pause, edge, and deposit address co
assert.match(strategySource, /pause-pair/); assert.match(strategySource, /pause-pair/);
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-max-notional/);
assert.match(strategySource, /Edge bps for/); assert.match(strategySource, /Edge bps for/);
assert.match(strategySource, /Max notional for/);
assert.match(strategySource, /Init/);
assert.match(strategySource, /deposit_address/); assert.match(strategySource, /deposit_address/);
assert.match(strategySource, /Copy/); assert.match(strategySource, /Copy/);
}); });

View file

@ -123,6 +123,7 @@ test('edge update creates a new active strategy version', async () => {
const next = await createPairStrategyConfigVersion(pool, { const next = await createPairStrategyConfigVersion(pool, {
pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`,
edgeBps: 75, edgeBps: 75,
maxNotional: '42',
changedBy: 'test', changedBy: 'test',
reason: 'test edge update', 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(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.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); 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 () => { test('observe-only enable does not downgrade an active trading pair', async () => {
const pool = createMemoryPool(); const pool = createMemoryPool();
await seedTradingConfig(pool); 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, { const updated = await setTradingPairMode(pool, {
pairId, pairId,
mode: 'maker', mode: 'maker',
edgeBps: 75,
maxNotional: '25',
changedBy: 'test', changedBy: 'test',
reason: 'operator activation 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); const pair = snapshot.pairByKey.get(pairId);
assert.equal(updated.mode, 'maker'); 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.enabled, true);
assert.equal(pair.makerEnabled, 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.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 () => { test('pair pause disables trading without deleting strategy config or seed restoring it', async () => {