新增支付平台收入样本与分布分析

This commit is contained in:
2026-10-30 05:25:05 +08:00
parent 6d26a5b0c1
commit 69fa0b240f
10 changed files with 2172 additions and 29 deletions
+5 -1
View File
@@ -4,14 +4,17 @@ export type ResearchCompany = {
team: number | null; teamBound?: ">" | "≥"; location: string | null; business: string; buyer: string;
delivery: string; notes: string; cohort: string; teamBasis: string | null;
price: string; stage: string; sourceType: string; evidenceIds: string[];
revenueStatus: "self" | "estimate" | "unknown";
revenueStatus: "self" | "estimate" | "platform" | "unknown";
extraUrl?: string; pricingEvidence?: string;
businessModel?: string; entityBasis?: string;
profileOnly?: boolean; classificationBasis?: string; directoryTags?: string[];
};
export type ResearchObservation = {
id: string; company: string; name: string; kind: string; value: number;
unit: string; timing: string; evidence: string; scope: string;
selection: string; reason: string; bound: string; comparisonGroup: string | null;
metric?: "mrr" | "arr" | "revenue-30d" | "annualized-revenue";
observedAt?: string; provider?: string; connectionExpired?: boolean; origin?: "payment-platform";
};
export type ResearchEvidence = {
id: string; companyId: string | null; title: string; url: string;
@@ -88,6 +91,7 @@ export function categorySummary(snapshot: ResearchSnapshot) {
const companies = snapshot.companies.filter((c) => c.cat === category.id);
return { ...category, count: companies.length,
self: companies.filter((c) => c.revenueStatus === "self").length,
platform: companies.filter((c) => c.revenueStatus === "platform").length,
estimate: companies.filter((c) => c.revenueStatus === "estimate").length,
unknown: companies.filter((c) => c.revenueStatus === "unknown").length };
});
+82
View File
@@ -0,0 +1,82 @@
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<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))! }));
}