sheeply 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/dist/analysis/analyse.js +28 -0
- package/dist/analysis/black-sheep-calibration.js +7 -0
- package/dist/analysis/black-sheep.js +167 -0
- package/dist/analysis/data-quality.js +90 -0
- package/dist/analysis/dataset.js +126 -0
- package/dist/analysis/entry-maturity.js +72 -0
- package/dist/analysis/interpretation.js +105 -0
- package/dist/analysis/investments.js +107 -0
- package/dist/analysis/leadership.js +42 -0
- package/dist/analysis/partial-date.js +17 -0
- package/dist/analysis/ratio.js +28 -0
- package/dist/analysis/sector-exposure.js +64 -0
- package/dist/analysis/stages.js +30 -0
- package/dist/analysis/syndicates.js +70 -0
- package/dist/analysis/types.js +1 -0
- package/dist/cli/analyse-command.js +106 -0
- package/dist/cli/db-command.js +28 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/program.js +38 -0
- package/dist/config.js +53 -0
- package/dist/db/database.js +53 -0
- package/dist/db/schema.js +159 -0
- package/dist/dealroom/client.js +208 -0
- package/dist/dealroom/endpoints.js +17 -0
- package/dist/dealroom/pagination.js +25 -0
- package/dist/dealroom/schemas.js +121 -0
- package/dist/ingest/extract.js +137 -0
- package/dist/ingest/fetch.js +80 -0
- package/dist/ingest/resolve.js +74 -0
- package/dist/reports/json.js +97 -0
- package/dist/reports/sheep.js +63 -0
- package/dist/util.js +19 -0
- package/dist/version.js +9 -0
- package/docs/api-limitations.md +36 -0
- package/docs/dealroom-field-mapping.md +90 -0
- package/docs/methodology.md +203 -0
- package/package.json +52 -4
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { identified } from "./investments.js";
|
|
2
|
+
import { countShare, rate, ratio, summarise, tally } from "./ratio.js";
|
|
3
|
+
import { STAGE_BUCKETS, normaliseLabel, stageBucket } from "./stages.js";
|
|
4
|
+
const POSITIONS = ["0", "1", "2", "3+", "uncertain"];
|
|
5
|
+
const position = (prior, uncertain) => uncertain > 0 ? "uncertain" : prior >= 3 ? "3+" : String(prior);
|
|
6
|
+
/**
|
|
7
|
+
* Stage of the round in which the investor first appears on each company, from Dealroom's own
|
|
8
|
+
* `round_label`. Company age and prior capital are reported beside it to explain the stage, not
|
|
9
|
+
* scored: they measure the same company maturity.
|
|
10
|
+
*/
|
|
11
|
+
export function entryMaturity(history) {
|
|
12
|
+
const companies = identified(history);
|
|
13
|
+
const initials = companies.map((company) => company.initial);
|
|
14
|
+
const counts = Object.fromEntries(STAGE_BUCKETS.map((bucket) => [bucket, 0]));
|
|
15
|
+
const noLabel = [];
|
|
16
|
+
const unmapped = [];
|
|
17
|
+
for (const round of initials) {
|
|
18
|
+
const bucket = stageBucket(round.roundLabel);
|
|
19
|
+
if (bucket)
|
|
20
|
+
counts[bucket]++;
|
|
21
|
+
else if (round.roundLabel === null)
|
|
22
|
+
noLabel.push(round.standardizedRound ?? "(none)");
|
|
23
|
+
else
|
|
24
|
+
unmapped.push(normaliseLabel(round.roundLabel));
|
|
25
|
+
}
|
|
26
|
+
const total = initials.length;
|
|
27
|
+
const known = total - noLabel.length - unmapped.length;
|
|
28
|
+
const distribution = Object.fromEntries(STAGE_BUCKETS.map((bucket) => [bucket, { ...countShare(counts[bucket], total), share_of_known_stage: ratio(counts[bucket], known) }]));
|
|
29
|
+
const entries = companies.map((company) => company.entry);
|
|
30
|
+
const positions = entries.map((entry) => position(entry.priorVcRounds, entry.uncertainVcRounds));
|
|
31
|
+
const positionCounts = tally(positions);
|
|
32
|
+
const atFirst = companies.filter((c) => c.entry.uncertainVcRounds === 0 && c.entry.companyFirstVcRoundId === c.initial.id).length;
|
|
33
|
+
const certain = positions.filter((p) => p !== "uncertain").length;
|
|
34
|
+
const ages = entries.map((entry) => entry.companyAgeAtEntryYears).filter((age) => age !== null);
|
|
35
|
+
const completeCapital = entries.filter((entry) => entry.uncertainVcRounds === 0 && entry.priorVcRoundsWithoutAmount === 0);
|
|
36
|
+
const currencies = [...new Set(initials.map((round) => round.currency).filter(Boolean))];
|
|
37
|
+
return {
|
|
38
|
+
role: "metric",
|
|
39
|
+
question: "How early does this investor enter companies?",
|
|
40
|
+
stage_source: "round_label",
|
|
41
|
+
first_investments: total,
|
|
42
|
+
known_stage: known,
|
|
43
|
+
distribution: { ...distribution, unknown: { ...countShare(total - known, total), share_of_known_stage: null } },
|
|
44
|
+
unknown_breakdown: {
|
|
45
|
+
no_round_label_by_standardized_round: tally(noLabel),
|
|
46
|
+
unmapped_round_labels: tally(unmapped),
|
|
47
|
+
},
|
|
48
|
+
first_investments_by_standardized_round: tally(initials.map((round) => round.standardizedRound ?? "(none)")),
|
|
49
|
+
explanatory_attributes: {
|
|
50
|
+
note: "Descriptive context for entry stage. Not scored: company age and prior capital measure the same maturity as stage.",
|
|
51
|
+
entry_position: {
|
|
52
|
+
basis: "company VC rounds dated definitely before the investor's first observed round",
|
|
53
|
+
prior_vc_rounds: Object.fromEntries(POSITIONS.map((p) => [p, countShare(positionCounts[p] ?? 0, total)])),
|
|
54
|
+
entered_at_company_first_known_vc_round: rate(atFirst, certain),
|
|
55
|
+
},
|
|
56
|
+
company_age_at_entry_years: {
|
|
57
|
+
basis: "entry year minus company launch year",
|
|
58
|
+
known: ages.length,
|
|
59
|
+
unknown: total - ages.length,
|
|
60
|
+
entry_before_recorded_launch: ages.filter((age) => age < 0).length,
|
|
61
|
+
summary: summarise(ages.filter((age) => age >= 0)),
|
|
62
|
+
},
|
|
63
|
+
prior_vc_capital: {
|
|
64
|
+
basis: "sum of disclosed amounts of company VC rounds definitely before the entry",
|
|
65
|
+
currency: currencies.length === 1 ? currencies[0] : null,
|
|
66
|
+
complete: completeCapital.length,
|
|
67
|
+
incomplete: total - completeCapital.length,
|
|
68
|
+
summary_of_complete: summarise(completeCapital.map((entry) => entry.priorVcCapital)),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed thresholds behind every statement. They describe this investor's own observed data; none
|
|
3
|
+
* is a market benchmark, so statements say "most" or "a minority", never "high" or "low". Only the
|
|
4
|
+
* two metrics are interpreted; sector exposure and syndicate relationships stay descriptive.
|
|
5
|
+
*/
|
|
6
|
+
export const THRESHOLDS = {
|
|
7
|
+
minimumSample: 10,
|
|
8
|
+
majorityShare: 0.5,
|
|
9
|
+
lowCoverage: 0.7,
|
|
10
|
+
};
|
|
11
|
+
const pct = (share) => (share === null ? "n/a" : `${Math.round(share * 100)}%`);
|
|
12
|
+
export function interpret(entry, leadership, quality) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const t = THRESHOLDS;
|
|
15
|
+
const d = entry.distribution;
|
|
16
|
+
const early = d.pre_seed.count + d.seed.count;
|
|
17
|
+
const later = d.series_a.count + d.series_b_plus.count;
|
|
18
|
+
if (entry.known_stage < t.minimumSample) {
|
|
19
|
+
out.push({
|
|
20
|
+
rule: "entry_maturity.insufficient_sample",
|
|
21
|
+
statement: `Too few first investments with a known stage (${entry.known_stage}) to describe entry maturity.`,
|
|
22
|
+
evidence: { known_stage: entry.known_stage, minimum_sample: t.minimumSample },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const earlyShare = early / entry.known_stage;
|
|
27
|
+
const majority = earlyShare >= t.majorityShare;
|
|
28
|
+
out.push({
|
|
29
|
+
rule: majority ? "entry_maturity.majority_pre_seed_or_seed" : "entry_maturity.majority_series_a_or_later",
|
|
30
|
+
statement: `${majority ? "Most" : "A minority of"} first investments with a known stage were at pre-seed or seed ` +
|
|
31
|
+
`(${early} of ${entry.known_stage}, ${pct(earlyShare)}). A further ${d.unknown.count} of ${entry.first_investments} ` +
|
|
32
|
+
`first investments (${pct(d.unknown.share)}) have no Dealroom stage.`,
|
|
33
|
+
evidence: {
|
|
34
|
+
pre_seed_or_seed: early,
|
|
35
|
+
series_a_or_later: later,
|
|
36
|
+
known_stage: entry.known_stage,
|
|
37
|
+
unknown_stage: d.unknown.count,
|
|
38
|
+
threshold: t.majorityShare,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const atFirst = entry.explanatory_attributes.entry_position.entered_at_company_first_known_vc_round;
|
|
42
|
+
out.push({
|
|
43
|
+
rule: "entry_maturity.entry_position",
|
|
44
|
+
statement: `The first observed investment was the company's earliest known VC round in ${atFirst.numerator} of ${atFirst.denominator} companies (${pct(atFirst.value)}).`,
|
|
45
|
+
evidence: { numerator: atFirst.numerator, denominator: atFirst.denominator },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const initial = leadership.initial_investments;
|
|
49
|
+
const leadRate = initial.lead_rate;
|
|
50
|
+
if (initial.usable_lead_status < t.minimumSample) {
|
|
51
|
+
out.push({
|
|
52
|
+
rule: "investment_leadership.insufficient_sample",
|
|
53
|
+
statement: `Too few first investments with a usable lead status (${initial.usable_lead_status}) to describe lead behaviour.`,
|
|
54
|
+
evidence: { usable_lead_status: initial.usable_lead_status, minimum_sample: t.minimumSample },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const majority = (leadRate.value ?? 0) >= t.majorityShare;
|
|
59
|
+
out.push({
|
|
60
|
+
rule: majority ? "investment_leadership.led_majority" : "investment_leadership.led_minority",
|
|
61
|
+
statement: `Recorded as lead or co-lead on ${majority ? "most" : "a minority"} of first investments with a usable lead status (${leadRate.numerator} of ${leadRate.denominator}, ${pct(leadRate.value)}).`,
|
|
62
|
+
evidence: { numerator: leadRate.numerator, denominator: leadRate.denominator, threshold: t.majorityShare },
|
|
63
|
+
});
|
|
64
|
+
if (!majority) {
|
|
65
|
+
out.push({
|
|
66
|
+
rule: "investment_leadership.caveat",
|
|
67
|
+
statement: "A minority lead rate alone does not establish follower behaviour: it is also consistent with deliberate co-investing, and the lead flag shows observed participation, not who originated the round.",
|
|
68
|
+
evidence: {},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const asReported = initial.sensitivity.lead_rate_if_every_false_is_non_lead;
|
|
73
|
+
if (initial.false_with_no_lead_on_round > 0 && leadRate.value !== null) {
|
|
74
|
+
out.push({
|
|
75
|
+
rule: "investment_leadership.sensitivity",
|
|
76
|
+
statement: `${initial.false_with_no_lead_on_round} first investments are on rounds where nobody is flagged as lead. Reading every false flag as a confirmed non-lead would give ${pct(asReported.value)} instead of ${pct(leadRate.value)}.`,
|
|
77
|
+
evidence: {
|
|
78
|
+
false_with_no_lead_on_round: initial.false_with_no_lead_on_round,
|
|
79
|
+
lead_rate: leadRate.value,
|
|
80
|
+
lead_rate_if_every_false_is_non_lead: asReported.value,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const [key, label] of [
|
|
85
|
+
["entry_stage", "entry stage"],
|
|
86
|
+
["lead_status", "lead status"],
|
|
87
|
+
]) {
|
|
88
|
+
const coverage = quality.coverage[key];
|
|
89
|
+
if (coverage.value !== null && coverage.value < t.lowCoverage) {
|
|
90
|
+
out.push({
|
|
91
|
+
rule: `data_quality.${key}_coverage`,
|
|
92
|
+
statement: `Only ${coverage.numerator} of ${coverage.denominator} first investments (${pct(coverage.value)}) have a usable ${label}, so that figure describes a subset that may not be representative.`,
|
|
93
|
+
evidence: { numerator: coverage.numerator, denominator: coverage.denominator, threshold: t.lowCoverage },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (quality.portfolio.complete === false) {
|
|
98
|
+
out.push({
|
|
99
|
+
rule: "data_quality.portfolio_incomplete",
|
|
100
|
+
statement: `Dealroom reports ${quality.portfolio.reported_by_dealroom} portfolio companies but returned ${quality.portfolio.rows_returned}.`,
|
|
101
|
+
evidence: { reported: quality.portfolio.reported_by_dealroom, returned: quality.portfolio.rows_returned },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { compareStrings } from "../util.js";
|
|
2
|
+
import { dateSpan, definitelyBefore, overlaps } from "./partial-date.js";
|
|
3
|
+
import { stageRank } from "./stages.js";
|
|
4
|
+
export const participantOf = (round, entityId) => round.participants.find((participant) => participant.id === entityId);
|
|
5
|
+
/**
|
|
6
|
+
* Chronological order using only facts known at the time of each round: the announced date, then
|
|
7
|
+
* the earlier stage, then the Dealroom id. Undated rounds sort last because they cannot be placed.
|
|
8
|
+
*/
|
|
9
|
+
export function compareRounds(a, b) {
|
|
10
|
+
const spanA = dateSpan(a.announcedDate);
|
|
11
|
+
const spanB = dateSpan(b.announcedDate);
|
|
12
|
+
if (spanA === null || spanB === null) {
|
|
13
|
+
if (spanA !== spanB)
|
|
14
|
+
return spanA === null ? 1 : -1;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
const byDate = compareStrings(spanA.start, spanB.start) || compareStrings(spanA.end, spanB.end);
|
|
18
|
+
if (byDate !== 0)
|
|
19
|
+
return byDate;
|
|
20
|
+
}
|
|
21
|
+
return stageRank(a.roundLabel) - stageRank(b.roundLabel) || a.id - b.id;
|
|
22
|
+
}
|
|
23
|
+
export function buildInvestmentHistory(dataset) {
|
|
24
|
+
const investorId = dataset.investor.id;
|
|
25
|
+
const roundsByCompany = new Map();
|
|
26
|
+
for (const round of dataset.rounds) {
|
|
27
|
+
const list = roundsByCompany.get(round.companyId) ?? [];
|
|
28
|
+
list.push(round);
|
|
29
|
+
roundsByCompany.set(round.companyId, list);
|
|
30
|
+
}
|
|
31
|
+
const companies = dataset.portfolio.map((entry) => {
|
|
32
|
+
const companyRounds = [...(roundsByCompany.get(entry.companyId) ?? [])].sort(compareRounds);
|
|
33
|
+
const participations = companyRounds.filter((round) => participantOf(round, investorId));
|
|
34
|
+
const [initial = null, ...followOns] = participations;
|
|
35
|
+
let status;
|
|
36
|
+
if (entry.companySubtype !== null && entry.companySubtype !== "company")
|
|
37
|
+
status = "not_a_company";
|
|
38
|
+
else if (initial)
|
|
39
|
+
status = "initial_identified";
|
|
40
|
+
else if (entry.roundsCount === 0)
|
|
41
|
+
status = "no_round_attribution";
|
|
42
|
+
else if (companyRounds.some((round) => !round.investorsDisclosed))
|
|
43
|
+
status = "participation_undisclosed";
|
|
44
|
+
else
|
|
45
|
+
status = "rounds_not_matched";
|
|
46
|
+
const identified = status === "initial_identified" && initial !== null;
|
|
47
|
+
return {
|
|
48
|
+
companyId: entry.companyId,
|
|
49
|
+
companyName: entry.companyName,
|
|
50
|
+
status,
|
|
51
|
+
roundsCountReported: entry.roundsCount,
|
|
52
|
+
companyRoundsObserved: companyRounds.length,
|
|
53
|
+
participations,
|
|
54
|
+
initial: identified ? initial : null,
|
|
55
|
+
followOns: identified ? followOns : [],
|
|
56
|
+
flags: identified ? flagsFor(initial, followOns, companyRounds) : [],
|
|
57
|
+
entry: identified ? entryContext(initial, companyRounds, dataset.companies[entry.companyId]) : null,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const portfolioIds = new Set(dataset.portfolio.map((entry) => entry.companyId));
|
|
61
|
+
return {
|
|
62
|
+
investorId,
|
|
63
|
+
companies,
|
|
64
|
+
roundsOutsidePortfolio: dataset.rounds.filter((round) => !portfolioIds.has(round.companyId)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** True unless `round` is definitely after the entry, i.e. it could have come first. */
|
|
68
|
+
const mayPrecede = (round, entrySpan) => {
|
|
69
|
+
const span = dateSpan(round.announcedDate);
|
|
70
|
+
return entrySpan === null || span === null || !definitelyBefore(entrySpan, span);
|
|
71
|
+
};
|
|
72
|
+
function flagsFor(initial, followOns, companyRounds) {
|
|
73
|
+
const flags = [];
|
|
74
|
+
const initialSpan = dateSpan(initial.announcedDate);
|
|
75
|
+
if (initialSpan === null)
|
|
76
|
+
flags.push("initial_undated");
|
|
77
|
+
if (followOns.some((round) => round.announcedDate === null) && initialSpan !== null)
|
|
78
|
+
flags.push("undated_participation");
|
|
79
|
+
const next = followOns[0];
|
|
80
|
+
const nextSpan = next ? dateSpan(next.announcedDate) : null;
|
|
81
|
+
if (initialSpan && nextSpan && overlaps(initialSpan, nextSpan))
|
|
82
|
+
flags.push("initial_ordering_ambiguous");
|
|
83
|
+
if (companyRounds.some((round) => !round.investorsDisclosed && round.id !== initial.id && mayPrecede(round, initialSpan))) {
|
|
84
|
+
flags.push("earlier_round_investors_undisclosed");
|
|
85
|
+
}
|
|
86
|
+
return flags;
|
|
87
|
+
}
|
|
88
|
+
function entryContext(initial, companyRounds, company) {
|
|
89
|
+
const entrySpan = dateSpan(initial.announcedDate);
|
|
90
|
+
const vcRounds = companyRounds.filter((round) => round.isVcRound === true);
|
|
91
|
+
const others = vcRounds.filter((round) => round.id !== initial.id);
|
|
92
|
+
const prior = others.filter((round) => {
|
|
93
|
+
const span = dateSpan(round.announcedDate);
|
|
94
|
+
return entrySpan !== null && span !== null && definitelyBefore(span, entrySpan);
|
|
95
|
+
});
|
|
96
|
+
const entryYear = initial.announcedDate === null ? null : Number(initial.announcedDate.slice(0, 4));
|
|
97
|
+
const launchYear = company?.launchDate ? Number(company.launchDate.slice(0, 4)) : null;
|
|
98
|
+
return {
|
|
99
|
+
companyFirstVcRoundId: vcRounds[0]?.id ?? null,
|
|
100
|
+
priorVcRounds: prior.length,
|
|
101
|
+
uncertainVcRounds: others.filter((round) => !prior.includes(round) && mayPrecede(round, entrySpan)).length,
|
|
102
|
+
priorVcCapital: prior.reduce((sum, round) => sum + (round.amount ?? 0), 0),
|
|
103
|
+
priorVcRoundsWithoutAmount: prior.filter((round) => round.amount === null).length,
|
|
104
|
+
companyAgeAtEntryYears: entryYear === null || launchYear === null ? null : entryYear - launchYear,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export const identified = (history) => history.companies.filter((company) => company.initial !== null);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { identified, participantOf } from "./investments.js";
|
|
2
|
+
import { rate } from "./ratio.js";
|
|
3
|
+
/** Reads only the round's own participant flags; announcement dates never decide who led. */
|
|
4
|
+
export function leadStatus(round, investorId) {
|
|
5
|
+
const self = participantOf(round, investorId);
|
|
6
|
+
if (!self || self.isLead === null)
|
|
7
|
+
return "unknown";
|
|
8
|
+
const otherLead = round.participants.some((p) => p.id !== investorId && p.isLead === true);
|
|
9
|
+
if (self.isLead)
|
|
10
|
+
return otherLead ? "co_lead" : "sole_lead";
|
|
11
|
+
return otherLead ? "not_led" : "no_lead_recorded";
|
|
12
|
+
}
|
|
13
|
+
export function leadStats(rounds, investorId) {
|
|
14
|
+
const counts = { sole_lead: 0, co_lead: 0, not_led: 0, no_lead_recorded: 0, unknown: 0 };
|
|
15
|
+
for (const round of rounds)
|
|
16
|
+
counts[leadStatus(round, investorId)]++;
|
|
17
|
+
const led = counts.sole_lead + counts.co_lead;
|
|
18
|
+
const usable = led + counts.not_led;
|
|
19
|
+
return {
|
|
20
|
+
rounds: rounds.length,
|
|
21
|
+
recorded_lead: led,
|
|
22
|
+
sole_lead: counts.sole_lead,
|
|
23
|
+
co_lead: counts.co_lead,
|
|
24
|
+
recorded_non_lead: counts.not_led,
|
|
25
|
+
false_with_no_lead_on_round: counts.no_lead_recorded,
|
|
26
|
+
null_lead_flag: counts.unknown,
|
|
27
|
+
usable_lead_status: usable,
|
|
28
|
+
lead_rate: rate(led, usable),
|
|
29
|
+
lead_status_completeness: rate(usable, rounds.length),
|
|
30
|
+
sensitivity: { lead_rate_if_every_false_is_non_lead: rate(led, usable + counts.no_lead_recorded) },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function investmentLeadership(history) {
|
|
34
|
+
const companies = identified(history);
|
|
35
|
+
return {
|
|
36
|
+
role: "metric",
|
|
37
|
+
question: "How frequently does this investor lead its initial investments?",
|
|
38
|
+
measures: "Dealroom's lead flag on the investor's first observed participation in each company. This is observable participation, not who originated or committed first.",
|
|
39
|
+
initial_investments: leadStats(companies.map((company) => company.initial), history.investorId),
|
|
40
|
+
all_participations: leadStats(companies.flatMap((company) => company.participations), history.investorId),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function dateSpan(value) {
|
|
2
|
+
if (value === null)
|
|
3
|
+
return null;
|
|
4
|
+
const [year, month, day] = value.split("-");
|
|
5
|
+
if (!year)
|
|
6
|
+
return null;
|
|
7
|
+
if (day && month)
|
|
8
|
+
return { start: value, end: value, precision: "day" };
|
|
9
|
+
if (month) {
|
|
10
|
+
const lastDay = new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate();
|
|
11
|
+
return { start: `${year}-${month}-01`, end: `${year}-${month}-${String(lastDay).padStart(2, "0")}`, precision: "month" };
|
|
12
|
+
}
|
|
13
|
+
return { start: `${year}-01-01`, end: `${year}-12-31`, precision: "year" };
|
|
14
|
+
}
|
|
15
|
+
/** True only when every day `a` could mean falls before every day `b` could mean. */
|
|
16
|
+
export const definitelyBefore = (a, b) => a.end < b.start;
|
|
17
|
+
export const overlaps = (a, b) => !definitelyBefore(a, b) && !definitelyBefore(b, a);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A share rounded to four decimals, or null when the denominator is empty rather than a misleading zero. */
|
|
2
|
+
export function ratio(numerator, denominator) {
|
|
3
|
+
if (denominator === 0)
|
|
4
|
+
return null;
|
|
5
|
+
return Math.round((numerator / denominator) * 10_000) / 10_000;
|
|
6
|
+
}
|
|
7
|
+
export const countShare = (count, denominator) => ({ count, share: ratio(count, denominator) });
|
|
8
|
+
export const rate = (numerator, denominator) => ({ numerator, denominator, value: ratio(numerator, denominator) });
|
|
9
|
+
export function tally(values) {
|
|
10
|
+
const counts = new Map();
|
|
11
|
+
for (const value of values)
|
|
12
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
13
|
+
return Object.fromEntries([...counts].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0)));
|
|
14
|
+
}
|
|
15
|
+
/** Quartiles by linear interpolation between order statistics, rounded to two decimals. */
|
|
16
|
+
export function summarise(values) {
|
|
17
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
18
|
+
const at = (q) => {
|
|
19
|
+
if (sorted.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
const position = (sorted.length - 1) * q;
|
|
22
|
+
const low = Math.floor(position);
|
|
23
|
+
const high = Math.ceil(position);
|
|
24
|
+
const value = sorted[low] + (sorted[high] - sorted[low]) * (position - low);
|
|
25
|
+
return Math.round(value * 100) / 100;
|
|
26
|
+
};
|
|
27
|
+
return { n: sorted.length, min: at(0), p25: at(0.25), median: at(0.5), p75: at(0.75), max: at(1) };
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { compareStrings } from "../util.js";
|
|
2
|
+
import { identified } from "./investments.js";
|
|
3
|
+
import { ratio } from "./ratio.js";
|
|
4
|
+
export const SECTOR_FAMILY = "industry";
|
|
5
|
+
/**
|
|
6
|
+
* Multi-label industry exposure of the companies behind the first investments. Dealroom exposes no
|
|
7
|
+
* mutually exclusive sector field, so a company with two industries counts in both and shares can
|
|
8
|
+
* sum past one. This is the company's current classification, not the one at investment time.
|
|
9
|
+
*/
|
|
10
|
+
export function sectorExposure(history, companies) {
|
|
11
|
+
const analysed = identified(history).map((c) => companies[c.companyId]);
|
|
12
|
+
const counts = new Map();
|
|
13
|
+
let notReturned = 0;
|
|
14
|
+
let without = 0;
|
|
15
|
+
let multiple = 0;
|
|
16
|
+
let assignments = 0;
|
|
17
|
+
let maxLabels = 0;
|
|
18
|
+
for (const company of analysed) {
|
|
19
|
+
if (!company || !company.taxonomyKnown) {
|
|
20
|
+
notReturned++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const industries = company.taxonomy.filter((term) => term.type === SECTOR_FAMILY);
|
|
24
|
+
if (industries.length === 0)
|
|
25
|
+
without++;
|
|
26
|
+
if (industries.length > 1)
|
|
27
|
+
multiple++;
|
|
28
|
+
assignments += industries.length;
|
|
29
|
+
maxLabels = Math.max(maxLabels, industries.length);
|
|
30
|
+
for (const term of industries) {
|
|
31
|
+
const entry = counts.get(term.id) ?? { name: term.name, companies: 0, weight: 0 };
|
|
32
|
+
entry.companies++;
|
|
33
|
+
entry.weight += 1 / industries.length;
|
|
34
|
+
counts.set(term.id, entry);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const labelled = analysed.length - notReturned - without;
|
|
38
|
+
const distribution = [...counts]
|
|
39
|
+
.sort(([idA, a], [idB, b]) => b.companies - a.companies || compareStrings(a.name, b.name) || idA - idB)
|
|
40
|
+
.map(([id, entry]) => ({ id, name: entry.name, companies: entry.companies, share: ratio(entry.companies, labelled) }));
|
|
41
|
+
const concentration = [...counts.values()].reduce((sum, entry) => sum + (entry.weight / labelled) ** 2, 0);
|
|
42
|
+
return {
|
|
43
|
+
role: "descriptive",
|
|
44
|
+
question: "Which sectors does this investor enter?",
|
|
45
|
+
classification: {
|
|
46
|
+
exclusive_sector_classification_available: false,
|
|
47
|
+
family: SECTOR_FAMILY,
|
|
48
|
+
note: "Dealroom's API exposes no mutually exclusive sector field. Industry is its highest-level classification and a company can hold two, so shares can sum past 100%.",
|
|
49
|
+
max_labels_per_company_observed: maxLabels,
|
|
50
|
+
},
|
|
51
|
+
first_investments: analysed.length,
|
|
52
|
+
taxonomy_not_returned: notReturned,
|
|
53
|
+
without_industry: without,
|
|
54
|
+
labelled,
|
|
55
|
+
with_multiple_industries: multiple,
|
|
56
|
+
label_assignments: assignments,
|
|
57
|
+
distribution,
|
|
58
|
+
breadth: {
|
|
59
|
+
distinct_industries: counts.size,
|
|
60
|
+
top_industry_share: distribution[0]?.share ?? null,
|
|
61
|
+
effective_number_of_industries: labelled === 0 ? null : Math.round((1 / concentration) * 100) / 100,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const STAGE_BUCKETS = ["pre_seed", "seed", "series_a", "series_b_plus"];
|
|
2
|
+
/** Dealroom returns `round_label` as e.g. "MICRO-SEED" or "SERIES A EXTENSION"; reference data spells it "Micro-Seed". */
|
|
3
|
+
export function normaliseLabel(label) {
|
|
4
|
+
return label
|
|
5
|
+
.toUpperCase()
|
|
6
|
+
.replace(/[^A-Z0-9+]+/g, " ")
|
|
7
|
+
.trim();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Maps Dealroom's own stage label to an entry-maturity bucket. Labels outside the documented
|
|
11
|
+
* Pre-Seed to Series F ladder return null so they are reported as unmapped, not guessed.
|
|
12
|
+
*/
|
|
13
|
+
export function stageBucket(roundLabel) {
|
|
14
|
+
if (roundLabel === null)
|
|
15
|
+
return null;
|
|
16
|
+
const label = normaliseLabel(roundLabel);
|
|
17
|
+
if (label === "PRE SEED")
|
|
18
|
+
return "pre_seed";
|
|
19
|
+
if (["MICRO SEED", "SEED", "SEED+", "SEED EXTENSION"].includes(label))
|
|
20
|
+
return "seed";
|
|
21
|
+
if (label === "SERIES A" || label === "SERIES A EXTENSION")
|
|
22
|
+
return "series_a";
|
|
23
|
+
if (/^SERIES [B-Z]( EXTENSION)?$/.test(label))
|
|
24
|
+
return "series_b_plus";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function stageRank(roundLabel) {
|
|
28
|
+
const bucket = stageBucket(roundLabel);
|
|
29
|
+
return bucket === null ? STAGE_BUCKETS.length : STAGE_BUCKETS.indexOf(bucket);
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { compareStrings } from "../util.js";
|
|
2
|
+
import { identified } from "./investments.js";
|
|
3
|
+
import { leadStatus } from "./leadership.js";
|
|
4
|
+
import { ratio } from "./ratio.js";
|
|
5
|
+
export const TOP_PARTNERS = 10;
|
|
6
|
+
function rank(tallies, denominator) {
|
|
7
|
+
return [...tallies.values()]
|
|
8
|
+
.sort((a, b) => b.rounds - a.rounds || compareStrings(a.participant.name, b.participant.name) || compareStrings(a.participant.id, b.participant.id))
|
|
9
|
+
.map(({ participant, rounds }) => ({
|
|
10
|
+
id: participant.id,
|
|
11
|
+
name: participant.name,
|
|
12
|
+
type: participant.type,
|
|
13
|
+
subtype: participant.subtype,
|
|
14
|
+
rounds,
|
|
15
|
+
share: ratio(rounds, denominator),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
function add(tallies, participant, weight) {
|
|
19
|
+
const entry = tallies.get(participant.id) ?? { participant, rounds: 0, weight: 0 };
|
|
20
|
+
entry.rounds++;
|
|
21
|
+
entry.weight += weight;
|
|
22
|
+
tallies.set(participant.id, entry);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Who led the initial rounds this investor joined while another investor led, and who it co-invests with.
|
|
26
|
+
* A round with several leads credits each of them once in the counts; the Herfindahl index splits
|
|
27
|
+
* that round's weight equally so shares sum to one.
|
|
28
|
+
*/
|
|
29
|
+
export function syndicates(history) {
|
|
30
|
+
const investorId = history.investorId;
|
|
31
|
+
const companies = identified(history);
|
|
32
|
+
const ledByOthers = companies.map((c) => c.initial).filter((round) => leadStatus(round, investorId) === "not_led");
|
|
33
|
+
const leads = new Map();
|
|
34
|
+
for (const round of ledByOthers) {
|
|
35
|
+
const roundLeads = round.participants.filter((p) => p.id !== investorId && p.isLead === true);
|
|
36
|
+
for (const lead of roundLeads)
|
|
37
|
+
add(leads, lead, 1 / roundLeads.length);
|
|
38
|
+
}
|
|
39
|
+
const n = ledByOthers.length;
|
|
40
|
+
const rankedLeads = rank(leads, n);
|
|
41
|
+
const herfindahl = n === 0 ? null : Math.round([...leads.values()].reduce((sum, t) => sum + (t.weight / n) ** 2, 0) * 10_000) / 10_000;
|
|
42
|
+
const disclosed = companies.flatMap((c) => c.participations).filter((round) => round.investorsDisclosed);
|
|
43
|
+
const coInvestors = new Map();
|
|
44
|
+
for (const round of disclosed) {
|
|
45
|
+
for (const participant of round.participants)
|
|
46
|
+
if (participant.id !== investorId)
|
|
47
|
+
add(coInvestors, participant, 1);
|
|
48
|
+
}
|
|
49
|
+
const rankedCo = rank(coInvestors, disclosed.length);
|
|
50
|
+
return {
|
|
51
|
+
role: "descriptive",
|
|
52
|
+
note: "Context for investment leadership, not a separate measure: who led the first rounds this investor joined without leading, and who it co-invests with.",
|
|
53
|
+
lead_partners: {
|
|
54
|
+
basis: "initial_rounds_led_by_another_investor",
|
|
55
|
+
rounds: n,
|
|
56
|
+
unique_lead_partners: leads.size,
|
|
57
|
+
top_lead: rankedLeads[0] ?? null,
|
|
58
|
+
top_lead_dependence: rankedLeads[0]?.share ?? null,
|
|
59
|
+
herfindahl_index: herfindahl,
|
|
60
|
+
top: rankedLeads.slice(0, TOP_PARTNERS),
|
|
61
|
+
},
|
|
62
|
+
co_investors: {
|
|
63
|
+
basis: "all_participations_with_disclosed_investors",
|
|
64
|
+
rounds_considered: disclosed.length,
|
|
65
|
+
unique_co_investors: coInvestors.size,
|
|
66
|
+
repeat_co_investors: rankedCo.filter((partner) => partner.rounds >= 2).length,
|
|
67
|
+
top: rankedCo.slice(0, TOP_PARTNERS),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { analyse } from "../analysis/analyse.js";
|
|
5
|
+
import { blackSheepScore, sheepProfile } from "../analysis/black-sheep.js";
|
|
6
|
+
import { CALIBRATION } from "../analysis/black-sheep-calibration.js";
|
|
7
|
+
import { latestCompleteRun, loadDataset } from "../analysis/dataset.js";
|
|
8
|
+
import { loadDealroomConfig } from "../config.js";
|
|
9
|
+
import { DealroomClient } from "../dealroom/client.js";
|
|
10
|
+
import { extractionIsStale, openDatabase } from "../db/database.js";
|
|
11
|
+
import { rebuildDerivedTables } from "../ingest/extract.js";
|
|
12
|
+
import { ingestInvestor } from "../ingest/fetch.js";
|
|
13
|
+
import { resolveInvestor } from "../ingest/resolve.js";
|
|
14
|
+
import { buildReport, renderJson } from "../reports/json.js";
|
|
15
|
+
import { renderBlackSheep } from "../reports/sheep.js";
|
|
16
|
+
export class UsageError extends Error {
|
|
17
|
+
name = "UsageError";
|
|
18
|
+
}
|
|
19
|
+
export const SUPPORTED_FORMATS = ["sheep", "json"];
|
|
20
|
+
export function openWorkingDatabase(path, log) {
|
|
21
|
+
const db = openDatabase(path);
|
|
22
|
+
if (extractionIsStale(db)) {
|
|
23
|
+
log("Stored tables come from an older extraction version; rebuilding them from stored responses");
|
|
24
|
+
rebuildDerivedTables(db);
|
|
25
|
+
}
|
|
26
|
+
return db;
|
|
27
|
+
}
|
|
28
|
+
export async function runAnalyse(options, io) {
|
|
29
|
+
if (options.offline && options.refresh)
|
|
30
|
+
throw new UsageError("--offline and --refresh cannot be combined.");
|
|
31
|
+
if (!SUPPORTED_FORMATS.includes(options.format)) {
|
|
32
|
+
throw new UsageError(`Unsupported --format "${options.format}". Supported: ${SUPPORTED_FORMATS.join(", ")}.`);
|
|
33
|
+
}
|
|
34
|
+
if (!options.investor && !options.investorId)
|
|
35
|
+
throw new UsageError("Give an investor name or --investor-id <uuid>.");
|
|
36
|
+
const log = (message) => io.stderr(`${message}\n`);
|
|
37
|
+
const db = openWorkingDatabase(options.db, log);
|
|
38
|
+
try {
|
|
39
|
+
let client;
|
|
40
|
+
const getClient = () => (client ??= new DealroomClient(loadDealroomConfig(io.env), { fetch: io.fetch, sleep: io.sleep, log }));
|
|
41
|
+
const resolution = await resolveInvestor({
|
|
42
|
+
db,
|
|
43
|
+
query: options.investor,
|
|
44
|
+
investorId: options.investorId,
|
|
45
|
+
offline: options.offline,
|
|
46
|
+
client: getClient,
|
|
47
|
+
choose: options.interactive && io.isInteractive ? promptChoice(log) : undefined,
|
|
48
|
+
});
|
|
49
|
+
const stored = latestCompleteRun(db, resolution.id);
|
|
50
|
+
if (options.offline) {
|
|
51
|
+
log(`Using stored data for ${resolution.name} (offline)`);
|
|
52
|
+
}
|
|
53
|
+
else if (stored === null || options.refresh) {
|
|
54
|
+
log(`${stored === null ? "Fetching" : "Refreshing"} ${resolution.name} (${resolution.id}) from Dealroom`);
|
|
55
|
+
await ingestInvestor(db, getClient(), resolution.id, { log });
|
|
56
|
+
const usage = getClient().usage;
|
|
57
|
+
log(`Fetched with ${usage.requests} requests, ${usage.credits} Dealroom credits`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
log(`Using stored data for ${resolution.name} from run ${stored}; pass --refresh to fetch again`);
|
|
61
|
+
}
|
|
62
|
+
const dataset = loadDataset(db, resolution.id);
|
|
63
|
+
const analysis = analyse(dataset);
|
|
64
|
+
const blackSheep = blackSheepScore(sheepProfile(analysis.history, analysis.entryMaturity, dataset.investor.name), CALIBRATION);
|
|
65
|
+
const text = options.format === "json"
|
|
66
|
+
? renderJson(buildReport(dataset, analysis, blackSheep, { verbose: options.verbose }))
|
|
67
|
+
: renderBlackSheep(dataset.investor.name, scoredValue(dataset.investor.name, blackSheep));
|
|
68
|
+
if (options.output) {
|
|
69
|
+
const path = resolve(options.output);
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
writeFileSync(path, text);
|
|
72
|
+
log(`Wrote ${path}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
io.stdout(text);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function scoredValue(name, score) {
|
|
83
|
+
if (score.value === null) {
|
|
84
|
+
throw new UsageError(`No black sheep score for ${name}. ${score.reasons.join(" ")} Use --format json for the full report.`);
|
|
85
|
+
}
|
|
86
|
+
return score.value;
|
|
87
|
+
}
|
|
88
|
+
function promptChoice(log) {
|
|
89
|
+
return async (query, candidates) => {
|
|
90
|
+
log(`"${query}" matches several investors:`);
|
|
91
|
+
candidates.forEach((candidate, index) => log(` ${index + 1}. ${describeCandidate(candidate)}`));
|
|
92
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
93
|
+
try {
|
|
94
|
+
const answer = (await rl.question(`Choose 1-${candidates.length} (anything else cancels): `)).trim();
|
|
95
|
+
const index = Number(answer);
|
|
96
|
+
return Number.isInteger(index) && index >= 1 && index <= candidates.length ? candidates[index - 1] : null;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
rl.close();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function describeCandidate(candidate) {
|
|
104
|
+
const details = [candidate.domain, candidate.location].filter(Boolean).join(", ");
|
|
105
|
+
return `${candidate.name}${details ? ` (${details})` : ""} --investor-id ${candidate.id}`;
|
|
106
|
+
}
|