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,
|
||||
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}`,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue