148 lines
8.8 KiB
TypeScript
148 lines
8.8 KiB
TypeScript
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
|
|
|
|
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
|
|
|
|
/** An evidence ledger, never a comparable sample: retain historical claims and estimates separately. */
|
|
export function arrDisclosures(snapshot: ResearchSnapshot, options: { category?: string; foundedYear?: number } = {}) {
|
|
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
|
|
return snapshot.observations.flatMap((observation) => {
|
|
const company = companies.get(observation.company);
|
|
if (!company || ![2025, 2026].includes(company.year ?? 0) || observation.metric !== "arr" ||
|
|
!["signal", "estimate", "historical", "historical-estimate"].includes(observation.selection) ||
|
|
!Number.isFinite(observation.value) || observation.value < 0 ||
|
|
(options.category && company.cat !== options.category) ||
|
|
(options.foundedYear && company.year !== options.foundedYear)) return [];
|
|
return [{ company, observation }];
|
|
});
|
|
}
|
|
|
|
/** Count companies once; missing ARR is a coverage gap, never zero revenue. */
|
|
export function arrDisclosureCoverage(snapshot: ResearchSnapshot, foundedYear?: number) {
|
|
const rows = arrDisclosures(snapshot, { foundedYear });
|
|
return snapshot.categories.map((category) => {
|
|
const companies = snapshot.companies.filter((company) => company.cat === category.id &&
|
|
[2025, 2026].includes(company.year ?? 0) && (!foundedYear || company.year === foundedYear));
|
|
const claims = rows.filter((row) => row.company.cat === category.id);
|
|
const disclosed = new Set(claims.map((row) => row.company.id));
|
|
const reported = new Set(claims.filter((row) =>
|
|
["signal", "historical"].includes(row.observation.selection)).map((row) => row.company.id));
|
|
return { id: category.id, name: category.name, companies: companies.length,
|
|
disclosed: disclosed.size, reported: reported.size, estimatedOnly: disclosed.size - reported.size,
|
|
missing: companies.length - disclosed.size, records: claims.length };
|
|
});
|
|
}
|
|
|
|
export type ArrFieldGap = "date" | "currency" | "amount" | "estimate";
|
|
|
|
/** Prerequisites for inspecting exact, dated USD disclosures; never a comparability or audit verdict. */
|
|
export function arrFieldGaps(observation: ResearchObservation, asOf: string): ArrFieldGap[] {
|
|
const gaps: ArrFieldGap[] = [];
|
|
const day = calendarDay(observation.observedAt);
|
|
const end = calendarDay(asOf);
|
|
if (day === null || end === null || day > end) gaps.push("date");
|
|
if (observation.unit !== "USD") gaps.push("currency");
|
|
if (observation.bound !== "=" || !Number.isFinite(observation.value) || observation.value < 0) gaps.push("amount");
|
|
if (["estimate", "historical-estimate"].includes(observation.selection)) gaps.push("estimate");
|
|
return gaps;
|
|
}
|
|
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<string, ResearchObservation>();
|
|
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<string, { category: string; provider: string; model: string; points: IncomePoint[] }>();
|
|
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))! }));
|
|
}
|
|
|
|
/** Compare descriptive statistics only within the same provider and delivery model. */
|
|
export function compareIncomeGroups(groups: ReturnType<typeof groupIncome>) {
|
|
const buckets = new Map<string, typeof groups>();
|
|
for (const group of groups) {
|
|
if (group.stats.n < 3 || group.stats.cv === null || group.stats.largestRemovalDrop === null ||
|
|
group.model === "全部收费模式" || group.model === "收费模式未核验") continue;
|
|
const key = JSON.stringify([group.provider, group.model]);
|
|
const bucket = buckets.get(key) ?? [];
|
|
bucket.push(group);
|
|
buckets.set(key, bucket);
|
|
}
|
|
return [...buckets.entries()].flatMap(([key, rows]) => {
|
|
if (rows.length < 2) return [];
|
|
const byCV = [...rows].sort((a, b) => a.stats.cv! - b.stats.cv! || a.key.localeCompare(b.key));
|
|
const bySensitivity = [...rows].sort((a, b) => a.stats.largestRemovalDrop! - b.stats.largestRemovalDrop! || a.key.localeCompare(b.key));
|
|
return [{ key, provider: rows[0].provider, model: rows[0].model,
|
|
leastDispersed: byCV[0], mostDispersed: byCV[byCV.length - 1],
|
|
leastSensitive: bySensitivity[0], mostSensitive: bySensitivity[bySensitivity.length - 1] }];
|
|
});
|
|
}
|