import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research"; export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number }; export type IncomeStats = { n: number; mean: number; median: number; min: number; max: number; sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null; largestShare: number | null; meanWithoutLargest: number | null; largestRemovalDrop: number | null; }; /** Describes the supplied sample only; missing values never become zero. */ export function describeIncome(values: number[]): IncomeStats | null { if (!values.length || values.some((value) => !Number.isFinite(value) || value < 0)) return null; const ordered = [...values].sort((a, b) => a - b); const n = ordered.length; const total = ordered.reduce((sum, value) => sum + value, 0); const mean = total / n; const median = n % 2 ? ordered[(n - 1) / 2] : (ordered[n / 2 - 1] + ordered[n / 2]) / 2; const sampleDeviation = n > 1 ? Math.sqrt(ordered.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1)) : null; const max = ordered[n - 1]; const meanWithoutLargest = n > 1 ? (total - max) / (n - 1) : null; return { n, mean, median, min: ordered[0], max, sampleDeviation, cv: sampleDeviation !== null && mean > 0 ? sampleDeviation / mean : null, meanMedianRatio: median > 0 ? mean / median : null, largestShare: total > 0 ? max / total : null, meanWithoutLargest, largestRemovalDrop: meanWithoutLargest !== null && mean > 0 ? (mean - meanWithoutLargest) / mean : null }; } function calendarDay(value: string | undefined): number | null { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; const time = Date.parse(`${value}T00:00:00Z`); return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? time / 86400000 : null; } /** Latest dated platform MRR per product, never ARR, cash collections or estimates. */ export function platformIncomeSample(snapshot: ResearchSnapshot, options: { windowDays: number | null; foundedYear?: number; annualize?: boolean; }) { const end = calendarDay(snapshot.asOf); if (end === null || (options.windowDays !== null && (!Number.isInteger(options.windowDays) || options.windowDays < 0))) throw new Error("Invalid income sample window"); const companies = new Map(snapshot.companies.map((company) => [company.id, company])); const latest = new Map(); const excluded: { observation: ResearchObservation; reason: string }[] = []; const candidates = snapshot.observations.filter((o) => o.selection === "platform" && o.metric === "mrr"); for (const observation of candidates) { const company = companies.get(observation.company); const day = calendarDay(observation.observedAt); let reason = ""; if (!company || ![2025, 2026].includes(company.year ?? 0)) reason = "创立年份未纳入2025—2026组"; else if (options.foundedYear && company.year !== options.foundedYear) reason = "不属于所选创立年份"; else if (observation.origin !== "payment-platform" || observation.unit !== "USD" || observation.bound !== "=" || observation.comparisonGroup !== "trustmrr-usd-mrr" || !observation.provider || !Number.isFinite(observation.value) || observation.value < 0) reason = "来源、币种、指标或数值口径不满足条件"; else if (day === null) reason = "缺少有效数据日期"; else if (day > end) reason = "数据日期晚于研究截止日"; else if (options.windowDays !== null && end - day > options.windowDays) reason = "最后同步日期超出所选窗口"; if (reason) { excluded.push({ observation, reason }); continue; } const previous = latest.get(observation.company); if (!previous || observation.observedAt! > previous.observedAt!) { if (previous) excluded.push({ observation: previous, reason: "同一产品的较早观测" }); latest.set(observation.company, observation); } else excluded.push({ observation, reason: "同一产品已选较新或同日观测" }); } const points: IncomePoint[] = [...latest.values()].map((observation) => ({ company: companies.get(observation.company)!, observation, value: observation.value * (options.annualize ? 12 : 1), })); return { points, excluded }; } export function groupIncome(points: IncomePoint[], splitModels = true) { const groups = new Map(); for (const point of points) { const category = point.company.cat; const provider = point.observation.provider!; const model = splitModels ? point.company.businessModel ?? "收费模式未核验" : "全部收费模式"; const key = JSON.stringify([category, provider, model]); if (!groups.has(key)) groups.set(key, { category, provider, model, points: [] }); groups.get(key)!.points.push(point); } return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! })); }