import type { HeadlessBundleResult, HeadlessPaneContext, HeadlessPaneDefinition, HeadlessPaneLoadArgs, } from "../../../types/plugin"; import { dividendReferencePrice, fetchDividendData, type DividendData } from "./client"; import type { DividendPayment } from "./types"; import { formatDividendYield, toDividendRows } from "./view"; import { formatDistributionAmount, formatPercent } from "../../../utils/format"; import { currencyMinorDigits, formatMarketPriceWithCurrency } from "../../../market-data/market/format"; import type { Quote } from "../../../types/financials"; import { dividendPriceStatus, dividendQuotePriceMetadata } from "./reference-price"; const PAYMENT_COLUMNS = [ { key: "exDate", header: "Ex-date" }, { key: "amount", header: "Amount", align: "right" as const, format: (value: unknown, row: Record) => typeof value === "number" && typeof row.currency === "string" && row.currency ? formatDistributionAmount(value, row.currency) : value == null ? "-" : String(value) }, { key: "currency", header: "CCY" }, { key: "type", header: "Type" }, ]; export interface DividendYieldHeadlessDependencies { loadData(symbol: string, context: HeadlessPaneContext): Promise; } const defaultDependencies: DividendYieldHeadlessDependencies = { async loadData(symbol, context) { let currentPrice: number | null = null; let currentPriceCurrency: string | undefined; let referenceQuote: Quote | undefined; const instrument = await context.resolveInstrument?.(symbol); const exchange = instrument?.exchange ?? ""; try { const quote = await context.marketData.getQuote(symbol, exchange); currentPrice = quote.price ?? null; currentPriceCurrency = quote.currency; referenceQuote = quote; } catch { currentPrice = null; } const data = await fetchDividendData(symbol, currentPrice, exchange, currentPriceCurrency); if (referenceQuote && dividendReferencePrice(currentPrice, currentPriceCurrency, data.currency ?? "") != null) { Object.assign(data, dividendQuotePriceMetadata(referenceQuote)); } return data; }, }; function dateEntry(label: string, date: Date | null) { return { label, value: date, formatted: date ? date.toISOString().slice(0, 10) : "—" }; } function matchesType(payment: DividendPayment, type: string): boolean { return type === "all" || payment.type === type; } export function projectDividendYieldHeadless( data: DividendData, args: HeadlessPaneLoadArgs, ): HeadlessBundleResult { const type = String(args.options.type ?? "all"); const limit = Number(args.options.limit ?? 40); const matching = data.payments.filter((payment) => matchesType(payment, type)); const selectedPayments = matching.slice(0, limit); const rows = toDividendRows(selectedPayments).map((row, index) => { const payment = selectedPayments[index]; return { ...row, recordDate: payment?.recordDate?.toISOString() ?? null, paymentDate: payment?.paymentDate?.toISOString() ?? null, declarationDate: payment?.declarationDate?.toISOString() ?? null, type: payment?.type ?? null, }; }); const metrics = data.metrics; const currency = data.currency ?? data.payments[0]?.currency ?? ""; const priceStatus = dividendPriceStatus(data.price, data.priceAsOf, data.priceStale); const errors = [data.historyError, data.summaryError].filter((error): error is string => !!error); return { ...(errors.length > 0 ? { complete: false, errors } : {}), sections: [ { title: "Dividend metrics", entries: [ { label: "Price", value: data.price, ...(data.price != null && currency ? { formatted: formatMarketPriceWithCurrency(data.price, currency, { minimumFractionDigits: Math.min(2, currencyMinorDigits(currency)) }) } : {}) }, ...(data.stale ? [{ label: "History status", value: "Stale cash history; recent distributions may be missing." }] : []), ...(priceStatus ? [{ label: "Price status", value: priceStatus === "stale" ? "Stale reference price; cash yield may be out of date." : "Reference price time unavailable; cash yield may be out of date." }] : []), { label: "Trailing yield", value: metrics.trailingYield, formatted: formatDividendYield(metrics.trailingYield) }, { label: "Forward yield", value: metrics.forwardYield, formatted: formatDividendYield(metrics.forwardYield) }, { label: "Trailing rate", value: metrics.trailingRate, formatted: currency ? formatDistributionAmount(metrics.trailingRate ?? undefined, currency) : "—" }, { label: "Forward rate", value: metrics.forwardRate, formatted: currency ? formatDistributionAmount(metrics.forwardRate ?? undefined, currency) : "—" }, { label: "Earnings Payout", value: metrics.payoutRatio, formatted: formatDividendYield(metrics.payoutRatio) }, { label: "1Y Cash Growth", value: metrics.growth1Y, formatted: formatPercent(metrics.growth1Y ?? undefined) }, { label: "3Y Cash CAGR", value: metrics.growth3Y, formatted: formatPercent(metrics.growth3Y ?? undefined) }, { label: "Frequency", value: metrics.paymentFrequency }, dateEntry("Last ex-dividend", metrics.lastExDividendDate), dateEntry("Next ex-dividend", metrics.nextExDividendDate), dateEntry("Next pay", metrics.nextPayDate), ...(data.payments.length > 0 && metrics.trailingRate === 0 ? [{ label: "Cash status", value: "No cash distributions reported in the past 12 months." }] : []), ], }, { title: "Dividend history", columns: PAYMENT_COLUMNS, rows, }, ], metadata: { totalPayments: matching.length, returnedPayments: rows.length, truncated: rows.length < matching.length, type, currency: data.currency ?? data.payments[0]?.currency ?? null, historyAvailable: data.historyAvailable ?? true, historyError: data.historyError ?? null, summaryError: data.summaryError ?? null, providerId: data.providerId ?? null, historyFetchedAt: data.fetchedAt ?? null, historyStale: data.stale ?? null, priceAsOf: data.priceAsOf ?? null, priceStale: data.priceStale ?? null, limitations: data.notes ?? [], yieldMethod: "Cash distributions with ex-dates in the preceding 12 months, one payment per period for a regular cadence, divided by the reference share price; excludes reinvestment and is not SEC yield or total return.", forwardRateMethod: "Provider indicated annual cash rate, only when its currency units are comparable with the share price.", paymentFrequencyMethod: "Cadence inferred from ex-dates in the last two years, unavailable when no cash was reported in the last year; not an announced payment schedule.", }, }; } export function createDividendYieldHeadless( dependencies: DividendYieldHeadlessDependencies = defaultDependencies, ): HeadlessPaneDefinition<"bundle"> { return { shape: "bundle", argument: { kind: "ticker", placeholder: "ticker", description: "Company symbol.", }, options: [ { key: "type", description: "Dividend payment type to include.", type: "enum", values: [ { value: "all" }, { value: "cash" }, { value: "special" }, { value: "stock" }, { value: "unknown" }, ], defaultValue: "all", }, { key: "limit", description: "Maximum historical payments to return.", type: "integer", defaultValue: 40, minimum: 1, maximum: 200, }, ], describe: (args) => `Dividend Yield | ${String(args.argument)}`, async load(args, context) { const symbol = String(args.argument); return projectDividendYieldHeadless( await dependencies.loadData(symbol, context), args, ); }, }; } export const dividendYieldHeadless = createDividendYieldHeadless();