feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
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
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user