Merge branch 't3code/company-overview-loading-cache'
This commit is contained in:
@@ -559,10 +559,11 @@ export async function getSearchAnswer(input: {
|
||||
}, 'Unable to generate cited answer');
|
||||
}
|
||||
|
||||
export async function getCompanyAnalysis(ticker: string) {
|
||||
export async function getCompanyAnalysis(ticker: string, options?: { refresh?: boolean }) {
|
||||
const result = await client.api.analysis.company.get({
|
||||
$query: {
|
||||
ticker: ticker.trim().toUpperCase()
|
||||
ticker: ticker.trim().toUpperCase(),
|
||||
...(options?.refresh ? { refresh: 'true' } : {})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ import type {
|
||||
ResearchArtifactSource
|
||||
} from '@/lib/types';
|
||||
|
||||
export function companyAnalysisQueryOptions(ticker: string) {
|
||||
export function companyAnalysisQueryOptions(ticker: string, options?: { refresh?: boolean }) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.companyAnalysis(normalizedTicker),
|
||||
queryFn: () => getCompanyAnalysis(normalizedTicker),
|
||||
queryFn: () => getCompanyAnalysis(normalizedTicker, options),
|
||||
staleTime: 120_000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,12 +70,6 @@ import {
|
||||
updateWatchlistReviewByTicker,
|
||||
upsertWatchlistItemRecord
|
||||
} from '@/lib/server/repos/watchlist';
|
||||
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
||||
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
|
||||
import { getRecentDevelopments } from '@/lib/server/recent-developments';
|
||||
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
|
||||
import { getCompanyDescription } from '@/lib/server/sec-description';
|
||||
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
|
||||
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
|
||||
import {
|
||||
enqueueTask,
|
||||
@@ -86,6 +80,7 @@ import {
|
||||
listRecentTasks,
|
||||
updateTaskNotification
|
||||
} from '@/lib/server/tasks';
|
||||
import { getCompanyAnalysisPayload } from '@/lib/server/company-analysis';
|
||||
|
||||
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
|
||||
const FINANCIAL_FORMS: ReadonlySet<Filing['filing_type']> = new Set(['10-K', '10-Q']);
|
||||
@@ -1390,145 +1385,20 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const [filings, holding, watchlistItem, liveQuote, priceHistory, benchmarkHistory, journalPreview, memo, secProfile] = await Promise.all([
|
||||
listFilingsRecords({ ticker, limit: 40 }),
|
||||
getHoldingByTicker(session.user.id, ticker),
|
||||
getWatchlistItemByTicker(session.user.id, ticker),
|
||||
getQuote(ticker),
|
||||
getPriceHistory(ticker),
|
||||
getPriceHistory('^GSPC'),
|
||||
listResearchJournalEntries(session.user.id, ticker, 6),
|
||||
getResearchMemoByTicker(session.user.id, ticker),
|
||||
getSecCompanyProfile(ticker)
|
||||
]);
|
||||
const redactedFilings = filings
|
||||
.map(redactInternalFilingAnalysisFields)
|
||||
.map(withFinancialMetricsPolicy);
|
||||
|
||||
const latestFiling = redactedFilings[0] ?? null;
|
||||
const companyName = latestFiling?.company_name
|
||||
?? secProfile?.companyName
|
||||
?? holding?.company_name
|
||||
?? watchlistItem?.company_name
|
||||
?? ticker;
|
||||
|
||||
const financials = redactedFilings
|
||||
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
|
||||
.map((entry) => ({
|
||||
filingDate: entry.filing_date,
|
||||
filingType: entry.filing_type,
|
||||
revenue: entry.metrics?.revenue ?? null,
|
||||
netIncome: entry.metrics?.netIncome ?? null,
|
||||
totalAssets: entry.metrics?.totalAssets ?? null,
|
||||
cash: entry.metrics?.cash ?? null,
|
||||
debt: entry.metrics?.debt ?? null
|
||||
}));
|
||||
|
||||
const aiReports = redactedFilings
|
||||
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
|
||||
.slice(0, 8)
|
||||
.map((entry) => ({
|
||||
accessionNumber: entry.accession_number,
|
||||
filingDate: entry.filing_date,
|
||||
filingType: entry.filing_type,
|
||||
provider: entry.analysis?.provider ?? 'unknown',
|
||||
model: entry.analysis?.model ?? 'unknown',
|
||||
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
|
||||
}));
|
||||
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
|
||||
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
|
||||
const keyMetrics = {
|
||||
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
|
||||
revenue: referenceMetrics?.revenue ?? null,
|
||||
netIncome: referenceMetrics?.netIncome ?? null,
|
||||
totalAssets: referenceMetrics?.totalAssets ?? null,
|
||||
cash: referenceMetrics?.cash ?? null,
|
||||
debt: referenceMetrics?.debt ?? null,
|
||||
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
|
||||
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
|
||||
: null
|
||||
};
|
||||
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
|
||||
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
|
||||
getCompanyDescription(annualFiling),
|
||||
getYahooCompanyDescription(ticker),
|
||||
getRecentDevelopments(ticker, { filings: redactedFilings })
|
||||
]);
|
||||
const description = yahooDescription ?? secDescription;
|
||||
const latestFilingSummary = latestFiling
|
||||
? {
|
||||
accessionNumber: latestFiling.accession_number,
|
||||
filingDate: latestFiling.filing_date,
|
||||
filingType: latestFiling.filing_type,
|
||||
filingUrl: latestFiling.filing_url,
|
||||
submissionUrl: latestFiling.submission_url ?? null,
|
||||
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
|
||||
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
|
||||
}
|
||||
: null;
|
||||
const companyProfile = toCompanyProfile(secProfile, description);
|
||||
const valuationSnapshot = deriveValuationSnapshot({
|
||||
quote: liveQuote,
|
||||
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
|
||||
revenue: keyMetrics.revenue,
|
||||
cash: keyMetrics.cash,
|
||||
debt: keyMetrics.debt,
|
||||
netIncome: keyMetrics.netIncome
|
||||
});
|
||||
const synthesis = await synthesizeCompanyOverview({
|
||||
const refresh = asBoolean(query.refresh, false);
|
||||
const analysis = await getCompanyAnalysisPayload({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
companyName,
|
||||
description,
|
||||
memo,
|
||||
latestFilingSummary,
|
||||
recentAiReports: aiReports.slice(0, 5),
|
||||
recentDevelopments: synthesizedDevelopments.items
|
||||
refresh
|
||||
});
|
||||
const recentDevelopments = {
|
||||
...synthesizedDevelopments,
|
||||
weeklySnapshot: synthesis.weeklySnapshot,
|
||||
status: synthesizedDevelopments.items.length > 0
|
||||
? synthesis.weeklySnapshot ? 'ready' : 'partial'
|
||||
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
|
||||
} as const;
|
||||
|
||||
return Response.json({
|
||||
analysis: {
|
||||
company: {
|
||||
ticker,
|
||||
companyName,
|
||||
sector: watchlistItem?.sector ?? null,
|
||||
category: watchlistItem?.category ?? null,
|
||||
tags: watchlistItem?.tags ?? [],
|
||||
cik: latestFiling?.cik ?? null
|
||||
},
|
||||
quote: liveQuote,
|
||||
position: holding,
|
||||
priceHistory,
|
||||
benchmarkHistory,
|
||||
financials,
|
||||
filings: redactedFilings.slice(0, 20),
|
||||
aiReports,
|
||||
coverage: watchlistItem
|
||||
? {
|
||||
...watchlistItem,
|
||||
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
|
||||
}
|
||||
: null,
|
||||
journalPreview,
|
||||
recentAiReports: aiReports.slice(0, 5),
|
||||
latestFilingSummary,
|
||||
keyMetrics,
|
||||
companyProfile,
|
||||
valuationSnapshot,
|
||||
bullBear: synthesis.bullBear,
|
||||
recentDevelopments
|
||||
}
|
||||
analysis
|
||||
});
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
refresh: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.get('/financials/company', async ({ query }) => {
|
||||
|
||||
@@ -127,7 +127,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
|
||||
'0008_research_workspace.sql',
|
||||
'0009_task_notification_context.sql',
|
||||
'0010_taxonomy_surface_sidecar.sql',
|
||||
'0011_remove_legacy_xbrl_defaults.sql'
|
||||
'0011_remove_legacy_xbrl_defaults.sql',
|
||||
'0012_company_overview_cache.sql'
|
||||
];
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
@@ -165,6 +166,7 @@ function clearProjectionTables(client: { exec: (query: string) => void }) {
|
||||
client.exec('DELETE FROM holding;');
|
||||
client.exec('DELETE FROM watchlist_item;');
|
||||
client.exec('DELETE FROM portfolio_insight;');
|
||||
client.exec('DELETE FROM company_overview_cache;');
|
||||
client.exec('DELETE FROM filing;');
|
||||
}
|
||||
|
||||
@@ -246,6 +248,73 @@ async function jsonRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCachedAnalysisPayload(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
bull?: string[];
|
||||
}) {
|
||||
return {
|
||||
company: {
|
||||
ticker: input.ticker,
|
||||
companyName: input.companyName,
|
||||
sector: null,
|
||||
category: null,
|
||||
tags: [],
|
||||
cik: null
|
||||
},
|
||||
quote: 100,
|
||||
position: null,
|
||||
priceHistory: [],
|
||||
benchmarkHistory: [],
|
||||
financials: [],
|
||||
filings: [],
|
||||
aiReports: [],
|
||||
coverage: null,
|
||||
journalPreview: [],
|
||||
recentAiReports: [],
|
||||
latestFilingSummary: null,
|
||||
keyMetrics: {
|
||||
referenceDate: null,
|
||||
revenue: null,
|
||||
netIncome: null,
|
||||
totalAssets: null,
|
||||
cash: null,
|
||||
debt: null,
|
||||
netMargin: null
|
||||
},
|
||||
companyProfile: {
|
||||
description: null,
|
||||
exchange: null,
|
||||
industry: null,
|
||||
country: null,
|
||||
website: null,
|
||||
fiscalYearEnd: null,
|
||||
employeeCount: null,
|
||||
source: 'unavailable'
|
||||
},
|
||||
valuationSnapshot: {
|
||||
sharesOutstanding: null,
|
||||
marketCap: null,
|
||||
enterpriseValue: null,
|
||||
trailingPe: null,
|
||||
evToRevenue: null,
|
||||
evToEbitda: null,
|
||||
source: 'unavailable'
|
||||
},
|
||||
bullBear: {
|
||||
source: input.bull && input.bull.length > 0 ? 'memo_fallback' : 'unavailable',
|
||||
bull: input.bull ?? [],
|
||||
bear: [],
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
recentDevelopments: {
|
||||
status: 'unavailable',
|
||||
items: [],
|
||||
weeklySnapshot: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||
describe('task workflow hybrid migration e2e', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -472,7 +541,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||
ticker: 'NFLX',
|
||||
accessionNumber: '0000000000-26-000777',
|
||||
filingType: '10-K',
|
||||
filingDate: '2026-02-15',
|
||||
filingDate: '2026-03-10',
|
||||
companyName: 'Netflix, Inc.',
|
||||
metrics: {
|
||||
revenue: 41000000000,
|
||||
@@ -575,6 +644,157 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||
}).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('serves cached analysis until refresh is requested', async () => {
|
||||
if (!sqliteClient) {
|
||||
throw new Error('sqlite client not initialized');
|
||||
}
|
||||
|
||||
seedFilingRecord(sqliteClient, {
|
||||
ticker: 'CACH',
|
||||
accessionNumber: '0000000000-26-000901',
|
||||
filingType: '10-K',
|
||||
filingDate: '2026-02-20',
|
||||
companyName: 'Live Corp'
|
||||
});
|
||||
const filingRow = sqliteClient.query(`
|
||||
SELECT created_at, updated_at
|
||||
FROM filing
|
||||
WHERE ticker = 'CACH'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get() as { created_at: string; updated_at: string } | null;
|
||||
if (!filingRow) {
|
||||
throw new Error('cached filing row not found');
|
||||
}
|
||||
|
||||
const { __companyAnalysisInternals } = await import('../company-analysis');
|
||||
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'CACH',
|
||||
localInputs: {
|
||||
filings: [{
|
||||
id: 1,
|
||||
ticker: 'CACH',
|
||||
filing_type: '10-K',
|
||||
filing_date: '2026-02-20',
|
||||
accession_number: '0000000000-26-000901',
|
||||
cik: '0000000000',
|
||||
company_name: 'Live Corp',
|
||||
filing_url: 'https://www.sec.gov/Archives/0000000000-26-000901.htm',
|
||||
submission_url: 'https://www.sec.gov/submissions/0000000000-26-000901.json',
|
||||
primary_document: '0000000000-26-000901.htm',
|
||||
metrics: null,
|
||||
analysis: null,
|
||||
created_at: filingRow.created_at,
|
||||
updated_at: filingRow.updated_at
|
||||
}],
|
||||
holding: null,
|
||||
watchlistItem: null,
|
||||
journalPreview: [],
|
||||
memo: null
|
||||
}
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
|
||||
sqliteClient.query(`
|
||||
INSERT INTO company_overview_cache (
|
||||
user_id,
|
||||
ticker,
|
||||
cache_version,
|
||||
source_signature,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
TEST_USER_ID,
|
||||
'CACH',
|
||||
1,
|
||||
sourceSignature,
|
||||
JSON.stringify(buildCachedAnalysisPayload({
|
||||
ticker: 'CACH',
|
||||
companyName: 'Cached Corp'
|
||||
})),
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
const cached = await jsonRequest('GET', '/api/analysis/company?ticker=CACH');
|
||||
expect(cached.response.status).toBe(200);
|
||||
expect((cached.json as {
|
||||
analysis: { company: { companyName: string } };
|
||||
}).analysis.company.companyName).toBe('Cached Corp');
|
||||
|
||||
const refreshed = await jsonRequest('GET', '/api/analysis/company?ticker=CACH&refresh=true');
|
||||
expect(refreshed.response.status).toBe(200);
|
||||
expect((refreshed.json as {
|
||||
analysis: { company: { companyName: string } };
|
||||
}).analysis.company.companyName).toBe('Live Corp');
|
||||
});
|
||||
|
||||
it('invalidates cached analysis when the memo changes', async () => {
|
||||
if (!sqliteClient) {
|
||||
throw new Error('sqlite client not initialized');
|
||||
}
|
||||
|
||||
seedFilingRecord(sqliteClient, {
|
||||
ticker: 'MEMO',
|
||||
accessionNumber: '0000000000-26-000902',
|
||||
filingType: '10-K',
|
||||
filingDate: '2026-02-20',
|
||||
companyName: 'Memo Corp'
|
||||
});
|
||||
|
||||
sqliteClient.query(`
|
||||
INSERT INTO research_memo (
|
||||
user_id,
|
||||
organization_id,
|
||||
ticker,
|
||||
rating,
|
||||
conviction,
|
||||
time_horizon_months,
|
||||
packet_title,
|
||||
packet_subtitle,
|
||||
thesis_markdown,
|
||||
variant_view_markdown,
|
||||
catalysts_markdown,
|
||||
risks_markdown,
|
||||
disconfirming_evidence_markdown,
|
||||
next_actions_markdown,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, NULL, ?, 'buy', 'high', 24, NULL, NULL, ?, '', '', '', '', '', ?, ?)
|
||||
`).run(
|
||||
TEST_USER_ID,
|
||||
'MEMO',
|
||||
'Legacy thesis still holds.',
|
||||
'2026-03-13T00:00:00.000Z',
|
||||
'2026-03-13T00:00:00.000Z'
|
||||
);
|
||||
|
||||
const first = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
|
||||
expect(first.response.status).toBe(200);
|
||||
expect((first.json as {
|
||||
analysis: { bullBear: { bull: string[] } };
|
||||
}).analysis.bullBear.bull.join(' ')).toContain('Legacy thesis');
|
||||
|
||||
sqliteClient.query(`
|
||||
UPDATE research_memo
|
||||
SET thesis_markdown = ?, updated_at = ?
|
||||
WHERE user_id = ? AND ticker = ?
|
||||
`).run(
|
||||
'Updated thesis drives the next refresh.',
|
||||
'2026-03-13T01:00:00.000Z',
|
||||
TEST_USER_ID,
|
||||
'MEMO'
|
||||
);
|
||||
|
||||
const second = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
|
||||
expect(second.response.status).toBe(200);
|
||||
expect((second.json as {
|
||||
analysis: { bullBear: { bull: string[] } };
|
||||
}).analysis.bullBear.bull.join(' ')).toContain('Updated thesis');
|
||||
});
|
||||
|
||||
it('persists nullable holding company names and allows later enrichment', async () => {
|
||||
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
|
||||
ticker: 'ORCL',
|
||||
|
||||
251
lib/server/company-analysis.test.ts
Normal file
251
lib/server/company-analysis.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import type {
|
||||
CompanyAnalysis,
|
||||
Filing,
|
||||
ResearchMemo
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
__companyAnalysisInternals,
|
||||
getCompanyAnalysisPayload
|
||||
} from './company-analysis';
|
||||
|
||||
function buildFiling(updatedAt = '2026-03-10T00:00:00.000Z'): Filing {
|
||||
return {
|
||||
id: 1,
|
||||
ticker: 'MSFT',
|
||||
filing_type: '10-K',
|
||||
filing_date: '2026-02-01',
|
||||
accession_number: '0000000000-26-000001',
|
||||
cik: '0000789019',
|
||||
company_name: 'Microsoft Corporation',
|
||||
filing_url: 'https://www.sec.gov/Archives/test.htm',
|
||||
submission_url: 'https://www.sec.gov/submissions/test.json',
|
||||
primary_document: 'test.htm',
|
||||
metrics: null,
|
||||
analysis: null,
|
||||
created_at: updatedAt,
|
||||
updated_at: updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function buildMemo(updatedAt = '2026-03-10T00:00:00.000Z', thesis = 'Azure remains durable.'): ResearchMemo {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 'user-1',
|
||||
organization_id: null,
|
||||
ticker: 'MSFT',
|
||||
rating: 'buy',
|
||||
conviction: 'high',
|
||||
time_horizon_months: 24,
|
||||
packet_title: null,
|
||||
packet_subtitle: null,
|
||||
thesis_markdown: thesis,
|
||||
variant_view_markdown: '',
|
||||
catalysts_markdown: '',
|
||||
risks_markdown: '',
|
||||
disconfirming_evidence_markdown: '',
|
||||
next_actions_markdown: '',
|
||||
created_at: updatedAt,
|
||||
updated_at: updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalInputs(overrides: Partial<Parameters<typeof __companyAnalysisInternals.buildCompanyAnalysisSourceSignature>[0]['localInputs']> = {}) {
|
||||
return {
|
||||
filings: [buildFiling()],
|
||||
holding: null,
|
||||
watchlistItem: null,
|
||||
journalPreview: [],
|
||||
memo: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
||||
return {
|
||||
company: {
|
||||
ticker: 'MSFT',
|
||||
companyName,
|
||||
sector: null,
|
||||
category: null,
|
||||
tags: [],
|
||||
cik: null
|
||||
},
|
||||
quote: 100,
|
||||
position: null,
|
||||
priceHistory: [],
|
||||
benchmarkHistory: [],
|
||||
financials: [],
|
||||
filings: [],
|
||||
aiReports: [],
|
||||
coverage: null,
|
||||
journalPreview: [],
|
||||
recentAiReports: [],
|
||||
latestFilingSummary: null,
|
||||
keyMetrics: {
|
||||
referenceDate: null,
|
||||
revenue: null,
|
||||
netIncome: null,
|
||||
totalAssets: null,
|
||||
cash: null,
|
||||
debt: null,
|
||||
netMargin: null
|
||||
},
|
||||
companyProfile: {
|
||||
description: null,
|
||||
exchange: null,
|
||||
industry: null,
|
||||
country: null,
|
||||
website: null,
|
||||
fiscalYearEnd: null,
|
||||
employeeCount: null,
|
||||
source: 'unavailable'
|
||||
},
|
||||
valuationSnapshot: {
|
||||
sharesOutstanding: null,
|
||||
marketCap: null,
|
||||
enterpriseValue: null,
|
||||
trailingPe: null,
|
||||
evToRevenue: null,
|
||||
evToEbitda: null,
|
||||
source: 'unavailable'
|
||||
},
|
||||
bullBear: {
|
||||
source: 'unavailable',
|
||||
bull: [],
|
||||
bear: [],
|
||||
updatedAt: null
|
||||
},
|
||||
recentDevelopments: {
|
||||
status: 'unavailable',
|
||||
items: [],
|
||||
weeklySnapshot: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildCacheRecord(payload: CompanyAnalysis, sourceSignature: string) {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 'user-1',
|
||||
ticker: 'MSFT',
|
||||
cache_version: 1,
|
||||
source_signature: sourceSignature,
|
||||
payload,
|
||||
created_at: '2026-03-13T11:55:00.000Z',
|
||||
updated_at: '2026-03-13T11:55:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
describe('company analysis cache orchestration', () => {
|
||||
it('changes the source signature when local inputs change', () => {
|
||||
const base = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs: buildLocalInputs()
|
||||
});
|
||||
const memoChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs: buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z') })
|
||||
});
|
||||
const filingChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs: buildLocalInputs({ filings: [buildFiling('2026-03-11T00:00:00.000Z')] })
|
||||
});
|
||||
const journalChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs: buildLocalInputs({
|
||||
journalPreview: [{
|
||||
id: 7,
|
||||
user_id: 'user-1',
|
||||
ticker: 'MSFT',
|
||||
accession_number: null,
|
||||
entry_type: 'note',
|
||||
title: 'Updated note',
|
||||
body_markdown: 'Body',
|
||||
metadata: null,
|
||||
created_at: '2026-03-01T00:00:00.000Z',
|
||||
updated_at: '2026-03-11T00:00:00.000Z'
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
expect(memoChanged).not.toBe(base);
|
||||
expect(filingChanged).not.toBe(base);
|
||||
expect(journalChanged).not.toBe(base);
|
||||
});
|
||||
|
||||
it('returns a fresh cached payload when signature and ttl match', async () => {
|
||||
const localInputs = buildLocalInputs();
|
||||
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs
|
||||
});
|
||||
const buildOverview = mock(async () => buildAnalysisPayload('Built Corp'));
|
||||
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature));
|
||||
|
||||
const analysis = await getCompanyAnalysisPayload({
|
||||
userId: 'user-1',
|
||||
ticker: 'MSFT',
|
||||
now: new Date('2026-03-13T12:00:00.000Z')
|
||||
}, {
|
||||
getLocalInputs: async () => localInputs,
|
||||
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
|
||||
buildOverview,
|
||||
upsertCachedOverview
|
||||
});
|
||||
|
||||
expect(analysis.company.companyName).toBe('Cached Corp');
|
||||
expect(buildOverview).not.toHaveBeenCalled();
|
||||
expect(upsertCachedOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bypasses the cache when refresh is requested', async () => {
|
||||
const localInputs = buildLocalInputs();
|
||||
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs
|
||||
});
|
||||
const buildOverview = mock(async () => buildAnalysisPayload('Refreshed Corp'));
|
||||
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Refreshed Corp'), sourceSignature));
|
||||
|
||||
const analysis = await getCompanyAnalysisPayload({
|
||||
userId: 'user-1',
|
||||
ticker: 'MSFT',
|
||||
refresh: true,
|
||||
now: new Date('2026-03-13T12:00:00.000Z')
|
||||
}, {
|
||||
getLocalInputs: async () => localInputs,
|
||||
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
|
||||
buildOverview,
|
||||
upsertCachedOverview
|
||||
});
|
||||
|
||||
expect(analysis.company.companyName).toBe('Refreshed Corp');
|
||||
expect(buildOverview).toHaveBeenCalledTimes(1);
|
||||
expect(upsertCachedOverview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebuilds when local inputs invalidate the cached signature', async () => {
|
||||
const cachedInputs = buildLocalInputs({ memo: buildMemo('2026-03-10T00:00:00.000Z', 'Old memo') });
|
||||
const freshInputs = buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z', 'New memo') });
|
||||
const staleSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||
ticker: 'MSFT',
|
||||
localInputs: cachedInputs
|
||||
});
|
||||
const buildOverview = mock(async () => buildAnalysisPayload('Rebuilt Corp'));
|
||||
|
||||
const analysis = await getCompanyAnalysisPayload({
|
||||
userId: 'user-1',
|
||||
ticker: 'MSFT',
|
||||
now: new Date('2026-03-13T12:00:00.000Z')
|
||||
}, {
|
||||
getLocalInputs: async () => freshInputs,
|
||||
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), staleSignature),
|
||||
buildOverview,
|
||||
upsertCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Rebuilt Corp'), staleSignature)
|
||||
});
|
||||
|
||||
expect(analysis.company.companyName).toBe('Rebuilt Corp');
|
||||
expect(buildOverview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
336
lib/server/company-analysis.ts
Normal file
336
lib/server/company-analysis.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type {
|
||||
CompanyAiReport,
|
||||
CompanyAnalysis,
|
||||
Filing,
|
||||
Holding,
|
||||
ResearchJournalEntry,
|
||||
ResearchMemo,
|
||||
WatchlistItem
|
||||
} from '@/lib/types';
|
||||
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
|
||||
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
||||
import { getRecentDevelopments } from '@/lib/server/recent-developments';
|
||||
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
|
||||
import { getCompanyDescription } from '@/lib/server/sec-description';
|
||||
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
|
||||
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
|
||||
import { listFilingsRecords } from '@/lib/server/repos/filings';
|
||||
import { getHoldingByTicker } from '@/lib/server/repos/holdings';
|
||||
import { getResearchMemoByTicker } from '@/lib/server/repos/research-library';
|
||||
import { listResearchJournalEntries } from '@/lib/server/repos/research-journal';
|
||||
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
|
||||
import {
|
||||
CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||
getCompanyOverviewCache,
|
||||
upsertCompanyOverviewCache
|
||||
} from '@/lib/server/repos/company-overview-cache';
|
||||
|
||||
const FINANCIAL_FORMS = new Set<Filing['filing_type']>(['10-K', '10-Q']);
|
||||
const COMPANY_OVERVIEW_CACHE_TTL_MS = 1000 * 60 * 15;
|
||||
|
||||
export type CompanyAnalysisLocalInputs = {
|
||||
filings: Filing[];
|
||||
holding: Holding | null;
|
||||
watchlistItem: WatchlistItem | null;
|
||||
journalPreview: ResearchJournalEntry[];
|
||||
memo: ResearchMemo | null;
|
||||
};
|
||||
|
||||
function withFinancialMetricsPolicy(filing: Filing): Filing {
|
||||
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
||||
return filing;
|
||||
}
|
||||
|
||||
return {
|
||||
...filing,
|
||||
metrics: null
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCompanyAnalysisSourceSignature(input: {
|
||||
ticker: string;
|
||||
localInputs: CompanyAnalysisLocalInputs;
|
||||
cacheVersion?: number;
|
||||
}) {
|
||||
const payload = {
|
||||
ticker: input.ticker.trim().toUpperCase(),
|
||||
cacheVersion: input.cacheVersion ?? CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||
filings: input.localInputs.filings.map((filing) => ({
|
||||
accessionNumber: filing.accession_number,
|
||||
updatedAt: filing.updated_at
|
||||
})),
|
||||
memoUpdatedAt: input.localInputs.memo?.updated_at ?? null,
|
||||
watchlistUpdatedAt: input.localInputs.watchlistItem?.updated_at ?? null,
|
||||
holdingUpdatedAt: input.localInputs.holding?.updated_at ?? null,
|
||||
journalPreview: input.localInputs.journalPreview.map((entry) => ({
|
||||
id: entry.id,
|
||||
updatedAt: entry.updated_at
|
||||
}))
|
||||
};
|
||||
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
}
|
||||
|
||||
export function isCompanyOverviewCacheFresh(input: {
|
||||
updatedAt: string;
|
||||
sourceSignature: string;
|
||||
expectedSourceSignature: string;
|
||||
cacheVersion: number;
|
||||
refresh?: boolean;
|
||||
ttlMs?: number;
|
||||
now?: Date;
|
||||
}) {
|
||||
if (input.refresh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.cacheVersion !== CURRENT_COMPANY_OVERVIEW_CACHE_VERSION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.sourceSignature !== input.expectedSourceSignature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date();
|
||||
const updatedAt = Date.parse(input.updatedAt);
|
||||
if (!Number.isFinite(updatedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return now.getTime() - updatedAt <= (input.ttlMs ?? COMPANY_OVERVIEW_CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
async function getCompanyAnalysisLocalInputs(input: { userId: string; ticker: string }): Promise<CompanyAnalysisLocalInputs> {
|
||||
const ticker = input.ticker.trim().toUpperCase();
|
||||
|
||||
const [filings, holding, watchlistItem, journalPreview, memo] = await Promise.all([
|
||||
listFilingsRecords({ ticker, limit: 40 }),
|
||||
getHoldingByTicker(input.userId, ticker),
|
||||
getWatchlistItemByTicker(input.userId, ticker),
|
||||
listResearchJournalEntries(input.userId, ticker, 6),
|
||||
getResearchMemoByTicker(input.userId, ticker)
|
||||
]);
|
||||
|
||||
return {
|
||||
filings,
|
||||
holding,
|
||||
watchlistItem,
|
||||
journalPreview,
|
||||
memo
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCompanyAnalysisPayload(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
localInputs: CompanyAnalysisLocalInputs;
|
||||
}): Promise<CompanyAnalysis> {
|
||||
const ticker = input.ticker.trim().toUpperCase();
|
||||
const redactedFilings = input.localInputs.filings
|
||||
.map(redactInternalFilingAnalysisFields)
|
||||
.map(withFinancialMetricsPolicy);
|
||||
|
||||
const [liveQuote, priceHistory, benchmarkHistory, secProfile] = await Promise.all([
|
||||
getQuote(ticker),
|
||||
getPriceHistory(ticker),
|
||||
getPriceHistory('^GSPC'),
|
||||
getSecCompanyProfile(ticker)
|
||||
]);
|
||||
|
||||
const latestFiling = redactedFilings[0] ?? null;
|
||||
const companyName = latestFiling?.company_name
|
||||
?? secProfile?.companyName
|
||||
?? input.localInputs.holding?.company_name
|
||||
?? input.localInputs.watchlistItem?.company_name
|
||||
?? ticker;
|
||||
|
||||
const financials = redactedFilings
|
||||
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
|
||||
.map((entry) => ({
|
||||
filingDate: entry.filing_date,
|
||||
filingType: entry.filing_type,
|
||||
revenue: entry.metrics?.revenue ?? null,
|
||||
netIncome: entry.metrics?.netIncome ?? null,
|
||||
totalAssets: entry.metrics?.totalAssets ?? null,
|
||||
cash: entry.metrics?.cash ?? null,
|
||||
debt: entry.metrics?.debt ?? null
|
||||
}));
|
||||
|
||||
const aiReports: CompanyAiReport[] = redactedFilings
|
||||
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
|
||||
.slice(0, 8)
|
||||
.map((entry) => ({
|
||||
accessionNumber: entry.accession_number,
|
||||
filingDate: entry.filing_date,
|
||||
filingType: entry.filing_type,
|
||||
provider: entry.analysis?.provider ?? 'unknown',
|
||||
model: entry.analysis?.model ?? 'unknown',
|
||||
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
|
||||
}));
|
||||
|
||||
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
|
||||
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
|
||||
const keyMetrics = {
|
||||
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
|
||||
revenue: referenceMetrics?.revenue ?? null,
|
||||
netIncome: referenceMetrics?.netIncome ?? null,
|
||||
totalAssets: referenceMetrics?.totalAssets ?? null,
|
||||
cash: referenceMetrics?.cash ?? null,
|
||||
debt: referenceMetrics?.debt ?? null,
|
||||
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
|
||||
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
|
||||
: null
|
||||
};
|
||||
|
||||
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
|
||||
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
|
||||
getCompanyDescription(annualFiling),
|
||||
getYahooCompanyDescription(ticker),
|
||||
getRecentDevelopments(ticker, { filings: redactedFilings })
|
||||
]);
|
||||
|
||||
const description = yahooDescription ?? secDescription;
|
||||
const latestFilingSummary = latestFiling
|
||||
? {
|
||||
accessionNumber: latestFiling.accession_number,
|
||||
filingDate: latestFiling.filing_date,
|
||||
filingType: latestFiling.filing_type,
|
||||
filingUrl: latestFiling.filing_url,
|
||||
submissionUrl: latestFiling.submission_url ?? null,
|
||||
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
|
||||
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
|
||||
}
|
||||
: null;
|
||||
const companyProfile = toCompanyProfile(secProfile, description);
|
||||
const valuationSnapshot = deriveValuationSnapshot({
|
||||
quote: liveQuote,
|
||||
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
|
||||
revenue: keyMetrics.revenue,
|
||||
cash: keyMetrics.cash,
|
||||
debt: keyMetrics.debt,
|
||||
netIncome: keyMetrics.netIncome
|
||||
});
|
||||
const synthesis = await synthesizeCompanyOverview({
|
||||
ticker,
|
||||
companyName,
|
||||
description,
|
||||
memo: input.localInputs.memo,
|
||||
latestFilingSummary,
|
||||
recentAiReports: aiReports.slice(0, 5),
|
||||
recentDevelopments: synthesizedDevelopments.items
|
||||
});
|
||||
const recentDevelopments = {
|
||||
...synthesizedDevelopments,
|
||||
weeklySnapshot: synthesis.weeklySnapshot,
|
||||
status: synthesizedDevelopments.items.length > 0
|
||||
? synthesis.weeklySnapshot ? 'ready' : 'partial'
|
||||
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
|
||||
} as const;
|
||||
|
||||
return {
|
||||
company: {
|
||||
ticker,
|
||||
companyName,
|
||||
sector: input.localInputs.watchlistItem?.sector ?? null,
|
||||
category: input.localInputs.watchlistItem?.category ?? null,
|
||||
tags: input.localInputs.watchlistItem?.tags ?? [],
|
||||
cik: latestFiling?.cik ?? null
|
||||
},
|
||||
quote: liveQuote,
|
||||
position: input.localInputs.holding,
|
||||
priceHistory,
|
||||
benchmarkHistory,
|
||||
financials,
|
||||
filings: redactedFilings.slice(0, 20),
|
||||
aiReports,
|
||||
coverage: input.localInputs.watchlistItem
|
||||
? {
|
||||
...input.localInputs.watchlistItem,
|
||||
latest_filing_date: latestFiling?.filing_date ?? input.localInputs.watchlistItem.latest_filing_date ?? null
|
||||
}
|
||||
: null,
|
||||
journalPreview: input.localInputs.journalPreview,
|
||||
recentAiReports: aiReports.slice(0, 5),
|
||||
latestFilingSummary,
|
||||
keyMetrics,
|
||||
companyProfile,
|
||||
valuationSnapshot,
|
||||
bullBear: synthesis.bullBear,
|
||||
recentDevelopments
|
||||
};
|
||||
}
|
||||
|
||||
type GetCompanyAnalysisPayloadOptions = {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
refresh?: boolean;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
type GetCompanyAnalysisPayloadDeps = {
|
||||
getLocalInputs?: (input: { userId: string; ticker: string }) => Promise<CompanyAnalysisLocalInputs>;
|
||||
getCachedOverview?: typeof getCompanyOverviewCache;
|
||||
upsertCachedOverview?: typeof upsertCompanyOverviewCache;
|
||||
buildOverview?: (input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
localInputs: CompanyAnalysisLocalInputs;
|
||||
}) => Promise<CompanyAnalysis>;
|
||||
};
|
||||
|
||||
export async function getCompanyAnalysisPayload(
|
||||
input: GetCompanyAnalysisPayloadOptions,
|
||||
deps?: GetCompanyAnalysisPayloadDeps
|
||||
): Promise<CompanyAnalysis> {
|
||||
const ticker = input.ticker.trim().toUpperCase();
|
||||
const now = input.now ?? new Date();
|
||||
const localInputs = await (deps?.getLocalInputs ?? getCompanyAnalysisLocalInputs)({
|
||||
userId: input.userId,
|
||||
ticker
|
||||
});
|
||||
const sourceSignature = buildCompanyAnalysisSourceSignature({
|
||||
ticker,
|
||||
localInputs
|
||||
});
|
||||
const cached = await (deps?.getCachedOverview ?? getCompanyOverviewCache)({
|
||||
userId: input.userId,
|
||||
ticker
|
||||
});
|
||||
|
||||
if (cached && isCompanyOverviewCacheFresh({
|
||||
updatedAt: cached.updated_at,
|
||||
sourceSignature: cached.source_signature,
|
||||
expectedSourceSignature: sourceSignature,
|
||||
cacheVersion: cached.cache_version,
|
||||
refresh: input.refresh,
|
||||
now
|
||||
})) {
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
const analysis = await (deps?.buildOverview ?? buildCompanyAnalysisPayload)({
|
||||
userId: input.userId,
|
||||
ticker,
|
||||
localInputs
|
||||
});
|
||||
|
||||
await (deps?.upsertCachedOverview ?? upsertCompanyOverviewCache)({
|
||||
userId: input.userId,
|
||||
ticker,
|
||||
sourceSignature,
|
||||
payload: analysis
|
||||
});
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
export const __companyAnalysisInternals = {
|
||||
COMPANY_OVERVIEW_CACHE_TTL_MS,
|
||||
buildCompanyAnalysisPayload,
|
||||
buildCompanyAnalysisSourceSignature,
|
||||
getCompanyAnalysisLocalInputs,
|
||||
isCompanyOverviewCacheFresh,
|
||||
withFinancialMetricsPolicy
|
||||
};
|
||||
@@ -47,6 +47,7 @@ describe('sqlite schema compatibility bootstrap', () => {
|
||||
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'company_overview_cache')).toBe(true);
|
||||
|
||||
__dbInternals.loadSqliteExtensions(client);
|
||||
__dbInternals.ensureSearchVirtualTables(client);
|
||||
|
||||
@@ -471,6 +471,10 @@ function ensureLocalSqliteSchema(client: Database) {
|
||||
applySqlFile(client, '0007_company_financial_bundles.sql');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'company_overview_cache')) {
|
||||
applySqlFile(client, '0012_company_overview_cache.sql');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'research_journal_entry')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (
|
||||
|
||||
@@ -607,6 +607,20 @@ export const companyFinancialBundle = sqliteTable('company_financial_bundle', {
|
||||
companyFinancialBundleTickerIndex: index('company_financial_bundle_ticker_idx').on(table.ticker, table.updated_at)
|
||||
}));
|
||||
|
||||
export const companyOverviewCache = sqliteTable('company_overview_cache', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
ticker: text('ticker').notNull(),
|
||||
cache_version: integer('cache_version').notNull(),
|
||||
source_signature: text('source_signature').notNull(),
|
||||
payload: text('payload', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
companyOverviewCacheUnique: uniqueIndex('company_overview_cache_uidx').on(table.user_id, table.ticker),
|
||||
companyOverviewCacheLookupIndex: index('company_overview_cache_lookup_idx').on(table.user_id, table.ticker, table.updated_at)
|
||||
}));
|
||||
|
||||
export const filingLink = sqliteTable('filing_link', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
filing_id: integer('filing_id').notNull().references(() => filing.id, { onDelete: 'cascade' }),
|
||||
@@ -831,6 +845,7 @@ export const appSchema = {
|
||||
filingTaxonomyFact,
|
||||
filingTaxonomyMetricValidation,
|
||||
companyFinancialBundle,
|
||||
companyOverviewCache,
|
||||
filingLink,
|
||||
taskRun,
|
||||
taskStageEvent,
|
||||
|
||||
64
lib/server/prices.test.ts
Normal file
64
lib/server/prices.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { __pricesInternals, getPriceHistory, getQuote } from './prices';
|
||||
|
||||
describe('price caching', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
__pricesInternals.resetCaches();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
__pricesInternals.resetCaches();
|
||||
});
|
||||
|
||||
it('reuses the cached quote within the ttl window', async () => {
|
||||
const fetchMock = mock(async () => Response.json({
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
meta: {
|
||||
regularMarketPrice: 123.45
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})) as unknown as typeof fetch;
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const first = await getQuote('MSFT');
|
||||
const second = await getQuote('MSFT');
|
||||
|
||||
expect(first).toBe(123.45);
|
||||
expect(second).toBe(123.45);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reuses cached price history within the ttl window', async () => {
|
||||
const fetchMock = mock(async () => Response.json({
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
timestamp: [1735689600, 1736294400],
|
||||
indicators: {
|
||||
quote: [
|
||||
{
|
||||
close: [100, 105]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})) as unknown as typeof fetch;
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const first = await getPriceHistory('MSFT');
|
||||
const second = await getPriceHistory('MSFT');
|
||||
|
||||
expect(first).toHaveLength(2);
|
||||
expect(second).toEqual(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
||||
const QUOTE_CACHE_TTL_MS = 1000 * 60;
|
||||
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
|
||||
|
||||
type CacheEntry<T> = {
|
||||
expiresAt: number;
|
||||
value: T;
|
||||
};
|
||||
|
||||
const quoteCache = new Map<string, CacheEntry<number>>();
|
||||
const priceHistoryCache = new Map<string, CacheEntry<Array<{ date: string; close: number }>>>();
|
||||
|
||||
function buildYahooChartUrl(ticker: string, params: string) {
|
||||
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
|
||||
@@ -17,7 +27,12 @@ function fallbackQuote(ticker: string) {
|
||||
|
||||
export async function getQuote(ticker: string): Promise<number> {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const cached = quoteCache.get(normalizedTicker);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
let quote = fallbackQuote(normalizedTicker);
|
||||
try {
|
||||
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
|
||||
headers: {
|
||||
@@ -27,7 +42,11 @@ export async function getQuote(ticker: string): Promise<number> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return fallbackQuote(normalizedTicker);
|
||||
quoteCache.set(normalizedTicker, {
|
||||
value: quote,
|
||||
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
||||
});
|
||||
return quote;
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
@@ -38,13 +57,23 @@ export async function getQuote(ticker: string): Promise<number> {
|
||||
|
||||
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
||||
if (typeof price !== 'number' || !Number.isFinite(price)) {
|
||||
return fallbackQuote(normalizedTicker);
|
||||
quoteCache.set(normalizedTicker, {
|
||||
value: quote,
|
||||
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
||||
});
|
||||
return quote;
|
||||
}
|
||||
|
||||
return price;
|
||||
quote = price;
|
||||
} catch {
|
||||
return fallbackQuote(normalizedTicker);
|
||||
// fall through to cached fallback
|
||||
}
|
||||
|
||||
quoteCache.set(normalizedTicker, {
|
||||
value: quote,
|
||||
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
|
||||
@@ -145,6 +174,10 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[]
|
||||
|
||||
export async function getPriceHistory(ticker: string): Promise<Array<{ date: string; close: number }>> {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const cached = priceHistoryCache.get(normalizedTicker);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
|
||||
@@ -190,6 +223,10 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
||||
.filter((entry): entry is { date: string; close: number } => entry !== null);
|
||||
|
||||
if (points.length > 0) {
|
||||
priceHistoryCache.set(normalizedTicker, {
|
||||
value: points,
|
||||
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
||||
});
|
||||
return points;
|
||||
}
|
||||
} catch {
|
||||
@@ -201,7 +238,7 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
||||
|
||||
const totalWeeks = 20 * 52;
|
||||
|
||||
return Array.from({ length: totalWeeks }, (_, index) => {
|
||||
const syntheticHistory = Array.from({ length: totalWeeks }, (_, index) => {
|
||||
const step = (totalWeeks - 1) - index;
|
||||
const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const wave = Math.sin(index / 8) * 0.06;
|
||||
@@ -213,4 +250,20 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
||||
close: Number(close.toFixed(2))
|
||||
};
|
||||
});
|
||||
|
||||
priceHistoryCache.set(normalizedTicker, {
|
||||
value: syntheticHistory,
|
||||
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
return syntheticHistory;
|
||||
}
|
||||
|
||||
export const __pricesInternals = {
|
||||
PRICE_HISTORY_CACHE_TTL_MS,
|
||||
QUOTE_CACHE_TTL_MS,
|
||||
resetCaches() {
|
||||
quoteCache.clear();
|
||||
priceHistoryCache.clear();
|
||||
}
|
||||
};
|
||||
|
||||
202
lib/server/repos/company-overview-cache.test.ts
Normal file
202
lib/server/repos/company-overview-cache.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
|
||||
const TEST_USER_ID = 'overview-cache-user';
|
||||
|
||||
let tempDir: string | null = null;
|
||||
let sqliteClient: Database | null = null;
|
||||
let overviewCacheRepo: typeof import('./company-overview-cache') | null = null;
|
||||
|
||||
function resetDbSingletons() {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
__fiscalSqliteClient?: Database;
|
||||
__fiscalDrizzleDb?: unknown;
|
||||
};
|
||||
|
||||
globalState.__fiscalSqliteClient?.close();
|
||||
globalState.__fiscalSqliteClient = undefined;
|
||||
globalState.__fiscalDrizzleDb = undefined;
|
||||
}
|
||||
|
||||
function applyMigration(client: Database, fileName: string) {
|
||||
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function ensureUser(client: Database) {
|
||||
const now = Date.now();
|
||||
client.exec(`
|
||||
INSERT OR REPLACE INTO user (id, name, email, emailVerified, image, createdAt, updatedAt, role, banned, banReason, banExpires)
|
||||
VALUES ('${TEST_USER_ID}', 'Overview Cache User', 'overview-cache@example.com', 1, NULL, ${now}, ${now}, NULL, 0, NULL, NULL);
|
||||
`);
|
||||
}
|
||||
|
||||
function clearCache(client: Database) {
|
||||
client.exec('DELETE FROM company_overview_cache;');
|
||||
}
|
||||
|
||||
function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
||||
return {
|
||||
company: {
|
||||
ticker: 'MSFT',
|
||||
companyName,
|
||||
sector: null,
|
||||
category: null,
|
||||
tags: [],
|
||||
cik: null
|
||||
},
|
||||
quote: 100,
|
||||
position: null,
|
||||
priceHistory: [],
|
||||
benchmarkHistory: [],
|
||||
financials: [],
|
||||
filings: [],
|
||||
aiReports: [],
|
||||
coverage: null,
|
||||
journalPreview: [],
|
||||
recentAiReports: [],
|
||||
latestFilingSummary: null,
|
||||
keyMetrics: {
|
||||
referenceDate: null,
|
||||
revenue: null,
|
||||
netIncome: null,
|
||||
totalAssets: null,
|
||||
cash: null,
|
||||
debt: null,
|
||||
netMargin: null
|
||||
},
|
||||
companyProfile: {
|
||||
description: null,
|
||||
exchange: null,
|
||||
industry: null,
|
||||
country: null,
|
||||
website: null,
|
||||
fiscalYearEnd: null,
|
||||
employeeCount: null,
|
||||
source: 'unavailable'
|
||||
},
|
||||
valuationSnapshot: {
|
||||
sharesOutstanding: null,
|
||||
marketCap: null,
|
||||
enterpriseValue: null,
|
||||
trailingPe: null,
|
||||
evToRevenue: null,
|
||||
evToEbitda: null,
|
||||
source: 'unavailable'
|
||||
},
|
||||
bullBear: {
|
||||
source: 'unavailable',
|
||||
bull: [],
|
||||
bear: [],
|
||||
updatedAt: null
|
||||
},
|
||||
recentDevelopments: {
|
||||
status: 'unavailable',
|
||||
items: [],
|
||||
weeklySnapshot: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('company overview cache repo', () => {
|
||||
beforeAll(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'fiscal-overview-cache-'));
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
env.DATABASE_URL = `file:${join(tempDir, 'repo.sqlite')}`;
|
||||
env.NODE_ENV = 'test';
|
||||
|
||||
resetDbSingletons();
|
||||
|
||||
sqliteClient = new Database(join(tempDir, 'repo.sqlite'), { create: true });
|
||||
sqliteClient.exec('PRAGMA foreign_keys = ON;');
|
||||
applyMigration(sqliteClient, '0000_cold_silver_centurion.sql');
|
||||
applyMigration(sqliteClient, '0012_company_overview_cache.sql');
|
||||
ensureUser(sqliteClient);
|
||||
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
__fiscalSqliteClient?: Database;
|
||||
__fiscalDrizzleDb?: unknown;
|
||||
};
|
||||
globalState.__fiscalSqliteClient = sqliteClient;
|
||||
globalState.__fiscalDrizzleDb = undefined;
|
||||
|
||||
overviewCacheRepo = await import('./company-overview-cache');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
sqliteClient?.close();
|
||||
resetDbSingletons();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
if (!sqliteClient) {
|
||||
throw new Error('sqlite client not initialized');
|
||||
}
|
||||
|
||||
clearCache(sqliteClient);
|
||||
});
|
||||
|
||||
it('upserts and reloads cached overview payloads', async () => {
|
||||
if (!overviewCacheRepo) {
|
||||
throw new Error('overview cache repo not initialized');
|
||||
}
|
||||
|
||||
const first = await overviewCacheRepo.upsertCompanyOverviewCache({
|
||||
userId: TEST_USER_ID,
|
||||
ticker: 'msft',
|
||||
sourceSignature: 'sig-1',
|
||||
payload: buildAnalysisPayload('Microsoft Corporation')
|
||||
});
|
||||
|
||||
const loaded = await overviewCacheRepo.getCompanyOverviewCache({
|
||||
userId: TEST_USER_ID,
|
||||
ticker: 'MSFT'
|
||||
});
|
||||
|
||||
expect(first.ticker).toBe('MSFT');
|
||||
expect(loaded?.source_signature).toBe('sig-1');
|
||||
expect(loaded?.payload.company.companyName).toBe('Microsoft Corporation');
|
||||
});
|
||||
|
||||
it('updates existing cached rows in place', async () => {
|
||||
if (!overviewCacheRepo) {
|
||||
throw new Error('overview cache repo not initialized');
|
||||
}
|
||||
|
||||
const first = await overviewCacheRepo.upsertCompanyOverviewCache({
|
||||
userId: TEST_USER_ID,
|
||||
ticker: 'MSFT',
|
||||
sourceSignature: 'sig-1',
|
||||
payload: buildAnalysisPayload('Old Name')
|
||||
});
|
||||
const second = await overviewCacheRepo.upsertCompanyOverviewCache({
|
||||
userId: TEST_USER_ID,
|
||||
ticker: 'MSFT',
|
||||
sourceSignature: 'sig-2',
|
||||
payload: buildAnalysisPayload('New Name')
|
||||
});
|
||||
|
||||
const loaded = await overviewCacheRepo.getCompanyOverviewCache({
|
||||
userId: TEST_USER_ID,
|
||||
ticker: 'MSFT'
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(loaded?.source_signature).toBe('sig-2');
|
||||
expect(loaded?.payload.company.companyName).toBe('New Name');
|
||||
});
|
||||
});
|
||||
102
lib/server/repos/company-overview-cache.ts
Normal file
102
lib/server/repos/company-overview-cache.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
import { getSqliteClient } from '@/lib/server/db';
|
||||
import { companyOverviewCache, schema } from '@/lib/server/db/schema';
|
||||
|
||||
export const CURRENT_COMPANY_OVERVIEW_CACHE_VERSION = 1;
|
||||
|
||||
export type CompanyOverviewCacheRecord = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
ticker: string;
|
||||
cache_version: number;
|
||||
source_signature: string;
|
||||
payload: CompanyAnalysis;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toRecord(row: typeof companyOverviewCache.$inferSelect): CompanyOverviewCacheRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
ticker: row.ticker,
|
||||
cache_version: row.cache_version,
|
||||
source_signature: row.source_signature,
|
||||
payload: row.payload as CompanyAnalysis,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
return drizzle(getSqliteClient(), { schema });
|
||||
}
|
||||
|
||||
export async function getCompanyOverviewCache(input: { userId: string; ticker: string }) {
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [row] = await getDb()
|
||||
.select()
|
||||
.from(companyOverviewCache)
|
||||
.where(and(
|
||||
eq(companyOverviewCache.user_id, input.userId),
|
||||
eq(companyOverviewCache.ticker, normalizedTicker)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return row ? toRecord(row) : null;
|
||||
}
|
||||
|
||||
export async function upsertCompanyOverviewCache(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
sourceSignature: string;
|
||||
payload: CompanyAnalysis;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
|
||||
const [saved] = await getDb()
|
||||
.insert(companyOverviewCache)
|
||||
.values({
|
||||
user_id: input.userId,
|
||||
ticker: normalizedTicker,
|
||||
cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||
source_signature: input.sourceSignature,
|
||||
payload: input.payload as unknown as Record<string, unknown>,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [companyOverviewCache.user_id, companyOverviewCache.ticker],
|
||||
set: {
|
||||
cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||
source_signature: input.sourceSignature,
|
||||
payload: input.payload as unknown as Record<string, unknown>,
|
||||
updated_at: now
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toRecord(saved);
|
||||
}
|
||||
|
||||
export async function deleteCompanyOverviewCache(input: { userId: string; ticker: string }) {
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
|
||||
return await getDb()
|
||||
.delete(companyOverviewCache)
|
||||
.where(and(
|
||||
eq(companyOverviewCache.user_id, input.userId),
|
||||
eq(companyOverviewCache.ticker, normalizedTicker)
|
||||
));
|
||||
}
|
||||
|
||||
export const __companyOverviewCacheInternals = {
|
||||
CACHE_VERSION: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION
|
||||
};
|
||||
@@ -67,7 +67,8 @@ describe('task repos', () => {
|
||||
'0006_coverage_journal_tracking.sql',
|
||||
'0007_company_financial_bundles.sql',
|
||||
'0008_research_workspace.sql',
|
||||
'0009_task_notification_context.sql'
|
||||
'0009_task_notification_context.sql',
|
||||
'0012_company_overview_cache.sql'
|
||||
]) {
|
||||
applyMigration(sqliteClient, file);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user