diff --git a/src/core/maker-competitiveness.mjs b/src/core/maker-competitiveness.mjs
index 152f098..cbc06e1 100644
--- a/src/core/maker-competitiveness.mjs
+++ b/src/core/maker-competitiveness.mjs
@@ -103,6 +103,7 @@ export function normalizeCompetitivenessEntry(row = {}) {
pair_config_id: row.pair_config_id || decision.pair_config_id || execution.pair_config_id || null,
pair_config_version:
row.pair_config_version || decision.pair_config_version || execution.pair_config_version || null,
+ edge_bps: row.edge_bps ?? decision.edge_bps ?? row.command?.edge_bps ?? execution.edge_bps ?? null,
direction: row.direction || decision.direction || row.command?.direction || 'unknown',
request_kind: row.request_kind || decision.request_kind || row.command?.request_kind || 'unknown',
result_code: resultCode || 'no_result',
@@ -154,6 +155,7 @@ function groupEntries(entries) {
entry.pair || 'unknown',
entry.direction || 'unknown',
entry.request_kind || 'unknown',
+ entry.edge_bps ?? 'unknown',
entry.result_code || 'no_result',
entry.failure_category || 'none',
entry.quote_age_bucket,
@@ -173,6 +175,7 @@ function summarizeGroup(entries) {
pair: first.pair || null,
direction: first.direction || 'unknown',
request_kind: first.request_kind || 'unknown',
+ edge_bps: first.edge_bps ?? null,
result_code: first.result_code || 'no_result',
failure_category: first.failure_category || null,
quote_age_bucket: first.quote_age_bucket,
diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs
index 4ba9ab6..5102f2c 100644
--- a/src/core/operator-dashboard.mjs
+++ b/src/core/operator-dashboard.mjs
@@ -1248,6 +1248,7 @@ export function deriveQuoteLifecycleRows({
pair: outcome?.pair || null,
direction: outcome?.direction || null,
request_kind: outcome?.request_kind || null,
+ edge_bps: outcome?.edge_bps || null,
gross_edge_pct: outcome?.gross_edge_pct || null,
notional: outcome?.notional || null,
notional_asset_id: outcome?.notional_asset_id || null,
diff --git a/src/core/quote-outcomes.mjs b/src/core/quote-outcomes.mjs
index b6f5485..e5e4519 100644
--- a/src/core/quote-outcomes.mjs
+++ b/src/core/quote-outcomes.mjs
@@ -311,6 +311,7 @@ function baseOutcomeRecord({
pair: command?.pair || decision?.pair || submission.pair || null,
direction: decision?.direction || command?.direction || null,
request_kind: command?.request_kind || decision?.request_kind || null,
+ edge_bps: command?.edge_bps || decision?.edge_bps || submission.edge_bps || null,
gross_edge_pct: decision?.gross_edge_pct || null,
notional: decision?.notional || command?.notional || null,
notional_asset_id: decision?.notional_asset_id || command?.notional_asset_id || null,
@@ -443,6 +444,7 @@ function normalizeCommand(entry) {
pair: payload.pair || null,
direction: payload.direction || null,
request_kind: payload.request_kind || null,
+ edge_bps: payload.edge_bps || null,
notional: payload.notional || null,
notional_asset_id: payload.notional_asset_id || null,
notional_symbol: payload.notional_symbol || null,
@@ -476,6 +478,7 @@ function normalizeDecision(entry) {
pair: payload.pair || null,
direction: payload.direction || null,
request_kind: payload.request_kind || null,
+ edge_bps: payload.edge_bps || null,
gross_edge_pct: payload.gross_edge_pct || null,
notional: payload.notional || null,
notional_asset_id: payload.notional_asset_id || null,
diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs
index a138255..8c2306c 100644
--- a/src/lib/postgres.mjs
+++ b/src/lib/postgres.mjs
@@ -3390,6 +3390,7 @@ function normalizeQuoteOutcomeRow(row) {
pair: payload.pair || null,
direction: payload.direction || null,
request_kind: payload.request_kind || null,
+ edge_bps: payload.edge_bps || null,
gross_edge_pct: payload.gross_edge_pct || null,
notional: payload.notional || null,
notional_asset_id: payload.notional_asset_id || null,
diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx
index f4c63cc..efe7cb4 100644
--- a/src/operator-dashboard/static/pages/StrategyPage.jsx
+++ b/src/operator-dashboard/static/pages/StrategyPage.jsx
@@ -293,6 +293,7 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
Pair |
Direction |
Request |
+ Edge |
Result |
Age / notional |
Outcome |
@@ -304,13 +305,14 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
{groups.length ? groups.slice(0, 12).map((group, index) => {
const quoteToRelay = group.latency_stages?.find((stage) => stage.stage === 'quote_to_relay_result_ms');
return (
-
+
|
{pairDisplayLabel(group.pair, pairConfig)}
{truncateMiddle(group.pair || '', 42)}
|
{plainCodeLabel(group.direction)} |
{plainCodeLabel(group.request_kind)} |
+ {group.edge_bps == null ? 'Unavailable' : `${group.edge_bps} bps`} |
{plainCodeLabel(group.result_code)}
{group.failure_category ? {plainCodeLabel(group.failure_category)} : null}
@@ -331,7 +333,7 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
|
);
}) : (
- | No competitiveness rows are available yet. |
+ | No competitiveness rows are available yet. |
)}
diff --git a/test/maker-timing-competitiveness.test.mjs b/test/maker-timing-competitiveness.test.mjs
index e9155c1..288968d 100644
--- a/test/maker-timing-competitiveness.test.mjs
+++ b/test/maker-timing-competitiveness.test.mjs
@@ -77,6 +77,7 @@ test('maker competitiveness aggregates pair, direction, request kind, result, fa
pair: `${nbtc}->${eure}`,
direction: 'base_to_quote',
request_kind: 'exact_in',
+ edge_bps: '49',
notional: '5',
notional_symbol: 'EURe',
outcome_status: 'submitted',
@@ -98,6 +99,7 @@ test('maker competitiveness aggregates pair, direction, request kind, result, fa
pair: `${nbtc}->${usdc}`,
direction: 'base_to_quote',
request_kind: 'exact_in',
+ edge_bps: '20',
notional: '8',
notional_symbol: 'USDC',
execution_result_at: '2026-05-18T10:00:00.400Z',
@@ -120,6 +122,7 @@ test('maker competitiveness aggregates pair, direction, request kind, result, fa
pair: `${nbtc}->${usdc}`,
direction: 'base_to_quote',
request_kind: 'exact_in',
+ edge_bps: '20',
notional: '12',
notional_symbol: 'USDC',
maker_timing: extendMakerTiming(baseTiming, {
@@ -145,6 +148,7 @@ test('maker competitiveness aggregates pair, direction, request kind, result, fa
assert.ok(summary.groups.some((group) => (
group.pair === `${nbtc}->${usdc}`
&& group.request_kind === 'exact_in'
+ && group.edge_bps === '20'
&& group.result_code === 'submission_failed'
&& group.failure_category === 'quote_not_found_or_finished'
&& group.quote_age_bucket === '250-500ms'
@@ -159,3 +163,31 @@ test('maker competitiveness aggregates pair, direction, request kind, result, fa
assert.equal(summary.latest_errors[0].error_message, 'quote not found or already finished');
assert.equal(summary.policy_skips[0].reason_code, 'maker_quote_too_old');
});
+
+test('maker competitiveness keeps edge bps as a grouping dimension', () => {
+ const nbtc = 'nep141:nbtc.bridge.near';
+ const usdc = 'nep141:usdc.omft.near';
+ const base = {
+ pair: `${nbtc}->${usdc}`,
+ direction: 'base_to_quote',
+ request_kind: 'exact_in',
+ notional: '8',
+ notional_symbol: 'USDC',
+ outcome_status: 'not_filled',
+ execution: {
+ status: 'submitted',
+ result_code: 'quote_response_ok',
+ },
+ };
+
+ const summary = buildMakerCompetitivenessSummary({
+ lifecycleRows: [
+ { ...base, quote_id: 'quote-edge-1', edge_bps: '1' },
+ { ...base, quote_id: 'quote-edge-20', edge_bps: '20' },
+ ],
+ });
+
+ const edgeGroups = summary.groups.filter((group) => group.pair === `${nbtc}->${usdc}`);
+ assert.equal(edgeGroups.length, 2);
+ assert.deepEqual(edgeGroups.map((group) => group.edge_bps).sort(), ['1', '20']);
+});
diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs
index 5b3d2e9..8c47bd3 100644
--- a/test/operator-dashboard-ui-static.test.mjs
+++ b/test/operator-dashboard-ui-static.test.mjs
@@ -127,6 +127,7 @@ test('strategy page exposes maker timing waterfall and competitiveness summaries
assert.match(strategySource, /quote_not_found_or_finished/);
assert.match(strategySource, /maker_competitiveness/);
assert.match(strategySource, /pairDisplayLabel/);
+ assert.match(strategySource, /group\.edge_bps/);
assert.match(stylesSource, /\.two-column-grid/);
assert.match(stylesSource, /\.timing-waterfall-table/);
});
diff --git a/test/postgres-quote-outcomes-refresh.test.mjs b/test/postgres-quote-outcomes-refresh.test.mjs
index a7a6f83..9103860 100644
--- a/test/postgres-quote-outcomes-refresh.test.mjs
+++ b/test/postgres-quote-outcomes-refresh.test.mjs
@@ -48,6 +48,7 @@ test('quote outcome refresh bounds source queries and joins by recent quote ids'
command_id: 'cmd-1',
decision_id: 'decision-1',
quote_id: 'quote-1',
+ edge_bps: '10',
min_deadline_ms: '15000',
asset_in: eureAsset.assetId,
asset_out: btcAsset.assetId,
@@ -124,6 +125,7 @@ test('quote outcome refresh bounds source queries and joins by recent quote ids'
assert.equal(records.length, 1);
assert.equal(records[0].quote_id, 'quote-1');
+ assert.equal(records[0].payload.edge_bps, '10');
assert.equal(queries.filter((entry) => entry.sql.includes('INSERT INTO quote_outcome_attributions')).length, 1);
});