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.
Files changed (40) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +155 -2
  4. package/dist/analysis/analyse.js +28 -0
  5. package/dist/analysis/black-sheep-calibration.js +7 -0
  6. package/dist/analysis/black-sheep.js +167 -0
  7. package/dist/analysis/data-quality.js +90 -0
  8. package/dist/analysis/dataset.js +126 -0
  9. package/dist/analysis/entry-maturity.js +72 -0
  10. package/dist/analysis/interpretation.js +105 -0
  11. package/dist/analysis/investments.js +107 -0
  12. package/dist/analysis/leadership.js +42 -0
  13. package/dist/analysis/partial-date.js +17 -0
  14. package/dist/analysis/ratio.js +28 -0
  15. package/dist/analysis/sector-exposure.js +64 -0
  16. package/dist/analysis/stages.js +30 -0
  17. package/dist/analysis/syndicates.js +70 -0
  18. package/dist/analysis/types.js +1 -0
  19. package/dist/cli/analyse-command.js +106 -0
  20. package/dist/cli/db-command.js +28 -0
  21. package/dist/cli/index.js +39 -0
  22. package/dist/cli/program.js +38 -0
  23. package/dist/config.js +53 -0
  24. package/dist/db/database.js +53 -0
  25. package/dist/db/schema.js +159 -0
  26. package/dist/dealroom/client.js +208 -0
  27. package/dist/dealroom/endpoints.js +17 -0
  28. package/dist/dealroom/pagination.js +25 -0
  29. package/dist/dealroom/schemas.js +121 -0
  30. package/dist/ingest/extract.js +137 -0
  31. package/dist/ingest/fetch.js +80 -0
  32. package/dist/ingest/resolve.js +74 -0
  33. package/dist/reports/json.js +97 -0
  34. package/dist/reports/sheep.js +63 -0
  35. package/dist/util.js +19 -0
  36. package/dist/version.js +9 -0
  37. package/docs/api-limitations.md +36 -0
  38. package/docs/dealroom-field-mapping.md +90 -0
  39. package/docs/methodology.md +203 -0
  40. package/package.json +52 -4
