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:
parent
acdc3c061e
commit
a0e7a698a1
6 changed files with 322 additions and 18 deletions
|
|
@ -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',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue