Market-multiple valuation of private companies from their published financial statements.
Upload an income statement and a balance sheet, name the industry, and get back a valuation across three methods — price/earnings, EV/EBITDA (with an EBIT fallback) and price/sales — each showing the industry benchmark it used and the reliability checks it passed or failed, reduced to a single concluded range.
Every figure is computed deterministically. The language model transcribes
the statements into structured line items; it performs no arithmetic. The same
inputs always produce the same valuation, and every number in a report can be
traced to a formula in src/valuation/domain/.
python -m venv .venv && . .venv/Scripts/activate # Windows
# python -m venv .venv && source .venv/bin/activate # Linux / macOS
pip install -e ".[dev]"
cp .env.example .env # then set GOOGLE_API_KEY
python -m valuationThe API is on http://127.0.0.1:8000, with interactive docs at /docs.
Nothing above needs an API key if you set LLM__PROVIDER=mock — the whole
pipeline runs offline, and the POST /v1/valuations/compute endpoint never
touches a model at all.
# Resolve a business description to a benchmark industry (no model call)
curl -s localhost:8000/v1/industries/classify \
-H 'content-type: application/json' \
-d '{"query": "B2B SaaS platform for logistics"}'
# Value a company from figures you already hold (no model call, no upload)
curl -s localhost:8000/v1/valuations/compute \
-H 'content-type: application/json' \
-d '{
"industry": "enterprise software",
"company_name": "Acme Ltd",
"currency": "USD",
"units_label": "in 000",
"scale_multiplier": 1000,
"periods": [
{"fiscal_year": 2023, "revenue": 240000, "net_income": 30000,
"profit_before_tax": 50000, "interest_expense": 6000, "tax_expense": 20000,
"depreciation_amortisation": 12000, "total_assets": 260000,
"total_equity": 95000, "non_current_debt": 40000,
"cash_and_equivalents": 9000},
{"fiscal_year": 2022, "revenue": 200000, "net_income": 24000,
"profit_before_tax": 40000, "interest_expense": 5000, "tax_expense": 16000,
"depreciation_amortisation": 10000, "total_assets": 235000,
"total_equity": 78000, "non_current_debt": 45000,
"cash_and_equivalents": 6000}
]
}'
# Value a company from its statements
curl -s -X POST 'localhost:8000/v1/valuations?wait=true' \
-F 'files=@income_statement.pdf' \
-F 'files=@balance_sheet.pdf' \
-F 'industry=cement manufacturing'| Method | Path | Purpose |
|---|---|---|
GET |
/health, /ready |
Liveness and readiness probes |
GET |
/v1/industries |
The benchmark catalogue, with an optional ?search= filter |
GET |
/v1/industries/{key} |
One industry's multiples, growth rates and thresholds |
POST |
/v1/industries/classify |
Resolve free text to an industry, with scored alternatives |
POST |
/v1/valuations |
Submit statements; add ?wait=true to block for the report |
GET |
/v1/valuations/{run_id} |
Run status, stage, token usage and the report once ready |
GET |
/v1/valuations/{run_id}/report |
Just the report |
POST |
/v1/valuations/compute |
Value from supplied figures — deterministic, no model, no upload |
Errors carry a stable code, a message and optional details. Every response
echoes an X-Request-ID header; quote it when reporting a problem.
Environment variables, or a .env file. Nested settings use a double
underscore. See .env.example for the full set.
| Variable | Default | Purpose |
|---|---|---|
LLM__PROVIDER |
google |
google, anthropic, openai or mock |
LLM__MODEL |
per provider | Overrides the provider's default model |
GOOGLE_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY |
— | Key for the selected provider |
API__API_KEYS |
(empty) | Comma-separated keys for X-API-Key. Empty means unauthenticated |
API__CORS_ALLOW_ORIGINS |
(empty) | Comma-separated allowed origins |
DOCUMENTS__MAX_FILE_BYTES |
26214400 |
Per-file upload limit |
RUNS__TTL_SECONDS |
86400 |
How long a finished run is retained |
ENVIRONMENT |
local |
local, staging or production |
The default provider model is gemini-2.5-flash; Anthropic and OpenAI default to
claude-sonnet-5 and gpt-4.1. Switching provider is a one-line config change —
no code imports a vendor SDK.
upload ──▶ parse ──▶ extract ──▶ normalise ──▶ classify ──▶ compute ──▶ summarise
PDF/XLSX LLM + units, lexical, multiples, weighted
→ text schema signs model constraints range
▲ arbitration
└── the only model call in the numeric path,
and it returns line items, never figures it derived
- Parse — PDF via
pypdf, workbooks viaopenpyxl, both off the event loop. - Extract — one schema-enforced model call returns the statement line items.
- Normalise — apply the statement's scale (
Rs. in '000→ ×1000) and coerce expense signs. - Classify — resolve the industry lexically; consult the model only when the lexical score is weak, and only to pick from the real taxonomy.
- Compute — derive growth, volatility and leverage; apply each method's multiple; evaluate its constraints.
- Summarise — reduce the applicable methods to a low/central/high range, weighted by how cleanly each passed its checks.
Read ARCHITECTURE.md for the reasoning behind the layering, and MIGRATION.md for what changed from the six-service predecessor and the defects that motivated it.
| Method | Value | Driver | Hard constraints | Soft constraints |
|---|---|---|---|---|
| Price / Earnings | Equity | Latest net income | Latest earnings > 0; every period profitable | Earnings volatility ≤ industry limit; income growth vs industry ≥ threshold |
| EV / EBITDA (EBIT fallback) | Enterprise → equity | Latest EBITDA or EBIT | Operating earnings > 0 | Debt/assets and debt/equity ≤ industry limits; net debt known |
| Price / Sales | Equity | Latest revenue | Latest revenue > 0 | Revenue growth vs industry ≥ threshold |
A hard failure withholds the method — the arithmetic would produce a number without meaning. A soft failure reports the value, flags the breach, and halves its weight in the concluded range. A constraint that could not be tested is recorded as untested, never as passed.
Enterprise values are bridged to equity by subtracting net debt. Where net debt is unknown the enterprise figure is reported on its own but excluded from the range, because it measures a different quantity from the two equity methods.
data/industry_benchmarks.json holds 94 industries: growth CAGRs, four
multiples and five risk thresholds each. It is versioned, and every report
records the dataset_version it was computed against.
The file is generated by scripts/build_benchmarks.py,
which merges the four partial copies the legacy services each carried and asserts
that every overlapping column agrees before writing. To refresh the multiples,
edit the JSON and bump dataset_version.
pytest # full suite, no network, no API key
pytest --cov=valuation --cov-report=term-missing
ruff check . && ruff format --check .
mypyThe domain layer is pure, so its tests are ordinary function calls. The pipeline
and API tests run against MockClient, which serves scripted responses — the
whole product is exercisable offline.
docker compose -f docker/docker-compose.yml up --buildThe image runs as a non-root user and speaks plain HTTP; terminate TLS at your
ingress. Scale with replicas rather than in-process workers: the default run
store is per-process, and python -m valuation refuses to start with
WEB_CONCURRENCY > 1 rather than serve intermittent 404s on run polling.
Proprietary.