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,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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -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('<script>'));
|
||||
});
|
||||
Reference in New Issue
Block a user