Fix late settlement ntfy notifications
All checks were successful
deploy / deploy (push) Successful in 56s
All checks were successful
deploy / deploy (push) Successful in 56s
Proof: Targeted notification and outcome refresh tests pass; full npm test passes with 284 tests; operator dashboard bundle builds. Assumptions: A completed settlement can be computed after its inventory movement timestamp, and notification_deliveries remains the canonical once-only de-dup guard for ntfy delivery. Still fake: Venue-native terminal fill ids and fee-complete realized PnL remain unavailable; ntfy is still an external utility service outside the unrip app deployment ownership.
This commit is contained in:
parent
e5c0a5dc6e
commit
5542a6f00b
6 changed files with 42 additions and 7 deletions
|
|
@ -10,7 +10,7 @@ import {
|
||||||
buildLiquidityActionNotification,
|
buildLiquidityActionNotification,
|
||||||
buildQuoteOutcomeNotification,
|
buildQuoteOutcomeNotification,
|
||||||
createNotificationDispatcher,
|
createNotificationDispatcher,
|
||||||
observedAtOrAfter,
|
outcomeNotificationObservedAtOrAfter,
|
||||||
} from '../core/notification-layer.mjs';
|
} from '../core/notification-layer.mjs';
|
||||||
import { createNtfyNotificationClient } from '../core/notification-client.mjs';
|
import { createNtfyNotificationClient } from '../core/notification-client.mjs';
|
||||||
import { buildPortfolioMetricId, computePortfolioMetric } from '../core/portfolio-metrics.mjs';
|
import { buildPortfolioMetricId, computePortfolioMetric } from '../core/portfolio-metrics.mjs';
|
||||||
|
|
@ -725,14 +725,14 @@ async function publishLiquidityNotification({ topic, event }) {
|
||||||
|
|
||||||
async function publishQuoteOutcomeNotifications(records, { minObservedAt = null } = {}) {
|
async function publishQuoteOutcomeNotifications(records, { minObservedAt = null } = {}) {
|
||||||
for (const record of records || []) {
|
for (const record of records || []) {
|
||||||
if (!observedAtOrAfter(record.outcome_observed_at, minObservedAt)) continue;
|
if (!outcomeNotificationObservedAtOrAfter(record, minObservedAt)) continue;
|
||||||
await publishNotification(buildQuoteOutcomeNotification({ record, config }));
|
await publishNotification(buildQuoteOutcomeNotification({ record, config }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishIntentRequestOutcomeNotifications(records, { minObservedAt = null } = {}) {
|
async function publishIntentRequestOutcomeNotifications(records, { minObservedAt = null } = {}) {
|
||||||
for (const record of records || []) {
|
for (const record of records || []) {
|
||||||
if (!observedAtOrAfter(record.outcome_observed_at, minObservedAt)) continue;
|
if (!outcomeNotificationObservedAtOrAfter(record, minObservedAt)) continue;
|
||||||
await publishNotification(buildIntentRequestOutcomeNotification({ record, config }));
|
await publishNotification(buildIntentRequestOutcomeNotification({ record, config }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,11 @@ export function observedAtOrAfter(value, minObservedAt) {
|
||||||
return Number.isFinite(valueTs) && Number.isFinite(minTs) && valueTs >= minTs;
|
return Number.isFinite(valueTs) && Number.isFinite(minTs) && valueTs >= minTs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function outcomeNotificationObservedAtOrAfter(record = {}, minObservedAt) {
|
||||||
|
return observedAtOrAfter(record.outcome_observed_at || record.payload?.outcome_observed_at, minObservedAt)
|
||||||
|
|| observedAtOrAfter(record.computed_at || record.payload?.computed_at, minObservedAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function hasProvenSettlement(record = {}) {
|
export function hasProvenSettlement(record = {}) {
|
||||||
const payload = record.payload || {};
|
const payload = record.payload || {};
|
||||||
const status = record.outcome_status || payload.outcome_status;
|
const status = record.outcome_status || payload.outcome_status;
|
||||||
|
|
|
||||||
|
|
@ -3745,14 +3745,18 @@ export async function refreshQuoteOutcomes(pool, {
|
||||||
const computedAt = new Date(
|
const computedAt = new Date(
|
||||||
typeof now === 'number' ? now : Date.parse(now),
|
typeof now === 'number' ? now : Date.parse(now),
|
||||||
).toISOString();
|
).toISOString();
|
||||||
for (const record of records) {
|
const computedRecords = records.map((record) => ({
|
||||||
|
...record,
|
||||||
|
computed_at: computedAt,
|
||||||
|
}));
|
||||||
|
for (const record of computedRecords) {
|
||||||
await upsertQuoteOutcome(pool, {
|
await upsertQuoteOutcome(pool, {
|
||||||
...record,
|
...record,
|
||||||
computedAt,
|
computedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return records;
|
return computedRecords;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertQuoteOutcome(pool, {
|
export async function upsertQuoteOutcome(pool, {
|
||||||
|
|
@ -4000,13 +4004,17 @@ export async function refreshIntentRequestOutcomes(pool, {
|
||||||
const computedAt = new Date(
|
const computedAt = new Date(
|
||||||
typeof now === 'number' ? now : Date.parse(now),
|
typeof now === 'number' ? now : Date.parse(now),
|
||||||
).toISOString();
|
).toISOString();
|
||||||
for (const record of records) {
|
const computedRecords = records.map((record) => ({
|
||||||
|
...record,
|
||||||
|
computed_at: computedAt,
|
||||||
|
}));
|
||||||
|
for (const record of computedRecords) {
|
||||||
await upsertIntentRequestOutcome(pool, {
|
await upsertIntentRequestOutcome(pool, {
|
||||||
...record,
|
...record,
|
||||||
computedAt,
|
computedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return records;
|
return computedRecords;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadIntentRequestPreflightRefreshCandidates(pool, { limit }) {
|
async function loadIntentRequestPreflightRefreshCandidates(pool, { limit }) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
buildQuoteOutcomeNotification,
|
buildQuoteOutcomeNotification,
|
||||||
createNotificationDispatcher,
|
createNotificationDispatcher,
|
||||||
hasProvenSettlement,
|
hasProvenSettlement,
|
||||||
|
outcomeNotificationObservedAtOrAfter,
|
||||||
observedAtOrAfter,
|
observedAtOrAfter,
|
||||||
} from '../src/core/notification-layer.mjs';
|
} from '../src/core/notification-layer.mjs';
|
||||||
|
|
||||||
|
|
@ -203,3 +204,22 @@ test('outcome recency filter keeps startup refresh from sending historical notif
|
||||||
assert.equal(observedAtOrAfter('2026-04-16T10:00:00.000Z', '2026-04-16T09:59:59.000Z'), true);
|
assert.equal(observedAtOrAfter('2026-04-16T10:00:00.000Z', '2026-04-16T09:59:59.000Z'), true);
|
||||||
assert.equal(observedAtOrAfter('2026-04-16T09:00:00.000Z', '2026-04-16T09:59:59.000Z'), false);
|
assert.equal(observedAtOrAfter('2026-04-16T09:00:00.000Z', '2026-04-16T09:59:59.000Z'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('outcome notification recency accepts settlements computed after their observed movement', () => {
|
||||||
|
assert.equal(outcomeNotificationObservedAtOrAfter({
|
||||||
|
outcome_observed_at: '2026-06-12T13:02:32.053Z',
|
||||||
|
computed_at: '2026-06-12T13:08:42.800Z',
|
||||||
|
}, '2026-06-12T13:08:10.000Z'), true);
|
||||||
|
|
||||||
|
assert.equal(outcomeNotificationObservedAtOrAfter({
|
||||||
|
payload: {
|
||||||
|
outcome_observed_at: '2026-06-12T13:02:32.053Z',
|
||||||
|
computed_at: '2026-06-12T13:08:42.800Z',
|
||||||
|
},
|
||||||
|
}, '2026-06-12T13:08:10.000Z'), true);
|
||||||
|
|
||||||
|
assert.equal(outcomeNotificationObservedAtOrAfter({
|
||||||
|
outcome_observed_at: '2026-06-12T13:02:32.053Z',
|
||||||
|
computed_at: '2026-06-12T13:07:42.800Z',
|
||||||
|
}, '2026-06-12T13:08:10.000Z'), false);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ test('intent request outcome refresh bounds source queries to refresh candidates
|
||||||
assert.equal(records.length, 1);
|
assert.equal(records.length, 1);
|
||||||
assert.equal(records[0].request_id, 'request-1');
|
assert.equal(records[0].request_id, 'request-1');
|
||||||
assert.equal(records[0].outcome_status, 'completed');
|
assert.equal(records[0].outcome_status, 'completed');
|
||||||
|
assert.equal(records[0].computed_at, '2026-05-13T10:00:20.000Z');
|
||||||
assert.equal(queries.filter((entry) => entry.sql.includes('INSERT INTO intent_request_outcomes')).length, 1);
|
assert.equal(queries.filter((entry) => entry.sql.includes('INSERT INTO intent_request_outcomes')).length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ test('quote outcome refresh bounds source queries and joins by recent quote ids'
|
||||||
|
|
||||||
assert.equal(records.length, 1);
|
assert.equal(records.length, 1);
|
||||||
assert.equal(records[0].quote_id, 'quote-1');
|
assert.equal(records[0].quote_id, 'quote-1');
|
||||||
|
assert.equal(records[0].computed_at, '2026-05-13T10:00:20.000Z');
|
||||||
assert.equal(records[0].payload.edge_bps, '10');
|
assert.equal(records[0].payload.edge_bps, '10');
|
||||||
assert.equal(queries.filter((entry) => entry.sql.includes('INSERT INTO quote_outcome_attributions')).length, 1);
|
assert.equal(queries.filter((entry) => entry.sql.includes('INSERT INTO quote_outcome_attributions')).length, 1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue