新增支付平台收入样本与分布分析
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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))! }));
|
||||
}
|
||||
Reference in New Issue
Block a user