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
+125
View File
@@ -0,0 +1,125 @@
// Investor Flow — ReportRunner (Slice 22): generates self-contained HTML research notes.
//
// Pure/cache-deterministic given cache data. Same inputs → same output.
// ADR-0007: educational framing, no trade verbs.
// ADR-0005: Analyst Voice.
export type ReportScope = 'symbol' | 'watchlist' | 'portfolio' | 'rotation' | 'sizing_year' | 'risk_posture';
export interface ReportInput {
scope: ReportScope;
symbol?: string;
data: Record<string, unknown>;
}
export interface ReportOutput {
html: string;
title: string;
generatedAt: string;
}
/** Generate a self-contained HTML research note. */
export function generateReport(input: ReportInput): ReportOutput {
const { scope, symbol, data } = input;
const title = researchTitle(scope, symbol);
const bodySections = buildSections(scope, data);
const html = wrapHtml(title, bodySections);
return {
html,
title,
generatedAt: new Date().toISOString(),
};
}
function researchTitle(scope: ReportScope, symbol?: string): string {
const prefix = 'Research Note';
switch (scope) {
case 'symbol': return `${prefix}: ${symbol ?? 'Unknown'}`;
case 'watchlist': return `${prefix}: Watchlist Summary`;
case 'portfolio': return `${prefix}: Portfolio Analysis`;
case 'rotation': return `${prefix}: Sector Rotation Report`;
case 'sizing_year': return `${prefix}: Sizing Year in Review`;
case 'risk_posture': return `${prefix}: Risk Posture Report`;
}
}
function buildSections(scope: ReportScope, data: Record<string, unknown>): string[] {
const sections: string[] = [];
// Overview
sections.push(`<div class="section">
<h2>Overview</h2>
<p>This research note provides an educational analysis based on cached market data. It is not investment advice.</p>
${data.overview ? `<p>${escapeHtml(String(data.overview))}</p>` : ''}
</div>`);
// Scope-specific content
switch (scope) {
case 'symbol':
if (data.keyMetrics) {
sections.push(`<div class="section">
<h2>Key Metrics</h2>
<p>${escapeHtml(String(data.keyMetrics))}</p>
</div>`);
}
break;
case 'portfolio':
if (data.holdings) {
const holdings = data.holdings as Array<{ symbol: string; shares: number; avgCost: number }>;
sections.push(`<div class="section">
<h2>Holdings</h2>
<table><tr><th>Symbol</th><th>Shares</th><th>Avg Cost</th><th>Value</th></tr>
${holdings.map((h) => `<tr><td>${escapeHtml(h.symbol)}</td><td>${h.shares}</td><td>$${h.avgCost.toFixed(2)}</td><td>$${(h.shares * h.avgCost).toFixed(0)}</td></tr>`).join('')}
</table>
</div>`);
}
break;
}
// Recommended actions (considerations, not instructions)
if (data.recommendedActions) {
const actions = data.recommendedActions as Array<{ id: string; tradeOff: string; explanation: string }>;
sections.push(`<div class="section">
<h2>Considerations</h2>
${actions.map((a) => `<div class="consideration"><p><strong>${escapeHtml(a.tradeOff)}</strong></p><p>${escapeHtml(a.explanation)}</p></div>`).join('')}
</div>`);
}
return sections;
}
function wrapHtml(title: string, sections: string[]): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; background: #0a0b0f; color: #e6e7ec; }
h1 { color: #4f8cff; border-bottom: 1px solid #2a2b3a; padding-bottom: 0.5rem; }
h2 { color: #e6e7ec; margin-top: 1.5rem; }
.section { margin: 1.5rem 0; padding: 1rem; background: #121318; border: 1px solid #2a2b3a; border-radius: 0.5rem; }
.consideration { padding: 0.75rem; margin: 0.5rem 0; border-left: 3px solid #4f8cff; background: #1a1b24; border-radius: 0.25rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #2a2b3a; }
th { color: #8a8b9a; font-size: 0.85rem; }
.footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #2a2b3a; font-size: 0.75rem; color: #5a5b6a; }
svg { max-width: 100%; height: auto; }
</style>
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<p class="meta">Generated ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</p>
${sections.join('\n')}
<div class="footer">
<p>Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.</p>
<p>This report is generated from cached market data and may not reflect real-time conditions.</p>
</div>
</body>
</html>`;
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
@@ -0,0 +1,65 @@
// Tests — ReportRunner (Slice 22). Pure, no network.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { generateReport } from '../ReportRunner.ts';
test('generateReport produces valid HTML for a symbol report', () => {
const result = generateReport({ scope: 'symbol', symbol: 'NVDA', data: { overview: 'NVDA overview text.' } });
assert.ok(result.html.startsWith('<!DOCTYPE html>'));
assert.ok(result.html.includes('NVDA'));
assert.ok(result.html.includes('Overview'));
assert.ok(result.html.includes('Educational analysis'));
assert.ok(result.html.includes('</html>'));
assert.equal(result.title, 'Research Note: NVDA');
});
test('generateReport produces valid HTML for portfolio report', () => {
const result = generateReport({
scope: 'portfolio',
data: {
holdings: [
{ symbol: 'NVDA', shares: 10, avgCost: 250 },
{ symbol: 'AAPL', shares: 5, avgCost: 180 },
],
},
});
assert.ok(result.html.includes('Holdings'));
assert.ok(result.html.includes('NVDA'));
assert.ok(result.html.includes('AAPL'));
assert.ok(result.html.includes('$2500')); // 10 * 250
assert.equal(result.title, 'Research Note: Portfolio Analysis');
});
test('generateReport includes recommended actions when provided', () => {
const result = generateReport({
scope: 'risk_posture',
data: {
recommendedActions: [
{ id: 'consider_reducing_position', tradeOff: 'Test trade-off', explanation: 'Test explanation.' },
],
},
});
assert.ok(result.html.includes('Considerations'));
assert.ok(result.html.includes('Test trade-off'));
assert.ok(result.html.includes('Test explanation'));
});
test('generateReport includes ADR-0007 footer', () => {
const result = generateReport({ scope: 'symbol', symbol: 'TEST', data: {} });
assert.ok(result.html.includes('not investment advice'));
assert.ok(result.html.includes('responsible for your own decisions'));
});
test('generateReport outputs deterministic content given same inputs', () => {
const input = { scope: 'symbol' as const, symbol: 'NVDA', data: { overview: 'test' } };
const a = generateReport(input);
const b = generateReport(input);
assert.equal(a.html, b.html);
assert.equal(a.title, b.title);
});
test('generateReport escapes HTML in user data', () => {
const result = generateReport({ scope: 'symbol', symbol: '<script>', data: { overview: '<script>alert("xss")</script>' } });
assert.ok(!result.html.includes('<script>'));
assert.ok(result.html.includes('&lt;script&gt;'));
});