fix: dedup fund_capture alerts for same-date sibling records and add clear-all

fund_capture alerts for a symbol with multiple capture records sharing one
as_of re-fired every batch cycle: the single comparison-state slot
ping-ponged between sibling evidence URLs, and the pre-check dedup key
never matched the stored buildDedupKey format. Track a seen-set of URLs in
comparison state and align dedup-key construction with buildDedupKey.
Also add alerts.clearAll (hard delete) with an immediate-refresh bell.
This commit is contained in:
Investor Flow Build
2026-08-12 22:39:19 -04:00
parent e5a7ff9f33
commit 0bc7b636e5
6 changed files with 185 additions and 21 deletions
@@ -29,6 +29,10 @@ function subscribe(db: DatabaseSync, userId: string, type: string, symbol: strin
).run(`sub-${userId}-${type}-${symbol ?? 'x'}`, userId, symbol, type, new Date().toISOString());
}
function alertEventCount(db: DatabaseSync, type: string): number {
return (db.prepare('SELECT COUNT(*) AS n FROM alert_events WHERE type = ?').get(type) as { n: number }).n;
}
const hasAdvice = (s: string) => /you should\b|^\s*(buy|sell)\s/i.test(s);
test('fund_capture: fires once per subscribed user, dedupes, mechanical framing', async () => {
@@ -51,6 +55,58 @@ test('fund_capture: fires once per subscribed user, dedupes, mechanical framing'
assert.equal(second.length, 0); // no new post → no re-fire
});
test('fund_capture: multiple same-date capture records for one symbol fire once each, never re-fire', async () => {
const db = freshDb();
const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' });
subscribe(db, 'u1', 'fund_capture', 'INOD');
// Mirrors the real-world flood: 3 distinct X posts for the same symbol share
// the same as_of, so the liveBook MAX(as_of) join returns all 3 as "current".
for (const url of [
'https://x.com/fund/status/2069061783432872271',
'https://x.com/fund/status/2069061809240486316',
'https://x.com/fund/status/2069062138937884689',
]) {
insertPositionRecord(db, {
fund_id: fund.id, symbol: 'INOD', as_of: '2026-06-22', source: 'capture', evidence_url: url,
});
}
const first = await fundCaptureProducer.check(db);
assert.equal(first.length, 3, 'one alert per distinct capture record');
const second = await fundCaptureProducer.check(db);
assert.equal(second.length, 0, 're-check must not re-fire despite the comparison-state slot cycling');
const third = await fundCaptureProducer.check(db);
assert.equal(third.length, 0);
assert.equal(alertEventCount(db, 'fund_capture'), 3);
});
test('fund_capture: clearing alert events must not re-arm re-firing for same-date siblings', async () => {
const db = freshDb();
const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' });
subscribe(db, 'u1', 'fund_capture', 'INOD');
for (const url of [
'https://x.com/fund/status/2069061783432872271',
'https://x.com/fund/status/2069061809240486316',
'https://x.com/fund/status/2069062138937884689',
]) {
insertPositionRecord(db, {
fund_id: fund.id, symbol: 'INOD', as_of: '2026-06-22', source: 'capture', evidence_url: url,
});
}
assert.equal((await fundCaptureProducer.check(db)).length, 3);
// User clears their alert feed (the "Clear all" button deletes alert_events).
db.prepare('DELETE FROM alert_events').run();
// Producers must not re-fire: comparison state remembers the seen URLs even
// with an empty alert_events table.
assert.equal((await fundCaptureProducer.check(db)).length, 0, 'clearing events must not re-arm re-firing');
assert.equal(alertEventCount(db, 'fund_capture'), 0);
});
test('mirror_diff: fires on a book change, mechanical framing, dedupes', async () => {
const db = freshDb();
const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Fund X', manager_name: 'M' });
@@ -11,7 +11,7 @@
import type { DatabaseSync } from 'node:sqlite';
import type { Alert } from '../AlertEngine.ts';
import { createAlert } from '../AlertEngine.ts';
import { createAlert, buildDedupKey } from '../AlertEngine.ts';
import type { AlertProducer } from './types.ts';
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
import { liveBook } from '../../db/fundRepository.ts';
@@ -31,15 +31,25 @@ export const fundCaptureProducer: AlertProducer = {
for (const pos of book) {
if (pos.source !== 'capture' && pos.source !== 'claim') continue;
const key = `fund_capture:${fund.id}:${pos.symbol}`;
// Each distinct evidence_url (post id) is one capture. liveBook can
// return multiple records for a symbol sharing the same as_of, so we
// track a SET of already-alerted URLs rather than a single slot —
// a single slot ping-pongs between sibling records and re-fires.
const prev = readComparisonState(db, key, 'fund_capture');
const prevId = prev ? (JSON.parse(prev.state).latestKey as string | undefined) : null;
// Use evidence_url as the stable id (post id) — each new post is a new capture.
// Seed with any previously-alerted URLs (both the current "seen" array
// and the legacy single-slot "latestKey") so sibling records that share
// an as_of stop ping-ponging instead of re-firing.
const prevState = prev ? (JSON.parse(prev.state) as Record<string, unknown>) : {};
const seen = new Set<string>(
(prevState.seen as string[] | undefined) ??
(prevState.latestKey ? [prevState.latestKey as string] : []),
);
const latestKey = pos.evidence_url ?? `${pos.as_of}:${pos.shares}`;
if (latestKey === prevId) continue;
if (seen.has(latestKey)) continue;
const users = getSubscribedUsers(db, 'fund_capture', pos.symbol);
for (const userId of users) {
const dedup = `fund_capture:${userId}:${fund.id}:${latestKey}`;
if (isDuplicate(db, dedup)) continue;
if (isDuplicate(db, buildDedupKey('fund_capture', pos.symbol, dedup))) continue;
const shares = pos.shares != null ? `${rnd(pos.shares).toLocaleString('en-US')} shares` : 'a disclosed position';
const alert = createAlert(
crypto.randomUUID(), userId, 'fund_capture', pos.symbol,
@@ -49,7 +59,8 @@ export const fundCaptureProducer: AlertProducer = {
persistAlert(db, alert);
alerts.push(alert);
}
writeComparisonState(db, key, 'fund_capture', { latestKey });
seen.add(latestKey);
writeComparisonState(db, key, 'fund_capture', { seen: Array.from(seen) });
}
}
return alerts;
@@ -87,7 +98,7 @@ export const fund13FRecordProducer: AlertProducer = {
}
for (const [userId, sym] of userSet) {
const dedup = `fund_13f:${userId}:${f.id}:${latest.accession}`;
if (isDuplicate(db, dedup)) continue;
if (isDuplicate(db, buildDedupKey('fund_13f', sym, dedup))) continue;
const alert = createAlert(
crypto.randomUUID(), userId, 'fund_13f', sym,
`${f.fund_name} filed its latest 13F for ${latest.reported_quarter}.`,
@@ -123,7 +134,7 @@ export const mirrorDiffProducer: AlertProducer = {
if (sig === prevSig) continue;
for (const userId of userIds) {
const dedup = `mirror_diff:${userId}:${fund.id}:${pos.symbol}:${sig}`;
if (isDuplicate(db, dedup)) continue;
if (isDuplicate(db, buildDedupKey('mirror_diff', pos.symbol, dedup))) continue;
const delta = pos.shares != null
? `To match your mirror target for ${pos.symbol}, the delta is ${rnd(pos.shares).toLocaleString('en-US')} shares as disclosed ${pos.as_of}.`
: `The ${pos.symbol} position in the mirror target updated as of ${pos.as_of}.`;
+8
View File
@@ -2085,6 +2085,14 @@ const alertsRouter = router({
return { ok: true };
}),
/** Permanently delete all alert events for the current user. */
clearAll: protectedProcedure
.mutation(async ({ ctx }) => {
const userId = ctx.userId as string;
ctx.db.prepare('DELETE FROM alert_events WHERE user_id = ?').run(userId);
return { ok: true };
}),
/** Get unacknowledged alert count. */
unackedCount: protectedProcedure
.query(async ({ ctx }) => {