Add research workspace and graphing flows
This commit is contained in:
215
lib/api.ts
215
lib/api.ts
@@ -12,8 +12,19 @@ import type {
|
||||
Holding,
|
||||
PortfolioInsight,
|
||||
PortfolioSummary,
|
||||
ResearchArtifact,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchJournalEntry,
|
||||
ResearchJournalEntryType,
|
||||
ResearchLibraryResponse,
|
||||
ResearchMemo,
|
||||
ResearchMemoConviction,
|
||||
ResearchMemoEvidenceLink,
|
||||
ResearchMemoRating,
|
||||
ResearchMemoSection,
|
||||
ResearchPacket,
|
||||
ResearchWorkspace,
|
||||
Task,
|
||||
TaskStatus,
|
||||
TaskTimeline,
|
||||
@@ -105,7 +116,7 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
|
||||
|
||||
async function requestJson<T>(input: {
|
||||
path: string;
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
}, fallback: string) {
|
||||
const response = await fetch(`${API_BASE}${input.path}`, {
|
||||
@@ -206,6 +217,208 @@ export async function createResearchJournalEntry(input: {
|
||||
}, 'Unable to create journal entry');
|
||||
}
|
||||
|
||||
export async function getResearchWorkspace(ticker: string) {
|
||||
return await requestJson<{ workspace: ResearchWorkspace }>({
|
||||
path: `/api/research/workspace?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research workspace');
|
||||
}
|
||||
|
||||
export async function listResearchLibrary(input: {
|
||||
ticker: string;
|
||||
q?: string;
|
||||
kind?: ResearchArtifactKind;
|
||||
tag?: string;
|
||||
source?: ResearchArtifactSource;
|
||||
linkedToMemo?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
});
|
||||
|
||||
if (input.q?.trim()) {
|
||||
params.set('q', input.q.trim());
|
||||
}
|
||||
|
||||
if (input.kind) {
|
||||
params.set('kind', input.kind);
|
||||
}
|
||||
|
||||
if (input.tag?.trim()) {
|
||||
params.set('tag', input.tag.trim());
|
||||
}
|
||||
|
||||
if (input.source) {
|
||||
params.set('source', input.source);
|
||||
}
|
||||
|
||||
if (input.linkedToMemo !== undefined) {
|
||||
params.set('linkedToMemo', input.linkedToMemo ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (typeof input.limit === 'number') {
|
||||
params.set('limit', String(input.limit));
|
||||
}
|
||||
|
||||
return await requestJson<ResearchLibraryResponse>({
|
||||
path: `/api/research/library?${params.toString()}`
|
||||
}, 'Unable to fetch research library');
|
||||
}
|
||||
|
||||
export async function createResearchArtifact(input: {
|
||||
ticker: string;
|
||||
accessionNumber?: string;
|
||||
kind: ResearchArtifactKind;
|
||||
source?: ResearchArtifactSource;
|
||||
subtype?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
bodyMarkdown?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return await requestJson<{ artifact: ResearchArtifact }>({
|
||||
path: '/api/research/library',
|
||||
method: 'POST',
|
||||
body: {
|
||||
...input,
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
}
|
||||
}, 'Unable to create research artifact');
|
||||
}
|
||||
|
||||
export async function updateResearchArtifact(id: number, input: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
bodyMarkdown?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return await requestJson<{ artifact: ResearchArtifact }>({
|
||||
path: `/api/research/library/${id}`,
|
||||
method: 'PATCH',
|
||||
body: input
|
||||
}, 'Unable to update research artifact');
|
||||
}
|
||||
|
||||
export async function deleteResearchArtifact(id: number) {
|
||||
return await requestJson<{ success: boolean }>({
|
||||
path: `/api/research/library/${id}`,
|
||||
method: 'DELETE'
|
||||
}, 'Unable to delete research artifact');
|
||||
}
|
||||
|
||||
export async function uploadResearchArtifact(input: {
|
||||
ticker: string;
|
||||
file: File;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
}) {
|
||||
const form = new FormData();
|
||||
form.set('ticker', input.ticker.trim().toUpperCase());
|
||||
form.set('file', input.file);
|
||||
|
||||
if (input.title?.trim()) {
|
||||
form.set('title', input.title.trim());
|
||||
}
|
||||
|
||||
if (input.summary?.trim()) {
|
||||
form.set('summary', input.summary.trim());
|
||||
}
|
||||
|
||||
if (input.tags && input.tags.length > 0) {
|
||||
form.set('tags', input.tags.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/research/library/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: form
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
extractErrorMessage({ value: payload }, 'Unable to upload research file'),
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new ApiError('Unable to upload research file', response.status);
|
||||
}
|
||||
|
||||
return payload as { artifact: ResearchArtifact };
|
||||
}
|
||||
|
||||
export function getResearchArtifactFileUrl(id: number) {
|
||||
return `${API_BASE}/api/research/library/${id}/file`;
|
||||
}
|
||||
|
||||
export async function getResearchMemo(ticker: string) {
|
||||
return await requestJson<{ memo: ResearchMemo | null }>({
|
||||
path: `/api/research/memo?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research memo');
|
||||
}
|
||||
|
||||
export async function upsertResearchMemo(input: {
|
||||
ticker: string;
|
||||
rating?: ResearchMemoRating | null;
|
||||
conviction?: ResearchMemoConviction | null;
|
||||
timeHorizonMonths?: number | null;
|
||||
packetTitle?: string;
|
||||
packetSubtitle?: string;
|
||||
thesisMarkdown?: string;
|
||||
variantViewMarkdown?: string;
|
||||
catalystsMarkdown?: string;
|
||||
risksMarkdown?: string;
|
||||
disconfirmingEvidenceMarkdown?: string;
|
||||
nextActionsMarkdown?: string;
|
||||
}) {
|
||||
return await requestJson<{ memo: ResearchMemo }>({
|
||||
path: '/api/research/memo',
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...input,
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
}
|
||||
}, 'Unable to save research memo');
|
||||
}
|
||||
|
||||
export async function addResearchMemoEvidence(input: {
|
||||
memoId: number;
|
||||
artifactId: number;
|
||||
section: ResearchMemoSection;
|
||||
annotation?: string;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
return await requestJson<{ evidence: ResearchMemoEvidenceLink[] }>({
|
||||
path: `/api/research/memo/${input.memoId}/evidence`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
artifactId: input.artifactId,
|
||||
section: input.section,
|
||||
annotation: input.annotation,
|
||||
sortOrder: input.sortOrder
|
||||
}
|
||||
}, 'Unable to attach memo evidence');
|
||||
}
|
||||
|
||||
export async function deleteResearchMemoEvidence(memoId: number, linkId: number) {
|
||||
return await requestJson<{ success: boolean }>({
|
||||
path: `/api/research/memo/${memoId}/evidence/${linkId}`,
|
||||
method: 'DELETE'
|
||||
}, 'Unable to delete memo evidence');
|
||||
}
|
||||
|
||||
export async function getResearchPacket(ticker: string) {
|
||||
return await requestJson<{ packet: ResearchPacket }>({
|
||||
path: `/api/research/packet?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research packet');
|
||||
}
|
||||
|
||||
export async function updateResearchJournalEntry(id: number, input: {
|
||||
title?: string;
|
||||
bodyMarkdown?: string;
|
||||
|
||||
147
lib/financial-metrics.ts
Normal file
147
lib/financial-metrics.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind,
|
||||
FinancialUnit,
|
||||
RatioRow
|
||||
} from '@/lib/types';
|
||||
|
||||
export type GraphableFinancialSurfaceKind = Extract<
|
||||
FinancialSurfaceKind,
|
||||
'income_statement' | 'balance_sheet' | 'cash_flow_statement' | 'ratios'
|
||||
>;
|
||||
|
||||
export type StatementMetricDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
localNames?: readonly string[];
|
||||
labelIncludes?: readonly string[];
|
||||
};
|
||||
|
||||
export type RatioMetricDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: RatioRow['unit'];
|
||||
denominatorKey: string | null;
|
||||
supportedCadences?: readonly FinancialCadence[];
|
||||
};
|
||||
|
||||
export const GRAPHABLE_FINANCIAL_SURFACES: readonly GraphableFinancialSurfaceKind[] = [
|
||||
'income_statement',
|
||||
'balance_sheet',
|
||||
'cash_flow_statement',
|
||||
'ratios'
|
||||
] as const;
|
||||
|
||||
export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
|
||||
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
|
||||
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
|
||||
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
|
||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
|
||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
|
||||
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
|
||||
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
|
||||
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
|
||||
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
|
||||
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
|
||||
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
|
||||
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
|
||||
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
|
||||
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
|
||||
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
|
||||
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
|
||||
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
|
||||
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
|
||||
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
|
||||
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
|
||||
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
|
||||
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
|
||||
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
|
||||
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
|
||||
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
|
||||
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders’ equity', 'stockholders equity'] },
|
||||
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const CASH_FLOW_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
|
||||
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
|
||||
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
|
||||
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
|
||||
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
|
||||
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
|
||||
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
|
||||
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const RATIO_DEFINITIONS: RatioMetricDefinition[] = [
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
|
||||
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
|
||||
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
|
||||
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
|
||||
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
|
||||
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
|
||||
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
|
||||
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
|
||||
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
|
||||
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
|
||||
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
|
||||
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
|
||||
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
|
||||
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
|
||||
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
|
||||
] as const satisfies RatioMetricDefinition[];
|
||||
|
||||
export const RATIO_CATEGORY_ORDER = [
|
||||
'margins',
|
||||
'returns',
|
||||
'financial_health',
|
||||
'per_share',
|
||||
'growth',
|
||||
'valuation'
|
||||
] as const;
|
||||
51
lib/graphing/catalog.test.ts
Normal file
51
lib/graphing/catalog.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
DEFAULT_GRAPH_TICKERS,
|
||||
buildGraphingHref,
|
||||
metricsForSurfaceAndCadence,
|
||||
normalizeGraphTickers,
|
||||
parseGraphingParams
|
||||
} from '@/lib/graphing/catalog';
|
||||
|
||||
describe('graphing catalog', () => {
|
||||
it('normalizes compare set tickers with dedupe and max five', () => {
|
||||
expect(normalizeGraphTickers(' msft, aapl, msft, nvda, amd, goog, meta ')).toEqual([
|
||||
'MSFT',
|
||||
'AAPL',
|
||||
'NVDA',
|
||||
'AMD',
|
||||
'GOOG'
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to defaults when params are missing or invalid', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=invalid&metric=made_up&chart=nope'));
|
||||
|
||||
expect(state.tickers).toEqual([...DEFAULT_GRAPH_TICKERS]);
|
||||
expect(state.surface).toBe('income_statement');
|
||||
expect(state.metric).toBe('revenue');
|
||||
expect(state.chart).toBe('line');
|
||||
expect(state.scale).toBe('millions');
|
||||
});
|
||||
|
||||
it('filters annual-only ratio metrics for non-annual views', () => {
|
||||
const quarterlyMetricKeys = metricsForSurfaceAndCadence('ratios', 'quarterly').map((metric) => metric.key);
|
||||
|
||||
expect(quarterlyMetricKeys).not.toContain('3y_revenue_cagr');
|
||||
expect(quarterlyMetricKeys).not.toContain('5y_eps_cagr');
|
||||
expect(quarterlyMetricKeys).toContain('gross_margin');
|
||||
});
|
||||
|
||||
it('replaces invalid metrics after surface and cadence normalization', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));
|
||||
|
||||
expect(state.surface).toBe('ratios');
|
||||
expect(state.cadence).toBe('quarterly');
|
||||
expect(state.metric).toBe('gross_margin');
|
||||
expect(state.tickers).toEqual(['MSFT', 'AAPL']);
|
||||
});
|
||||
|
||||
it('builds graphing hrefs with the primary ticker leading the compare set', () => {
|
||||
expect(buildGraphingHref('amd')).toContain('tickers=AMD%2CMSFT%2CAAPL%2CNVDA');
|
||||
});
|
||||
});
|
||||
238
lib/graphing/catalog.ts
Normal file
238
lib/graphing/catalog.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialUnit,
|
||||
NumberScaleUnit
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
|
||||
GRAPHABLE_FINANCIAL_SURFACES,
|
||||
type GraphableFinancialSurfaceKind,
|
||||
INCOME_STATEMENT_METRIC_DEFINITIONS,
|
||||
RATIO_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
type SearchParamsLike = {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
|
||||
export type GraphChartKind = 'line' | 'bar';
|
||||
|
||||
export type GraphMetricDefinition = {
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
supportedCadences: readonly FinancialCadence[];
|
||||
};
|
||||
|
||||
export type GraphingUrlState = {
|
||||
tickers: string[];
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
metric: string;
|
||||
cadence: FinancialCadence;
|
||||
chart: GraphChartKind;
|
||||
scale: NumberScaleUnit;
|
||||
};
|
||||
|
||||
export const DEFAULT_GRAPH_TICKERS = ['MSFT', 'AAPL', 'NVDA'] as const;
|
||||
export const DEFAULT_GRAPH_SURFACE: GraphableFinancialSurfaceKind = 'income_statement';
|
||||
export const DEFAULT_GRAPH_CADENCE: FinancialCadence = 'annual';
|
||||
export const DEFAULT_GRAPH_CHART: GraphChartKind = 'line';
|
||||
export const DEFAULT_GRAPH_SCALE: NumberScaleUnit = 'millions';
|
||||
|
||||
export const GRAPH_SURFACE_LABELS: Record<GraphableFinancialSurfaceKind, string> = {
|
||||
income_statement: 'Income Statement',
|
||||
balance_sheet: 'Balance Sheet',
|
||||
cash_flow_statement: 'Cash Flow Statement',
|
||||
ratios: 'Ratios'
|
||||
};
|
||||
|
||||
export const GRAPH_CADENCE_OPTIONS: Array<{ value: FinancialCadence; label: string }> = [
|
||||
{ value: 'annual', label: 'Annual' },
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'ltm', label: 'LTM' }
|
||||
];
|
||||
|
||||
export const GRAPH_CHART_OPTIONS: Array<{ value: GraphChartKind; label: string }> = [
|
||||
{ value: 'line', label: 'Line' },
|
||||
{ value: 'bar', label: 'Bar' }
|
||||
];
|
||||
|
||||
export const GRAPH_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||
{ value: 'thousands', label: 'Thousands (K)' },
|
||||
{ value: 'millions', label: 'Millions (M)' },
|
||||
{ value: 'billions', label: 'Billions (B)' }
|
||||
];
|
||||
|
||||
function buildStatementMetrics(
|
||||
surface: Extract<GraphableFinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>,
|
||||
metrics: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
}>
|
||||
) {
|
||||
return metrics.map((metric) => ({
|
||||
...metric,
|
||||
surface,
|
||||
supportedCadences: ['annual', 'quarterly', 'ltm'] as const
|
||||
})) satisfies GraphMetricDefinition[];
|
||||
}
|
||||
|
||||
export const GRAPH_METRIC_CATALOG: Record<GraphableFinancialSurfaceKind, GraphMetricDefinition[]> = {
|
||||
income_statement: buildStatementMetrics('income_statement', INCOME_STATEMENT_METRIC_DEFINITIONS),
|
||||
balance_sheet: buildStatementMetrics('balance_sheet', BALANCE_SHEET_METRIC_DEFINITIONS),
|
||||
cash_flow_statement: buildStatementMetrics('cash_flow_statement', CASH_FLOW_STATEMENT_METRIC_DEFINITIONS),
|
||||
ratios: RATIO_DEFINITIONS.map((metric) => ({
|
||||
surface: 'ratios',
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
category: metric.category,
|
||||
order: metric.order,
|
||||
unit: metric.unit,
|
||||
supportedCadences: metric.supportedCadences ?? ['annual', 'quarterly', 'ltm']
|
||||
}))
|
||||
};
|
||||
|
||||
export const DEFAULT_GRAPH_METRIC_BY_SURFACE: Record<GraphableFinancialSurfaceKind, string> = {
|
||||
income_statement: 'revenue',
|
||||
balance_sheet: 'total_assets',
|
||||
cash_flow_statement: 'free_cash_flow',
|
||||
ratios: 'gross_margin'
|
||||
};
|
||||
|
||||
export function normalizeGraphTickers(value: string | null | undefined) {
|
||||
const raw = (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const ticker of raw) {
|
||||
unique.add(ticker);
|
||||
if (unique.size >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
export function isGraphSurfaceKind(value: string | null | undefined): value is GraphableFinancialSurfaceKind {
|
||||
return GRAPHABLE_FINANCIAL_SURFACES.includes(value as GraphableFinancialSurfaceKind);
|
||||
}
|
||||
|
||||
export function isGraphCadence(value: string | null | undefined): value is FinancialCadence {
|
||||
return value === 'annual' || value === 'quarterly' || value === 'ltm';
|
||||
}
|
||||
|
||||
export function isGraphChartKind(value: string | null | undefined): value is GraphChartKind {
|
||||
return value === 'line' || value === 'bar';
|
||||
}
|
||||
|
||||
export function isNumberScaleUnit(value: string | null | undefined): value is NumberScaleUnit {
|
||||
return value === 'thousands' || value === 'millions' || value === 'billions';
|
||||
}
|
||||
|
||||
export function metricsForSurfaceAndCadence(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence
|
||||
) {
|
||||
return GRAPH_METRIC_CATALOG[surface].filter((metric) => metric.supportedCadences.includes(cadence));
|
||||
}
|
||||
|
||||
export function resolveGraphMetric(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence,
|
||||
metric: string | null | undefined
|
||||
) {
|
||||
const metrics = metricsForSurfaceAndCadence(surface, cadence);
|
||||
const normalizedMetric = metric?.trim() ?? '';
|
||||
const match = metrics.find((candidate) => candidate.key === normalizedMetric);
|
||||
|
||||
if (match) {
|
||||
return match.key;
|
||||
}
|
||||
|
||||
const surfaceDefault = metrics.find((candidate) => candidate.key === DEFAULT_GRAPH_METRIC_BY_SURFACE[surface]);
|
||||
return surfaceDefault?.key ?? metrics[0]?.key ?? DEFAULT_GRAPH_METRIC_BY_SURFACE[surface];
|
||||
}
|
||||
|
||||
export function getGraphMetricDefinition(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence,
|
||||
metric: string
|
||||
) {
|
||||
return metricsForSurfaceAndCadence(surface, cadence).find((candidate) => candidate.key === metric) ?? null;
|
||||
}
|
||||
|
||||
export function defaultGraphingState(): GraphingUrlState {
|
||||
return {
|
||||
tickers: [...DEFAULT_GRAPH_TICKERS],
|
||||
surface: DEFAULT_GRAPH_SURFACE,
|
||||
metric: DEFAULT_GRAPH_METRIC_BY_SURFACE[DEFAULT_GRAPH_SURFACE],
|
||||
cadence: DEFAULT_GRAPH_CADENCE,
|
||||
chart: DEFAULT_GRAPH_CHART,
|
||||
scale: DEFAULT_GRAPH_SCALE
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGraphingParams(searchParams: SearchParamsLike): GraphingUrlState {
|
||||
const tickers = normalizeGraphTickers(searchParams.get('tickers'));
|
||||
const surface = isGraphSurfaceKind(searchParams.get('surface'))
|
||||
? searchParams.get('surface') as GraphableFinancialSurfaceKind
|
||||
: DEFAULT_GRAPH_SURFACE;
|
||||
const cadence = isGraphCadence(searchParams.get('cadence'))
|
||||
? searchParams.get('cadence') as FinancialCadence
|
||||
: DEFAULT_GRAPH_CADENCE;
|
||||
const metric = resolveGraphMetric(surface, cadence, searchParams.get('metric'));
|
||||
const chart = isGraphChartKind(searchParams.get('chart'))
|
||||
? searchParams.get('chart') as GraphChartKind
|
||||
: DEFAULT_GRAPH_CHART;
|
||||
const scale = isNumberScaleUnit(searchParams.get('scale'))
|
||||
? searchParams.get('scale') as NumberScaleUnit
|
||||
: DEFAULT_GRAPH_SCALE;
|
||||
|
||||
return {
|
||||
tickers: tickers.length > 0 ? tickers : [...DEFAULT_GRAPH_TICKERS],
|
||||
surface,
|
||||
metric,
|
||||
cadence,
|
||||
chart,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGraphingParams(state: GraphingUrlState) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tickers', state.tickers.join(','));
|
||||
params.set('surface', state.surface);
|
||||
params.set('metric', state.metric);
|
||||
params.set('cadence', state.cadence);
|
||||
params.set('chart', state.chart);
|
||||
params.set('scale', state.scale);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function withPrimaryGraphTicker(ticker: string | null | undefined) {
|
||||
const normalized = ticker?.trim().toUpperCase() ?? '';
|
||||
if (!normalized) {
|
||||
return [...DEFAULT_GRAPH_TICKERS];
|
||||
}
|
||||
|
||||
return normalizeGraphTickers([normalized, ...DEFAULT_GRAPH_TICKERS].join(','));
|
||||
}
|
||||
|
||||
export function buildGraphingHref(primaryTicker?: string | null) {
|
||||
const tickers = withPrimaryGraphTicker(primaryTicker);
|
||||
const params = serializeGraphingParams({
|
||||
...defaultGraphingState(),
|
||||
tickers
|
||||
});
|
||||
return `/graphing?${params}`;
|
||||
}
|
||||
225
lib/graphing/series.test.ts
Normal file
225
lib/graphing/series.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildGraphingComparisonData } from '@/lib/graphing/series';
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialStatementPeriod,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
|
||||
function createPeriod(input: {
|
||||
id: string;
|
||||
filingId: number;
|
||||
filingDate: string;
|
||||
periodEnd: string;
|
||||
filingType?: '10-K' | '10-Q';
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
filingId: input.filingId,
|
||||
accessionNumber: `0000-${input.filingId}`,
|
||||
filingDate: input.filingDate,
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: input.periodEnd,
|
||||
filingType: input.filingType ?? '10-Q',
|
||||
periodLabel: input.id
|
||||
} satisfies FinancialStatementPeriod;
|
||||
}
|
||||
|
||||
function createStatementRow(key: string, values: Record<string, number | null>, unit: StandardizedFinancialRow['unit'] = 'currency') {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
category: 'test',
|
||||
order: 10,
|
||||
unit,
|
||||
values,
|
||||
sourceConcepts: [key],
|
||||
sourceRowKeys: [key],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
|
||||
} satisfies StandardizedFinancialRow;
|
||||
}
|
||||
|
||||
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
|
||||
return {
|
||||
...createStatementRow(key, values, unit),
|
||||
denominatorKey: null
|
||||
} satisfies RatioRow;
|
||||
}
|
||||
|
||||
function createFinancials(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
periods: FinancialStatementPeriod[];
|
||||
statementRows?: StandardizedFinancialRow[];
|
||||
ratioRows?: RatioRow[];
|
||||
}) {
|
||||
return {
|
||||
company: {
|
||||
ticker: input.ticker,
|
||||
companyName: input.companyName,
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
displayModes: ['standardized'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: input.periods,
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: input.statementRows ?? []
|
||||
},
|
||||
ratioRows: input.ratioRows ?? [],
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
categories: [],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: input.periods.length,
|
||||
rows: input.statementRows?.length ?? 0,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: input.periods.length,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
} satisfies CompanyFinancialStatementsResponse;
|
||||
}
|
||||
|
||||
describe('graphing series', () => {
|
||||
it('aligns multiple companies onto a union date axis', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'MSFT',
|
||||
financials: createFinancials({
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft',
|
||||
periods: [
|
||||
createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
|
||||
createPeriod({ id: 'msft-q2', filingId: 2, filingDate: '2025-04-28', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'msft-q1': 100, 'msft-q2': 120 })]
|
||||
})
|
||||
},
|
||||
{
|
||||
ticker: 'AAPL',
|
||||
financials: createFinancials({
|
||||
ticker: 'AAPL',
|
||||
companyName: 'Apple',
|
||||
periods: [
|
||||
createPeriod({ id: 'aapl-q1', filingId: 3, filingDate: '2025-02-02', periodEnd: '2025-01-31' }),
|
||||
createPeriod({ id: 'aapl-q2', filingId: 4, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'aapl-q1': 90, 'aapl-q2': 130 })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.chartData.map((entry) => entry.dateKey)).toEqual([
|
||||
'2024-12-31',
|
||||
'2025-01-31',
|
||||
'2025-03-31'
|
||||
]);
|
||||
expect(data.chartData[0]?.MSFT).toBe(100);
|
||||
expect(data.chartData[0]?.AAPL).toBeNull();
|
||||
expect(data.chartData[1]?.AAPL).toBe(90);
|
||||
});
|
||||
|
||||
it('preserves partial failures without blanking the whole chart', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'MSFT',
|
||||
financials: createFinancials({
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft',
|
||||
periods: [createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
|
||||
statementRows: [createStatementRow('revenue', { 'msft-q1': 100 })]
|
||||
})
|
||||
},
|
||||
{
|
||||
ticker: 'FAIL',
|
||||
error: 'Ticker not found'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.hasAnyData).toBe(true);
|
||||
expect(data.hasPartialData).toBe(true);
|
||||
expect(data.latestRows.find((row) => row.ticker === 'FAIL')?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('marks companies with missing metric values as no metric data', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'ratios',
|
||||
metric: 'gross_margin',
|
||||
results: [
|
||||
{
|
||||
ticker: 'NVDA',
|
||||
financials: createFinancials({
|
||||
ticker: 'NVDA',
|
||||
companyName: 'NVIDIA',
|
||||
periods: [createPeriod({ id: 'nvda-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
|
||||
ratioRows: [createRatioRow('gross_margin', { 'nvda-q1': null })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]?.status).toBe('no_metric_data');
|
||||
expect(data.hasAnyData).toBe(false);
|
||||
});
|
||||
|
||||
it('derives latest and prior values for the summary table', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'AMD',
|
||||
financials: createFinancials({
|
||||
ticker: 'AMD',
|
||||
companyName: 'AMD',
|
||||
periods: [
|
||||
createPeriod({ id: 'amd-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
|
||||
createPeriod({ id: 'amd-q2', filingId: 2, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'amd-q1': 50, 'amd-q2': 70 })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]).toMatchObject({
|
||||
ticker: 'AMD',
|
||||
latestValue: 70,
|
||||
priorValue: 50,
|
||||
changeValue: 20,
|
||||
latestDateKey: '2025-03-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
189
lib/graphing/series.ts
Normal file
189
lib/graphing/series.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialUnit,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
import type { GraphableFinancialSurfaceKind } from '@/lib/financial-metrics';
|
||||
|
||||
type GraphingMetricRow = StandardizedFinancialRow | RatioRow;
|
||||
|
||||
export type GraphingFetchResult = {
|
||||
ticker: string;
|
||||
financials?: CompanyFinancialStatementsResponse;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type GraphingSeriesPoint = {
|
||||
periodId: string;
|
||||
dateKey: string;
|
||||
filingType: '10-K' | '10-Q';
|
||||
filingDate: string;
|
||||
periodEnd: string | null;
|
||||
periodLabel: string;
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
export type GraphingCompanySeries = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
status: 'ready' | 'error' | 'no_metric_data';
|
||||
errorMessage: string | null;
|
||||
unit: FinancialUnit | null;
|
||||
points: GraphingSeriesPoint[];
|
||||
latestPoint: GraphingSeriesPoint | null;
|
||||
priorPoint: GraphingSeriesPoint | null;
|
||||
};
|
||||
|
||||
export type GraphingLatestValueRow = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
status: GraphingCompanySeries['status'];
|
||||
errorMessage: string | null;
|
||||
latestValue: number | null;
|
||||
priorValue: number | null;
|
||||
changeValue: number | null;
|
||||
latestDateKey: string | null;
|
||||
latestPeriodLabel: string | null;
|
||||
latestFilingType: '10-K' | '10-Q' | null;
|
||||
};
|
||||
|
||||
export type GraphingChartDatum = Record<string, unknown> & {
|
||||
dateKey: string;
|
||||
dateMs: number;
|
||||
};
|
||||
|
||||
export type GraphingComparisonData = {
|
||||
companies: GraphingCompanySeries[];
|
||||
chartData: GraphingChartDatum[];
|
||||
latestRows: GraphingLatestValueRow[];
|
||||
hasAnyData: boolean;
|
||||
hasPartialData: boolean;
|
||||
};
|
||||
|
||||
function sortPeriods(left: { periodEnd: string | null; filingDate: string }, right: { periodEnd: string | null; filingDate: string }) {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
}
|
||||
|
||||
function extractMetricRow(
|
||||
financials: CompanyFinancialStatementsResponse,
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
metric: string
|
||||
): GraphingMetricRow | null {
|
||||
if (surface === 'ratios') {
|
||||
return financials.ratioRows?.find((row) => row.key === metric) ?? null;
|
||||
}
|
||||
|
||||
return financials.statementRows?.standardized.find((row) => row.key === metric) ?? null;
|
||||
}
|
||||
|
||||
function extractCompanySeries(
|
||||
result: GraphingFetchResult,
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
metric: string
|
||||
): GraphingCompanySeries {
|
||||
if (result.error || !result.financials) {
|
||||
return {
|
||||
ticker: result.ticker,
|
||||
companyName: result.ticker,
|
||||
status: 'error',
|
||||
errorMessage: result.error ?? 'Unable to load financial history',
|
||||
unit: null,
|
||||
points: [],
|
||||
latestPoint: null,
|
||||
priorPoint: null
|
||||
};
|
||||
}
|
||||
|
||||
const metricRow = extractMetricRow(result.financials, surface, metric);
|
||||
const periods = [...result.financials.periods].sort(sortPeriods);
|
||||
const points = periods.map((period) => ({
|
||||
periodId: period.id,
|
||||
dateKey: period.periodEnd ?? period.filingDate,
|
||||
filingType: period.filingType,
|
||||
filingDate: period.filingDate,
|
||||
periodEnd: period.periodEnd,
|
||||
periodLabel: period.periodLabel,
|
||||
value: metricRow?.values[period.id] ?? null
|
||||
}));
|
||||
const populatedPoints = points.filter((point) => point.value !== null);
|
||||
const latestPoint = populatedPoints[populatedPoints.length - 1] ?? null;
|
||||
const priorPoint = populatedPoints.length > 1 ? populatedPoints[populatedPoints.length - 2] ?? null : null;
|
||||
|
||||
return {
|
||||
ticker: result.financials.company.ticker,
|
||||
companyName: result.financials.company.companyName,
|
||||
status: latestPoint ? 'ready' : 'no_metric_data',
|
||||
errorMessage: latestPoint ? null : 'No data available for the selected metric.',
|
||||
unit: metricRow?.unit ?? null,
|
||||
points,
|
||||
latestPoint,
|
||||
priorPoint
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphingComparisonData(input: {
|
||||
results: GraphingFetchResult[];
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
metric: string;
|
||||
}): GraphingComparisonData {
|
||||
const companies = input.results.map((result) => extractCompanySeries(result, input.surface, input.metric));
|
||||
const chartDatumByDate = new Map<string, GraphingChartDatum>();
|
||||
|
||||
for (const company of companies) {
|
||||
for (const point of company.points) {
|
||||
const dateMs = Date.parse(point.dateKey);
|
||||
const existing = chartDatumByDate.get(point.dateKey) ?? {
|
||||
dateKey: point.dateKey,
|
||||
dateMs
|
||||
};
|
||||
|
||||
existing[company.ticker] = point.value;
|
||||
existing[`meta__${company.ticker}`] = point;
|
||||
chartDatumByDate.set(point.dateKey, existing);
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = [...chartDatumByDate.values()]
|
||||
.sort((left, right) => left.dateMs - right.dateMs)
|
||||
.map((datum) => {
|
||||
for (const company of companies) {
|
||||
if (!(company.ticker in datum)) {
|
||||
datum[company.ticker] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return datum;
|
||||
});
|
||||
|
||||
const latestRows = companies.map((company) => ({
|
||||
ticker: company.ticker,
|
||||
companyName: company.companyName,
|
||||
status: company.status,
|
||||
errorMessage: company.errorMessage,
|
||||
latestValue: company.latestPoint?.value ?? null,
|
||||
priorValue: company.priorPoint?.value ?? null,
|
||||
changeValue:
|
||||
company.latestPoint?.value !== null
|
||||
&& company.latestPoint?.value !== undefined
|
||||
&& company.priorPoint?.value !== null
|
||||
&& company.priorPoint?.value !== undefined
|
||||
? company.latestPoint.value - company.priorPoint.value
|
||||
: null,
|
||||
latestDateKey: company.latestPoint?.dateKey ?? null,
|
||||
latestPeriodLabel: company.latestPoint?.periodLabel ?? null,
|
||||
latestFilingType: company.latestPoint?.filingType ?? null
|
||||
}));
|
||||
|
||||
const hasAnyData = companies.some((company) => company.latestPoint !== null);
|
||||
const hasPartialData = companies.some((company) => company.status !== 'ready')
|
||||
|| companies.some((company) => company.points.some((point) => point.value === null));
|
||||
|
||||
return {
|
||||
companies,
|
||||
chartData,
|
||||
latestRows,
|
||||
hasAnyData,
|
||||
hasPartialData
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,18 @@ export const queryKeys = {
|
||||
) => ['financials-v3', ticker, surfaceKind, cadence, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const,
|
||||
filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const,
|
||||
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
||||
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
|
||||
researchLibrary: (
|
||||
ticker: string,
|
||||
q: string,
|
||||
kind: string,
|
||||
tag: string,
|
||||
source: string,
|
||||
linkedToMemo: string,
|
||||
limit: number
|
||||
) => ['research', 'library', ticker, q, kind, tag, source, linkedToMemo, limit] as const,
|
||||
researchMemo: (ticker: string) => ['research', 'memo', ticker] as const,
|
||||
researchPacket: (ticker: string) => ['research', 'packet', ticker] as const,
|
||||
watchlist: () => ['watchlist'] as const,
|
||||
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
|
||||
holdings: () => ['portfolio', 'holdings'] as const,
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
getCompanyFinancialStatements,
|
||||
getLatestPortfolioInsight,
|
||||
getPortfolioSummary,
|
||||
getResearchMemo,
|
||||
getResearchPacket,
|
||||
getResearchWorkspace,
|
||||
getTask,
|
||||
getTaskTimeline,
|
||||
listFilings,
|
||||
listResearchLibrary,
|
||||
listHoldings,
|
||||
listRecentTasks,
|
||||
listResearchJournal,
|
||||
@@ -16,7 +20,9 @@ import {
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind
|
||||
FinancialSurfaceKind,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource
|
||||
} from '@/lib/types';
|
||||
|
||||
export function companyAnalysisQueryOptions(ticker: string) {
|
||||
@@ -96,6 +102,68 @@ export function aiReportQueryOptions(accessionNumber: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function researchWorkspaceQueryOptions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchWorkspace(normalizedTicker),
|
||||
queryFn: () => getResearchWorkspace(normalizedTicker),
|
||||
staleTime: 15_000
|
||||
});
|
||||
}
|
||||
|
||||
export function researchLibraryQueryOptions(input: {
|
||||
ticker: string;
|
||||
q?: string;
|
||||
kind?: ResearchArtifactKind;
|
||||
tag?: string;
|
||||
source?: ResearchArtifactSource;
|
||||
linkedToMemo?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
const q = input.q?.trim() ?? '';
|
||||
const kind = input.kind ?? '';
|
||||
const tag = input.tag?.trim() ?? '';
|
||||
const source = input.source ?? '';
|
||||
const linkedToMemo = input.linkedToMemo === undefined ? '' : input.linkedToMemo ? 'true' : 'false';
|
||||
const limit = input.limit ?? 100;
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchLibrary(normalizedTicker, q, kind, tag, source, linkedToMemo, limit),
|
||||
queryFn: () => listResearchLibrary({
|
||||
ticker: normalizedTicker,
|
||||
q,
|
||||
kind: input.kind,
|
||||
tag,
|
||||
source: input.source,
|
||||
linkedToMemo: input.linkedToMemo,
|
||||
limit
|
||||
}),
|
||||
staleTime: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
export function researchMemoQueryOptions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchMemo(normalizedTicker),
|
||||
queryFn: () => getResearchMemo(normalizedTicker),
|
||||
staleTime: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
export function researchPacketQueryOptions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchPacket(normalizedTicker),
|
||||
queryFn: () => getResearchPacket(normalizedTicker),
|
||||
staleTime: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
export function watchlistQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.watchlist(),
|
||||
|
||||
@@ -7,7 +7,12 @@ import type {
|
||||
FinancialCadence,
|
||||
FinancialStatementKind,
|
||||
FinancialSurfaceKind,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchJournalEntryType,
|
||||
ResearchMemoConviction,
|
||||
ResearchMemoRating,
|
||||
ResearchMemoSection,
|
||||
TaskStatus
|
||||
} from '@/lib/types';
|
||||
import { auth } from '@/lib/auth';
|
||||
@@ -32,6 +37,22 @@ import {
|
||||
upsertHoldingRecord
|
||||
} from '@/lib/server/repos/holdings';
|
||||
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
|
||||
import {
|
||||
addResearchMemoEvidenceLink,
|
||||
createAiReportArtifactFromAccession,
|
||||
createFilingArtifactFromAccession,
|
||||
createResearchArtifactRecord,
|
||||
deleteResearchArtifactRecord,
|
||||
deleteResearchMemoEvidenceLink,
|
||||
getResearchArtifactFileResponse,
|
||||
getResearchMemoByTicker,
|
||||
getResearchPacket,
|
||||
getResearchWorkspace,
|
||||
listResearchArtifacts,
|
||||
storeResearchUpload,
|
||||
updateResearchArtifactRecord,
|
||||
upsertResearchMemoRecord
|
||||
} from '@/lib/server/repos/research-library';
|
||||
import {
|
||||
createResearchJournalEntryRecord,
|
||||
deleteResearchJournalEntryRecord,
|
||||
@@ -82,6 +103,18 @@ const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
|
||||
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
|
||||
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
|
||||
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
|
||||
const RESEARCH_ARTIFACT_KINDS: ResearchArtifactKind[] = ['filing', 'ai_report', 'note', 'upload', 'memo_snapshot', 'status_change'];
|
||||
const RESEARCH_ARTIFACT_SOURCES: ResearchArtifactSource[] = ['system', 'user'];
|
||||
const RESEARCH_MEMO_RATINGS: ResearchMemoRating[] = ['strong_buy', 'buy', 'hold', 'sell'];
|
||||
const RESEARCH_MEMO_CONVICTIONS: ResearchMemoConviction[] = ['low', 'medium', 'high'];
|
||||
const RESEARCH_MEMO_SECTIONS: ResearchMemoSection[] = [
|
||||
'thesis',
|
||||
'variant_view',
|
||||
'catalysts',
|
||||
'risks',
|
||||
'disconfirming_evidence',
|
||||
'next_actions'
|
||||
];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
@@ -205,6 +238,44 @@ function asJournalEntryType(value: unknown) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchArtifactKind(value: unknown) {
|
||||
return RESEARCH_ARTIFACT_KINDS.includes(value as ResearchArtifactKind)
|
||||
? value as ResearchArtifactKind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchArtifactSource(value: unknown) {
|
||||
return RESEARCH_ARTIFACT_SOURCES.includes(value as ResearchArtifactSource)
|
||||
? value as ResearchArtifactSource
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchMemoRating(value: unknown) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RESEARCH_MEMO_RATINGS.includes(value as ResearchMemoRating)
|
||||
? value as ResearchMemoRating
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchMemoConviction(value: unknown) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RESEARCH_MEMO_CONVICTIONS.includes(value as ResearchMemoConviction)
|
||||
? value as ResearchMemoConviction
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchMemoSection(value: unknown) {
|
||||
return RESEARCH_MEMO_SECTIONS.includes(value as ResearchMemoSection)
|
||||
? value as ResearchMemoSection
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatLabel(value: string) {
|
||||
return value
|
||||
.split('_')
|
||||
@@ -212,6 +283,10 @@ function formatLabel(value: string) {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function normalizeTicker(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toUpperCase() : '';
|
||||
}
|
||||
|
||||
function withFinancialMetricsPolicy(filing: Filing): Filing {
|
||||
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
||||
return filing;
|
||||
@@ -707,6 +782,383 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
|
||||
return Response.json({ insight });
|
||||
})
|
||||
.get('/research/workspace', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const workspace = await getResearchWorkspace(session.user.id, ticker);
|
||||
return Response.json({ workspace });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/library', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const linkedToMemo = query.linkedToMemo === undefined
|
||||
? null
|
||||
: asBoolean(query.linkedToMemo, false);
|
||||
|
||||
const library = await listResearchArtifacts(session.user.id, {
|
||||
ticker,
|
||||
q: asOptionalString(query.q),
|
||||
kind: asResearchArtifactKind(query.kind) ?? null,
|
||||
tag: asOptionalString(query.tag),
|
||||
source: asResearchArtifactSource(query.source) ?? null,
|
||||
linkedToMemo,
|
||||
limit: typeof query.limit === 'number' ? query.limit : Number(query.limit ?? 100)
|
||||
});
|
||||
|
||||
return Response.json(library);
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
q: t.Optional(t.String()),
|
||||
kind: t.Optional(t.String()),
|
||||
tag: t.Optional(t.String()),
|
||||
source: t.Optional(t.String()),
|
||||
linkedToMemo: t.Optional(t.String()),
|
||||
limit: t.Optional(t.Numeric())
|
||||
})
|
||||
})
|
||||
.post('/research/library', async ({ body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||
const kind = asResearchArtifactKind(payload.kind);
|
||||
const source = asResearchArtifactSource(payload.source);
|
||||
const title = asOptionalString(payload.title);
|
||||
const summary = asOptionalString(payload.summary);
|
||||
const bodyMarkdown = asOptionalString(payload.bodyMarkdown);
|
||||
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
if (!kind) {
|
||||
return jsonError('kind is required');
|
||||
}
|
||||
|
||||
if (kind === 'upload') {
|
||||
return jsonError('Use /api/research/library/upload for file uploads');
|
||||
}
|
||||
|
||||
if (!title && !summary && !bodyMarkdown) {
|
||||
return jsonError('title, summary, or bodyMarkdown is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const artifact = await createResearchArtifactRecord({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
accessionNumber: asOptionalString(payload.accessionNumber),
|
||||
kind,
|
||||
source: source ?? 'user',
|
||||
subtype: asOptionalString(payload.subtype),
|
||||
title,
|
||||
summary,
|
||||
bodyMarkdown,
|
||||
tags: asTags(payload.tags),
|
||||
metadata: asOptionalRecord(payload.metadata)
|
||||
});
|
||||
|
||||
await updateWatchlistReviewByTicker(session.user.id, ticker, artifact.updated_at);
|
||||
|
||||
return Response.json({ artifact });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to create research artifact'));
|
||||
}
|
||||
})
|
||||
.post('/research/library/upload', async ({ request }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const ticker = normalizeTicker(String(form.get('ticker') ?? ''));
|
||||
const title = asOptionalString(String(form.get('title') ?? ''));
|
||||
const summary = asOptionalString(String(form.get('summary') ?? ''));
|
||||
const tags = asTags(String(form.get('tags') ?? ''));
|
||||
const file = form.get('file');
|
||||
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return jsonError('file is required');
|
||||
}
|
||||
|
||||
const artifact = await storeResearchUpload({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
file,
|
||||
title,
|
||||
summary,
|
||||
tags
|
||||
});
|
||||
|
||||
return Response.json({ artifact });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to upload research file'));
|
||||
}
|
||||
})
|
||||
.patch('/research/library/:id', async ({ params, body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid artifact id', 400);
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
|
||||
try {
|
||||
const artifact = await updateResearchArtifactRecord({
|
||||
userId: session.user.id,
|
||||
id: numericId,
|
||||
title: payload.title === undefined ? undefined : asOptionalString(payload.title),
|
||||
summary: payload.summary === undefined ? undefined : asOptionalString(payload.summary),
|
||||
bodyMarkdown: payload.bodyMarkdown === undefined
|
||||
? undefined
|
||||
: (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : ''),
|
||||
tags: payload.tags === undefined ? undefined : asTags(payload.tags),
|
||||
metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata)
|
||||
});
|
||||
|
||||
if (!artifact) {
|
||||
return jsonError('Research artifact not found', 404);
|
||||
}
|
||||
|
||||
return Response.json({ artifact });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to update research artifact'));
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.delete('/research/library/:id', async ({ params }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid artifact id', 400);
|
||||
}
|
||||
|
||||
const removed = await deleteResearchArtifactRecord(session.user.id, numericId);
|
||||
if (!removed) {
|
||||
return jsonError('Research artifact not found', 404);
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/library/:id/file', async ({ params }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid artifact id', 400);
|
||||
}
|
||||
|
||||
const fileResponse = await getResearchArtifactFileResponse(session.user.id, numericId);
|
||||
if (!fileResponse) {
|
||||
return jsonError('Research upload not found', 404);
|
||||
}
|
||||
|
||||
return fileResponse;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/memo', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const memo = await getResearchMemoByTicker(session.user.id, ticker);
|
||||
return Response.json({ memo });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.put('/research/memo', async ({ body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const rating = asResearchMemoRating(payload.rating);
|
||||
const conviction = asResearchMemoConviction(payload.conviction);
|
||||
|
||||
if (payload.rating !== undefined && rating === undefined) {
|
||||
return jsonError('Invalid memo rating', 400);
|
||||
}
|
||||
|
||||
if (payload.conviction !== undefined && conviction === undefined) {
|
||||
return jsonError('Invalid memo conviction', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const memo = await upsertResearchMemoRecord({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
rating,
|
||||
conviction,
|
||||
timeHorizonMonths: payload.timeHorizonMonths === undefined
|
||||
? undefined
|
||||
: (typeof payload.timeHorizonMonths === 'number' ? payload.timeHorizonMonths : Number(payload.timeHorizonMonths)),
|
||||
packetTitle: payload.packetTitle === undefined ? undefined : asOptionalString(payload.packetTitle),
|
||||
packetSubtitle: payload.packetSubtitle === undefined ? undefined : asOptionalString(payload.packetSubtitle),
|
||||
thesisMarkdown: payload.thesisMarkdown === undefined ? undefined : String(payload.thesisMarkdown),
|
||||
variantViewMarkdown: payload.variantViewMarkdown === undefined ? undefined : String(payload.variantViewMarkdown),
|
||||
catalystsMarkdown: payload.catalystsMarkdown === undefined ? undefined : String(payload.catalystsMarkdown),
|
||||
risksMarkdown: payload.risksMarkdown === undefined ? undefined : String(payload.risksMarkdown),
|
||||
disconfirmingEvidenceMarkdown: payload.disconfirmingEvidenceMarkdown === undefined ? undefined : String(payload.disconfirmingEvidenceMarkdown),
|
||||
nextActionsMarkdown: payload.nextActionsMarkdown === undefined ? undefined : String(payload.nextActionsMarkdown)
|
||||
});
|
||||
|
||||
return Response.json({ memo });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to save research memo'));
|
||||
}
|
||||
})
|
||||
.post('/research/memo/:id/evidence', async ({ params, body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid memo id', 400);
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const section = asResearchMemoSection(payload.section);
|
||||
const artifactId = typeof payload.artifactId === 'number' ? payload.artifactId : Number(payload.artifactId);
|
||||
|
||||
if (!section) {
|
||||
return jsonError('section is required', 400);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) {
|
||||
return jsonError('artifactId is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const evidence = await addResearchMemoEvidenceLink({
|
||||
userId: session.user.id,
|
||||
memoId: numericId,
|
||||
artifactId,
|
||||
section,
|
||||
annotation: asOptionalString(payload.annotation),
|
||||
sortOrder: payload.sortOrder === undefined
|
||||
? undefined
|
||||
: (typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder))
|
||||
});
|
||||
|
||||
return Response.json({ evidence });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to attach memo evidence'));
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.delete('/research/memo/:id/evidence/:linkId', async ({ params }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const memoId = Number(params.id);
|
||||
const linkId = Number(params.linkId);
|
||||
if (!Number.isInteger(memoId) || memoId <= 0 || !Number.isInteger(linkId) || linkId <= 0) {
|
||||
return jsonError('Invalid memo evidence id', 400);
|
||||
}
|
||||
|
||||
const removed = await deleteResearchMemoEvidenceLink(session.user.id, memoId, linkId);
|
||||
if (!removed) {
|
||||
return jsonError('Memo evidence not found', 404);
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 }),
|
||||
linkId: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/packet', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const packet = await getResearchPacket(session.user.id, ticker);
|
||||
return Response.json({ packet });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/journal', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
@@ -762,6 +1214,10 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
metadata
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
return jsonError('Failed to create journal entry', 500);
|
||||
}
|
||||
|
||||
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
|
||||
|
||||
return Response.json({ entry });
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('sqlite schema compatibility bootstrap', () => {
|
||||
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(false);
|
||||
|
||||
__dbInternals.ensureLocalSqliteSchema(client);
|
||||
|
||||
@@ -37,6 +39,9 @@ describe('sqlite schema compatibility bootstrap', () => {
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
|
||||
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);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
@@ -50,7 +50,304 @@ function applySqlFile(client: Database, fileName: string) {
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function applyBaseSchemaCompat(client: Database) {
|
||||
const sql = readFileSync(join(process.cwd(), 'drizzle', '0000_cold_silver_centurion.sql'), 'utf8')
|
||||
.replaceAll('CREATE TABLE `', 'CREATE TABLE IF NOT EXISTS `')
|
||||
.replaceAll('CREATE UNIQUE INDEX `', 'CREATE UNIQUE INDEX IF NOT EXISTS `')
|
||||
.replaceAll('CREATE INDEX `', 'CREATE INDEX IF NOT EXISTS `');
|
||||
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function ensureResearchWorkspaceSchema(client: Database) {
|
||||
if (!hasTable(client, 'research_artifact')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_artifact\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`organization_id\` text,
|
||||
\`ticker\` text NOT NULL,
|
||||
\`accession_number\` text,
|
||||
\`kind\` text NOT NULL,
|
||||
\`source\` text NOT NULL DEFAULT 'user',
|
||||
\`subtype\` text,
|
||||
\`title\` text,
|
||||
\`summary\` text,
|
||||
\`body_markdown\` text,
|
||||
\`search_text\` text,
|
||||
\`visibility_scope\` text NOT NULL DEFAULT 'private',
|
||||
\`tags\` text,
|
||||
\`metadata\` text,
|
||||
\`file_name\` text,
|
||||
\`mime_type\` text,
|
||||
\`file_size_bytes\` integer,
|
||||
\`storage_path\` text,
|
||||
\`created_at\` text NOT NULL,
|
||||
\`updated_at\` text NOT NULL,
|
||||
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'research_memo')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_memo\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`organization_id\` text,
|
||||
\`ticker\` text NOT NULL,
|
||||
\`rating\` text,
|
||||
\`conviction\` text,
|
||||
\`time_horizon_months\` integer,
|
||||
\`packet_title\` text,
|
||||
\`packet_subtitle\` text,
|
||||
\`thesis_markdown\` text NOT NULL DEFAULT '',
|
||||
\`variant_view_markdown\` text NOT NULL DEFAULT '',
|
||||
\`catalysts_markdown\` text NOT NULL DEFAULT '',
|
||||
\`risks_markdown\` text NOT NULL DEFAULT '',
|
||||
\`disconfirming_evidence_markdown\` text NOT NULL DEFAULT '',
|
||||
\`next_actions_markdown\` text NOT NULL DEFAULT '',
|
||||
\`created_at\` text NOT NULL,
|
||||
\`updated_at\` text NOT NULL,
|
||||
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'research_memo_evidence')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_memo_evidence\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`memo_id\` integer NOT NULL,
|
||||
\`artifact_id\` integer NOT NULL,
|
||||
\`section\` text NOT NULL,
|
||||
\`annotation\` text,
|
||||
\`sort_order\` integer NOT NULL DEFAULT 0,
|
||||
\`created_at\` text NOT NULL,
|
||||
FOREIGN KEY (\`memo_id\`) REFERENCES \`research_memo\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`artifact_id\`) REFERENCES \`research_artifact\`(\`id\`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_ticker_idx` ON `research_artifact` (`user_id`, `ticker`, `updated_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_kind_idx` ON `research_artifact` (`user_id`, `kind`, `updated_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_accession_idx` ON `research_artifact` (`user_id`, `accession_number`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_source_idx` ON `research_artifact` (`user_id`, `source`, `updated_at`);');
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_ticker_uidx` ON `research_memo` (`user_id`, `ticker`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_updated_idx` ON `research_memo` (`user_id`, `updated_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`, `section`, `sort_order`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);');
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`, `artifact_id`, `section`);');
|
||||
client.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS \`research_artifact_fts\` USING fts5(
|
||||
artifact_id UNINDEXED,
|
||||
user_id UNINDEXED,
|
||||
ticker UNINDEXED,
|
||||
title,
|
||||
summary,
|
||||
body_markdown,
|
||||
search_text,
|
||||
tags_text
|
||||
);
|
||||
`);
|
||||
|
||||
client.exec(`
|
||||
INSERT INTO \`research_artifact\` (
|
||||
\`user_id\`,
|
||||
\`organization_id\`,
|
||||
\`ticker\`,
|
||||
\`accession_number\`,
|
||||
\`kind\`,
|
||||
\`source\`,
|
||||
\`subtype\`,
|
||||
\`title\`,
|
||||
\`summary\`,
|
||||
\`body_markdown\`,
|
||||
\`search_text\`,
|
||||
\`visibility_scope\`,
|
||||
\`tags\`,
|
||||
\`metadata\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
r.\`user_id\`,
|
||||
NULL,
|
||||
r.\`ticker\`,
|
||||
r.\`accession_number\`,
|
||||
CASE
|
||||
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
|
||||
ELSE 'note'
|
||||
END,
|
||||
CASE
|
||||
WHEN r.\`entry_type\` = 'status_change' THEN 'system'
|
||||
ELSE 'user'
|
||||
END,
|
||||
r.\`entry_type\`,
|
||||
r.\`title\`,
|
||||
CASE
|
||||
WHEN r.\`body_markdown\` IS NULL OR TRIM(r.\`body_markdown\`) = '' THEN NULL
|
||||
ELSE SUBSTR(r.\`body_markdown\`, 1, 280)
|
||||
END,
|
||||
r.\`body_markdown\`,
|
||||
r.\`body_markdown\`,
|
||||
'private',
|
||||
NULL,
|
||||
r.\`metadata\`,
|
||||
r.\`created_at\`,
|
||||
r.\`updated_at\`
|
||||
FROM \`research_journal_entry\` r
|
||||
WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'research_journal_entry')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`research_artifact\` a
|
||||
WHERE a.\`user_id\` = r.\`user_id\`
|
||||
AND a.\`ticker\` = r.\`ticker\`
|
||||
AND IFNULL(a.\`accession_number\`, '') = IFNULL(r.\`accession_number\`, '')
|
||||
AND a.\`kind\` = CASE
|
||||
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
|
||||
ELSE 'note'
|
||||
END
|
||||
AND IFNULL(a.\`title\`, '') = IFNULL(r.\`title\`, '')
|
||||
AND a.\`created_at\` = r.\`created_at\`
|
||||
);
|
||||
`);
|
||||
|
||||
client.exec(`
|
||||
INSERT INTO \`research_artifact\` (
|
||||
\`user_id\`,
|
||||
\`organization_id\`,
|
||||
\`ticker\`,
|
||||
\`accession_number\`,
|
||||
\`kind\`,
|
||||
\`source\`,
|
||||
\`subtype\`,
|
||||
\`title\`,
|
||||
\`summary\`,
|
||||
\`body_markdown\`,
|
||||
\`search_text\`,
|
||||
\`visibility_scope\`,
|
||||
\`tags\`,
|
||||
\`metadata\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
w.\`user_id\`,
|
||||
NULL,
|
||||
f.\`ticker\`,
|
||||
f.\`accession_number\`,
|
||||
'ai_report',
|
||||
'system',
|
||||
'filing_analysis',
|
||||
f.\`filing_type\` || ' AI memo',
|
||||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
|
||||
'Stored AI memo for ' || f.\`company_name\` || ' (' || f.\`ticker\` || ').' || CHAR(10) ||
|
||||
'Accession: ' || f.\`accession_number\` || CHAR(10) || CHAR(10) ||
|
||||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
|
||||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
|
||||
'private',
|
||||
NULL,
|
||||
json_object(
|
||||
'provider', json_extract(f.\`analysis\`, '$.provider'),
|
||||
'model', json_extract(f.\`analysis\`, '$.model'),
|
||||
'filingType', f.\`filing_type\`,
|
||||
'filingDate', f.\`filing_date\`
|
||||
),
|
||||
f.\`created_at\`,
|
||||
f.\`updated_at\`
|
||||
FROM \`filing\` f
|
||||
JOIN \`watchlist_item\` w
|
||||
ON w.\`ticker\` = f.\`ticker\`
|
||||
WHERE f.\`analysis\` IS NOT NULL
|
||||
AND TRIM(COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights'), '')) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`research_artifact\` a
|
||||
WHERE a.\`user_id\` = w.\`user_id\`
|
||||
AND a.\`ticker\` = f.\`ticker\`
|
||||
AND a.\`accession_number\` = f.\`accession_number\`
|
||||
AND a.\`kind\` = 'ai_report'
|
||||
);
|
||||
`);
|
||||
|
||||
client.exec('DELETE FROM `research_artifact_fts`;');
|
||||
client.exec(`
|
||||
INSERT INTO \`research_artifact_fts\` (
|
||||
\`artifact_id\`,
|
||||
\`user_id\`,
|
||||
\`ticker\`,
|
||||
\`title\`,
|
||||
\`summary\`,
|
||||
\`body_markdown\`,
|
||||
\`search_text\`,
|
||||
\`tags_text\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`user_id\`,
|
||||
\`ticker\`,
|
||||
COALESCE(\`title\`, ''),
|
||||
COALESCE(\`summary\`, ''),
|
||||
COALESCE(\`body_markdown\`, ''),
|
||||
COALESCE(\`search_text\`, ''),
|
||||
CASE
|
||||
WHEN \`tags\` IS NULL OR TRIM(\`tags\`) = '' THEN ''
|
||||
ELSE REPLACE(REPLACE(REPLACE(\`tags\`, '[', ''), ']', ''), '\"', '')
|
||||
END
|
||||
FROM \`research_artifact\`;
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureLocalSqliteSchema(client: Database) {
|
||||
const missingBaseSchema = [
|
||||
'filing',
|
||||
'watchlist_item',
|
||||
'holding',
|
||||
'task_run',
|
||||
'portfolio_insight'
|
||||
].some((tableName) => !hasTable(client, tableName));
|
||||
|
||||
if (missingBaseSchema) {
|
||||
applyBaseSchemaCompat(client);
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'user')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`user\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`emailVerified\` integer NOT NULL DEFAULT 0,
|
||||
\`image\` text,
|
||||
\`createdAt\` integer NOT NULL,
|
||||
\`updatedAt\` integer NOT NULL,
|
||||
\`role\` text,
|
||||
\`banned\` integer DEFAULT 0,
|
||||
\`banReason\` text,
|
||||
\`banExpires\` integer
|
||||
);
|
||||
`);
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `user_email_uidx` ON `user` (`email`);');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'organization')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`organization\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`slug\` text NOT NULL,
|
||||
\`logo\` text,
|
||||
\`createdAt\` integer NOT NULL,
|
||||
\`metadata\` text
|
||||
);
|
||||
`);
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `organization_slug_uidx` ON `organization` (`slug`);');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'filing_statement_snapshot')) {
|
||||
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
|
||||
}
|
||||
@@ -142,6 +439,8 @@ function ensureLocalSqliteSchema(client: Database) {
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
|
||||
}
|
||||
|
||||
ensureResearchWorkspaceSchema(client);
|
||||
}
|
||||
|
||||
export function getSqliteClient() {
|
||||
|
||||
@@ -30,6 +30,18 @@ type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'erro
|
||||
type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
|
||||
type CoveragePriority = 'low' | 'medium' | 'high';
|
||||
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
||||
type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
|
||||
type ResearchArtifactSource = 'system' | 'user';
|
||||
type ResearchVisibilityScope = 'private' | 'organization';
|
||||
type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
|
||||
type ResearchMemoConviction = 'low' | 'medium' | 'high';
|
||||
type ResearchMemoSection =
|
||||
| 'thesis'
|
||||
| 'variant_view'
|
||||
| 'catalysts'
|
||||
| 'risks'
|
||||
| 'disconfirming_evidence'
|
||||
| 'next_actions';
|
||||
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||
type FinancialSurfaceKind =
|
||||
| 'income_statement'
|
||||
@@ -570,6 +582,72 @@ export const researchJournalEntry = sqliteTable('research_journal_entry', {
|
||||
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
|
||||
}));
|
||||
|
||||
export const researchArtifact = sqliteTable('research_artifact', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
|
||||
ticker: text('ticker').notNull(),
|
||||
accession_number: text('accession_number'),
|
||||
kind: text('kind').$type<ResearchArtifactKind>().notNull(),
|
||||
source: text('source').$type<ResearchArtifactSource>().notNull().default('user'),
|
||||
subtype: text('subtype'),
|
||||
title: text('title'),
|
||||
summary: text('summary'),
|
||||
body_markdown: text('body_markdown'),
|
||||
search_text: text('search_text'),
|
||||
visibility_scope: text('visibility_scope').$type<ResearchVisibilityScope>().notNull().default('private'),
|
||||
tags: text('tags', { mode: 'json' }).$type<string[]>(),
|
||||
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
|
||||
file_name: text('file_name'),
|
||||
mime_type: text('mime_type'),
|
||||
file_size_bytes: integer('file_size_bytes'),
|
||||
storage_path: text('storage_path'),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
researchArtifactTickerIndex: index('research_artifact_ticker_idx').on(table.user_id, table.ticker, table.updated_at),
|
||||
researchArtifactKindIndex: index('research_artifact_kind_idx').on(table.user_id, table.kind, table.updated_at),
|
||||
researchArtifactAccessionIndex: index('research_artifact_accession_idx').on(table.user_id, table.accession_number),
|
||||
researchArtifactSourceIndex: index('research_artifact_source_idx').on(table.user_id, table.source, table.updated_at)
|
||||
}));
|
||||
|
||||
export const researchMemo = sqliteTable('research_memo', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
|
||||
ticker: text('ticker').notNull(),
|
||||
rating: text('rating').$type<ResearchMemoRating>(),
|
||||
conviction: text('conviction').$type<ResearchMemoConviction>(),
|
||||
time_horizon_months: integer('time_horizon_months'),
|
||||
packet_title: text('packet_title'),
|
||||
packet_subtitle: text('packet_subtitle'),
|
||||
thesis_markdown: text('thesis_markdown').notNull().default(''),
|
||||
variant_view_markdown: text('variant_view_markdown').notNull().default(''),
|
||||
catalysts_markdown: text('catalysts_markdown').notNull().default(''),
|
||||
risks_markdown: text('risks_markdown').notNull().default(''),
|
||||
disconfirming_evidence_markdown: text('disconfirming_evidence_markdown').notNull().default(''),
|
||||
next_actions_markdown: text('next_actions_markdown').notNull().default(''),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
researchMemoTickerUnique: uniqueIndex('research_memo_ticker_uidx').on(table.user_id, table.ticker),
|
||||
researchMemoUpdatedIndex: index('research_memo_updated_idx').on(table.user_id, table.updated_at)
|
||||
}));
|
||||
|
||||
export const researchMemoEvidence = sqliteTable('research_memo_evidence', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
memo_id: integer('memo_id').notNull().references(() => researchMemo.id, { onDelete: 'cascade' }),
|
||||
artifact_id: integer('artifact_id').notNull().references(() => researchArtifact.id, { onDelete: 'cascade' }),
|
||||
section: text('section').$type<ResearchMemoSection>().notNull(),
|
||||
annotation: text('annotation'),
|
||||
sort_order: integer('sort_order').notNull().default(0),
|
||||
created_at: text('created_at').notNull()
|
||||
}, (table) => ({
|
||||
researchMemoEvidenceMemoIndex: index('research_memo_evidence_memo_idx').on(table.memo_id, table.section, table.sort_order),
|
||||
researchMemoEvidenceArtifactIndex: index('research_memo_evidence_artifact_idx').on(table.artifact_id),
|
||||
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
|
||||
}));
|
||||
|
||||
export const authSchema = {
|
||||
user,
|
||||
session,
|
||||
@@ -595,7 +673,10 @@ export const appSchema = {
|
||||
taskRun,
|
||||
taskStageEvent,
|
||||
portfolioInsight,
|
||||
researchJournalEntry
|
||||
researchJournalEntry,
|
||||
researchArtifact,
|
||||
researchMemo,
|
||||
researchMemoEvidence
|
||||
};
|
||||
|
||||
export const schema = {
|
||||
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
FinancialStatementKind,
|
||||
FinancialUnit
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
|
||||
INCOME_STATEMENT_METRIC_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
export type CanonicalRowDefinition = {
|
||||
key: string;
|
||||
@@ -13,73 +18,8 @@ export type CanonicalRowDefinition = {
|
||||
labelIncludes?: readonly string[];
|
||||
};
|
||||
|
||||
const INCOME_DEFINITIONS: CanonicalRowDefinition[] = [
|
||||
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
|
||||
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
|
||||
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
|
||||
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
|
||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
|
||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
|
||||
];
|
||||
|
||||
const BALANCE_DEFINITIONS: CanonicalRowDefinition[] = [
|
||||
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
|
||||
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
|
||||
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
|
||||
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
|
||||
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
|
||||
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
|
||||
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
|
||||
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
|
||||
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
|
||||
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
|
||||
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
|
||||
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
|
||||
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
|
||||
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
|
||||
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
|
||||
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
|
||||
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
|
||||
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
|
||||
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
|
||||
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
|
||||
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
|
||||
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders’ equity', 'stockholders equity'] },
|
||||
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
|
||||
];
|
||||
|
||||
const CASH_FLOW_DEFINITIONS: CanonicalRowDefinition[] = [
|
||||
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
|
||||
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
|
||||
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
|
||||
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
|
||||
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
|
||||
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
|
||||
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
|
||||
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
|
||||
];
|
||||
|
||||
export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = {
|
||||
income: INCOME_DEFINITIONS,
|
||||
balance: BALANCE_DEFINITIONS,
|
||||
cash_flow: CASH_FLOW_DEFINITIONS
|
||||
income: INCOME_STATEMENT_METRIC_DEFINITIONS,
|
||||
balance: BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
cash_flow: CASH_FLOW_STATEMENT_METRIC_DEFINITIONS
|
||||
};
|
||||
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
RATIO_CATEGORY_ORDER,
|
||||
RATIO_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
type StatementRowMap = {
|
||||
income: StandardizedFinancialRow[];
|
||||
@@ -11,50 +15,6 @@ type StatementRowMap = {
|
||||
cashFlow: StandardizedFinancialRow[];
|
||||
};
|
||||
|
||||
type RatioDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: RatioRow['unit'];
|
||||
denominatorKey: string | null;
|
||||
};
|
||||
|
||||
const RATIO_DEFINITIONS: RatioDefinition[] = [
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
|
||||
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
|
||||
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
|
||||
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
|
||||
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
|
||||
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
|
||||
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
|
||||
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
|
||||
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps' },
|
||||
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
|
||||
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
|
||||
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
|
||||
];
|
||||
|
||||
function valueFor(row: StandardizedFinancialRow | undefined, periodId: string) {
|
||||
return row?.values[periodId] ?? null;
|
||||
}
|
||||
@@ -358,12 +318,3 @@ export function buildRatioRows(input: {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const RATIO_CATEGORY_ORDER = [
|
||||
'margins',
|
||||
'returns',
|
||||
'financial_health',
|
||||
'per_share',
|
||||
'growth',
|
||||
'valuation'
|
||||
] as const;
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
StructuredKpiRow,
|
||||
TrendSeries
|
||||
} from '@/lib/types';
|
||||
import { RATIO_CATEGORY_ORDER } from '@/lib/financial-metrics';
|
||||
import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry';
|
||||
import { RATIO_CATEGORY_ORDER } from '@/lib/server/financials/ratios';
|
||||
|
||||
function toTrendSeriesRow(row: {
|
||||
key: string;
|
||||
|
||||
@@ -1,148 +1,6 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type {
|
||||
ResearchJournalEntry,
|
||||
ResearchJournalEntryType
|
||||
} from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { researchJournalEntry } from '@/lib/server/db/schema';
|
||||
|
||||
type ResearchJournalRow = typeof researchJournalEntry.$inferSelect;
|
||||
|
||||
function normalizeTicker(ticker: string) {
|
||||
return ticker.trim().toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeTitle(title?: string | null) {
|
||||
const normalized = title?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeAccessionNumber(accessionNumber?: string | null) {
|
||||
const normalized = accessionNumber?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata?: Record<string, unknown> | null) {
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function toResearchJournalEntry(row: ResearchJournalRow): ResearchJournalEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
ticker: row.ticker,
|
||||
accession_number: row.accession_number ?? null,
|
||||
entry_type: row.entry_type,
|
||||
title: row.title ?? null,
|
||||
body_markdown: row.body_markdown,
|
||||
metadata: row.metadata ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function listResearchJournalEntries(userId: string, ticker: string, limit = 100) {
|
||||
const normalizedTicker = normalizeTicker(ticker);
|
||||
if (!normalizedTicker) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 250);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.ticker, normalizedTicker)))
|
||||
.orderBy(desc(researchJournalEntry.created_at), desc(researchJournalEntry.id))
|
||||
.limit(safeLimit);
|
||||
|
||||
return rows.map(toResearchJournalEntry);
|
||||
}
|
||||
|
||||
export async function createResearchJournalEntryRecord(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
accessionNumber?: string | null;
|
||||
entryType: ResearchJournalEntryType;
|
||||
title?: string | null;
|
||||
bodyMarkdown: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const ticker = normalizeTicker(input.ticker);
|
||||
const bodyMarkdown = input.bodyMarkdown.trim();
|
||||
if (!ticker) {
|
||||
throw new Error('ticker is required');
|
||||
}
|
||||
|
||||
if (!bodyMarkdown) {
|
||||
throw new Error('bodyMarkdown is required');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const [created] = await db
|
||||
.insert(researchJournalEntry)
|
||||
.values({
|
||||
user_id: input.userId,
|
||||
ticker,
|
||||
accession_number: normalizeAccessionNumber(input.accessionNumber),
|
||||
entry_type: input.entryType,
|
||||
title: normalizeTitle(input.title),
|
||||
body_markdown: bodyMarkdown,
|
||||
metadata: normalizeMetadata(input.metadata),
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toResearchJournalEntry(created);
|
||||
}
|
||||
|
||||
export async function updateResearchJournalEntryRecord(input: {
|
||||
userId: string;
|
||||
id: number;
|
||||
title?: string | null;
|
||||
bodyMarkdown?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextBodyMarkdown = input.bodyMarkdown === undefined
|
||||
? existing.body_markdown
|
||||
: input.bodyMarkdown.trim();
|
||||
if (!nextBodyMarkdown) {
|
||||
throw new Error('bodyMarkdown is required');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(researchJournalEntry)
|
||||
.set({
|
||||
title: input.title === undefined ? existing.title : normalizeTitle(input.title),
|
||||
body_markdown: nextBodyMarkdown,
|
||||
metadata: input.metadata === undefined ? existing.metadata ?? null : normalizeMetadata(input.metadata),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
|
||||
.returning();
|
||||
|
||||
return updated ? toResearchJournalEntry(updated) : null;
|
||||
}
|
||||
|
||||
export async function deleteResearchJournalEntryRecord(userId: string, id: number) {
|
||||
const rows = await db
|
||||
.delete(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
|
||||
.returning({ id: researchJournalEntry.id });
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
export {
|
||||
createResearchJournalEntryCompat as createResearchJournalEntryRecord,
|
||||
deleteResearchJournalEntryCompat as deleteResearchJournalEntryRecord,
|
||||
listResearchJournalEntriesCompat as listResearchJournalEntries,
|
||||
updateResearchJournalEntryCompat as updateResearchJournalEntryRecord
|
||||
} from '@/lib/server/repos/research-library';
|
||||
|
||||
1122
lib/server/repos/research-library.ts
Normal file
1122
lib/server/repos/research-library.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
lib/types.ts
100
lib/types.ts
@@ -8,6 +8,19 @@ export type User = {
|
||||
export type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
|
||||
export type CoveragePriority = 'low' | 'medium' | 'high';
|
||||
export type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
||||
export type NumberScaleUnit = 'thousands' | 'millions' | 'billions';
|
||||
export type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
|
||||
export type ResearchArtifactSource = 'system' | 'user';
|
||||
export type ResearchVisibilityScope = 'private' | 'organization';
|
||||
export type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
|
||||
export type ResearchMemoConviction = 'low' | 'medium' | 'high';
|
||||
export type ResearchMemoSection =
|
||||
| 'thesis'
|
||||
| 'variant_view'
|
||||
| 'catalysts'
|
||||
| 'risks'
|
||||
| 'disconfirming_evidence'
|
||||
| 'next_actions';
|
||||
|
||||
export type WatchlistItem = {
|
||||
id: number;
|
||||
@@ -188,6 +201,93 @@ export type ResearchJournalEntry = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ResearchArtifact = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
organization_id: string | null;
|
||||
ticker: string;
|
||||
accession_number: string | null;
|
||||
kind: ResearchArtifactKind;
|
||||
source: ResearchArtifactSource;
|
||||
subtype: string | null;
|
||||
title: string | null;
|
||||
summary: string | null;
|
||||
body_markdown: string | null;
|
||||
search_text: string | null;
|
||||
visibility_scope: ResearchVisibilityScope;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
file_name: string | null;
|
||||
mime_type: string | null;
|
||||
file_size_bytes: number | null;
|
||||
storage_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
linked_to_memo: boolean;
|
||||
};
|
||||
|
||||
export type ResearchMemo = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
organization_id: string | null;
|
||||
ticker: string;
|
||||
rating: ResearchMemoRating | null;
|
||||
conviction: ResearchMemoConviction | null;
|
||||
time_horizon_months: number | null;
|
||||
packet_title: string | null;
|
||||
packet_subtitle: string | null;
|
||||
thesis_markdown: string;
|
||||
variant_view_markdown: string;
|
||||
catalysts_markdown: string;
|
||||
risks_markdown: string;
|
||||
disconfirming_evidence_markdown: string;
|
||||
next_actions_markdown: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ResearchMemoEvidenceLink = {
|
||||
id: number;
|
||||
memo_id: number;
|
||||
artifact_id: number;
|
||||
section: ResearchMemoSection;
|
||||
annotation: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
artifact: ResearchArtifact;
|
||||
};
|
||||
|
||||
export type ResearchPacketSection = {
|
||||
section: ResearchMemoSection;
|
||||
title: string;
|
||||
body_markdown: string;
|
||||
evidence: ResearchMemoEvidenceLink[];
|
||||
};
|
||||
|
||||
export type ResearchPacket = {
|
||||
ticker: string;
|
||||
companyName: string | null;
|
||||
generated_at: string;
|
||||
memo: ResearchMemo | null;
|
||||
sections: ResearchPacketSection[];
|
||||
};
|
||||
|
||||
export type ResearchLibraryResponse = {
|
||||
artifacts: ResearchArtifact[];
|
||||
availableTags: string[];
|
||||
};
|
||||
|
||||
export type ResearchWorkspace = {
|
||||
ticker: string;
|
||||
companyName: string | null;
|
||||
coverage: WatchlistItem | null;
|
||||
latestFilingDate: string | null;
|
||||
memo: ResearchMemo | null;
|
||||
library: ResearchArtifact[];
|
||||
packet: ResearchPacket;
|
||||
availableTags: string[];
|
||||
};
|
||||
|
||||
export type CompanyFinancialPoint = {
|
||||
filingDate: string;
|
||||
filingType: Filing['filing_type'];
|
||||
|
||||
Reference in New Issue
Block a user