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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user