fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+202
View File
@@ -0,0 +1,202 @@
// Investor Flow — Thesis Monitor (Slice 21)
//
// ADR-0006: Local-only LLM — no cloud API calls.
// ADR-0007: "consider reviewing your thesis" — educational, not prescriptive.
//
// Pure/cache-deterministic: no I/O. The LLM call happens outside this module.
import type { AlertType } from '../alerts/AlertEngine.ts';
// ─── Thesis Status ──────────────────────────────────────────────────────────
export type ThesisStatus = 'intact' | 'weakening' | 'broken';
export interface ThesisStatusResult {
symbol: string;
status: ThesisStatus;
confidence: number;
invalidationCriteria: string[];
triggeredCriteria: string[];
citations: ThesisCitation[];
recommendation: string;
}
export interface ThesisCitation {
source: string;
event: string;
date: string;
relevance: 'high' | 'medium' | 'low';
}
// ─── Thesis Definition ──────────────────────────────────────────────────────
export interface Thesis {
symbol: string;
statement: string;
invalidationCriteria: string[];
createdAt: string;
}
// ─── Event Input ─────────────────────────────────────────────────────────────
export interface ThesisEvent {
type: 'form4_buy' | 'form4_sell' | 'new_13f' | 'sentiment_shift' | 'price_drop' | 'price_rally' | 'regime_shift' | 'earnings_miss' | 'earnings_beat';
symbol: string;
date: string;
description: string;
severity: 'info' | 'warning' | 'critical';
}
// ─── Thesis Assessment Engine ───────────────────────────────────────────────
/**
* Assess thesis status by cross-referencing invalidation criteria against events.
* This is the pure logic core — the LLM layer wraps this for natural language
* interpretation of criteria.
*/
export function assessThesis(
thesis: Thesis,
events: ThesisEvent[],
): ThesisStatusResult {
const triggeredCriteria: string[] = [];
const citations: ThesisCitation[] = [];
for (const criterion of thesis.invalidationCriteria) {
const matchingEvents = matchCriterionToEvents(criterion, events, thesis.symbol);
if (matchingEvents.length > 0) {
triggeredCriteria.push(criterion);
for (const ev of matchingEvents) {
citations.push({
source: ev.type,
event: ev.description,
date: ev.date,
relevance: ev.severity === 'critical' ? 'high' : ev.severity === 'warning' ? 'medium' : 'low',
});
}
}
}
const total = thesis.invalidationCriteria.length;
const triggered = triggeredCriteria.length;
const ratio = total > 0 ? triggered / total : 0;
let status: ThesisStatus;
let confidence: number;
if (ratio >= 0.5 || (triggered > 0 && events.some((e) => e.severity === 'critical'))) {
status = 'broken';
confidence = Math.min(100, 50 + ratio * 50);
} else if (ratio > 0 || events.some((e) => e.severity === 'warning')) {
status = 'weakening';
confidence = Math.round(ratio * 60 + 20);
} else {
status = 'intact';
confidence = Math.max(50, 100 - events.filter((e) => e.severity !== 'info').length * 10);
}
const recommendation = status === 'broken'
? 'Thesis invalidation criteria have been met. Consider reviewing your thesis — never silently hold a broken thesis.'
: status === 'weakening'
? 'Some signals suggest the thesis may be weakening. Consider reviewing the thesis and invalidation criteria.'
: 'Thesis remains intact. No invalidation criteria have been triggered.';
return {
symbol: thesis.symbol,
status,
confidence,
invalidationCriteria: thesis.invalidationCriteria,
triggeredCriteria,
citations,
recommendation,
};
}
// ─── Criterion-to-Event Matching ────────────────────────────────────────────
function matchCriterionToEvents(
criterion: string,
events: ThesisEvent[],
symbol: string,
): ThesisEvent[] {
const lower = criterion.toLowerCase();
const symbolEvents = events.filter((e) => e.symbol === symbol);
// Keyword-based matching
if (lower.includes('insider') && lower.includes('sell')) {
return symbolEvents.filter((e) => e.type === 'form4_sell');
}
if (lower.includes('insider') && lower.includes('buy')) {
return symbolEvents.filter((e) => e.type === 'form4_buy');
}
if (lower.includes('institutional') || lower.includes('13f') || lower.includes('13d')) {
return symbolEvents.filter((e) => e.type === 'new_13f');
}
if (lower.includes('sentiment')) {
return symbolEvents.filter((e) => e.type === 'sentiment_shift');
}
if (lower.includes('price') && lower.includes('drop')) {
return symbolEvents.filter((e) => e.type === 'price_drop');
}
if (lower.includes('regime')) {
return symbolEvents.filter((e) => e.type === 'regime_shift');
}
if (lower.includes('earnings') && lower.includes('miss')) {
return symbolEvents.filter((e) => e.type === 'earnings_miss');
}
if (lower.includes('earnings') && lower.includes('beat')) {
return symbolEvents.filter((e) => e.type === 'earnings_beat');
}
// Default: match any critical event
return symbolEvents.filter((e) => e.severity === 'critical');
}
// ─── Map Thesis Status to Alert Type ────────────────────────────────────────
export function thesisStatusToAlertType(status: ThesisStatus): AlertType | null {
switch (status) {
case 'broken': return 'thesis_broken';
case 'weakening': return 'thesis_weakening';
case 'intact': return null;
}
}
// ─── Timeline ───────────────────────────────────────────────────────────────
export interface TimelineEntry {
date: string;
symbol: string;
status: ThesisStatus;
event: string;
priority: 1 | 2 | 3; // L1=broken, L2=weakening, L3=intact
}
export function buildTimeline(
assessments: ThesisStatusResult[],
events: ThesisEvent[],
): TimelineEntry[] {
const entries: TimelineEntry[] = [];
for (const assessment of assessments) {
const priority: TimelineEntry['priority'] =
assessment.status === 'broken' ? 1 :
assessment.status === 'weakening' ? 2 : 3;
for (const citation of assessment.citations) {
entries.push({
date: citation.date,
symbol: assessment.symbol,
status: assessment.status,
event: citation.event,
priority,
});
}
}
// Sort by priority (L1 first), then by date descending
entries.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return b.date.localeCompare(a.date);
});
return entries;
}