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:
parent
266d149b33
commit
edfa14f37e
8 changed files with 471 additions and 21 deletions
|
|
@ -52,7 +52,9 @@ import {
|
||||||
loadRecentQuotes,
|
loadRecentQuotes,
|
||||||
loadSubmissionPage,
|
loadSubmissionPage,
|
||||||
loadSubmissionSummary,
|
loadSubmissionSummary,
|
||||||
|
pauseTradingPair,
|
||||||
seedTradingConfig,
|
seedTradingConfig,
|
||||||
|
setTradingPairMode,
|
||||||
} from '../lib/postgres.mjs';
|
} from '../lib/postgres.mjs';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -641,6 +643,29 @@ async function invokeControl(control, body) {
|
||||||
return result;
|
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(
|
const response = await fetchJson(
|
||||||
`${lookupServiceBaseUrl(control.service)}${control.path}`,
|
`${lookupServiceBaseUrl(control.service)}${control.path}`,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,26 @@ const CONTROL_DEFINITIONS = [
|
||||||
page: 'strategy',
|
page: 'strategy',
|
||||||
risk_class: 'safe',
|
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',
|
service: 'trade-executor',
|
||||||
action: 'intent-request-preflight',
|
action: 'intent-request-preflight',
|
||||||
|
|
@ -590,6 +610,7 @@ export function buildDashboardBootstrap({
|
||||||
activeAlerts,
|
activeAlerts,
|
||||||
assetCatalog,
|
assetCatalog,
|
||||||
pairConfig,
|
pairConfig,
|
||||||
|
fundingHandles: funding.handles,
|
||||||
recentQuotes,
|
recentQuotes,
|
||||||
recentTradeDecisions,
|
recentTradeDecisions,
|
||||||
recentExecuteTradeCommands,
|
recentExecuteTradeCommands,
|
||||||
|
|
@ -1406,6 +1427,7 @@ function buildStrategySummary({
|
||||||
activeAlerts,
|
activeAlerts,
|
||||||
assetCatalog = null,
|
assetCatalog = null,
|
||||||
pairConfig = null,
|
pairConfig = null,
|
||||||
|
fundingHandles = [],
|
||||||
recentQuotes = [],
|
recentQuotes = [],
|
||||||
recentTradeDecisions = [],
|
recentTradeDecisions = [],
|
||||||
recentExecuteTradeCommands = [],
|
recentExecuteTradeCommands = [],
|
||||||
|
|
@ -1479,7 +1501,10 @@ function buildStrategySummary({
|
||||||
durable_control_state: strategyState.durable_control_state || null,
|
durable_control_state: strategyState.durable_control_state || null,
|
||||||
trading_config: strategyState.trading_config || null,
|
trading_config: strategyState.trading_config || null,
|
||||||
},
|
},
|
||||||
asset_catalog: assetCatalog || buildFallbackAssetCatalog(config),
|
asset_catalog: attachDepositHandlesToAssetCatalog(
|
||||||
|
assetCatalog || buildFallbackAssetCatalog(config),
|
||||||
|
fundingHandles,
|
||||||
|
),
|
||||||
pair_config: pairConfig || buildFallbackPairConfig(config),
|
pair_config: pairConfig || buildFallbackPairConfig(config),
|
||||||
executor_state: {
|
executor_state: {
|
||||||
armed: executorState.armed ?? null,
|
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 = []) {
|
function buildTradeFunnelSummary(lifecycleRows = []) {
|
||||||
const counts = {
|
const counts = {
|
||||||
observed: 0,
|
observed: 0,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
buildSeedStrategyConfig,
|
buildSeedStrategyConfig,
|
||||||
hashJson,
|
hashJson,
|
||||||
normalizeOneClickTokenResponse,
|
normalizeOneClickTokenResponse,
|
||||||
|
normalizePairMode,
|
||||||
pairCanMake,
|
pairCanMake,
|
||||||
pairCanObserve,
|
pairCanObserve,
|
||||||
pairCanTake,
|
pairCanTake,
|
||||||
|
|
@ -818,6 +819,110 @@ export async function enableObserveOnlyPair(pool, {
|
||||||
return pair;
|
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({
|
function buildTradingConfigSnapshot({
|
||||||
assetRows,
|
assetRows,
|
||||||
pairRows,
|
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) {
|
function normalizeStrategyConfigRow(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import EmptyState from '../components/EmptyState.jsx';
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
import MetricCard from '../components/MetricCard.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';
|
import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js';
|
||||||
|
|
||||||
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
|
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
|
||||||
|
const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']);
|
||||||
|
|
||||||
async function copyIdentifier(value) {
|
async function copyIdentifier(value) {
|
||||||
if (!value || !navigator?.clipboard?.writeText) return;
|
if (!value || !navigator?.clipboard?.writeText) return;
|
||||||
|
|
@ -328,6 +329,7 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
|
||||||
<th>Asset</th>
|
<th>Asset</th>
|
||||||
<th>Decimals</th>
|
<th>Decimals</th>
|
||||||
<th>Chain</th>
|
<th>Chain</th>
|
||||||
|
<th>Deposit</th>
|
||||||
<th>Price</th>
|
<th>Price</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -341,6 +343,32 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
|
||||||
</td>
|
</td>
|
||||||
<td>{asset.decimals}</td>
|
<td>{asset.decimals}</td>
|
||||||
<td>{asset.blockchain || asset.chain || 'Unavailable'}</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>{asset.latest_price || asset.latestPrice || 'Unavailable'}</td>
|
||||||
<td>
|
<td>
|
||||||
<Pill
|
<Pill
|
||||||
|
|
@ -350,7 +378,7 @@ function AssetCatalogSection({ assetCatalog, onControl }) {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</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 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) {
|
async function updateEdge(pair) {
|
||||||
const current = pair.strategyConfig?.edge_bps ?? pair.strategy_config?.edge_bps ?? pair.edge_bps ?? '';
|
const pairId = pair.pair_id || pair.pairId;
|
||||||
const next = window.prompt('edge_bps', current);
|
const next = edgeDrafts[pairId];
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
await onControl?.('operator-dashboard', 'update-pair-edge', {
|
await onControl?.('operator-dashboard', 'update-pair-edge', {
|
||||||
pair_id: pair.pair_id || pair.pairId,
|
pair_id: pairId,
|
||||||
edge_bps: Number(next),
|
edge_bps: Number(next),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableObserveOnly() {
|
async function applyPairMode(event) {
|
||||||
const assetIn = window.prompt('asset_in');
|
event.preventDefault();
|
||||||
if (!assetIn) return;
|
if (!pairForm.asset_in || !pairForm.asset_out || pairForm.asset_in === pairForm.asset_out) return;
|
||||||
const assetOut = window.prompt('asset_out');
|
if (
|
||||||
if (!assetOut) return;
|
TRADING_PAIR_MODES.has(pairForm.mode)
|
||||||
await onControl?.('operator-dashboard', 'enable-observe-only-pair', {
|
&& !window.confirm('Activate trading mode for this directed pair?')
|
||||||
asset_in: assetIn,
|
) {
|
||||||
asset_out: assetOut,
|
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>
|
||||||
<div className="pills">
|
<div className="pills">
|
||||||
<Pill label={pairConfig?.ok ? 'config loaded' : pairConfig?.block_reason || 'blocked'} stateLabel={pairConfig?.ok ? 'healthy' : 'warning'} />
|
<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>
|
||||||
</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>
|
<TableFrame>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -409,6 +542,7 @@ function PairConfigSection({ pairConfig, onControl }) {
|
||||||
<th>Route</th>
|
<th>Route</th>
|
||||||
<th>Blocked</th>
|
<th>Blocked</th>
|
||||||
<th>Config</th>
|
<th>Config</th>
|
||||||
|
<th>Controls</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -431,14 +565,46 @@ function PairConfigSection({ pairConfig, onControl }) {
|
||||||
<td>{pair.blockReason || pair.block_reason || 'No'}</td>
|
<td>{pair.blockReason || pair.block_reason || 'No'}</td>
|
||||||
<td>
|
<td>
|
||||||
<div>v{strategyConfig.version || 'Unavailable'}</div>
|
<div>v{strategyConfig.version || 'Unavailable'}</div>
|
||||||
<button className="button secondary trace-copy-button" onClick={() => updateEdge(pair)} type="button">
|
<div className="trace-row">
|
||||||
Edge
|
<input
|
||||||
</button>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}) : (
|
}) : (
|
||||||
<tr><td colSpan={7}>No directed pairs are configured.</td></tr>
|
<tr><td colSpan={8}>No directed pairs are configured.</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -481,7 +647,7 @@ export default function StrategyPage({ strategy, onControl }) {
|
||||||
|
|
||||||
<AssetCatalogSection assetCatalog={strategy.asset_catalog} onControl={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">
|
<section className="panel">
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
|
|
|
||||||
|
|
@ -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.match(source, /loadAssetCatalogSummary\(pool,\s*\{\s*limit:\s*250\s*\}\)/);
|
||||||
assert.doesNotMatch(source, /loadAssetCatalogSummary\(pool,\s*\{\s*limit:\s*80\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'/);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
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', () => {
|
test('system page exposes deduped environmental conditions history', () => {
|
||||||
assert.match(systemSource, /Environmental conditions/);
|
assert.match(systemSource, /Environmental conditions/);
|
||||||
|
|
|
||||||
|
|
@ -845,6 +845,14 @@ test('bootstrap balances and funding handles distinguish nBTC reserve from legac
|
||||||
'nep141:btc.omft.near',
|
'nep141:btc.omft.near',
|
||||||
]);
|
]);
|
||||||
assert.equal(bootstrap.funds.funding.handles[0].label, 'btc:mainnet funding handle');
|
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', () => {
|
test('bootstrap balances exclude imported catalog assets that are not inventory-enabled', () => {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import {
|
||||||
enableObserveOnlyPair,
|
enableObserveOnlyPair,
|
||||||
importSupportedAssets,
|
importSupportedAssets,
|
||||||
loadTradingConfig,
|
loadTradingConfig,
|
||||||
|
pauseTradingPair,
|
||||||
seedTradingConfig,
|
seedTradingConfig,
|
||||||
|
setTradingPairMode,
|
||||||
} from '../src/lib/postgres.mjs';
|
} from '../src/lib/postgres.mjs';
|
||||||
|
|
||||||
test('1Click token normalizer preserves live asset fields', () => {
|
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);
|
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 () => {
|
test('strategy uses DB pair config for current pair and persists config version', async () => {
|
||||||
const pool = createMemoryPool();
|
const pool = createMemoryPool();
|
||||||
const snapshot = await seedTradingConfig(pool);
|
const snapshot = await seedTradingConfig(pool);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue