CRM-agnostic revenue forecasting on a normalized pipeline schema.
Most revenue teams can't answer "what will we actually close this quarter?" without trusting a single CRM's built-in roll-up — and the moment data lives in two systems (a Salesforce org here, a HubSpot portal from an acquisition there, a partner's CSV exports), there is no shared way to forecast revenue or judge pipeline health at all. Forecast logic gets welded to one vendor's field names and becomes unportable.
This project solves that by splitting the problem in two:
- A normalized opportunity schema that any CRM's export or API maps onto with a small, declarative field-mapping config — Salesforce and HubSpot mappings ship as working examples.
- A forecasting engine of pure functions that only ever sees the normalized schema: three forecasting methodologies plus a blended ensemble, pipeline-health metrics (coverage, slip, conversion, win rates), and a data hygiene score.
An animated dashboard (anime.js v4) sits on top so the numbers read like a product, not a notebook.
![]() |
![]() |
Data & Privacy — every record in this repository is synthetic, generated by a seeded script (
scripts/generate_sample_data.py). No customer, employer, or otherwise proprietary data appears anywhere. Company names are invented; resemblance to real organizations is coincidental.
One command (frontend build + API + sample data included):
docker compose up --build
# → http://localhost:8000Local development:
# backend (Python 3.12+, uv)
cd backend
uv sync
REVOPS_AS_OF=2026-07-23 REVOPS_AUTOLOAD_SAMPLE=1 uv run uvicorn app.main:app --reload
# frontend (Node 22) — proxies /api to :8000
cd frontend
npm install
npm run dev
# tests
cd backend && uv run pytestREVOPS_AS_OF pins "today" so the bundled dataset (generated around
2026-07-23) demos meaningfully at any date; drop it for live use.
Every importer produces Opportunity records in this shape — the common
denominator across major CRMs, not any one vendor's field list:
| Field | Notes |
|---|---|
opportunity_id, account_name |
identity |
deal_amount, currency |
currency is carried, never converted |
stage |
normalized 7-stage funnel: Prospecting → Qualification → Proposal → Negotiation → Commit → Closed Won / Closed Lost |
forecast_category |
Pipeline / Best Case / Commit ("rep has a verified close plan and date — not a hope, a plan") / Closed |
close_date, created_date, last_stage_change_date |
ISO dates; MM/DD/YYYY accepted on import |
original_close_date (optional) |
first committed close date — powers slip-rate |
probability |
0–1 (0–100 inputs normalized); falls back to stage defaults |
owner_id, rep_name, team, region |
ownership |
segment |
SMB / Mid-Market / Enterprise |
deal_type |
New Business / Renewal / Expansion |
next_step (optional) |
absence is a hygiene flag |
All methods are pure functions in backend/forecasting/
— stdlib only, no framework imports, unit-tested against hand-computed golden
values.
1. Weighted pipeline — the expected value of the open pipeline due in the period, plus what already closed-won inside it:
forecast = closed_won + Σ (deal_amount × probability) over open deals due in period
2. Forecast category rollup — the classic sales-leadership call, weighting rep-committed categories (defaults: Closed ×1.0, Commit ×0.9, Best Case ×0.5, Pipeline ×0):
forecast = closed_won + 0.9·commit + 0.5·best_case
3. Historical trend / regression — what history says, independent of rep
judgment: a least-squares trend over monthly closed-won revenue projected
through the quarter (pro-rated for the elapsed month), averaged with a
win-rate estimate (due pipeline × trailing win rate + closed-won QTD).
Degrades gracefully: with under 3 months of history it falls back to the
win-rate estimate, and with no history at all to the weighted forecast — with
an explicit warning either way.
4. Ensemble — a weighted blend (0.35 / 0.40 / 0.25, renormalized if trend is degraded) with a confidence band spanning the methods' disagreement ±7.5%. Methods agreeing tightly ⇒ narrow band.
GET /api/metrics exposes the standard RevOps dashboard set: coverage
ratio (open pipeline due ÷ remaining target), slip rate (share of open
deals whose close date pushed past the original), stage-to-stage conversion
(snapshot estimate over open + won deals — flat exports don't say where lost
deals died, so this is documented as an optimistic approximation), win rate
by segment and by rep (count- and amount-based), and average sales cycle.
Each open deal starts at 100 and loses points for standard pipeline-hygiene violations: stale stage (>45 days without movement, >30 for Negotiation/Commit, −30), overdue close date (−30), missing next step (−20), slipped close date (−10), probability far from what the stage implies (−10). The dashboard surfaces the aggregate score, flag counts, and the worst deals.
The app never talks to a CRM directly — it consumes exports/API payloads
through a mapping config (mappings/*.json|yaml) that declares how native
fields translate:
- Salesforce: export an Opportunity report (or query the REST API) with the
columns in
mappings/salesforce.json, thenPOST /api/data/uploadwithmapping=salesforce. Probabilities arrive 0–100 and are normalized;Omittedforecast category folds into Pipeline. - HubSpot: export deal properties (or pull the CRM API) per
mappings/hubspot.json— internaldealstagecodes map to the normalized funnel; swap in your pipeline's stage IDs. - Anything else: copy
mappings/generic_csv.yamland fill in your field names. Bad rows are reported per-row, never fatal.
A mock CRM API client (backend/app/mock_crm.py) demonstrates the
API-pull integration shape — paginated fetches feeding the same importer. A
real connector replaces one class; nothing downstream changes. The CRM
Mapping tab animates exactly how each native field lands in the normalized
schema, and can import tiny demo exports through each mapping live.
flowchart LR
subgraph sources [Data sources]
CSV[CSV / JSON export]
MOCK[Mock CRM API client]
end
subgraph backend [FastAPI backend]
MAP[mapping configs] --> IMP[importer]
CSV --> IMP
MOCK --> IMP
IMP --> STORE[(in-memory store)]
STORE --> ENG[forecasting/ - pure functions]
ENG --> API[REST endpoints /api/*]
end
API --> UI[Vite + anime.js dashboard]
backend/forecasting/— schema + engine, zero web-framework importsbackend/app/— FastAPI layer: importer, store, mock CRM, routersfrontend/— vanilla JS + Vite; all motion honorsprefers-reduced-motion, animates onlytransform/opacity, and imports just the anime.js modules used- Storage is deliberately in-memory: this is an analysis tool, not a system of record — data reloads from upload/sample/sync on demand
| Endpoint | Purpose |
|---|---|
POST /api/data/sample |
load bundled synthetic dataset |
POST /api/data/upload |
multipart CSV/JSON + mapping form field |
POST /api/data/crm-sync |
pull via the mock CRM client |
GET /api/data/opportunities |
list (filter by stage, segment) |
GET /api/data/hygiene |
hygiene score + flagged deals |
GET /api/mappings, GET /api/mappings/{name} |
mapping configs |
GET /api/forecast, GET /api/forecast/{weighted|category|trend|ensemble} |
forecasts |
GET /api/metrics |
coverage, slip, conversion, win rates |
GET/PUT /api/targets |
revenue targets per quarter |
GET /api/meta |
as-of date, data source, deal count |
Interactive docs at /docs (FastAPI/OpenAPI).
- RevOps data modeling — a normalized schema designed from the common
denominator of real CRMs, with the judgment calls (slip-rate needs
original_close_date; conversion from snapshots is an approximation) documented rather than hidden. - Forecasting methodology — three independent methods with different failure modes, composed into an ensemble with an honest uncertainty band and explicit degradation when history is thin.
- CRM integration design — declarative field mappings + one importer, so adding a CRM is config, not code.
- Full-stack engineering — a tested pure-function engine (47 pytest tests), a thin typed API, a hand-built animated frontend with a validated accessible palette, one-command Docker deployment.
MIT — see LICENSE.


{ "name": "salesforce", "field_map": { "StageName": "stage", "Amount": "deal_amount", "CloseDate": "close_date" }, "stage_map": { "Proposal/Price Quote": "Proposal", "Negotiation/Review": "Negotiation" }, "category_map":{ "Omitted": "Pipeline" }, "defaults": { "currency": "USD" } }