Add dashboard pair controls
Some checks failed
deploy / deploy (push) Failing after 34s

Proof: npm test passed 171/171; npm run operator-dashboard:build passed; regression tests cover pair activation/pause fail-closed behavior, per-pair edge controls, and asset deposit address exposure.

Assumptions: Activating a directed pair is operator-approved live-funds-adjacent control; trading still requires existing DB strategy config, price route, armed services, and inventory, so unsupported imported assets remain blocked.

Still fake: no new price routes or liquidity discovery were added; non-current pairs without routes/config remain visible but cannot trade.
This commit is contained in:
philipp 2026-05-13 12:51:35 +02:00
parent 266d149b33
commit edfa14f37e
8 changed files with 471 additions and 21 deletions

View file

@ -52,7 +52,9 @@ import {
loadRecentQuotes,
loadSubmissionPage,
loadSubmissionSummary,
pauseTradingPair,
seedTradingConfig,
setTradingPairMode,
} from '../lib/postgres.mjs';
const config = loadConfig();
@ -641,6 +643,29 @@ async function invokeControl(control, body) {
return result;
}
if (control.service === 'operator-dashboard' && control.action === 'set-pair-mode') {
const result = await setTradingPairMode(pool, {
pairId: body.pair_id || body.pair,
assetIn: body.asset_in,
assetOut: body.asset_out,
mode: body.mode,
changedBy: body.changed_by || 'operator',
reason: body.reason || 'dashboard pair mode update',
});
await tradingConfigStore.forceRefresh();
return result;
}
if (control.service === 'operator-dashboard' && control.action === 'pause-pair') {
const result = await pauseTradingPair(pool, {
pairId: body.pair_id || body.pair,
changedBy: body.changed_by || 'operator',
reason: body.reason || 'dashboard pair pause',
});
await tradingConfigStore.forceRefresh();
return result;
}
const response = await fetchJson(
`${lookupServiceBaseUrl(control.service)}${control.path}`,
{

View file

@ -78,6 +78,26 @@ const CONTROL_DEFINITIONS = [
page: 'strategy',
risk_class: 'safe',
},
{
service: 'operator-dashboard',
action: 'set-pair-mode',
method: 'POST',
path: '/internal/set-pair-mode',
label: 'Set Pair Mode',
description: 'Activate a directed pair mode from durable DB config. Trading still requires valid strategy and price route state.',
page: 'strategy',
risk_class: 'live_funds',
},
{
service: 'operator-dashboard',
action: 'pause-pair',
method: 'POST',
path: '/internal/pause-pair',
label: 'Pause Pair',
description: 'Disable a directed pair in durable DB config without deleting historical strategy versions.',
page: 'strategy',
risk_class: 'safe',
},
{
service: 'trade-executor',
action: 'intent-request-preflight',
@ -590,6 +610,7 @@ export function buildDashboardBootstrap({
activeAlerts,
assetCatalog,
pairConfig,
fundingHandles: funding.handles,
recentQuotes,
recentTradeDecisions,
recentExecuteTradeCommands,
@ -1406,6 +1427,7 @@ function buildStrategySummary({
activeAlerts,
assetCatalog = null,
pairConfig = null,
fundingHandles = [],
recentQuotes = [],
recentTradeDecisions = [],
recentExecuteTradeCommands = [],
@ -1479,7 +1501,10 @@ function buildStrategySummary({
durable_control_state: strategyState.durable_control_state || null,
trading_config: strategyState.trading_config || null,
},
asset_catalog: assetCatalog || buildFallbackAssetCatalog(config),
asset_catalog: attachDepositHandlesToAssetCatalog(
assetCatalog || buildFallbackAssetCatalog(config),
fundingHandles,
),
pair_config: pairConfig || buildFallbackPairConfig(config),
executor_state: {
armed: executorState.armed ?? null,
@ -1503,6 +1528,39 @@ function buildStrategySummary({
};
}
function attachDepositHandlesToAssetCatalog(assetCatalog, fundingHandles = []) {
const handlesByAssetId = new Map();
const handlesByChain = new Map();
for (const handle of fundingHandles || []) {
if (!handle) continue;
if (handle.chain) handlesByChain.set(handle.chain, handle);
if (handle.asset_id) handlesByAssetId.set(handle.asset_id, handle);
for (const assetId of handle.asset_ids || []) {
handlesByAssetId.set(assetId, handle);
}
}
return {
...(assetCatalog || {}),
items: (assetCatalog?.items || []).map((asset) => {
const assetId = asset.asset_id || asset.assetId;
const chain = asset.chain || asset.blockchain || null;
const handle = handlesByAssetId.get(assetId) || handlesByChain.get(chain) || null;
return {
...asset,
depositAddress: handle?.address || null,
deposit_address: handle?.address || null,
depositChain: handle?.chain || chain,
deposit_chain: handle?.chain || chain,
depositMemo: handle?.memo || null,
deposit_memo: handle?.memo || null,
depositRefreshedAt: handle?.refreshed_at || null,
deposit_refreshed_at: handle?.refreshed_at || null,
};
}),
};
}
function buildTradeFunnelSummary(lifecycleRows = []) {
const counts = {
observed: 0,

View file

@ -10,6 +10,7 @@ import {
buildSeedStrategyConfig,
hashJson,
normalizeOneClickTokenResponse,
normalizePairMode,
pairCanMake,
pairCanObserve,
pairCanTake,
@ -818,6 +819,110 @@ export async function enableObserveOnlyPair(pool, {
return pair;
}
export async function setTradingPairMode(pool, {
venue = 'near-intents',
pairId = null,
pair = null,
assetIn = null,
assetOut = null,
mode = 'observe_only',
changedBy = 'operator',
reason = 'operator pair mode update',
} = {}) {
await ensureTradingConfigSchema(pool);
const normalizedMode = normalizePairMode(mode);
const resolvedPairId = pairId || pair || (assetIn && assetOut ? `${assetIn}->${assetOut}` : null);
if (!resolvedPairId) throw new Error('pair_id or asset_in/asset_out is required');
return withTransaction(pool, async (client) => {
const existingResult = await client.query(
`
SELECT *
FROM ${TRADING_PAIRS_TABLE}
WHERE pair_id = $1
LIMIT 1
`,
[resolvedPairId],
);
const existingPair = existingResult.rows[0] ? normalizeTradingPairRow(existingResult.rows[0]) : null;
const [pairAssetIn, pairAssetOut] = splitPairId(resolvedPairId);
const resolvedAssetIn = assetIn || existingPair?.assetIn || pairAssetIn;
const resolvedAssetOut = assetOut || existingPair?.assetOut || pairAssetOut;
if (!resolvedAssetIn || !resolvedAssetOut) throw new Error('asset_in and asset_out are required');
const assets = await loadTradingAssetsById(client);
if (!assets.has(resolvedAssetIn)) throw new Error(`asset_in is not registered: ${resolvedAssetIn}`);
if (!assets.has(resolvedAssetOut)) throw new Error(`asset_out is not registered: ${resolvedAssetOut}`);
const nextPair = {
pairId: resolvedPairId,
venue: existingPair?.venue || venue,
assetIn: resolvedAssetIn,
assetOut: resolvedAssetOut,
mode: normalizedMode,
enabled: true,
status: normalizedMode,
};
await upsertSeedPair(client, { pair: nextPair, now: new Date().toISOString() });
await insertConfigAuditLog(client, {
entityType: 'trading_pair',
entityId: resolvedPairId,
action: 'mode_set',
oldValue: existingPair,
newValue: nextPair,
changedBy,
reason,
});
return nextPair;
});
}
export async function pauseTradingPair(pool, {
pairId = null,
pair = null,
changedBy = 'operator',
reason = 'operator paused pair',
} = {}) {
await ensureTradingConfigSchema(pool);
const resolvedPairId = pairId || pair;
if (!resolvedPairId) throw new Error('pair_id is required');
return withTransaction(pool, async (client) => {
const existingResult = await client.query(
`
SELECT *
FROM ${TRADING_PAIRS_TABLE}
WHERE pair_id = $1
LIMIT 1
`,
[resolvedPairId],
);
const existingPair = existingResult.rows[0] ? normalizeTradingPairRow(existingResult.rows[0]) : null;
if (!existingPair) throw new Error(`trading pair not found: ${resolvedPairId}`);
const nextPair = {
pairId: existingPair.pairId,
venue: existingPair.venue,
assetIn: existingPair.assetIn,
assetOut: existingPair.assetOut,
mode: existingPair.mode,
enabled: false,
status: 'disabled',
};
await upsertSeedPair(client, { pair: nextPair, now: new Date().toISOString() });
await insertConfigAuditLog(client, {
entityType: 'trading_pair',
entityId: resolvedPairId,
action: 'paused',
oldValue: existingPair,
newValue: nextPair,
changedBy,
reason,
});
return nextPair;
});
}
function buildTradingConfigSnapshot({
assetRows,
pairRows,
@ -1057,6 +1162,12 @@ function normalizeTradingPairRow(row) {
};
}
function splitPairId(pairId) {
const parts = String(pairId || '').split('->');
if (parts.length !== 2 || !parts[0] || !parts[1]) return [null, null];
return parts;
}
function normalizeStrategyConfigRow(row) {
if (!row) return null;
return {

View file

@ -1,4 +1,4 @@
import { Fragment, useEffect, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import EmptyState from '../components/EmptyState.jsx';
import MetricCard from '../components/MetricCard.jsx';
@ -7,6 +7,7 @@ import TableFrame from '../components/TableFrame.jsx';
import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js';
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']);
async function copyIdentifier(value) {
if (!value || !navigator?.clipboard?.writeText) return;
@ -328,6 +329,7 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
<th>Asset</th>
<th>Decimals</th>
<th>Chain</th>
<th>Deposit</th>
<th>Price</th>
<th>Status</th>
</tr>
@ -341,6 +343,32 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
</td>
<td>{asset.decimals}</td>
<td>{asset.blockchain || asset.chain || 'Unavailable'}</td>
<td>
{asset.deposit_address || asset.depositAddress ? (
<>
<div className="trace-row">
<span
className="mono trace-id"
title={asset.deposit_address || asset.depositAddress}
>
{truncateMiddle(asset.deposit_address || asset.depositAddress, 34)}
</span>
<button
className="button secondary trace-copy-button"
onClick={() => copyIdentifier(asset.deposit_address || asset.depositAddress)}
type="button"
>
Copy
</button>
</div>
<div className="status-subtle">
{asset.deposit_memo || asset.depositMemo ? `Memo ${asset.deposit_memo || asset.depositMemo}` : asset.deposit_chain || asset.depositChain || 'Deposit handle'}
</div>
</>
) : (
<div className="status-subtle">Unavailable</div>
)}
</td>
<td>{asset.latest_price || asset.latestPrice || 'Unavailable'}</td>
<td>
<Pill
@ -350,7 +378,7 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
</td>
</tr>
)) : (
<tr><td colSpan={5}>No DB asset registry rows are available.</td></tr>
<tr><td colSpan={6}>No DB asset registry rows are available.</td></tr>
)}
</tbody>
</table>
@ -359,27 +387,85 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
);
}
function PairConfigSection({ pairConfig, onControl }) {
function assetOptionLabel(asset) {
return `${asset.label || asset.symbol || asset.asset_id || asset.assetId} - ${truncateMiddle(asset.asset_id || asset.assetId, 34)}`;
}
function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
const pairs = pairConfig?.pairs || [];
const assets = useMemo(() => (assetCatalog?.items || [])
.filter((asset) => asset.asset_id || asset.assetId)
.sort((left, right) => String(left.label || left.symbol || left.asset_id || '').localeCompare(
String(right.label || right.symbol || right.asset_id || ''),
)), [assetCatalog?.items]);
const [pairForm, setPairForm] = useState({
asset_in: '',
asset_out: '',
mode: 'observe_only',
});
const [edgeDrafts, setEdgeDrafts] = useState({});
useEffect(() => {
if (!assets.length) return;
setPairForm((current) => ({
...current,
asset_in: current.asset_in || assets[0]?.asset_id || assets[0]?.assetId || '',
asset_out: current.asset_out || assets[1]?.asset_id || assets[1]?.assetId || assets[0]?.asset_id || assets[0]?.assetId || '',
}));
}, [assets]);
useEffect(() => {
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 ?? '')];
})));
}, [pairs]);
async function updateEdge(pair) {
const current = pair.strategyConfig?.edge_bps ?? pair.strategy_config?.edge_bps ?? pair.edge_bps ?? '';
const next = window.prompt('edge_bps', current);
const pairId = pair.pair_id || pair.pairId;
const next = edgeDrafts[pairId];
if (!next) return;
await onControl?.('operator-dashboard', 'update-pair-edge', {
pair_id: pair.pair_id || pair.pairId,
pair_id: pairId,
edge_bps: Number(next),
});
}
async function enableObserveOnly() {
const assetIn = window.prompt('asset_in');
if (!assetIn) return;
const assetOut = window.prompt('asset_out');
if (!assetOut) return;
await onControl?.('operator-dashboard', 'enable-observe-only-pair', {
asset_in: assetIn,
asset_out: assetOut,
async function applyPairMode(event) {
event.preventDefault();
if (!pairForm.asset_in || !pairForm.asset_out || pairForm.asset_in === pairForm.asset_out) return;
if (
TRADING_PAIR_MODES.has(pairForm.mode)
&& !window.confirm('Activate trading mode for this directed pair?')
) {
return;
}
await onControl?.('operator-dashboard', 'set-pair-mode', {
asset_in: pairForm.asset_in,
asset_out: pairForm.asset_out,
mode: pairForm.mode,
});
}
async function pausePair(pair) {
await onControl?.('operator-dashboard', 'pause-pair', {
pair_id: pair.pair_id || pair.pairId,
});
}
async function activatePair(pair) {
const pairId = pair.pair_id || pair.pairId;
const nextMode = ['maker', 'taker', 'both'].includes(pair.mode) ? pair.mode : 'observe_only';
if (
TRADING_PAIR_MODES.has(nextMode)
&& !window.confirm('Reactivate trading mode for this directed pair?')
) {
return;
}
await onControl?.('operator-dashboard', 'set-pair-mode', {
pair_id: pairId,
mode: nextMode,
});
}
@ -395,9 +481,56 @@ function PairConfigSection({ pairConfig, onControl }) {
</div>
<div className="pills">
<Pill label={pairConfig?.ok ? 'config loaded' : pairConfig?.block_reason || 'blocked'} stateLabel={pairConfig?.ok ? 'healthy' : 'warning'} />
<button className="button secondary" onClick={enableObserveOnly} type="button">Observe-only pair</button>
</div>
</div>
<form onSubmit={applyPairMode}>
<div className="form-grid">
<div className="field">
<label htmlFor="pair-asset-in">Asset in</label>
<select
id="pair-asset-in"
onChange={(event) => setPairForm((current) => ({ ...current, asset_in: event.target.value }))}
value={pairForm.asset_in}
>
{assets.map((asset) => {
const assetId = asset.asset_id || asset.assetId;
return <option key={assetId} value={assetId}>{assetOptionLabel(asset)}</option>;
})}
</select>
</div>
<div className="field">
<label htmlFor="pair-asset-out">Asset out</label>
<select
id="pair-asset-out"
onChange={(event) => setPairForm((current) => ({ ...current, asset_out: event.target.value }))}
value={pairForm.asset_out}
>
{assets.map((asset) => {
const assetId = asset.asset_id || asset.assetId;
return <option key={assetId} value={assetId}>{assetOptionLabel(asset)}</option>;
})}
</select>
</div>
<div className="field">
<label htmlFor="pair-mode">Mode</label>
<select
id="pair-mode"
onChange={(event) => setPairForm((current) => ({ ...current, mode: event.target.value }))}
value={pairForm.mode}
>
<option value="observe_only">Observe only</option>
<option value="maker">Maker</option>
<option value="taker">Taker</option>
<option value="both">Maker and taker</option>
</select>
</div>
</div>
<div className="button-row">
<button className="button" disabled={!assets.length || pairForm.asset_in === pairForm.asset_out} type="submit">
Activate pair mode
</button>
</div>
</form>
<TableFrame>
<table>
<thead>
@ -409,6 +542,7 @@ function PairConfigSection({ pairConfig, onControl }) {
<th>Route</th>
<th>Blocked</th>
<th>Config</th>
<th>Controls</th>
</tr>
</thead>
<tbody>
@ -431,14 +565,46 @@ function PairConfigSection({ pairConfig, onControl }) {
<td>{pair.blockReason || pair.block_reason || 'No'}</td>
<td>
<div>v{strategyConfig.version || 'Unavailable'}</div>
<button className="button secondary trace-copy-button" onClick={() => updateEdge(pair)} type="button">
Edge
<div className="trace-row">
<input
aria-label={`Edge bps for ${pair.pair_id || pair.pairId}`}
min="1"
onChange={(event) => setEdgeDrafts((current) => ({
...current,
[pair.pair_id || pair.pairId]: event.target.value,
}))}
step="1"
style={{ maxWidth: 92 }}
type="number"
value={edgeDrafts[pair.pair_id || pair.pairId] ?? ''}
/>
<button
className="button secondary trace-copy-button"
disabled={!strategyConfig.config_id && !strategyConfig.configId}
onClick={() => updateEdge(pair)}
type="button"
>
Save
</button>
</div>
</td>
<td>
<div className="button-row">
{pair.enabled && pair.status !== 'disabled' ? (
<button className="button secondary trace-copy-button" onClick={() => pausePair(pair)} type="button">
Pause
</button>
) : (
<button className="button secondary trace-copy-button" onClick={() => activatePair(pair)} type="button">
Activate
</button>
)}
</div>
</td>
</tr>
);
}) : (
<tr><td colSpan={7}>No directed pairs are configured.</td></tr>
<tr><td colSpan={8}>No directed pairs are configured.</td></tr>
)}
</tbody>
</table>
@ -481,7 +647,7 @@ export default function StrategyPage({ strategy, onControl }) {
<AssetCatalogSection assetCatalog={strategy.asset_catalog} onControl={onControl} />
<PairConfigSection pairConfig={strategy.pair_config} onControl={onControl} />
<PairConfigSection assetCatalog={strategy.asset_catalog} pairConfig={strategy.pair_config} onControl={onControl} />
<section className="panel">
<div className="panel-head">

View file

@ -19,3 +19,10 @@ test('operator dashboard requests enough asset catalog rows for the current 1Cli
assert.match(source, /loadAssetCatalogSummary\(pool,\s*\{\s*limit:\s*250\s*\}\)/);
assert.doesNotMatch(source, /loadAssetCatalogSummary\(pool,\s*\{\s*limit:\s*80\s*\}\)/);
});
test('operator dashboard exposes DB-backed pair activation and pause controls', () => {
assert.match(source, /setTradingPairMode/);
assert.match(source, /pauseTradingPair/);
assert.match(source, /control\.action === 'set-pair-mode'/);
assert.match(source, /control\.action === 'pause-pair'/);
});

View file

@ -63,6 +63,15 @@ test('asset registry table renders the loaded catalog without a hidden 20 row ca
assert.doesNotMatch(strategySource, /items\.slice\(0,\s*20\)\.map/);
});
test('strategy page exposes pair activation, pause, edge, and deposit address controls', () => {
assert.match(strategySource, /set-pair-mode/);
assert.match(strategySource, /pause-pair/);
assert.match(strategySource, /Activate pair mode/);
assert.match(strategySource, /Edge bps for/);
assert.match(strategySource, /deposit_address/);
assert.match(strategySource, /Copy/);
});
test('system page exposes deduped environmental conditions history', () => {
assert.match(systemSource, /Environmental conditions/);

View file

@ -845,6 +845,14 @@ test('bootstrap balances and funding handles distinguish nBTC reserve from legac
'nep141:btc.omft.near',
]);
assert.equal(bootstrap.funds.funding.handles[0].label, 'btc:mainnet funding handle');
assert.equal(
bootstrap.strategy.asset_catalog.items.find((item) => item.asset_id === 'nep141:nbtc.bridge.near').deposit_address,
'bc1qdeposit',
);
assert.equal(
bootstrap.strategy.asset_catalog.items.find((item) => item.asset_id === 'nep141:btc.omft.near').deposit_address,
'bc1qdeposit',
);
});
test('bootstrap balances exclude imported catalog assets that are not inventory-enabled', () => {

View file

@ -13,7 +13,9 @@ import {
enableObserveOnlyPair,
importSupportedAssets,
loadTradingConfig,
pauseTradingPair,
seedTradingConfig,
setTradingPairMode,
} from '../src/lib/postgres.mjs';
test('1Click token normalizer preserves live asset fields', () => {
@ -175,6 +177,70 @@ test('repo seed does not re-enable pair runtime flags already stored in DB', asy
assert.equal(pair.takerEnabled, false);
});
test('pair mode updates activate a directed pair without inventing a price route', async () => {
const pool = createMemoryPool();
await seedTradingConfig(pool);
const pairId = `${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`;
await enableObserveOnlyPair(pool, {
assetIn: LEGACY_OMFT_BTC_ASSET_ID,
assetOut: CURRENT_EURE_ASSET_ID,
changedBy: 'test',
reason: 'watch legacy route',
});
const updated = await setTradingPairMode(pool, {
pairId,
mode: 'maker',
changedBy: 'test',
reason: 'operator activation test',
});
const snapshot = await loadTradingConfig(pool);
const pair = snapshot.pairByKey.get(pairId);
assert.equal(updated.mode, 'maker');
assert.equal(pair.enabled, true);
assert.equal(pair.makerEnabled, true);
assert.equal(pair.canTrade, false);
assert.equal(pair.blockReason, 'pair_strategy_config_missing');
});
test('pair pause disables trading without deleting strategy config or seed restoring it', async () => {
const pool = createMemoryPool();
await seedTradingConfig(pool);
const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`;
const paused = await pauseTradingPair(pool, {
pairId,
changedBy: 'test',
reason: 'operator pause test',
});
await seedTradingConfig(pool);
const snapshot = await loadTradingConfig(pool);
const pair = snapshot.pairByKey.get(pairId);
assert.equal(paused.status, 'disabled');
assert.equal(pair.enabled, false);
assert.equal(pair.mode, 'both');
assert.equal(pair.status, 'disabled');
assert.equal(pair.strategyConfig.edgeBps, 49);
assert.equal(pair.makerEnabled, false);
assert.equal(pair.takerEnabled, false);
});
test('pair mode activation requires both assets to be registered', async () => {
const pool = createMemoryPool();
await seedTradingConfig(pool);
await assert.rejects(
setTradingPairMode(pool, {
assetIn: CURRENT_NBTC_ASSET_ID,
assetOut: 'nep141:not-imported.near',
mode: 'maker',
}),
/asset_out is not registered/,
);
});
test('strategy uses DB pair config for current pair and persists config version', async () => {
const pool = createMemoryPool();
const snapshot = await seedTradingConfig(pool);