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
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
|
-
#
|
|
1
|
+
# Sheeply
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|