CI / Test & Type-Check (push) Canceled after 0s
Snapshot of in-progress module work across multiple slices: - Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain, dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels - Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers, fund holdings strip, live book, position capture ingest - Options: BSM, NormalizedOptionSurface types, OptionsChainRouter, ConvexityGate, option legs panel - Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk, mirror (fund_capture, fund_13f, mirror_diff) - FINRA short interest adapter + queue integration - SEC company tickers adapter + ingest (symbol search index seed) - Vendor gate (rate-limit-first data plane, ADR-0009) - CUSIP registry, reverse 13F refresh, stock float service - LRU cache, portfolio backtest engine - Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio, reports, screener, strategies, theses, guided-start, exits, more pages - Volume profile, workspace profile, visibility-aware poll - ADRs 0010 (mirror math not advice), 0011 (symbol search index) - VENDOR_INTEGRATIONS.md, END_USER_TEST.md - .gitignore: exclude DBs, .DS_Store, local config, agent scratch
97 lines
3.6 KiB
TypeScript
97 lines
3.6 KiB
TypeScript
// Investor Flow — Conviction tier unlock alert producer.
|
|
//
|
|
// conviction_unlock: fires when a user earns a new sizing unlock (tier_a,
|
|
// tier_a_star, etc.) from accumulated trading history. Watermark = last
|
|
// earned_at per user, so re-reading the same unlock stays silent.
|
|
//
|
|
// ADR-0007: an unlock is an earned capability, not a buy signal.
|
|
|
|
import type { DatabaseSync } from 'node:sqlite';
|
|
import type { Alert } from '../AlertEngine.ts';
|
|
import { createAlert, buildDedupKey } from '../AlertEngine.ts';
|
|
import type { AlertProducer } from './types.ts';
|
|
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
|
|
|
|
/** Human-readable tier names for the payload/unlock display. */
|
|
const UNLOCK_LABELS: Record<string, string> = {
|
|
tier_a: 'A tier',
|
|
tier_a_star: 'A* tier',
|
|
covered_income: 'covered income strategies',
|
|
uncovered_income: 'uncovered income strategies',
|
|
};
|
|
|
|
export const convictionUnlockProducer: AlertProducer = {
|
|
alertType: 'conviction_unlock',
|
|
frequency: 'batched',
|
|
|
|
async check(db: DatabaseSync): Promise<Alert[]> {
|
|
const alerts: Alert[] = [];
|
|
|
|
// Newest unlock per user.
|
|
const rows = db.prepare(
|
|
`SELECT s.user_id, s.unlock, s.earned_at, s.evidence
|
|
FROM sizing_unlocks s
|
|
JOIN (SELECT user_id, MAX(earned_at) AS max_earned
|
|
FROM sizing_unlocks
|
|
GROUP BY user_id) m
|
|
ON s.user_id = m.user_id AND s.earned_at = m.max_earned
|
|
ORDER BY s.earned_at DESC`,
|
|
).all() as Array<{ user_id: string; unlock: string; earned_at: string; evidence: string | null }>;
|
|
|
|
for (const row of rows) {
|
|
const userId = row.user_id;
|
|
const prev = readComparisonState(db, userId, 'conviction_unlock');
|
|
const prevState = prev ? JSON.parse(prev.state) : { lastEarnedAt: null, lastUnlock: null };
|
|
|
|
if (prevState.lastEarnedAt === row.earned_at && prevState.lastUnlock === row.unlock) {
|
|
continue;
|
|
}
|
|
|
|
// First observation records the watermark without firing (existing
|
|
// unlocks at boot shouldn't page anyone).
|
|
if (prevState.lastUnlock === null) {
|
|
writeComparisonState(db, userId, 'conviction_unlock', {
|
|
lastEarnedAt: row.earned_at,
|
|
lastUnlock: row.unlock,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// A genuinely new unlock → notify the earning user first (they are the
|
|
// primary subscriber; a global subscription is honored too).
|
|
const users = new Set(getSubscribedUsers(db, 'conviction_unlock', userId));
|
|
users.add(userId);
|
|
for (const uid of users) {
|
|
const eventId = `${userId}:${row.unlock}:${row.earned_at}`;
|
|
const dedupKey = buildDedupKey('conviction_unlock', undefined, eventId);
|
|
if (isDuplicate(db, dedupKey)) continue;
|
|
|
|
const label = UNLOCK_LABELS[row.unlock] ?? row.unlock;
|
|
const description =
|
|
`Your trading history now qualifies for the ${label} conviction tier` +
|
|
(row.evidence ? `. Evidence: ${row.evidence}` : '') +
|
|
`. Sizing at this tier uses the higher conviction multiplier.`;
|
|
|
|
const alert = createAlert(
|
|
crypto.randomUUID(),
|
|
uid,
|
|
'conviction_unlock',
|
|
undefined,
|
|
description,
|
|
eventId,
|
|
{ unlock: row.unlock, earnedAt: row.earned_at, evidence: row.evidence },
|
|
);
|
|
persistAlert(db, alert);
|
|
alerts.push(alert);
|
|
}
|
|
|
|
writeComparisonState(db, userId, 'conviction_unlock', {
|
|
lastEarnedAt: row.earned_at,
|
|
lastUnlock: row.unlock,
|
|
});
|
|
}
|
|
|
|
return alerts;
|
|
},
|
|
};
|