package/.env.example ADDED
@@ -0,0 +1,15 @@
1
+ # Dealroom API key: create one at https://app.dealroom.co (Settings > API). A key is a client id + secret pair.
2
+ DEALROOM_CLIENT_ID=
3
+ DEALROOM_CLIENT_SECRET=
4
+
5
+ # Optional. Defaults shown. Keys issued for the open beta host need
6
+ # DEALROOM_API_BASE_URL=https://api.beta.dealroom.app
7
+ DEALROOM_API_BASE_URL=
8
+ DEALROOM_AUDIENCE=
9
+ DEALROOM_TOKEN_URL=
10
+ DEALROOM_API_VERSION=
11
+ DEALROOM_REQUESTS_PER_SECOND=
12
+ DEALROOM_TIMEOUT_MS=
13
+
14
+ # Optional. Where the local SQLite cache lives (default ./sheeply.sqlite).
15
+ SHEEPLY_DB=
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 40 for 40
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # Sheeply
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Is your VC a black sheep or one of the flock?
4
+
5
+ Every investor says they back founders early and lead with conviction. Sheeply checks. Give it a firm's name and it pulls the firm's portfolio and every funding round from [Dealroom](https://dealroom.co), works out how often the firm leads or goes in first rather than following someone else, and draws you a sheep.
6
+
7
+ ```text
8
+ $ sheeply "Example Ventures"
9
+ Investor: Example Ventures
10
+ .-~~-.-~~-.-~~-.
11
+ ,--. .( @ @ @@ @ @ @@ ).
12
+ ( o \(@@ @ @@ @ @ @@ @ @ )
13
+ \ /(@ @ @ @@ @ @ @@ @ @)
14
+ `-' `(@ @@ @ @ @@ @ @)'
15
+ `-~~-'-~~-'-~~-'
16
+ || ||
17
+ ^^ ^^
18
+ 42% black sheep: Grey area. Shear indecision.
19
+ ```
20
+
21
+ The fuller the fleece, the more the firm follows the flock. An empty outline is a true black sheep.
22
+
23
+ Built as dry run #1 for [40 for 40](https://build40for40.com). It runs on your machine: no server, no accounts, no AI guessing.
24
+
25
+ ## What the score means
26
+
27
+ The black sheep score runs from 0 to 100 and asks two questions about each firm's first investment in a company since 2020:
28
+
29
+ 1. **Did it lead?** Was it the round's lead investor, or the only investor, rather than joining a round someone else led?
30
+ 2. **Did it go in first?** Was this the company's first VC round, rather than joining a company that had already raised behind someone else's lead?
31
+
32
+ Each answer becomes a rank against a peer set of 31 UK and European early-stage investors, and the score is the average of the two ranks. So 50 means "about as independent as a typical early-stage peer", and 100 means "more independent than every one of them".
33
+
34
+ | Score | Label |
35
+ | --- | --- |
36
+ | 0 to 15 | Pure white sheep. Baaa-sically the whole flock. |
37
+ | 16 to 35 | Follows the flock |
38
+ | 36 to 55 | Grey area. Shear indecision. |
39
+ | 56 to 75 | Ewe-nique taste |
40
+ | 76 to 90 | Ram-pant contrarian |
41
+ | 91 to 100 | True Blaaaaack Sheep |
42
+
43
+ **Take it with a pinch of salt.** It only sees announced rounds in Dealroom. It can't see who committed first, how fast a firm moved or whether it asked "who's leading?" before saying yes. Dealroom also leaves gaps: some rounds name no lead at all, and some list only one investor because nobody recorded the others. The JSON report gives a range for every score (firms whose ranges overlap shouldn't be called different), plus the counts behind it. A firm with too little recent data, or one that mostly invests after seed, gets an explanation instead of a score.
44
+
45
+ ## Getting started
46
+
47
+ You need Node.js 22.13 or later and a Dealroom API key.
48
+
49
+ ```bash
50
+ git clone https://github.com/build40for40/sheeply.git
51
+ cd sheeply
52
+ npm install
53
+ npm run build
54
+ cp .env.example .env # then add your Dealroom key, see below
55
+ node dist/cli/index.js "Seedcamp"
56
+ ```
57
+
58
+ Once it's published to npm, this becomes `npx sheeply "Seedcamp"`.
59
+
60
+ ### Your Dealroom key
61
+
62
+ A Dealroom API key is a client id and a secret, created under API settings in Dealroom. Put both in `.env` in the folder you run Sheeply from:
63
+
64
+ ```bash
65
+ DEALROOM_CLIENT_ID=your-client-id
66
+ DEALROOM_CLIENT_SECRET=your-client-secret
67
+ ```
68
+
69
+ Keys issued during Dealroom's open beta also need `DEALROOM_API_BASE_URL=https://api.beta.dealroom.app`.
70
+
71
+ **It costs credits.** Dealroom charges about one credit per record. A firm with about 500 portfolio companies costs roughly 2,300 credits the first time. After that the data is cached, and rerunning is free.
72
+
73
+ ## Using it
74
+
75
+ ```bash
76
+ sheeply "Seedcamp" # draw the sheep (fetches the firm the first time)
77
+ sheeply "Seedcamp" --format json # the full report, with every number behind the score
78
+ sheeply "Seedcamp" --offline # use only what's cached, never call Dealroom
79
+ sheeply "Seedcamp" --refresh # fetch fresh data from Dealroom
80
+ ```
81
+
82
+ **If a name matches more than one firm,** Sheeply asks which one you mean. In a script, or with `--no-interactive`, it stops and lists the candidates with their ids instead. Pass one back with `--investor-id <id>`.
83
+
84
+ All the options:
85
+
86
+ | Option | What it does |
87
+ | --- | --- |
88
+ | `--format sheep` or `json` | The sheep (default), or the full JSON report |
89
+ | `--verbose` | Adds every company and round behind the numbers to the JSON |
90
+ | `--output <file>` | Writes the output to a file instead of the screen |
91
+ | `--investor-id <id>` | Skips name matching and uses an exact Dealroom id |
92
+ | `--offline` | Uses only cached data, never calls Dealroom |
93
+ | `--refresh` | Fetches the firm again even if it's cached |
94
+ | `--db <file>` | Uses a different cache file (default `./sheeply.sqlite`) |
95
+ | `--no-interactive` | Fails with the candidates instead of asking which firm you meant |
96
+
97
+ Two housekeeping commands: `sheeply db status` lists what's cached, and `sheeply db rebuild` rebuilds the cache's tables from the stored Dealroom responses without calling the API.
98
+
99
+ Messages go to stderr and the result to stdout, so piping works: `sheeply "Seedcamp" --format json | jq .black_sheep_score`. Exit code `2` means a usage problem, an ambiguous name or not enough data; `3` means Dealroom returned an error.
100
+
101
+ ## The full report
102
+
103
+ `--format json` gives you everything the score is built from, and more:
104
+
105
+ - **Black sheep score:** the score, its range, both ranks and the raw counts behind them.
106
+ - **Entry maturity:** the stage of each first investment (pre-seed, seed, Series A, Series B+ or unknown), plus company age and how much the company had raised before.
107
+ - **Investment leadership:** how often the firm is recorded as lead, and how complete Dealroom's lead data is for that firm.
108
+ - **Sector exposure:** which industries the firm's companies are in.
109
+ - **Syndicate relationships:** who led the rounds the firm joined, and who it keeps investing alongside.
110
+ - **Data quality:** what was missing, what was left out and why.
111
+ - **Interpretation:** plain-English statements, each with the rule and threshold behind it.
112
+
113
+ Every percentage comes with its count and denominator. The same cached data always gives exactly the same report, so results can be checked and compared.
114
+
115
+ How each number is calculated is in [docs/methodology.md](docs/methodology.md). Which Dealroom field feeds what is in [docs/dealroom-field-mapping.md](docs/dealroom-field-mapping.md), and known gaps in Dealroom's API are in [docs/api-limitations.md](docs/api-limitations.md).
116
+
117
+ ## Your data stays yours
118
+
119
+ Everything Sheeply fetches lives in a SQLite file on your machine. Dealroom's data is licensed, so none of it is committed to this repo or shipped in the package. The only Dealroom-derived numbers in the package are the anonymised peer figures the score ranks against: two sorted lists of shares with no names attached.
120
+
121
+ ## How it works
122
+
123
+ ```text
124
+ Dealroom API ──► raw responses, stored verbatim ──► tables ──► analysis ──► sheep or JSON
125
+ (sheeply.sqlite) rebuilt pure functions,
126
+ any time no network
127
+ ```
128
+
129
+ The cached file is a convenience, not the source of truth: delete it and Sheeply fetches everything again. The peer figures come from an internal script, `npm run calibrate`, which fetches the peer list in `scripts/peers.ts` and regenerates `src/analysis/black-sheep-calibration.ts`. Users never need to run it.
130
+
131
+ ```text
132
+ src/
133
+ cli/ the commands
134
+ dealroom/ API client: login, retries, rate limits, schema checks, paging
135
+ db/ the SQLite cache
136
+ ingest/ finding the firm, fetching it and turning responses into tables
137
+ analysis/ the metrics, the black sheep score and its peer figures
138
+ reports/ the JSON report and the sheep
139
+ scripts/ peer list and calibration (internal)
140
+ tests/ fictional Dealroom data, an in-memory fake API, CLI tests and an optional live check
141
+ ```
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ npm run dev -- "Seedcamp" # run from source
147
+ npm test # all tests, no Dealroom key needed
148
+ npm run typecheck
149
+ npm run test:smoke # a quick live check, about a dozen credits, skipped without a key
150
+ ```
151
+
152
+ Unit tests sit next to the file they cover (`leadership.ts` and `leadership.test.ts`). Test data is made up: never commit real Dealroom responses. All settings, including rate limits and timeouts, are read from environment variables in `src/config.ts`.
153
+
154
+ ## Licence
155
+
156
+ MIT
@@ -0,0 +1,28 @@
1
+ import { dataQuality } from "./data-quality.js";
2
+ import { entryMaturity } from "./entry-maturity.js";
3
+ import { interpret } from "./interpretation.js";
4
+ import { buildInvestmentHistory } from "./investments.js";
5
+ import { investmentLeadership } from "./leadership.js";
6
+ import { sectorExposure } from "./sector-exposure.js";
7
+ import { syndicates } from "./syndicates.js";
8
+ /**
9
+ * Two metrics and two descriptive outputs, each computed independently from the same investment
10
+ * history. The black sheep score is not one of them: it ranks an investor against stored peers, so
11
+ * it lives in `black-sheep.ts` and is computed by the caller that has the peer set.
12
+ */
13
+ export function analyse(dataset) {
14
+ const history = buildInvestmentHistory(dataset);
15
+ const entry = entryMaturity(history);
16
+ const leadership = investmentLeadership(history);
17
+ const sectors = sectorExposure(history, dataset.companies);
18
+ const quality = dataQuality(dataset, history, { entry, leadership, sectors });
19
+ return {
20
+ history,
21
+ entryMaturity: entry,
22
+ investmentLeadership: leadership,
23
+ sectorExposure: sectors,
24
+ syndicateRelationships: syndicates(history),
25
+ dataQuality: quality,
26
+ interpretation: interpret(entry, leadership, quality),
27
+ };
28
+ }
@@ -0,0 +1,7 @@
1
+ /** Generated by `npm run calibrate` from the peer set in scripts/peers.ts. Shares only, sorted, with no peer names or ids. */
2
+ export const CALIBRATION = {
3
+ peer_set_version: "2026-10-28",
4
+ algorithm_version: "0.3.0",
5
+ leads: [0.031151, 0.050179, 0.08, 0.115702, 0.128205, 0.158416, 0.177778, 0.191489, 0.193548, 0.25, 0.265306, 0.291667, 0.303571, 0.304348, 0.310345, 0.313725, 0.315068, 0.333333, 0.393258, 0.416667, 0.435714, 0.459459, 0.47619, 0.47619, 0.5, 0.533333, 0.544, 0.560976, 0.642857, 0.75, 0.827381],
6
+ independent_entry: [0.010277, 0.010384, 0.03277, 0.04977, 0.08284, 0.086124, 0.092593, 0.102041, 0.12987, 0.131494, 0.133333, 0.137456, 0.147959, 0.15625, 0.157396, 0.16124, 0.162955, 0.171429, 0.181409, 0.197802, 0.223529, 0.242424, 0.254464, 0.258906, 0.258912, 0.265187, 0.267492, 0.5],
7
+ };
@@ -0,0 +1,167 @@
1
+ import { canonicalJson, sha256 } from "../util.js";
2
+ import { identified } from "./investments.js";
3
+ import { leadStatus } from "./leadership.js";
4
+ import { rate } from "./ratio.js";
5
+ export const BLACK_SHEEP = {
6
+ sinceYear: 2020,
7
+ minimumSample: 10,
8
+ minimumPeers: 10,
9
+ minimumEarlyStageShare: 0.5,
10
+ weights: { leads: 0.5, independent_entry: 0.5 },
11
+ resamples: 1000,
12
+ interval: 0.9,
13
+ seed: 1,
14
+ };
15
+ export function sheepProfile(history, entry, name) {
16
+ const d = entry.distribution;
17
+ const observations = identified(history)
18
+ .filter((company) => Number(company.initial.announcedDate?.slice(0, 4)) >= BLACK_SHEEP.sinceYear)
19
+ .map((company) => {
20
+ const round = company.initial;
21
+ const status = leadStatus(round, history.investorId);
22
+ const soleInvestor = round.participants.length === 1;
23
+ const context = company.entry;
24
+ return {
25
+ late: context.priorVcRounds > 0 ? true : context.uncertainVcRounds > 0 ? null : false,
26
+ led: soleInvestor || status === "sole_lead" || status === "co_lead" ? true : status === "not_led" ? false : null,
27
+ soleInvestor,
28
+ };
29
+ });
30
+ return {
31
+ investorId: history.investorId,
32
+ name,
33
+ earlyStageShare: entry.known_stage === 0 ? null : (d.pre_seed.count + d.seed.count) / entry.known_stage,
34
+ observations,
35
+ };
36
+ }
37
+ const share = (r) => (r.denominator === 0 ? null : r.numerator / r.denominator);
38
+ function behaviour(observations) {
39
+ const usable = observations.filter((o) => o.led !== null);
40
+ const ordered = observations.filter((o) => o.late !== null);
41
+ const late = ordered.filter((o) => o.late);
42
+ const lateUsable = late.filter((o) => o.led !== null);
43
+ const follow = rate(usable.filter((o) => !o.led).length, usable.length);
44
+ const lateRate = rate(late.length, ordered.length);
45
+ const followWhenLate = rate(lateUsable.filter((o) => !o.led).length, lateUsable.length);
46
+ const lateShare = share(lateRate);
47
+ const followLateShare = share(followWhenLate);
48
+ return {
49
+ follow,
50
+ late: lateRate,
51
+ followWhenLate,
52
+ followShare: share(follow),
53
+ lateFollowShare: lateShare === null || followLateShare === null ? null : lateShare * followLateShare,
54
+ };
55
+ }
56
+ const SHARE_DECIMALS = 1_000_000;
57
+ const roundShare = (value) => Math.round(value * SHARE_DECIMALS) / SHARE_DECIMALS;
58
+ const isEarlyStage = (profile) => profile.earlyStageShare !== null && profile.earlyStageShare >= BLACK_SHEEP.minimumEarlyStageShare;
59
+ /** Builds the peer distributions from stored peers, keeping only peers with enough early-stage data for each component. */
60
+ export function calibrate(peers, peerSetVersion, algorithmVersion) {
61
+ const points = (pick, minimum) => peers
62
+ .filter(isEarlyStage)
63
+ .map((peer) => behaviour(peer.observations))
64
+ .filter(minimum)
65
+ .map((b) => roundShare(pick(b)))
66
+ .sort((a, b) => a - b);
67
+ const enoughLeads = (b) => b.follow.denominator >= BLACK_SHEEP.minimumSample;
68
+ return {
69
+ peer_set_version: peerSetVersion,
70
+ algorithm_version: algorithmVersion,
71
+ leads: points((b) => b.followShare, enoughLeads),
72
+ independent_entry: points((b) => b.lateFollowShare, (b) => enoughLeads(b) && b.followWhenLate.denominator >= BLACK_SHEEP.minimumSample),
73
+ };
74
+ }
75
+ /** Drops one share equal to the investor's own, so a peer is not ranked against itself. */
76
+ function withoutSelf(shares, own) {
77
+ const index = own === null ? -1 : shares.indexOf(roundShare(own));
78
+ return index === -1 ? shares : [...shares.slice(0, index), ...shares.slice(index + 1)];
79
+ }
80
+ /** Share of peers below `value`, ties counted half, so 100 means above every peer. */
81
+ const percentile = (value, peers) => (100 * (peers.filter((p) => p < value).length + peers.filter((p) => p === value).length / 2)) / peers.length;
82
+ function mulberry32(seed) {
83
+ let state = seed;
84
+ return () => {
85
+ state = (state + 0x6d2b79f5) | 0;
86
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
87
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
88
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
89
+ };
90
+ }
91
+ const quantile = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))];
92
+ /**
93
+ * Ranks one investor against the calibrated early-stage peers on two behaviours: how rarely it follows another
94
+ * lead, and how rarely it joins an already funded company behind someone else's lead. Each is a
95
+ * percentile among peers, inverted so that higher is more contrarian, and the score averages them.
96
+ */
97
+ export function blackSheepScore(target, calibration) {
98
+ const p = BLACK_SHEEP;
99
+ const own = behaviour(target.observations);
100
+ const followPeers = withoutSelf(calibration.leads, own.followShare);
101
+ const latePeers = withoutSelf(calibration.independent_entry, own.lateFollowShare);
102
+ const reasons = [];
103
+ if (!isEarlyStage(target)) {
104
+ reasons.push("Most first investments with a known stage are after seed, so the early-stage peer set does not apply.");
105
+ }
106
+ if (own.follow.denominator < p.minimumSample) {
107
+ reasons.push(`Only ${own.follow.denominator} first investments since ${p.sinceYear} have a usable lead status (minimum ${p.minimumSample}).`);
108
+ }
109
+ if (own.followWhenLate.denominator < p.minimumSample) {
110
+ reasons.push(`Only ${own.followWhenLate.denominator} first investments since ${p.sinceYear} joined an already funded company with a usable lead status (minimum ${p.minimumSample}).`);
111
+ }
112
+ if (followPeers.length < p.minimumPeers || latePeers.length < p.minimumPeers) {
113
+ reasons.push(`The calibration has too few peers (minimum ${p.minimumPeers}).`);
114
+ }
115
+ const scoreOf = (b) => {
116
+ const leads = 100 - percentile(roundShare(b.followShare), followPeers);
117
+ const independent = 100 - percentile(roundShare(b.lateFollowShare), latePeers);
118
+ return { leads, independent, value: p.weights.leads * leads + p.weights.independent_entry * independent };
119
+ };
120
+ let scored = null;
121
+ let range = null;
122
+ if (reasons.length === 0) {
123
+ scored = scoreOf(own);
124
+ const random = mulberry32(p.seed);
125
+ const n = target.observations.length;
126
+ const values = [];
127
+ for (let i = 0; i < p.resamples; i++) {
128
+ const sample = Array.from({ length: n }, () => target.observations[Math.floor(random() * n)]);
129
+ const b = behaviour(sample);
130
+ if (b.followShare !== null && b.lateFollowShare !== null)
131
+ values.push(scoreOf(b).value);
132
+ }
133
+ values.sort((a, b) => a - b);
134
+ const tail = (1 - p.interval) / 2;
135
+ range = { low: Math.round(quantile(values, tail)), high: Math.round(quantile(values, 1 - tail)), interval: p.interval, resamples: values.length };
136
+ }
137
+ return {
138
+ role: "score",
139
+ question: "How often does this investor lead or go in first, rather than follow others, compared with early-stage peers?",
140
+ scale: "0 follows the flock the most among peers, 100 is the most contrarian",
141
+ status: scored ? "scored" : "not_scored",
142
+ reasons,
143
+ value: scored && Math.round(scored.value),
144
+ range,
145
+ components: {
146
+ leads: {
147
+ score: scored && Math.round(scored.leads),
148
+ follows_others: own.follow,
149
+ sole_investor: target.observations.filter((o) => o.soleInvestor).length,
150
+ peers: followPeers.length,
151
+ },
152
+ independent_entry: {
153
+ score: scored && Math.round(scored.independent),
154
+ late_entry: own.late,
155
+ follows_others_when_late: own.followWhenLate,
156
+ late_follow_share: own.lateFollowShare === null ? null : Math.round(own.lateFollowShare * 10_000) / 10_000,
157
+ peers: latePeers.length,
158
+ },
159
+ },
160
+ calibration: {
161
+ peer_set_version: calibration.peer_set_version,
162
+ algorithm_version: calibration.algorithm_version,
163
+ fingerprint: `sha256:${sha256(canonicalJson(calibration))}`,
164
+ },
165
+ parameters: BLACK_SHEEP,
166
+ };
167
+ }
@@ -0,0 +1,90 @@
1
+ import { identified } from "./investments.js";
2
+ import { rate } from "./ratio.js";
3
+ import { stageBucket } from "./stages.js";
4
+ const STATUSES = [
5
+ "initial_identified",
6
+ "no_round_attribution",
7
+ "rounds_not_matched",
8
+ "participation_undisclosed",
9
+ "not_a_company",
10
+ ];
11
+ const FLAGS = [
12
+ "initial_ordering_ambiguous",
13
+ "undated_participation",
14
+ "initial_undated",
15
+ "earlier_round_investors_undisclosed",
16
+ ];
17
+ export function dataQuality(dataset, history, outputs) {
18
+ const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
19
+ for (const company of history.companies)
20
+ byStatus[company.status]++;
21
+ const flags = Object.fromEntries(FLAGS.map((f) => [f, 0]));
22
+ for (const company of identified(history))
23
+ for (const flag of company.flags)
24
+ flags[flag]++;
25
+ const companies = history.companies.filter((c) => c.status !== "not_a_company");
26
+ const reported = dataset.investor.reportedPortfolioTotal;
27
+ const { entry, leadership, sectors } = outputs;
28
+ const initial = leadership.initial_investments;
29
+ const roundsById = new Map(dataset.rounds.map((round) => [round.id, round]));
30
+ const lateStart = identified(history).filter((company) => {
31
+ const firstVc = company.entry.companyFirstVcRoundId;
32
+ const bucket = firstVc === null ? null : stageBucket(roundsById.get(firstVc)?.roundLabel ?? null);
33
+ return bucket === "series_a" || bucket === "series_b_plus";
34
+ }).length;
35
+ const withParticipants = dataset.rounds.filter((r) => r.participants.length > 0);
36
+ const participants = dataset.rounds.flatMap((r) => r.participants);
37
+ const exclusions = [
38
+ { output: "all", reason: "portfolio row is not a company", count: byStatus.not_a_company },
39
+ { output: "all", reason: "no round attribution in Dealroom (rounds_count 0)", count: byStatus.no_round_attribution },
40
+ { output: "all", reason: "portfolio claims rounds but none of the returned rounds lists the investor", count: byStatus.rounds_not_matched },
41
+ { output: "all", reason: "investor not found and some company rounds have no investor list", count: byStatus.participation_undisclosed },
42
+ { output: "entry_maturity", reason: "first investment has no Dealroom round_label (counted as unknown)", count: sum(entry.unknown_breakdown.no_round_label_by_standardized_round) },
43
+ { output: "entry_maturity", reason: "first investment has a round_label outside the mapped ladder (counted as unknown)", count: sum(entry.unknown_breakdown.unmapped_round_labels) },
44
+ { output: "investment_leadership", reason: "is_lead false but nobody on the first-investment round is flagged as lead", count: initial.false_with_no_lead_on_round },
45
+ { output: "investment_leadership", reason: "is_lead null on the first-investment round", count: initial.null_lead_flag },
46
+ { output: "sector_exposure", reason: "taxonomy not returned for company", count: sectors.taxonomy_not_returned },
47
+ { output: "sector_exposure", reason: "company has no industry label", count: sectors.without_industry },
48
+ ];
49
+ return {
50
+ portfolio: {
51
+ rows_returned: dataset.portfolio.length,
52
+ reported_by_dealroom: reported,
53
+ complete: reported === null ? null : dataset.portfolio.length >= reported,
54
+ by_status: byStatus,
55
+ rounds_count_mismatch: identified(history).filter((c) => c.participations.length !== c.roundsCountReported).length,
56
+ },
57
+ rounds: {
58
+ returned: dataset.rounds.length,
59
+ reported_by_dealroom: dataset.provenance.transactionsReportedTotal,
60
+ undated: dataset.rounds.filter((r) => r.announcedDate === null).length,
61
+ year_precision_only: dataset.rounds.filter((r) => r.announcedDate?.length === 4).length,
62
+ investors_not_disclosed: dataset.rounds.filter((r) => !r.investorsDisclosed).length,
63
+ without_round_label: dataset.rounds.filter((r) => r.roundLabel === null).length,
64
+ outside_portfolio_listing: history.roundsOutsidePortfolio.length,
65
+ },
66
+ funding_histories: {
67
+ companies_without_any_round: companies.filter((c) => c.companyRoundsObserved === 0).length,
68
+ history_starts_at_series_a_or_later: lateStart,
69
+ first_investment_flags: flags,
70
+ },
71
+ lead_flags: {
72
+ participant_flags: {
73
+ true: participants.filter((p) => p.isLead === true).length,
74
+ false: participants.filter((p) => p.isLead === false).length,
75
+ null: participants.filter((p) => p.isLead === null).length,
76
+ },
77
+ rounds_with_participants: withParticipants.length,
78
+ rounds_with_a_flagged_lead: rate(withParticipants.filter((r) => r.participants.some((p) => p.isLead === true)).length, withParticipants.length),
79
+ single_participant_rounds_flagged_false: withParticipants.filter((r) => r.participants.length === 1 && r.participants[0].isLead === false).length,
80
+ },
81
+ coverage: {
82
+ first_investment_identification: rate(byStatus.initial_identified, companies.length),
83
+ entry_stage: rate(entry.known_stage, entry.first_investments),
84
+ lead_status: initial.lead_status_completeness,
85
+ sector: rate(sectors.labelled, sectors.first_investments),
86
+ },
87
+ exclusions: exclusions.filter((e) => e.count > 0),
88
+ };
89
+ }
90
+ const sum = (record) => Object.values(record).reduce((a, b) => a + b, 0);
@@ -0,0 +1,126 @@
1
+ import { compareStrings } from "../util.js";
2
+ export class NoStoredDataError extends Error {
3
+ name = "NoStoredDataError";
4
+ }
5
+ const str = (value) => (value == null ? null : String(value));
6
+ const flag = (value) => (value == null ? null : Number(value) === 1);
7
+ export function latestCompleteRun(db, investorId) {
8
+ const row = db.prepare("SELECT run_id FROM extraction_state WHERE investor_id = ?").get(investorId);
9
+ return row ? Number(row.run_id) : null;
10
+ }
11
+ /** Reads one investor's analysable dataset from the derived tables, in a fixed order. */
12
+ export function loadDataset(db, investorId) {
13
+ const runId = latestCompleteRun(db, investorId);
14
+ const investorRow = db.prepare("SELECT * FROM investors WHERE uuid = ?").get(investorId);
15
+ if (runId === null || !investorRow) {
16
+ throw new NoStoredDataError(`No complete ingestion is stored for investor ${investorId}.`);
17
+ }
18
+ const run = db.prepare("SELECT * FROM ingestion_runs WHERE id = ?").get(runId);
19
+ const responses = db
20
+ .prepare(`SELECT count(*) AS n, min(retrieved_at) AS first, max(retrieved_at) AS last FROM raw_responses WHERE run_id = ?`)
21
+ .get(runId);
22
+ const firstTransactions = db
23
+ .prepare("SELECT body_json FROM raw_responses WHERE run_id = ? AND kind = 'transactions' ORDER BY page_index LIMIT 1")
24
+ .get(runId);
25
+ const transactionsTotal = firstTransactions
26
+ ? JSON.parse(String(firstTransactions.body_json)).page?.total ?? null
27
+ : null;
28
+ const portfolio = db
29
+ .prepare(`SELECT p.company_id, c.name, p.company_subtype, p.rounds_count, p.is_lead_any, p.last_round_date
30
+ FROM portfolio_relationships p JOIN companies c ON c.uuid = p.company_id
31
+ WHERE p.investor_id = ? ORDER BY p.company_id`)
32
+ .all(investorId)
33
+ .map((row) => ({
34
+ companyId: String(row.company_id),
35
+ companyName: String(row.name),
36
+ companySubtype: str(row.company_subtype),
37
+ roundsCount: Number(row.rounds_count),
38
+ isLeadAny: Number(row.is_lead_any) === 1,
39
+ lastRoundDate: str(row.last_round_date),
40
+ }));
41
+ const roundRows = db
42
+ .prepare(`SELECT r.* FROM investor_round_scope s JOIN funding_rounds r ON r.id = s.round_id
43
+ WHERE s.investor_id = ? ORDER BY r.id`)
44
+ .all(investorId);
45
+ const participantRows = db
46
+ .prepare(`SELECT rp.round_id, rp.entity_id, rp.is_lead, e.name, e.type, e.subtype
47
+ FROM investor_round_scope s
48
+ JOIN round_participants rp ON rp.round_id = s.round_id
49
+ JOIN entities e ON e.uuid = rp.entity_id
50
+ WHERE s.investor_id = ? ORDER BY rp.round_id, rp.position, rp.entity_id`)
51
+ .all(investorId);
52
+ const participantsByRound = new Map();
53
+ for (const row of participantRows) {
54
+ const list = participantsByRound.get(Number(row.round_id)) ?? [];
55
+ list.push({
56
+ id: String(row.entity_id),
57
+ name: String(row.name),
58
+ type: String(row.type),
59
+ subtype: str(row.subtype),
60
+ isLead: flag(row.is_lead),
61
+ });
62
+ participantsByRound.set(Number(row.round_id), list);
63
+ }
64
+ const rounds = roundRows.map((row) => ({
65
+ id: Number(row.id),
66
+ companyId: String(row.company_id),
67
+ announcedDate: str(row.announced_date),
68
+ roundLabel: str(row.round_label),
69
+ standardizedRound: str(row.standardized_round),
70
+ roundType: str(row.round_type),
71
+ amount: row.amount == null ? null : Number(row.amount),
72
+ currency: str(row.currency),
73
+ isVcRound: flag(row.is_vc_round),
74
+ investorsDisclosed: Number(row.investors_disclosed) === 1,
75
+ participants: participantsByRound.get(Number(row.id)) ?? [],
76
+ }));
77
+ const companyIds = [...new Set([...portfolio.map((p) => p.companyId), ...rounds.map((r) => r.companyId)])].sort(compareStrings);
78
+ const companies = {};
79
+ const companyStatement = db.prepare("SELECT * FROM companies WHERE uuid = ?");
80
+ const taxonomyStatement = db.prepare(`SELECT t.id, t.name, t.type FROM company_taxonomy ct JOIN taxonomy_terms t ON t.id = ct.term_id
81
+ WHERE ct.company_id = ? ORDER BY t.type, t.id`);
82
+ for (const id of companyIds) {
83
+ const row = companyStatement.get(id);
84
+ if (!row)
85
+ continue;
86
+ companies[id] = {
87
+ id,
88
+ name: String(row.name),
89
+ launchDate: str(row.launch_date),
90
+ taxonomyKnown: Number(row.taxonomy_known) === 1,
91
+ taxonomy: taxonomyStatement.all(id).map((term) => ({ id: Number(term.id), name: String(term.name), type: String(term.type) })),
92
+ };
93
+ }
94
+ return {
95
+ investor: {
96
+ id: investorId,
97
+ name: String(investorRow.name),
98
+ type: String(investorRow.type),
99
+ subtype: str(investorRow.subtype),
100
+ firmTypes: JSON.parse(String(investorRow.firm_types_json)),
101
+ launchDate: str(investorRow.launch_date),
102
+ hqCountry: str(investorRow.hq_country),
103
+ dealroomUrl: str(investorRow.dealroom_url),
104
+ reportedPortfolioTotal: investorRow.reported_portfolio_total == null ? null : Number(investorRow.reported_portfolio_total),
105
+ reportedRoundsCount: investorRow.reported_rounds_count == null ? null : Number(investorRow.reported_rounds_count),
106
+ },
107
+ portfolio,
108
+ companies,
109
+ rounds,
110
+ provenance: {
111
+ runId,
112
+ startedAt: String(run.started_at),
113
+ finishedAt: String(run.finished_at),
114
+ apiBaseUrl: String(run.api_base_url),
115
+ apiVersion: String(run.api_version),
116
+ roundsStrategy: String(run.rounds_strategy),
117
+ extractionVersion: String(db.prepare("SELECT extraction_version FROM extraction_state WHERE investor_id = ?").get(investorId).extraction_version),
118
+ responsesStored: Number(responses.n),
119
+ firstRetrievedAt: String(responses.first),
120
+ lastRetrievedAt: String(responses.last),
121
+ requests: run.requests == null ? null : Number(run.requests),
122
+ creditsUsed: run.credits_used == null ? null : Number(run.credits_used),
123
+ transactionsReportedTotal: transactionsTotal,
124
+ },
125
+ };
126
+ }