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
@@ -0,0 +1,121 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Validation for the Dealroom responses Sheeply reads, checked against the OpenAPI spec for
4
+ * API version 2026-10-22. Only fields the analysis uses are declared; unknown fields pass through
5
+ * untouched because the raw body is stored separately. See docs/dealroom-field-mapping.md.
6
+ */
7
+ export const partialDate = z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/, "expected YYYY, YYYY-MM or YYYY-MM-DD");
8
+ export const entityRef = z.object({
9
+ uuid: z.string().min(1),
10
+ type: z.enum(["organization", "person"]),
11
+ subtype: z.string().nullable().optional(),
12
+ name: z.string(),
13
+ dealroom_url: z.string().nullable().optional(),
14
+ });
15
+ export const taxonomyTerm = z.object({
16
+ id: z.number(),
17
+ name: z.string(),
18
+ type: z.string(),
19
+ });
20
+ const location = z.object({
21
+ role: z.string(),
22
+ city: z.object({ id: z.number(), name: z.string() }).nullable().optional(),
23
+ country: z.object({ id: z.number(), name: z.string() }).nullable().optional(),
24
+ });
25
+ export const organizationSummary = z.object({
26
+ uuid: z.string().min(1),
27
+ name: z.string(),
28
+ type: z.string().optional(),
29
+ subtype: z.string().nullable().optional(),
30
+ launch_date: partialDate.nullable().optional(),
31
+ dealroom_url: z.string().nullable().optional(),
32
+ taxonomy: z.array(taxonomyTerm).optional(),
33
+ locations: z.array(location).optional(),
34
+ });
35
+ export const page = z.object({
36
+ next_cursor: z.string().nullable().optional(),
37
+ total: z.number().nullable().optional(),
38
+ tier: z.string().nullable().optional(),
39
+ capped: z.boolean().nullable().optional(),
40
+ result_cap: z.number().nullable().optional(),
41
+ });
42
+ export const roundInvestor = z.object({
43
+ is_lead: z.boolean().nullable(),
44
+ investor: entityRef,
45
+ });
46
+ /** One row of `/data/companies/{id}/funding-rounds`: a transaction without its `company`. */
47
+ export const companyRound = z.object({
48
+ id: z.number().int(),
49
+ amount: z.number().nullable(),
50
+ announced_date: partialDate.nullable(),
51
+ round_type: z.string().nullable(),
52
+ standardized_round: z.string().nullable(),
53
+ round_label: z.string().nullable().optional(),
54
+ valuation: z.number().nullable().optional(),
55
+ is_vc_round: z.boolean().nullable(),
56
+ is_exit: z.boolean().nullable().optional(),
57
+ investors: z.array(roundInvestor).nullable(),
58
+ source_url: z.string().nullable().optional(),
59
+ });
60
+ /** One row of `/data/transactions`. */
61
+ export const transaction = companyRound.extend({
62
+ company: organizationSummary,
63
+ deleted_at: z.string().nullable().optional(),
64
+ });
65
+ export const searchResponse = z.object({
66
+ data: z.array(z.object({
67
+ uuid: z.string().min(1),
68
+ type: z.string(),
69
+ name: z.string(),
70
+ tagline: z.string().nullable().optional(),
71
+ investor_rank: z.number().nullable().optional(),
72
+ website_domain: z.string().nullable().optional(),
73
+ hq_city: z.string().nullable().optional(),
74
+ hq_country: z.string().nullable().optional(),
75
+ })),
76
+ });
77
+ const namedCode = z.object({ id: z.number(), name: z.string(), code: z.string().optional() });
78
+ /** `/data/investors/{id}` returns either an investor firm or a person who invests. */
79
+ export const investorDetailResponse = z.object({
80
+ data: z.object({
81
+ uuid: z.string().min(1),
82
+ name: z.string(),
83
+ type: z.enum(["organization", "person"]),
84
+ subtype: z.string().nullable().optional(),
85
+ domain: z.string().nullable().optional(),
86
+ dealroom_url: z.string().nullable().optional(),
87
+ launch_date: partialDate.nullable().optional(),
88
+ roles: z.array(z.string()).optional(),
89
+ types: z.array(namedCode).optional(),
90
+ locations: z.array(location).optional(),
91
+ investments: z
92
+ .object({
93
+ count: z.number().nullable().optional(),
94
+ preferred_round: z.string().nullable().optional(),
95
+ last_round_date: partialDate.nullable().optional(),
96
+ })
97
+ .nullable()
98
+ .optional(),
99
+ portfolio: z
100
+ .object({
101
+ rounds_count: z.number().nullable().optional(),
102
+ companies: z.object({ total: z.number().nullable().optional() }).nullable().optional(),
103
+ })
104
+ .nullable()
105
+ .optional(),
106
+ }),
107
+ });
108
+ export const portfolioResponse = z.object({
109
+ data: z.array(z.object({
110
+ is_lead: z.boolean(),
111
+ rounds_count: z.number().int(),
112
+ last_round_date: partialDate.nullable(),
113
+ company: organizationSummary,
114
+ })),
115
+ page,
116
+ });
117
+ export const transactionsResponse = z.object({ data: z.array(transaction), page, currency: z.string().optional() });
118
+ export const companyRoundsResponse = z.object({ data: z.array(companyRound), page, currency: z.string().optional() });
119
+ export const errorEnvelope = z.object({
120
+ error: z.object({ code: z.string(), message: z.string() }),
121
+ });
@@ -0,0 +1,137 @@
1
+ import { resetDerivedTables, transaction } from "../db/database.js";
2
+ import { companyRoundsResponse, investorDetailResponse, portfolioResponse, transactionsResponse } from "../dealroom/schemas.js";
3
+ import { nameKey } from "../util.js";
4
+ import { EXTRACTION_VERSION } from "../version.js";
5
+ const bool = (value) => (value == null ? null : value ? 1 : 0);
6
+ function parse(schema, row) {
7
+ const parsed = schema.safeParse(JSON.parse(row.body_json));
8
+ if (!parsed.success)
9
+ throw new Error(`Stored response ${row.id} (${row.kind}) no longer matches its schema: ${parsed.error.message}`);
10
+ return parsed.data;
11
+ }
12
+ /**
13
+ * Derives the normalised tables from one complete run's raw responses. Global records (companies,
14
+ * rounds, entities) are upserted, so each holds the newest version observed; the investor's own
15
+ * portfolio and round scope are replaced wholesale, so records Dealroom dropped disappear too.
16
+ */
17
+ export function extractRun(db, runId) {
18
+ const run = db.prepare("SELECT investor_id, status FROM ingestion_runs WHERE id = ?").get(runId);
19
+ if (!run || run.status !== "complete")
20
+ throw new Error(`Run ${runId} is not a complete ingestion run`);
21
+ const investorId = String(run.investor_id);
22
+ const rows = db
23
+ .prepare("SELECT id, kind, subject_id, retrieved_at, body_json FROM raw_responses WHERE run_id = ? ORDER BY kind, subject_id, page_index, id")
24
+ .all(runId);
25
+ const upsertEntity = db.prepare(`INSERT INTO entities (uuid, name, type, subtype, dealroom_url, source_response_id) VALUES (?, ?, ?, ?, ?, ?)
26
+ ON CONFLICT (uuid) DO UPDATE SET name = excluded.name, type = excluded.type, subtype = excluded.subtype,
27
+ dealroom_url = excluded.dealroom_url, source_response_id = excluded.source_response_id`);
28
+ const upsertCompany = db.prepare(`INSERT INTO companies (uuid, name, subtype, launch_date, dealroom_url, taxonomy_known, source_response_id, retrieved_at)
29
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
30
+ ON CONFLICT (uuid) DO UPDATE SET name = excluded.name, subtype = excluded.subtype, launch_date = excluded.launch_date,
31
+ dealroom_url = excluded.dealroom_url, taxonomy_known = excluded.taxonomy_known,
32
+ source_response_id = excluded.source_response_id, retrieved_at = excluded.retrieved_at`);
33
+ const insertCompanyIfMissing = db.prepare(`INSERT OR IGNORE INTO companies (uuid, name, subtype, launch_date, dealroom_url, taxonomy_known, source_response_id, retrieved_at)
34
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
35
+ const clearTaxonomy = db.prepare("DELETE FROM company_taxonomy WHERE company_id = ?");
36
+ const upsertTerm = db.prepare("INSERT INTO taxonomy_terms (id, name, type) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, type = excluded.type");
37
+ const insertCompanyTerm = db.prepare("INSERT OR IGNORE INTO company_taxonomy (company_id, term_id, source_response_id) VALUES (?, ?, ?)");
38
+ const upsertRound = db.prepare(`INSERT INTO funding_rounds (id, company_id, announced_date, round_type, standardized_round, round_label, amount, valuation,
39
+ currency, is_vc_round, is_exit, investors_disclosed, source_url, source_response_id, retrieved_at)
40
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
41
+ ON CONFLICT (id) DO UPDATE SET company_id = excluded.company_id, announced_date = excluded.announced_date,
42
+ round_type = excluded.round_type, standardized_round = excluded.standardized_round, round_label = excluded.round_label,
43
+ amount = excluded.amount, valuation = excluded.valuation, currency = excluded.currency, is_vc_round = excluded.is_vc_round,
44
+ is_exit = excluded.is_exit, investors_disclosed = excluded.investors_disclosed, source_url = excluded.source_url,
45
+ source_response_id = excluded.source_response_id, retrieved_at = excluded.retrieved_at`);
46
+ const clearParticipants = db.prepare("DELETE FROM round_participants WHERE round_id = ?");
47
+ // A participant listed twice on one round keeps the most informative lead flag: true over false over unknown.
48
+ const insertParticipant = db.prepare(`INSERT INTO round_participants (round_id, entity_id, is_lead, position) VALUES (?, ?, ?, ?)
49
+ ON CONFLICT (round_id, entity_id) DO UPDATE SET is_lead = CASE
50
+ WHEN excluded.is_lead IS NULL THEN round_participants.is_lead
51
+ WHEN round_participants.is_lead IS NULL THEN excluded.is_lead
52
+ ELSE max(round_participants.is_lead, excluded.is_lead) END`);
53
+ const insertScope = db.prepare("INSERT OR IGNORE INTO investor_round_scope (investor_id, round_id) VALUES (?, ?)");
54
+ const writeCompany = (company, row, overwrite) => {
55
+ const args = [
56
+ company.uuid,
57
+ company.name,
58
+ company.subtype ?? null,
59
+ company.launch_date ?? null,
60
+ company.dealroom_url ?? null,
61
+ company.taxonomy === undefined ? 0 : 1,
62
+ row.id,
63
+ row.retrieved_at,
64
+ ];
65
+ const changed = overwrite ? upsertCompany.run(...args).changes : insertCompanyIfMissing.run(...args).changes;
66
+ if (!changed || company.taxonomy === undefined)
67
+ return;
68
+ clearTaxonomy.run(company.uuid);
69
+ for (const term of company.taxonomy) {
70
+ upsertTerm.run(term.id, term.name, term.type);
71
+ insertCompanyTerm.run(company.uuid, term.id, row.id);
72
+ }
73
+ };
74
+ const writeEntity = (ref, sourceId) => upsertEntity.run(ref.uuid, ref.name, ref.type, ref.subtype ?? null, ref.dealroom_url ?? null, sourceId);
75
+ const writeRound = (round, companyId, currency, row) => {
76
+ upsertRound.run(round.id, companyId, round.announced_date, round.round_type, round.standardized_round, round.round_label ?? null, round.amount, round.valuation ?? null, currency, bool(round.is_vc_round), bool(round.is_exit), round.investors === null ? 0 : 1, round.source_url ?? null, row.id, row.retrieved_at);
77
+ clearParticipants.run(round.id);
78
+ (round.investors ?? []).forEach((participant, position) => {
79
+ writeEntity(participant.investor, row.id);
80
+ insertParticipant.run(round.id, participant.investor.uuid, bool(participant.is_lead), position);
81
+ });
82
+ insertScope.run(investorId, round.id);
83
+ };
84
+ transaction(db, () => {
85
+ db.prepare("DELETE FROM portfolio_relationships WHERE investor_id = ?").run(investorId);
86
+ db.prepare("DELETE FROM investor_round_scope WHERE investor_id = ?").run(investorId);
87
+ const insertPortfolio = db.prepare(`INSERT OR REPLACE INTO portfolio_relationships
88
+ (investor_id, company_id, company_subtype, is_lead_any, rounds_count, last_round_date, source_response_id, retrieved_at)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
90
+ for (const row of rows.filter((r) => r.kind === "investor")) {
91
+ const investor = parse(investorDetailResponse, row).data;
92
+ db.prepare(`INSERT INTO investors (uuid, name, name_key, type, subtype, firm_types_json, launch_date, domain, hq_country, dealroom_url,
93
+ reported_portfolio_total, reported_rounds_count, source_response_id, retrieved_at)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
95
+ ON CONFLICT (uuid) DO UPDATE SET name = excluded.name, name_key = excluded.name_key, type = excluded.type,
96
+ subtype = excluded.subtype, firm_types_json = excluded.firm_types_json, launch_date = excluded.launch_date,
97
+ domain = excluded.domain, hq_country = excluded.hq_country, dealroom_url = excluded.dealroom_url,
98
+ reported_portfolio_total = excluded.reported_portfolio_total, reported_rounds_count = excluded.reported_rounds_count,
99
+ source_response_id = excluded.source_response_id, retrieved_at = excluded.retrieved_at`).run(investor.uuid, investor.name, nameKey(investor.name), investor.type, investor.subtype ?? null, JSON.stringify((investor.types ?? []).map((t) => t.name).sort()), investor.launch_date ?? null, investor.domain ?? null, investor.locations?.find((l) => l.role === "hq")?.country?.name ?? null, investor.dealroom_url ?? null, investor.portfolio?.companies?.total ?? null, investor.portfolio?.rounds_count ?? null, row.id, row.retrieved_at);
100
+ writeEntity({ uuid: investor.uuid, name: investor.name, type: investor.type, subtype: investor.subtype }, row.id);
101
+ }
102
+ for (const row of rows.filter((r) => r.kind === "portfolio")) {
103
+ for (const item of parse(portfolioResponse, row).data) {
104
+ writeCompany(item.company, row, true);
105
+ insertPortfolio.run(investorId, item.company.uuid, item.company.subtype ?? null, item.is_lead ? 1 : 0, item.rounds_count, item.last_round_date, row.id, row.retrieved_at);
106
+ }
107
+ }
108
+ for (const row of rows.filter((r) => r.kind === "transactions")) {
109
+ const body = parse(transactionsResponse, row);
110
+ for (const round of body.data) {
111
+ if (round.deleted_at)
112
+ continue;
113
+ writeCompany(round.company, row, false);
114
+ writeRound(round, round.company.uuid, body.currency ?? null, row);
115
+ }
116
+ }
117
+ for (const row of rows.filter((r) => r.kind === "company_rounds")) {
118
+ const body = parse(companyRoundsResponse, row);
119
+ for (const round of body.data)
120
+ writeRound(round, row.subject_id, body.currency ?? null, row);
121
+ }
122
+ db.prepare(`INSERT INTO extraction_state (investor_id, run_id, extraction_version) VALUES (?, ?, ?)
123
+ ON CONFLICT (investor_id) DO UPDATE SET run_id = excluded.run_id, extraction_version = excluded.extraction_version`).run(investorId, runId, EXTRACTION_VERSION);
124
+ db.prepare("INSERT INTO meta (key, value) VALUES ('extraction_version', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(EXTRACTION_VERSION);
125
+ });
126
+ }
127
+ /** Drops every derived table and replays the latest complete run of each investor, oldest first. */
128
+ export function rebuildDerivedTables(db) {
129
+ resetDerivedTables(db);
130
+ const runs = db
131
+ .prepare(`SELECT max(id) AS id FROM ingestion_runs WHERE status = 'complete' GROUP BY investor_id ORDER BY max(id)`)
132
+ .all()
133
+ .map((row) => Number(row.id));
134
+ for (const runId of runs)
135
+ extractRun(db, runId);
136
+ return runs;
137
+ }
@@ -0,0 +1,80 @@
1
+ import { DealroomApiError } from "../dealroom/client.js";
2
+ import { companyRoundPages, getInvestor, portfolioPages, portfolioTransactionPages } from "../dealroom/endpoints.js";
3
+ import { sha256 } from "../util.js";
4
+ import { TOOL_VERSION } from "../version.js";
5
+ import { extractRun } from "./extract.js";
6
+ /** Statuses where the transactions filter itself is unavailable, so per-company calls may still work. */
7
+ const FALLBACK_STATUS = new Set([400, 403, 404]);
8
+ /**
9
+ * Fetches everything one analysis needs and stores each response verbatim, then derives the
10
+ * normalised tables from those responses. A failed run is kept for debugging but never analysed.
11
+ */
12
+ export async function ingestInvestor(db, client, investorId, options = {}) {
13
+ const log = options.log ?? (() => { });
14
+ const now = options.now ?? (() => new Date());
15
+ const runId = Number(db
16
+ .prepare(`INSERT INTO ingestion_runs (investor_id, status, started_at, api_base_url, api_version, tool_version)
17
+ VALUES (?, 'running', ?, ?, ?, ?)`)
18
+ .run(investorId, now().toISOString(), client.config.baseUrl, client.config.apiVersion, TOOL_VERSION).lastInsertRowid);
19
+ const insertRaw = db.prepare(`INSERT INTO raw_responses
20
+ (run_id, kind, subject_id, page_index, endpoint, params_json, retrieved_at, api_version, credits_used, body_sha256, body_json)
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
22
+ const store = (kind, subjectId, pageIndex, response) => {
23
+ const body = JSON.stringify(response.raw);
24
+ insertRaw.run(runId, kind, subjectId, pageIndex, response.meta.endpoint, JSON.stringify(response.meta.params), response.meta.retrievedAt, response.meta.apiVersion, response.meta.creditsUsed, sha256(body), body);
25
+ };
26
+ const before = client.usage;
27
+ try {
28
+ const detail = await getInvestor(client, investorId);
29
+ store("investor", investorId, 0, detail);
30
+ const investor = detail.body.data;
31
+ const expected = investor.portfolio?.companies?.total;
32
+ log(`Fetching portfolio of ${investor.name}${expected == null ? "" : ` (${expected} companies reported)`}`);
33
+ const roundCompanies = [];
34
+ let portfolioRows = 0;
35
+ for await (const page of portfolioPages(client, investorId)) {
36
+ store("portfolio", investorId, page.pageIndex, page);
37
+ portfolioRows += page.body.data.length;
38
+ for (const row of page.body.data)
39
+ if (row.rounds_count > 0)
40
+ roundCompanies.push(row.company.uuid);
41
+ if (page.body.page.tier && page.body.page.tier !== "premium") {
42
+ log(`Warning: Dealroom served the "${page.body.page.tier}" tier, so some fields may be redacted`);
43
+ }
44
+ }
45
+ log(`Portfolio: ${portfolioRows} rows`);
46
+ let strategy = "portfolio_transactions";
47
+ try {
48
+ let rounds = 0;
49
+ for await (const page of portfolioTransactionPages(client, investorId)) {
50
+ store("transactions", investorId, page.pageIndex, page);
51
+ rounds += page.body.data.length;
52
+ const total = page.body.page.total;
53
+ log(`Funding rounds: ${rounds}${total == null ? "" : ` of ${total}`}`);
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (!(error instanceof DealroomApiError) || !FALLBACK_STATUS.has(error.status))
58
+ throw error;
59
+ strategy = "per_company_funding_rounds";
60
+ db.prepare("DELETE FROM raw_responses WHERE run_id = ? AND kind = 'transactions'").run(runId);
61
+ log(`The portfolio transactions filter is unavailable (${error.status} ${error.code}); fetching rounds per company`);
62
+ let done = 0;
63
+ for (const companyId of [...new Set(roundCompanies)].sort()) {
64
+ for await (const page of companyRoundPages(client, companyId))
65
+ store("company_rounds", companyId, page.pageIndex, page);
66
+ if (++done % 25 === 0)
67
+ log(`Funding rounds: ${done} of ${roundCompanies.length} companies`);
68
+ }
69
+ }
70
+ const after = client.usage;
71
+ db.prepare(`UPDATE ingestion_runs SET status = 'complete', finished_at = ?, rounds_strategy = ?, requests = ?, credits_used = ? WHERE id = ?`).run(now().toISOString(), strategy, after.requests - before.requests, after.credits - before.credits, runId);
72
+ }
73
+ catch (error) {
74
+ const after = client.usage;
75
+ db.prepare(`UPDATE ingestion_runs SET status = 'failed', finished_at = ?, requests = ?, credits_used = ?, error = ? WHERE id = ?`).run(now().toISOString(), after.requests - before.requests, after.credits - before.credits, error instanceof Error ? error.message : String(error), runId);
76
+ throw error;
77
+ }
78
+ extractRun(db, runId);
79
+ return runId;
80
+ }
@@ -0,0 +1,74 @@
1
+ import { searchInvestors } from "../dealroom/endpoints.js";
2
+ import { nameKey } from "../util.js";
3
+ export class InvestorResolutionError extends Error {
4
+ name = "InvestorResolutionError";
5
+ candidates;
6
+ constructor(message, candidates = []) {
7
+ super(message);
8
+ this.candidates = candidates;
9
+ }
10
+ }
11
+ const fromHit = (hit) => ({
12
+ id: hit.uuid,
13
+ name: hit.name,
14
+ location: [hit.hq_city, hit.hq_country].filter(Boolean).join(", ") || null,
15
+ domain: hit.website_domain ?? null,
16
+ rank: hit.investor_rank ?? null,
17
+ });
18
+ /**
19
+ * Only an exact name match is ever chosen automatically. Anything else is ambiguous: the caller
20
+ * either asks the user or fails with the candidate list, never guesses from relevance order.
21
+ */
22
+ export function matchCandidates(query, candidates) {
23
+ const key = nameKey(query);
24
+ const exact = candidates.filter((candidate) => nameKey(candidate.name) === key);
25
+ if (exact.length === 1)
26
+ return { match: exact[0] };
27
+ return { ambiguous: exact.length > 1 ? exact : candidates };
28
+ }
29
+ export function localCandidates(db, query) {
30
+ return db
31
+ .prepare("SELECT uuid, name, hq_country, domain FROM investors WHERE name_key = ? ORDER BY name, uuid")
32
+ .all(nameKey(query))
33
+ .map((row) => ({
34
+ id: String(row.uuid),
35
+ name: String(row.name),
36
+ location: row.hq_country == null ? null : String(row.hq_country),
37
+ domain: row.domain == null ? null : String(row.domain),
38
+ rank: null,
39
+ }));
40
+ }
41
+ export async function resolveInvestor(options) {
42
+ const { db, query, investorId, offline } = options;
43
+ if (investorId) {
44
+ const local = db.prepare("SELECT name FROM investors WHERE uuid = ?").get(investorId);
45
+ return { id: investorId, name: local ? String(local.name) : investorId, via: "investor_id" };
46
+ }
47
+ if (!query?.trim())
48
+ throw new InvestorResolutionError("Give an investor name or --investor-id <uuid>.");
49
+ const local = localCandidates(db, query);
50
+ if (local.length === 1)
51
+ return { id: local[0].id, name: local[0].name, via: "local_exact_name" };
52
+ if (offline) {
53
+ if (local.length === 0) {
54
+ throw new InvestorResolutionError(`No stored investor is named "${query}". Run once without --offline to fetch it.`);
55
+ }
56
+ return choose(options, query, local);
57
+ }
58
+ if (!options.client)
59
+ throw new InvestorResolutionError("No Dealroom client available for investor search.");
60
+ const { body } = await searchInvestors(options.client(), query);
61
+ const hits = body.data.filter((hit) => hit.type === "investor").map(fromHit);
62
+ if (hits.length === 0)
63
+ throw new InvestorResolutionError(`Dealroom has no investor matching "${query}".`);
64
+ const result = matchCandidates(query, hits);
65
+ if ("match" in result)
66
+ return { id: result.match.id, name: result.match.name, via: "search_exact_name" };
67
+ return choose(options, query, result.ambiguous);
68
+ }
69
+ async function choose(options, query, candidates) {
70
+ const chosen = options.choose ? await options.choose(query, candidates) : null;
71
+ if (chosen)
72
+ return { id: chosen.id, name: chosen.name, via: "interactive_choice" };
73
+ throw new InvestorResolutionError(`"${query}" matches more than one investor, or none exactly. Rerun with --investor-id <uuid>.`, candidates);
74
+ }
@@ -0,0 +1,97 @@
1
+ import { THRESHOLDS } from "../analysis/interpretation.js";
2
+ import { leadStatus } from "../analysis/leadership.js";
3
+ import { stageBucket } from "../analysis/stages.js";
4
+ import { canonicalJson, sha256 } from "../util.js";
5
+ import { ALGORITHM_VERSION, EXTRACTION_VERSION, REPORT_SCHEMA_VERSION, TOOL_NAME, TOOL_VERSION } from "../version.js";
6
+ /** Hash of exactly the records the analysis read, so two reports can be checked for the same input. */
7
+ export function datasetFingerprint(dataset) {
8
+ const { provenance: _provenance, ...records } = dataset;
9
+ return `sha256:${sha256(canonicalJson(records))}`;
10
+ }
11
+ function roundView(round, investorId) {
12
+ return {
13
+ round_id: round.id,
14
+ announced_date: round.announcedDate,
15
+ round_label: round.roundLabel,
16
+ stage_bucket: stageBucket(round.roundLabel),
17
+ standardized_round: round.standardizedRound,
18
+ round_type: round.roundType,
19
+ amount: round.amount,
20
+ currency: round.currency,
21
+ lead_status: leadStatus(round, investorId),
22
+ leads: round.participants.filter((p) => p.isLead === true && p.id !== investorId).map((p) => ({ id: p.id, name: p.name })),
23
+ participants: round.investorsDisclosed ? round.participants.length : null,
24
+ };
25
+ }
26
+ function contribution(company, investorId) {
27
+ const entry = company.entry;
28
+ return {
29
+ company_id: company.companyId,
30
+ company_name: company.companyName,
31
+ status: company.status,
32
+ rounds_count_reported: company.roundsCountReported,
33
+ company_rounds_observed: company.companyRoundsObserved,
34
+ initial_round: company.initial ? roundView(company.initial, investorId) : null,
35
+ entry_context: entry && {
36
+ company_first_known_vc_round_id: entry.companyFirstVcRoundId,
37
+ prior_vc_rounds: entry.priorVcRounds,
38
+ uncertain_vc_rounds: entry.uncertainVcRounds,
39
+ prior_vc_capital: entry.priorVcCapital,
40
+ prior_vc_rounds_without_amount: entry.priorVcRoundsWithoutAmount,
41
+ company_age_at_entry_years: entry.companyAgeAtEntryYears,
42
+ },
43
+ follow_on_rounds: company.followOns.map((round) => roundView(round, investorId)),
44
+ flags: company.flags,
45
+ };
46
+ }
47
+ export function buildReport(dataset, analysis, blackSheep, options) {
48
+ const { investor, provenance } = dataset;
49
+ const report = {
50
+ report_schema_version: REPORT_SCHEMA_VERSION,
51
+ tool: { name: TOOL_NAME, version: TOOL_VERSION, algorithm_version: ALGORITHM_VERSION, extraction_version: EXTRACTION_VERSION },
52
+ source: {
53
+ provider: "Dealroom",
54
+ api_base_url: provenance.apiBaseUrl,
55
+ api_version: provenance.apiVersion,
56
+ ingestion_run_id: provenance.runId,
57
+ retrieval_started_at: provenance.startedAt,
58
+ retrieval_finished_at: provenance.finishedAt,
59
+ rounds_strategy: provenance.roundsStrategy,
60
+ responses_stored: provenance.responsesStored,
61
+ requests: provenance.requests,
62
+ credits_used: provenance.creditsUsed,
63
+ dataset_fingerprint: datasetFingerprint(dataset),
64
+ },
65
+ investor: {
66
+ id: investor.id,
67
+ name: investor.name,
68
+ entity_type: investor.type,
69
+ entity_subtype: investor.subtype,
70
+ firm_types: investor.firmTypes,
71
+ launch_date: investor.launchDate,
72
+ hq_country: investor.hqCountry,
73
+ dealroom_url: investor.dealroomUrl,
74
+ analysis_level: investor.type === "person" ? "person" : "firm",
75
+ },
76
+ coverage: {
77
+ portfolio_companies_returned: analysis.dataQuality.portfolio.rows_returned,
78
+ portfolio_companies_reported: analysis.dataQuality.portfolio.reported_by_dealroom,
79
+ first_investments_identified: analysis.entryMaturity.first_investments,
80
+ first_investments_with_known_stage: analysis.entryMaturity.known_stage,
81
+ first_investments_with_usable_lead_status: analysis.investmentLeadership.initial_investments.usable_lead_status,
82
+ funding_rounds_analysed: dataset.rounds.length,
83
+ },
84
+ black_sheep_score: blackSheep,
85
+ entry_maturity: analysis.entryMaturity,
86
+ investment_leadership: analysis.investmentLeadership,
87
+ sector_exposure: analysis.sectorExposure,
88
+ syndicate_relationships: analysis.syndicateRelationships,
89
+ data_quality: analysis.dataQuality,
90
+ interpretation: { thresholds: THRESHOLDS, statements: analysis.interpretation },
91
+ ...(options.verbose
92
+ ? { contributions: analysis.history.companies.map((company) => contribution(company, investor.id)) }
93
+ : {}),
94
+ };
95
+ return report;
96
+ }
97
+ export const renderJson = (report) => `${JSON.stringify(report, null, 2)}\n`;
@@ -0,0 +1,63 @@
1
+ const SHEEP = [
2
+ " .-~~-.-~~-.-~~-.",
3
+ " ,--. .(@@@@@@@@@@@@@@@).",
4
+ " ( o \\(@@@@@@@@@@@@@@@@@@@)",
5
+ " \\ /(@@@@@@@@@@@@@@@@@@@)",
6
+ " `-' `(@@@@@@@@@@@@@@@)'",
7
+ " `-~~-'-~~-'-~~-'",
8
+ " || ||",
9
+ " ^^ ^^",
10
+ ];
11
+ const WOOL = "@";
12
+ const WOOL_ROWS = SHEEP.flatMap((text, line) => text.includes(WOOL) ? [{ line, start: text.indexOf(WOOL), width: text.lastIndexOf(WOOL) - text.indexOf(WOOL) + 1 }] : []);
13
+ export const WOOL_SLOTS = WOOL_ROWS.reduce((sum, row) => sum + row.width, 0);
14
+ const TIERS = [
15
+ [15, "Pure white sheep. Baaa-sically the whole flock."],
16
+ [35, "Follows the flock"],
17
+ [55, "Grey area. Shear indecision."],
18
+ [75, "Ewe-nique taste"],
19
+ [90, "Ram-pant contrarian"],
20
+ [100, "True Blaaaaack Sheep"],
21
+ ];
22
+ export const clampScore = (score) => Math.round(Math.min(100, Math.max(0, score)));
23
+ export const tierLabel = (score) => TIERS.find(([max]) => clampScore(score) <= max)[1];
24
+ export const woolCount = (score) => Math.round((WOOL_SLOTS * (100 - clampScore(score))) / 100);
25
+ function fleece(wool) {
26
+ let slot = 0;
27
+ return WOOL_ROWS.map((row) => Array.from({ length: row.width }, () => {
28
+ const filled = Math.floor(((slot + 1) * wool) / WOOL_SLOTS) > Math.floor((slot * wool) / WOOL_SLOTS);
29
+ slot++;
30
+ return filled;
31
+ }));
32
+ }
33
+ const offsetAlternateRows = (rows) => rows.map((slots, r) => (r % 2 === 1 ? [slots.at(-1), ...slots.slice(0, -1)] : slots));
34
+ /** Counts screen columns where the sparser mark sits directly above itself in the next row. */
35
+ function stripes(rows, sparse) {
36
+ let count = 0;
37
+ for (let r = 1; r < rows.length; r++) {
38
+ const shift = WOOL_ROWS[r].start - WOOL_ROWS[r - 1].start;
39
+ rows[r].forEach((slot, s) => {
40
+ if (slot === sparse && rows[r - 1][s + shift] === sparse)
41
+ count++;
42
+ });
43
+ }
44
+ return count;
45
+ }
46
+ export function renderSheep(score) {
47
+ const wool = woolCount(score);
48
+ const sparse = wool * 2 < WOOL_SLOTS;
49
+ const even = fleece(wool);
50
+ const offset = offsetAlternateRows(even);
51
+ const rows = stripes(offset, sparse) < stripes(even, sparse) ? offset : even;
52
+ const lines = [...SHEEP];
53
+ WOOL_ROWS.forEach(({ line, start, width }, r) => {
54
+ const text = lines[line];
55
+ const fill = rows[r].map((filled) => (filled ? WOOL : " ")).join("");
56
+ lines[line] = text.slice(0, start) + fill + text.slice(start + width);
57
+ });
58
+ return lines.join("\n");
59
+ }
60
+ export function renderBlackSheep(investorName, score) {
61
+ const clamped = clampScore(score);
62
+ return `Investor: ${investorName}\n${renderSheep(clamped)}\n${clamped}% black sheep: ${tierLabel(clamped)}\n`;
63
+ }
package/dist/util.js ADDED
@@ -0,0 +1,19 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Case-, accent- and punctuation-insensitive key, so "LocalGlobe" matches "Local Globe". */
3
+ export function nameKey(name) {
4
+ return name
5
+ .normalize("NFKD")
6
+ .replace(/\p{Diacritic}/gu, "")
7
+ .toLowerCase()
8
+ .replace(/[^\p{L}\p{N}]+/gu, "");
9
+ }
10
+ export function sha256(text) {
11
+ return createHash("sha256").update(text).digest("hex");
12
+ }
13
+ /** JSON with object keys sorted, so equal data always serialises to equal bytes. */
14
+ export function canonicalJson(value) {
15
+ return JSON.stringify(value, (_key, inner) => inner && typeof inner === "object" && !Array.isArray(inner)
16
+ ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
17
+ : inner);
18
+ }
19
+ export const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+ export const TOOL_NAME = "sheeply";
3
+ export const TOOL_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
4
+ /** Bump when the raw-response to table mapping changes; `sheeply db rebuild` re-derives tables. */
5
+ export const EXTRACTION_VERSION = "1";
6
+ /** Bump when any metric, threshold or interpretation rule changes. */
7
+ export const ALGORITHM_VERSION = "0.3.0";
8
+ /** Bump when the JSON report shape changes incompatibly. */
9
+ export const REPORT_SCHEMA_VERSION = "2";
@@ -0,0 +1,36 @@
1
+ # Dealroom API limitations and open questions
2
+
3
+ Checked against the docs and live API on 2026-10-28, API version `2026-10-22`, with a beta-issued key.
4
+
5
+ ## Limitations Sheeply works around
6
+
7
+ 1. **`is_lead: false` does not mean a confirmed non-lead.** The spec documents `is_lead` as nullable ("Whether this investor led the round, when known"), but all 4,652 participant flags on Seedcamp's portfolio rounds came back `true` or `false`. Only 37% of rounds with participants flag anyone as lead; 591 rounds list a single investor flagged `false`; lead coverage rises from 14% of pre-2015 rounds to 53% from 2023. Sheeply treats `false` as a non-lead only when another participant is flagged as lead, and reports the other reading as a sensitivity. See [methodology](methodology.md#metric-b-investment-leadership).
8
+ 2. **Many rounds have no stage label.** `round_label` was null on 825 of 1,777 rounds (46%), including 324 rounds standardised as Seed (279 of them with an amount). Those first investments are excluded from entry maturity and broken down by `standardized_round` instead.
9
+ 3. **`standardized_round` is not strictly standardised.** Where `round_label` exists, `standardized_round` is a coarser copy of it (952 of 952 rounds agreed). Where it is missing, `standardized_round` is the self-reported `round_type` mapped across, as the filter docs say ("falls back to the reported round type when Dealroom left a round unlabelled"). Sheeply uses `round_label` and never fills a missing label.
10
+ 4. **No exclusive sector classification.** The API exposes overlapping taxonomy families only. The top level, `industry` (31 values), allows two per company (182 of 513 Seedcamp companies have two). Sector exposure is therefore multi-label, with shares that can exceed 100%.
11
+ 5. **Portfolio stakes without rounds.** 47 of Seedcamp's 523 portfolio companies have `rounds_count: 0` and no rounds at all. They are counted and excluded, not guessed.
12
+ 6. **Partial dates.** Rounds carry `announced_date` at source precision (`YYYY` or `YYYY-MM`). Order inside an overlapping span is a tie-break and is flagged.
13
+ 7. **Funds are not linked to deals.** `/investors/{id}/funds` lists vehicles (name, size, date) with no round or company link, so fund-level attribution is impossible. Analysis is firm-level.
14
+ 8. **Credits.** Each returned record costs a credit (portfolio row, transaction row); investor detail costs 2; search and `/reference` are free. A full Seedcamp fetch cost 2,302 credits; Ada Ventures cost 278. `402 CREDITS_EXHAUSTED` stops the run without retrying, and the cost of each run is stored and reported.
15
+ 9. **Rate limit.** 5 requests per second per key. Sheeply spaces requests at 4 per second and honours `Retry-After` on 429.
16
+ 10. **Intermittent gateway errors.** `include_total=true` on `/portfolio` returned a Cloudflare 504 during testing, so Sheeply never asks the portfolio for a total and reads the count from the investor profile instead. 5xx and network errors are retried with backoff.
17
+ 11. **Production vs beta host.** The docs name `https://api.dealroom.app` with audience `https://api.dealroom.app`. A key issued during the open beta is refused by that audience (403) and works only with `DEALROOM_API_BASE_URL=https://api.beta.dealroom.app`.
18
+ 12. **Short version support.** During the open beta a pinned version is supported for 30 days after it is superseded. Sheeply pins `2026-10-22` and warns on stderr when Dealroom sends a `Sunset` header.
19
+ 13. **Redaction.** Callers without the premium tier get `investors: null` on rounds. API keys report `premium`; Sheeply still stores `investors_disclosed` and counts redacted rounds under data quality.
20
+
21
+ ## Open questions for Dealroom
22
+
23
+ 1. Does `is_lead: false` mean "confirmed not lead" or "not recorded"? Is `null` ever returned? Is there a way to tell a round with no lead from a round whose lead is unknown?
24
+ 2. Is `round_label` computed from information available at the time of the round, or recomputed with later data (for example, a company's later funding history)? If the latter, it is mild look-ahead for entry maturity.
25
+ 3. Why do labelled-looking seed rounds with amounts have no `round_label`? Which definition do they miss?
26
+ 4. Are rounds ever re-attributed between investor entities (for example, from a fund vehicle entity to the firm), and does `company_investor_id` follow those links?
27
+ 5. Does the portfolio include every company where the firm appears on a round, or only ones curated onto the profile? (For Seedcamp the two agreed exactly: zero `rounds_not_matched`.)
28
+ 6. One Seedcamp portfolio round is dated 2006, before Seedcamp's 2007 launch date. Are pre-founding rounds expected (for example, partners investing before the fund existed)?
29
+ 7. Is the 18-sector exclusive classification available through the API, or planned?
30
+ 8. What does the Dealroom licence allow for local caching of raw responses? Sheeply keeps them only in the user's local SQLite file and never ships them.
31
+
32
+ ## Unverified in this build
33
+
34
+ - The per-company fallback (`/companies/{id}/funding-rounds`) is covered by mocked tests only; the live key never needed it.
35
+ - `--refresh` against the live API was not re-run, to save credits. Refresh idempotency is covered by mocked integration tests.
36
+ - Windows and Linux were not run. SQLite comes from Node's built-in `node:sqlite`, so there is no native module to compile on any platform.