Skip to content

Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ForwardFlowAPI reference

API reference

The engine is a Rust crate. Everything a desk sees of it goes through one small Axum server, backend/src/main.rs, and one module, backend/src/api/forwardflow_api.rs. The server has no saved-run database: requests supply assumptions and receive results. It retains bounded computation caches and access-limit state. The workspace calls these calculations and derives display summaries and independent checks from the responses. The MCP interface exposes a bounded subset for assistants.

This chapter documents each route: method, path, request body field by field, response body field by field, a curl that was actually run against http://localhost:8080 on 2026-09-03 with its response trimmed, and the error semantics. It closes with the WebSocket protocol, client snippets in Python and TypeScript, the hosting notes and the result identity needed for reproduction. Examples dated 3 September 2026 are historical worked responses; consult the deployed OpenAPI schema for current field shapes.

Two conventions apply everywhere.

  • Money travels as strings. Every ledger amount is a cent-quantized Decimal and serializes as a JSON string ("1475.00", "0.05"). Ratios, moneyness, IRRs and statistics are f64 and serialize as JSON numbers. The split is deliberate: nothing that posts to the ledger is ever a float.
  • Same numerical request, same build. The engine is seeded (ChaCha20, seed in the configuration). Repeat numerical requests against the same build, historical data and runtime environment for deterministic reproduction; headers, timing and progress metadata are not the calculation. The Monte Carlo memoization below depends on this.

The configuration object, SimConfig, is the same for every POST route. Its fields are documented one by one in Every input; this chapter names them only where a route treats them specially.

The server

main.rs calls lib.rs::build_router, sizes the rayon pool to max(1, cores − 1) so the async accept loop is never starved by a Monte Carlo, wraps the routes in gzip compression and a CORS layer configured by FF_CORS_ORIGINS, and listens on 0.0.0.0:8080.

MethodPathWhat it doesHeavy
GET/healthLiveness, plain-text OKno
POST/api/forwardflow/simulateOne seeded run: outputs, per-Agreement table, path, hazard, postingsconditional
POST/api/forwardflow/montecarloN seeded runs, summarizedyes
GET/api/forwardflow/montecarlo/wsThe same, over a WebSocket with progress framesyes, per chunk
POST/api/forwardflow/solve_priceThe purchase price that clears a target USD IRRconditional
POST/api/forwardflow/heatmapA sensitivity grid, every cell a full runyes
POST/api/forwardflow/backtestOne run per seasoned historical vintageyes
GET/api/forwardflow/historyThe embedded monthly price seriesno
POST/api/forwardflow/riskThe exposure report and the frontier familyyes
GET/api/forwardflow/crash_libraryNamed monthly crash anchorsno
GET/api/forwardflow/surfacesIllustrative volatility surfacesno
POST/api/forwardflow/placementPlace the book’s exposure on a surfaceconditional
POST/api/forwardflow/fair_valueModel value under stated pricing assumptionsyes
POST/api/forwardflow/hedgeCompare hedged and unhedged resultsyes
POST/api/forwardflow/hedge_researchPaired raw Dollar/BTC observationsyes
POST/api/forwardflow/hedge_seriesMonthly cash, marks and risk exhibitsyes
POST/api/forwardflow/rebalance_policiesCompare trading policiesyes
POST/api/forwardflow/coin_seatDollar and coin results across drift assumptionsyes
GET/api/forwardflow/healthJSON engine and access-mode identityno
GET/api/forwardflow/openapi.jsonMachine-readable REST contractno

The additional pricing and hedge requests are documented in Surfaces and hedging and the API explorer. MCP uses /mcp on the engine and is a separate protocol.

“Heavy” means the route takes a permit from forwardflow_api.rs::HEAVY, a three-permit semaphore. At most three heavy calculations run at once and eight may wait in the compute queue. Capacity refusals return 503 with Retry-After. Executed requests also reserve memory from the shared process budget.

simulate, solve_price and placement take this guard when their estimate exceeds 1,000,000 Agreement-runs or 128,000,000 bytes at peak. Their current book caps cannot reach the count threshold, but the byte threshold can apply, including to detailed simulation responses. Request flags therefore matter to admission; the raw engine book estimate is not the complete response estimate.

Choose the reporting objective

simulate can include the complete unhedged USD/BTC observation with include_research: true. Use hedge_research with an explicit objective: "coin" or "dollar" for paired simulations. Legacy Dollar-only routes, including solve_price and montecarlo, accept an omitted objective for compatibility or an explicit "dollar"; an explicit Coin, null or unrecognized objective is refused. A Dollar purchase-price solution is not a BTC target return. There is no BTC purchase-price solver.

Detailed simulation requests reserve memory for their selected postings, Agreement rows, research arrays and JSON output as well as the underlying engine book. Serialization is bounded, and the reservation stays with the response through compression and transmission. Large output requests can be refused before calculation; request fewer Agreements or omit detail you do not need. This is a resource limit, not a change to Agreement economics.

Errors and caps

The engine validates its inputs with named assertions in engine.rs::validate. A failed assertion panics with a message that names the input; forwardflow_api.rs::catch_engine catches the panic and returns it as HTTP 400 with the message as the body. This is why the build must never set panic = "abort": the 400 depends on unwinding.

$ curl -s -i -X POST localhost:8080/api/forwardflow/simulate \
    -H 'Content-Type: application/json' -d @bad.json   # term_months: 1700
HTTP/1.1 400 Bad Request
term (input #6) must be 1–480 months, got 1700

A PathError from paths.rs also maps to 400. Asking for a historical replay that runs off the end of the series:

HTTP/1.1 400 Bad Request
historical replay needs 85 months from index 170, have 4

A missing field or wrong JSON value type normally returns 422 before reaching the engine. Invalid JSON syntax returns 400; a missing Content-Type: application/json returns 415. An explicitly unsupported objective also returns 400. A bounded simulation response that cannot fit its declared serialization allowance returns 413; internal failures can return 500. Read the response message rather than treating every 400 as an economic-input error or every 500 as a blocking-task failure.

The caps, all of which answer 400 with the input named (hosting hardening, spec v1.4 item 13, 2026-07-13):

CapLimitWhere
Cohorts (input #13)≤ 1,200engine.rs::validate
Book size (inputs #13 × #18)≤ 50,000 Agreementsengine.rs::validate
Term (input #6)1 – 480 monthsengine.rs::validate
Monte Carlo runs (input #14)1 – 100,000forwardflow_api.rs::validate_mc
Estimated work, every route≤ 24,000,000 Agreement-runsforwardflow_api.rs::WorkEstimate::check
Heatmap grid1 – 900 cellsff_heatmap
Frontier drifts1 – 8ff_risk
Greek seeds1 – 256ff_risk, exposure.rs::greeks
Price bump, vol bump(0, 0.5), (0, 1)exposure.rs::greeks

The 24 million Agreement-run ceiling is the 100,000-run Monte Carlo at the base 240-Agreement book, and since the audit of 2026-09-05 (finding 8) every route estimates its work in that unit before it takes a thread — simulate = book, montecarlo = book × runs, solve_price = book × 2, heatmap = book × cells, backtest = agreements_per_cohort × vintages, risk = book × (1 + 7 × greek_seeds), placement = book, fair_value = book × seeds, hedge = book × seeds × Σ structure units (+ 2 with the benchmarks), rebalance_policies = book × seeds × structure units × policies, coin_seat = book × seeds × (Σ structure units + 1) × drifts, hedge_series = book × (seeds × structure units + the Greeks’ runs when include_greeks) — to bound estimated work before execution. hedge_research uses the hedge estimate without the legacy shelf benchmarks, then adds 2 × book × seeds for paired cash and coverage, plus response-memory allowances. The estimate is not a runtime guarantee. A refusal names the estimate, the formula with the inputs’ values and the inputs to lower.

GET /health

Returns the text OK with status 200 for a liveness check. Deployment verification uses /api/forwardflow/health to check the running version, source build, data digest and access mode as well.

POST /api/forwardflow/simulate

One run of the engine on one seeded path. Handler: forwardflow_api.rs::ff_simulate.

Request

FieldTypeDefaultMeaning
configSimConfigrequiredThe whole inputs panel
include_postingsboolfalseAttach the raw double-entry postings
include_agreementsbooltrueAttach the per-Agreement table (about 106 KB of the 112 KB default body at the base book; 5,837 bytes remain without it); side-runs that only read scalars pass false
include_researchboolfalseAttach the unhedged USD/BTC research observation and monthly replay from this same run

Response

FieldTypeMeaning
outputsRunOutputsThe Holder’s outputs, below
agreementsAgreementRow[]One row per Agreement; empty when include_agreements is false
agreement_countintBook size, always populated
pathnumber[]The monthly price path, index = month, length horizon + 1
conservation_okboolThe ledger’s postings sum to zero (ledger.rs::conservation_sum)
hazard_monthlynumber[]The stop hazard by payment age for the configured scenario and term, length term + 1, h[0] = h[term] = 0 (defaults.rs::DefaultScenario::monthly_hazard)
postingsPosting[] or nullThe ledger, when asked for
research{outcome, conventions}Present only with include_research: true; otherwise omitted. outcome is a ResearchOutcome, and conventions is the same ResearchConventions returned by paired hedge research

The optional research observation uses research.rs::unhedged_observation on the existing simulated book; it does not run another path. It equals hedge_research.samples[0].unhedged for matching configuration, seed and zero USD discount rate. It always includes monthly spot, paper_cash, hedge_cash, margin, hedge_cash_required and total_cash_required; hedge and margin values are zero. npv_usd uses zero discounting and equals net_gain_usd. The BTC holding comparison retains matching dated net contributions; it is a monthly-conversion equivalent, not a funded wallet balance. USD cash assumes receipts remain in dollars. Agreement rows and raw postings can both be omitted independently. Invalid non-finite BTC conversions are refused with 400, using the same guards as paired research.

RunOutputs (outputs.rs::RunOutputs, computed by outputs.rs::analyze):

FieldTypeMeaning
irr_monthlynumber or nullMonthly IRR of the Holder’s net cash flows (outputs.rs::irr_analysis, the lowest root); null when the flows are single-signed, no root lies in [−0.999, 10] per month, or the evaluation failed (irr_unavailable says which). irr_ambiguous (boolean) and irr_root_count say when the flow has several roots
irr_nominal_panumber or null12 × monthly
irr_effective_panumber or null(1 + monthly)^12 − 1, the figure quoted everywhere
wal_monthsnumber or nullWeighted average life of gross inflows
payback_monthint or nullFirst month cumulative net cash is ≥ 0
undiscounted_multiplenumber or nullGross inflow ÷ gross outflow
owner_total_inflow, owner_total_outflowstringGross dollars to and from the Holder (Owner in the ledger)
cash_recovery[int, number][]Cumulative gross cash ÷ outflow at months 12, 24, 36
exit_splitExitSplitCounts: completed, settled (early completion), non_performance, non_performance_rational, conviction_walks, rational_boundary, open
suppressed_defaultsintStop draws suppressed by rational mode
coin_returned_to_obligorsstringSpot value of coin taken at early completion, net of the payoff; Buyer property, never cash
total_shortfall_usdstringΣ over stops of (remaining schedule − sale proceeds) where the sale fell short
buyer_refunds_usdstringΣ dollar refunds to stopped Buyers
stop_surplus_usdstringΣ proceeds above the remaining schedule delivered to the Holder
cumulative_net_cashnumber[]Cumulative net Holder cash by month; feeds the cash fan
btcnowobjectorigination_fees, flow_fees, total_take (their sum), purchase_prices, paper_spread (Σ purchase − coin cost, zero at par)
btcnow_fee_monthlynumber[]BTC Now’s revenue by month; sums to total_take plus the spread

The exit vocabulary in the JSON is the engine’s: non_performance is a stop, settled is an early completion. Identifiers were kept when the words were locked (Marc, 2026-09-02, spec v1.5).

AgreementRow (outputs.rs::AgreementRow, built by outputs.rs::agreement_table):

FieldTypeMeaning
id, origination_month, term_monthsintIdentity
strikestringThe coin’s cost at entry (dispersed within the cohort month when input #20 is on)
outcomestringcompleted, settled, non_performance, non_performance_rational, conviction_walk (singular here, conviction_walks in the split), rational_boundary or open
exit_monthint or nullMonth of the exit; null while open
payments_madeintPayments made before the exit
delivered_grossstringEvery dollar that entered the Holder’s delivery stream
owner_netstringThe same net of the 5% servicing fee
fee_to_btcnowstringServicing fee taken on this Agreement’s deliveries
origination_to_btcnowstringThe first N payments routed to BTC Now, as far as they were made
shortfall_usdstringRemaining schedule at the stop minus the sale proceeds, where positive
coin_returned_usdstringCoin value taken at early completion, net of the payoff
purchase_pricestringCapital the Holder deployed
stop_proceeds_usdstringRecorded sale proceeds V after haircut and sale cost
buyer_refund_usdstringThe Buyer’s refund, \( \min(A,\ \max(0,\ V + A - P)) \), from engine.rs::stop_sale (Marc, 2026-08-22 and 2026-09-03, spec v1.5)
stop_surplus_usdstringProceeds above the remaining schedule kept by the Holder
expected_netstringWhat full performance would deliver: (terminal − first N) × (1 − fee)
capital_pnlstringowner_net − purchase_price

A posting (ledger.rs::Posting) has month, from, to, amount, kind, agreement. Entities are "Owner" (the Holder), "BtcNow", "Market" and {"Obligor": id} (the Buyer). Kinds are PurchasePrice, OriginationFee, PaymentDelivery, FlowFee, MakeWholeDelivery, StopSaleDelivery, StopRefund.

Example. One Agreement at the base terms, run clean so the schedule can be read off: a $60,000 coin, 1.475×, 60 payments of $1,475, payment 1 to BTC Now, 5% on every delivered dollar, the Holder buys at par. The configuration is BASE_CONFIG from lib.ts with vol_annual: 0, lifetime: 0, settlement_propensity: 0, cohorts: 1, agreements_per_cohort: 1 and intramonth_strike_dispersion: false.

The response excerpt below requests both include_postings: true and include_agreements: true alongside that configuration.

$ curl -s -X POST http://localhost:8080/api/forwardflow/simulate \
    -H 'Content-Type: application/json' -d @one.json
{
  "outputs": {
    "irr_effective_pa": 0.139654410356713,
    "wal_months": 31.0,
    "payback_month": 44,
    "undiscounted_multiple": 1.3778958333333333,
    "owner_total_inflow": "82673.75",
    "owner_total_outflow": "60000.00",
    "exit_split": { "completed": 1, "settled": 0, "non_performance": 0, "…": 0 },
    "btcnow": { "origination_fees": "1475.00", "flow_fees": "4351.25",
                "total_take": "5826.25", "purchase_prices": "60000.00", "paper_spread": "0.00" },
    "…": "…"
  },
  "agreements": [ { "id": 0, "outcome": "completed", "exit_month": 60, "payments_made": 60,
                    "delivered_gross": "87025.00", "owner_net": "82673.75",
                    "fee_to_btcnow": "4351.25", "origination_to_btcnow": "1475.00",
                    "capital_pnl": "22673.75", "…": "…" } ],
  "agreement_count": 1,
  "conservation_ok": true,
  "postings": [
    { "month": 0, "from": "Owner", "to": "BtcNow", "amount": "60000.00", "kind": "PurchasePrice", "agreement": 0 },
    { "month": 1, "from": { "Obligor": 0 }, "to": "BtcNow", "amount": "1475.00", "kind": "OriginationFee", "agreement": 0 },
    { "month": 2, "from": { "Obligor": 0 }, "to": "Owner", "amount": "1401.25", "kind": "PaymentDelivery", "agreement": 0 },
    { "month": 2, "from": { "Obligor": 0 }, "to": "BtcNow", "amount": "73.75", "kind": "FlowFee", "agreement": 0 },
    "… 116 more"
  ]
}

Payments 2 to 60 deliver $1,401.25 to the Holder and $73.75 to BTC Now each; 59 × $1,401.25 = $82,673.75, and 59 × $73.75 + $1,475 = $5,826.25. The full base book (24 cohorts × 10 Agreements, 43% bridge, 40% lifetime stop share, seed 42) returns irr_effective_pa 0.1421, exits 84 completed / 55 early / 101 stopped, total_shortfall_usd 990,282.37, buyer_refunds_usd 603,951.54 and stop_surplus_usd 148,595.85. That body is 111,692 bytes raw, 17,500 gzipped, 2,547 gzipped with include_agreements: false.

POST /api/forwardflow/montecarlo

runs seeded runs with seeds seed, seed + 1, …, seed + runs − 1 (outputs.rs::run_monte_carlo, in parallel), summarized. Handler: forwardflow_api.rs::ff_monte_carlo.

Request: config (SimConfig) and runs (int, 1 – 100,000, subject to the Agreement-run cap).

Response (MonteCarloSummary):

FieldTypeMeaning
runsintRuns summarized
irr_effectivePercentiles or nullDistribution of irr_effective_pa over the runs that had one; null when no run has an IRR (spec v1.12 — never a numeric zero)
wal_monthsPercentilesDistribution of wal_months
pct_negative_irrnumber or nullShare of the runs with an IRR whose IRR is below zero — conditional; null when none has one
pct_cash_lossnumberShare of ALL runs whose undiscounted multiple is below one — the cash-loss frequency, never conditional on an IRR; read it first when runs_without_irr is not zero
multiplePercentilesThe undiscounted multiple over every run that deployed capital
runs_without_irr, without_irr_reasonsint, NoIrrCountsRuns without an IRR, counted by reason: single_signed, no_root_in_range, non_finite
notestringWhat the IRR statistics are conditional on
irr_histogram[number, int][]50 equal bins from the worst to the best IRR: (left edge, count)
cash_fanCashFanPoint[]Per month: month, p5, p25, p50, p75, p95 of cumulative_net_cash across runs
base_seedintThe configuration’s seed

Percentiles carries worst, p1, p3, p5, p25, p50, p75, p95, mean, es3, es5. In forwardflow_api.rs::percentiles, over the sorted sample of size \( n \), a percentile is the nearest-rank value

\[ x_{(i)},\quad i = \operatorname{round}\big(p,(n-1)\big), \]

and the expected shortfall is the mean of the worst \( \lceil p,n \rceil \) runs (at least one):

\[ \mathrm{ES}p = \frac{1}{k}\sum{j=1}^{k} x_{(j)},\quad k = \lceil p,n \rceil . \]

p3 is the empirical third percentile, not a worst-case guarantee; es3 and es5 are what you average if you land in the tail, not where the tail starts.

Memoization. Results are cached in forwardflow_api.rs::MC_CACHE, an LRU of 64 summaries keyed by runs plus the serde serialization of the configuration. The key is built server-side from the struct, so a client’s JSON key order does not matter. The base book at 1,000 runs took 0.30 s cold and 0.006 s memoized.

$ curl -s -X POST http://localhost:8080/api/forwardflow/montecarlo \
    -H 'Content-Type: application/json' -d '{"config": <BASE_CONFIG>, "runs": 1000}'
{
  "runs": 1000,
  "irr_effective": { "worst": 0.0721, "p1": 0.0918, "p3": 0.0970, "p5": 0.1029, "p25": 0.1291,
                     "p50": 0.1493, "p75": 0.1722, "p95": 0.2190, "mean": 0.1538,
                     "es3": 0.0903, "es5": 0.0946 },
  "wal_months": { "p5": 34.48, "p50": 35.91, "p95": 37.34, "…": "…" },
  "pct_negative_irr": 0.0,
  "irr_histogram": [ [0.0721, 3], [0.0777, 2], "… 48 more" ],
  "cash_fan": [ "…", { "month": 84, "p5": 2288927.49, "p50": 4529348.68, "p95": 8948182.21, "…": "…" } ],
  "base_seed": 42
}

The bridge is endpoint-pinned, so this distribution is the uncertainty between $60,000 and $60,000, not a view on where the price ends. The archived Model Card’s pricing stance uses the zero-drift bootstrap instead; see Price paths.

Unavailable IRR statistics are null

A path whose flows never change sign (every payment retained by BTC Now, a written call assigned deep in the money) has no IRR, and since the follow-up audit of 6 September 2026 (finding 1; spec v1.12) no route prints a zero for one. Here irr_effective and pct_negative_irr are null when no run has an IRR; on the hedge routes (/hedge, /hedge_series, /rebalance_policies, /coin_seat) every Dist’s median_irr, mean_irr, p5_irr, p95_irr and pct_negative are null when no seed has one (the coin seat’s pct_negative is over all seeds and stays present), and the figures derived from a missing median or p5 — HedgeResult.cost_irr_points, floor_p5_irr, hedged_excess_over_basis_pp — are null with it. What is always present is the cash: pct_cash_loss, the multiples, seeds_with_irr / seeds_without_irr and without_irr_reasons. The audit’s zero-receipts probe (12 of 12 payments to BTC Now, eight seeds) reads median_irr null … pct_cash_loss 1.0, seeds_with_irr 0, single_signed 8; tests/openapi_shapes.rs holds every nullable field documented as such in openapi.json. A client should read the count before the statistic, and the cockpit does (reading.ts: “—”, with the reason).

The engine’s identity travels with every answer

Every response carries X-Engine-Spec, X-Engine-Version, X-Engine-Build (0.7.0+v1.14+<sha>: crate version + spec + the source revision build.rs stamps from GIT_SHA or git rev-parse, else unknown) and X-Engine-Data (the SHA-256 of the compiled-in price series; model audit 2026-09-06, M07); the browser WebSocket API does not expose handshake headers, so Monte Carlo also puts the identity on its Complete frame as engine; /api/forwardflow/health reports them as engine_spec, version, build, git_sha and data_sha256. A desk that records a completed run should take the identity off that answer, never off a global “last response” — the cockpit’s record builders do (record.ts, spec v1.12 item 4). See The version headers.

GET /api/forwardflow/montecarlo/ws

The same computation over a WebSocket, in chunks of 1,000 runs (forwardflow_api.rs::MC_CHUNK), so a heavy book neither times out nor looks dead. Handler: forwardflow_api.rs::handle_mc_socket.

Protocol. The client’s first text frame is the MonteCarloRequest JSON, exactly the REST body. The server then sends text frames, each a JSON object with an event field:

FrameFieldsWhen
Progressdone, total, pct (integer, 100·done/total)After every chunk
Completesummary (a MonteCarloSummary)Once; the client should close
ErrormessageValidation failure, engine 400, or a first frame that is not valid JSON; the server closes

A request already in the memo cache skips straight to Complete. If the client disconnects, the server stops after the current chunk. The heavy permit is taken per chunk, not per request, so a long Monte Carlo yields to other work between chunks. A transcript from a Node client (WebSocket is global in Node 22+), base book, seed 4242 to miss the cache, 3,000 runs:

{"done":1000,"event":"Progress","pct":33,"total":3000}
{"done":2000,"event":"Progress","pct":66,"total":3000}
{"done":3000,"event":"Progress","pct":100,"total":3000}
{"event":"Complete","summary":{"runs":3000,"base_seed":4242,
  "irr_effective":{"p5":0.1024,"p50":0.1494,"es5":0.0933,"…":"…"},"pct_negative_irr":0,"…":"…"}}

and the two error shapes:

{"event":"Error","message":"runs must be 1–100000 (input #14)"}
{"event":"Error","message":"expected ident at line 1 column 2"}

POST /api/forwardflow/solve_price

The inverse of the deal card (input #19): given a target effective annual IRR and the client’s own configuration, the purchase price at which the paper returns exactly that rate. outputs.rs::solve_purchase_price solves it directly — the Holder’s receipts do not depend on the price paid, so the price is the present value of the net receipts at the target rate over the present value of the strikes at their origination months, from one prepared run on the configuration’s seed — and verifies it with a second run (see The inverse price solver).

Request: config and target_effective_irr (number, e.g. 0.12; must lie in (−0.9, 10)).

Response: pct_of_strike (string, eight decimals; null only when the Holder receives nothing), usd_at_start_price (number, the fraction × start_price, or null), target_effective_irr echoed, irr_check (the verifying run’s effective IRR at that price), attainable (true when the price lies inside the desk’s 1%–500% bracket and the check agrees within a basis point) and note (the method, or why the target is not attained — the bracket edge it lies beyond, or nothing received).

$ curl -s -X POST http://localhost:8080/api/forwardflow/solve_price \
    -H 'Content-Type: application/json' -d '{"config": <BASE_CONFIG>, "target_effective_irr": 0.12}'
{"pct_of_strike":"1.03835638","usd_at_start_price":62301.3828,"target_effective_irr":0.12,"irr_check":0.12000000467405858,"attainable":true,"note":"the present value of the Holder's net receipts at the target rate (monthly compounding of the effective annual target) over the present value of the strikes at their origination months, from one prepared run; verified by a run at that price"}

At a 12% hurdle the base book clears at 103.7% of the coin’s cost, $62,220.60 on a $60,000 coin. The clean single Agreement of the simulate example clears at 104.15%, $62,491.80.

POST /api/forwardflow/heatmap

A sensitivity grid: every cell is a full deterministic run on the configuration’s seed, cells computed in parallel (M4, spec v1.3). Handler: forwardflow_api.rs::ff_heatmap. The request carries config plus a flattened GridSpec selected by grid:

gridRow axisColumn axisWhat each cell runs
PriceDefaultlifetimes (number[], lifetime stop share)end_prices (number[], dollars)A bridge to the column’s end price at the base bridge vol (43% when the base path is not a bridge), the baseline curve at the row’s lifetime
VolConvictionx_underwater (number[], fraction)vols (number[], annual)A bridge to the base end price at the column’s vol, the lost-conviction rule forced on at the row’s X with the configuration’s Y

Response: cells[row][col], the effective annual IRR, or null where the run is undefined or the cell fails validation. A bad cell is a hole, never a 400, because the rest of the map is still information. Grids are capped at 900 cells.

$ curl -s -X POST http://localhost:8080/api/forwardflow/heatmap -H 'Content-Type: application/json' \
    -d '{"config": <BASE_CONFIG>, "grid": "PriceDefault", "end_prices": [30000,60000,90000], "lifetimes": [0.2,0.4,0.6]}'
{"cells":[[0.1360,0.1459,0.1547],[0.1224,0.1421,0.1578],[0.0999,0.1298,0.1525]]}

The centre cell, 40% and $60,000, is the base run’s 0.1421 exactly. Read across for the price, down for the stops; the break-even frontier is the IRR = 0 contour on a wider grid.

POST /api/forwardflow/backtest

The static-pool vintage exhibit: one cohort originated at every fully seasoned historical month, each replayed against the actual price path that followed, all by the same engine. Handler: forwardflow_api.rs::ff_backtest.

Request: config only. path, cohorts and origination_stop_month are overridden per vintage (HistoricalReplay { start_index }, 1, none); everything else, including agreements_per_cohort as the units per vintage, is the sensitivity set. The replay is rebased so month 0 equals start_price, which keeps dollar figures comparable across vintages.

A vintage is seasoned when term + 1 months of history follow it (the extra month lets a stop at the last age settle, spec v1.5 item 4). With 174 monthly bars, February 2012 to July 2026, and a 60-month term, that is 113 vintages, February 2012 to June 2021.

Response: vintages (VintageRow[]), blended_net_gain_usd, blended_deployed_usd, blended_moic (every vintage bought at equal size), failed (vintages the engine rejected; holes, not a 400).

VintageRow:

FieldTypeMeaning
month[int, int]Origination (year, month)
start_indexintIndex into the history series
entry_closenumberThat month’s actual close, a context label
irr_effective_pa, moicnumber or nullFrom analyze
net_gain_usd, deployed_usd, shortfall_usdnumberInflow − outflow, outflow, Σ shortfall
completed, settled, non_performanceintExits; non_performance sums all four stop buckets
coin_returned_usd, buyer_refund_usd, stop_surplus_usd, btcnow_take_usdnumberAs in RunOutputs
$ curl -s -X POST http://localhost:8080/api/forwardflow/backtest -H 'Content-Type: application/json' \
    -d '{"config": <BASE_CONFIG>}'
{ "vintages": [ "…", { "month": [2021, 6], "start_index": 112, "entry_close": 35026.9,
    "irr_effective_pa": 0.1729, "moic": 1.3697, "net_gain_usd": 219143.32, "deployed_usd": 592714.83,
    "shortfall_usd": 28602.45, "completed": 6, "settled": 0, "non_performance": 4,
    "buyer_refund_usd": 46994.76, "stop_surplus_usd": 23506.44, "btcnow_take_usd": 57300.22, "…": "…" } ],
  "blended_net_gain_usd": 138206160.2, "blended_deployed_usd": 66412793.69, "blended_moic": 3.081, "failed": 0 }

The early vintages dominate the blend: a 2012 entry at $4.90 rebased to $60,000 rides the whole rally. Read the rows, not the blend, and see Limits on survivorship in a one-asset history.

GET /api/forwardflow/history

The embedded monthly bars (paths.rs::historical_months, paths.rs::historical_closes, compiled in with include_str!; no data files at runtime). Response: months ([year, month][]) and closes (number[]), 174 each, [2012, 2] to [2026, 7]; the first close is 4.9, the last 62,875.5, the highest 115,765. The cockpit uses it to label replay start months and to grey out the ones without enough remaining history.

POST /api/forwardflow/risk

The exposure layer (spec v1.6, Marc’s “start”, 2026-09-03): the two lines, coverage by month and by cohort, the exposure ladder at a month, PD·LGD·EAD per vintage, the Greeks by bump-and-revalue, and the rational frontier for a family of believed drifts. Handler: forwardflow_api.rs::ff_risk; the report is exposure.rs::exposure_report, the frontier boundary.rs::rational_frontier. The derivations are in The risk desk; this section is the wire format.

Request

FieldTypeDefaultMeaning
configSimConfigrequiredconfig.bump must be null; the Greeks own the bump
ladder_monthint or nullmonth of peak capital at riskMonth for the ladder and the as-of month for the Greeks
price_bumpnumber0.05Fraction, for delta and gamma
vol_bumpnumber0.05Vol points as a fraction, for vega
frontier_musnumber[][0, 0.10, 0.25, 0.50]Believed annual drifts, 1 to 8
greek_seedsint16Seeds averaged for the Greeks, 1 to 256
frontier_paramsBoundaryParams or nullconfig.rational_boundary, else BoundaryParams::default() (σ 0.414, μ 0.25, r_c 0.15, walk cost 0.025)Lattice parameters other than μ

Response. The ExposureReport fields are flattened to the top level, then frontiers and frontier_params.

lines (LineRow[], one per payments made, t = 0 to term, from exposure.rs::two_lines, scale-invariant in the coin’s cost so one table serves the book):

FieldMeaning
tPayments made so far
schedule_lineRemaining nominal schedule ÷ entry price
capital_lineSale proceeds that return the Holder’s unrecovered capital ÷ entry price
capital_above_purchase_priceThe capital line sits in the surplus regime (see below)
remaining_schedule_usd, unrecovered_capital_usdThe same in dollars

With \( U_t = \max(0,\ \text{purchase} - \text{net received}) \), \( R_t \) the remaining schedule, \( A_t \) the payments made and \( f \) the fee, exposure.rs::capital_proceeds_needed gives

\[ V^{\text{cap}}_t = \begin{cases} U_t/(1-f) & \text{if } U_t/(1-f) \le R_t \\ A_t + U_t/(1-f) & \text{otherwise,} \end{cases} \]

because between \( R_t \) and the Purchase Price the Holder is delivered exactly \( R_t(1-f) \) and the rest refunds the Buyer. At the base terms the lines read 1.475 / 1.0526 at \( t = 0 \), 1.4504 / 1.0526 at \( t = 1 \), 0.885 / 0.4872 at \( t = 24 \), and the capital line is zero from \( t = 44 \).

coverage (CoveragePoint[], one per calendar month): month, spot, active, below_schedule, below_capital (active Agreements whose sale at this month’s price, haircut and sale cost applied, would not cover the line), notional_usd (Σ remaining schedule), capital_at_risk_usd (Σ unrecovered capital), intrinsic_shortfall_usd. active counts the price-exposed Agreements (engine.rs::price_exposed_at, the one lifecycle the hedge desk and the monthly mark read too): a stopped Agreement stays on the panel until the month its sale is booked, round(D + lag/30.4375) — the month after the missed date at the program’s 18 days, three months on at 90.

coverage_by_cohort (CohortCoverage[]): origination_month, agreements, months_below_schedule and months_below_capital (mean per Agreement), share_below_schedule and share_below_capital (share of active Agreement-months).

ladder (Ladder): month, spot, active, total_notional_usd, total_capital_at_risk_usd, and 30 cells, one per moneyness bucket (< 0.50, 0.50–0.70, 0.70–0.85, 0.85–1.00, 1.00–1.20, ≥ 1.20, sale proceeds ÷ remaining schedule) × tenor bucket (≤ 6 mo, 7–12 mo, 13–24 mo, 25–36 mo, > 36 mo), each with agreements, notional_usd, capital_at_risk_usd. The bucket labels are the moneyness and tenor strings above, verbatim.

credit (CreditRow[], one per vintage and a book row last with origination_month: null): agreements, stops, completed, completed_early, pd (stops ÷ Agreements), ead_usd (mean remaining schedule at the stop), lgd (Σ shortfall ÷ Σ remaining schedule at the stop), el_usd (Σ shortfall), el_rate (÷ capital deployed), capital_loss_usd (Σ negative capital P&L on stops), deployed_usd, refunds_usd, surplus_usd. The book row’s el_usd equals the simulate response’s total_shortfall_usd.

greeks (Greeks, from exposure.rs::greeks):

FieldMeaning
price_bump, vol_bump, as_of_month, as_of_spot, seeds, vega_methodWhat was run
base_net_gain_usd, base_irrSeed-ensemble mean of the unbumped book as it stands at as_of_month
delta_usd_per_pctΔ net gain per +1% in every price after as_of_month, no cohort originating after it
delta_commitment_usd_per_pctThe same with the full pacing, later cohorts striking at the bumped prices
delta_coinsdelta_usd_per_pct ÷ (1% × as_of_spot), the coins a market-neutral desk would short
gamma_usd_per_pct2Second difference per (1%)²
vega_usd_per_vol_pointΔ net gain per +1 vol point: the base path kept through as_of and the deviations of the log returns after it scaled around their mean, on every path mode (vega_method says so); an existing book has zero
theta_usd_per_monthFlat-path net gain per month of weighted average life, the markup accrual
delta_irr_pp_per_pct, vega_irr_pp_per_vol_pointThe same in IRR percentage points
base_vol_annualRealized annualized vol of the base path

Delta is the central difference over the seed ensemble,

\[ \Delta = \frac{1}{n_s}\sum_{s} \frac{G_s(1+b) - G_s(1-b)}{2 \cdot 100,b}, \]

with \( b \) the price bump; every Agreement draws from its own seeded stream, so the difference is the bump’s alone.

frontiers (FrontierFamily[]): for each mu_annual, rows (FrontierRow[], one per payment): m (row index, payment − 1), payment, walk_below_spot, walk_below_of_entry, walk_below_moneyness (the boundary below which walking is optimal, in dollars, as a fraction of entry, and as spot ÷ amortized obligation; null when no lattice node walks), remaining_obligation, remaining_schedule. frontier_params echoes the lattice parameters used.

$ curl -s -X POST http://localhost:8080/api/forwardflow/risk -H 'Content-Type: application/json' \
    -d '{"config": <BASE_CONFIG>}'
{
  "lines": [ { "t": 0, "schedule_line": 1.475, "capital_line": 1.0526, "capital_above_purchase_price": false,
               "remaining_schedule_usd": 88500.0, "unrecovered_capital_usd": 60000.0 }, "… 60 more" ],
  "coverage": [ "…", { "month": 12, "spot": 30126.08, "active": 123, "below_schedule": 123, "below_capital": 117,
                       "notional_usd": 6876400.20, "capital_at_risk_usd": 4542958.33, "intrinsic_shortfall_usd": 3180156.59 }, "…" ],
  "coverage_by_cohort": [ "… 24 rows" ],
  "ladder": { "month": 23, "spot": 33375.25, "active": 203, "cells": [ "… 30 cells" ], "…": "…" },
  "credit": [ "…", { "origination_month": null, "agreements": 240, "stops": 101, "completed": 84, "completed_early": 55,
                     "pd": 0.4208, "ead_usd": 43562.82, "lgd": 0.2251, "el_usd": 990282.37, "el_rate": 0.1029,
                     "capital_loss_usd": 230645.54, "deployed_usd": 9619863.62, "refunds_usd": 603951.54, "surplus_usd": 148595.85 } ],
  "greeks": { "as_of_month": 23, "as_of_spot": 33375.25, "seeds": 16, "base_net_gain_usd": 5072824.14, "base_irr": 0.1586,
              "delta_usd_per_pct": 23259.16, "delta_commitment_usd_per_pct": 24884.96, "delta_coins": 69.69,
              "gamma_usd_per_pct2": -175.64, "vega_usd_per_vol_point": 8631.36, "theta_usd_per_month": 127160.35,
              "delta_irr_pp_per_pct": 0.0910, "vega_irr_pp_per_vol_point": 0.0415, "base_vol_annual": 0.3941, "…": "…" },
  "frontiers": [ "…", { "mu_annual": 0.25, "rows": [ { "m": 0, "payment": 1, "walk_below_spot": 31270.64,
                   "walk_below_of_entry": 0.5212, "walk_below_moneyness": 0.5212, "remaining_obligation": 60000.0,
                   "remaining_schedule": 88500.0 }, "… 59 more" ] }, "…" ],
  "frontier_params": { "sigma_annual": 0.414, "mu_annual": 0.25, "r_c_annual": 0.15, "walk_cost_of_strike": 0.025 }
}

Two things to notice. The ladder month, left unset, came back as 23, the month of peak capital at risk, which on this path is also the last origination month; the existing-book delta and the commitment delta therefore coincide, since no cohort originates after the as-of month either way. And the base IRR in the Greeks (0.1586) is a 16-seed mean, not the seed-42 run’s 0.1421. The whole call took 0.05 s.

Connecting from your own code

The API is plain JSON over HTTP; no client library is needed. Python, standard library only:

import json, urllib.request

API = "http://localhost:8080"

def post(path, body):
    req = urllib.request.Request(
        f"{API}{path}", data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"{e.code}: {e.read().decode()}") from None  # 400 carries the named input

config = json.load(open("base_config.json"))          # a SimConfig; Decimals as strings
run = post("/api/forwardflow/simulate", {"config": config, "include_agreements": False})
print(run["outputs"]["irr_effective_pa"], run["agreement_count"])

mc = post("/api/forwardflow/montecarlo", {"config": config, "runs": 1000})
print(mc["irr_effective"]["p5"], mc["irr_effective"]["es5"])

Parse the string fields with decimal.Decimal, not float, if you intend to re-add them; the ledger is exact to the cent and a float sum will not be.

TypeScript, the cockpit’s own client. web/app/forwardflow/lib.ts exports the types in this chapter (SimConfig, RunOutputs, AgreementRow, MonteCarloSummary, RiskResponse, and the rest) and an api object built on one helper:

const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";

async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
  const r = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal,
  });
  if (!r.ok) throw new Error(await r.text());   // the 400 body is the engine's message
  return r.json();
}

export const api = {
  simulate: (config, includePostings = false, opts?) =>
    post<SimulateResponse>("/api/forwardflow/simulate",
      { config, include_postings: includePostings, include_agreements: opts?.includeAgreements ?? true },
      opts?.signal),
  monteCarlo: (config, runs, signal?) =>
    post<MonteCarloSummary>("/api/forwardflow/montecarlo", { config, runs }, signal),
  monteCarloWs: (config, runs, onProgress) =>
    new Promise<MonteCarloSummary>((resolve, reject) => {
      const ws = new WebSocket(`${API.replace(/^http/, "ws")}/api/forwardflow/montecarlo/ws`);
      ws.onopen = () => ws.send(JSON.stringify({ config, runs }));
      ws.onmessage = (ev) => {
        const msg = JSON.parse(ev.data as string);
        if (msg.event === "Progress") onProgress(msg.pct as number);
        else if (msg.event === "Complete") { resolve(msg.summary); ws.close(); }
        else if (msg.event === "Error") { reject(new Error(msg.message)); ws.close(); }
      };
      ws.onclose = () => reject(new Error("closed before finishing"));  // a no-op once settled
    }),
  // solvePrice, heatmap, backtest, risk, history follow the same shape
};

Two habits the cockpit keeps and a client should copy: abort superseded requests through the AbortSignal, and pass include_agreements: false on any run that only reads scalars. The real monteCarloWs also guards against settling the promise twice; the excerpt drops that for length.

Hosting notes

  • Where the client points. The workspace normally calls its same-origin /api/forwardflow/ proxy, which forwards to FF_API_URL and adds the server-held FF_API_KEY. NEXT_PUBLIC_API_URL explicitly bypasses that proxy for an open engine. The token route supplies the normal WebSocket URL and short-lived token; see Access.
  • CORS. A 24-hour max-age, so the browser pays one preflight per route per day, not one per POST. With FF_CORS_ORIGINS unset any origin is allowed; the hosted engine lists https://ff.btcnow.com (see Access).
  • Compression. CompressionLayer gzips every response the client accepts compressed; tiny bodies are skipped. Measured on the base book: 111,692 bytes raw, 17,500 gzipped, 2,547 gzipped and slim.
  • Memoization. Identical Monte Carlo requests, REST or WebSocket, are served from the 64-entry LRU. This is sound only because the engine is seeded; a change that breaks determinism breaks the cache silently, which is one reason the invariant suite diffs golden outputs.
  • Memory. The server uses jemalloc so freed pages return to the operating system after a Monte Carlo peak; the system allocator holds them.
  • Concurrency. Three heavy permits, rayon on cores − 1 threads, the async runtime on the rest. A deploy that puts the server behind a proxy should allow WebSocket upgrades on /api/forwardflow/montecarlo/ws and a request timeout long enough for a 100,000-run Monte Carlo, or use the WebSocket for anything large.

Result identity and configuration fingerprints

The completed run package records the arguments and engine identity associated with the response. Preserve that package for reproduction, including endpoint-specific settings. A scenario edited later is not the completed request.

Some chart and CSV exports use export.ts::sha8: the first eight hex characters of SHA-256 over JSON.stringify(config). That fingerprint depends on property order and numeric/string representation; it does not encode the assumptions and cannot identify an engine build by itself. Keep the full request with any standalone exhibit.

MCP uses a separate canonical replay hash with recursively sorted keys. Its run_id is not interchangeable with sha8. See Verification and reproduction and MCP result metadata.

Access — keys, tokens, limits

The engine runs in one of two modes, decided once at start by the environment it reads (backend/src/access.rs::AccessConfig::from_env).

  • Open modeFF_API_KEYS unset or empty. Every /api/forwardflow/* route answers without a credential and the engine logs one warning at start, FF_API_KEYS unset — the API is open. A local cargo run --release is open when no keys are supplied.
  • Keyed modeFF_API_KEYS holds name:key,name:key, every key at least 24 characters. Every /api/forwardflow/* route requires a credential except three that stay open in both modes: GET /health, GET /api/forwardflow/health and GET /api/forwardflow/openapi.json. The hosted engine at https://btcnow-forwardflow.fly.dev runs keyed.

Two more variables complete the contract: FF_TOKEN_SECRET, an HMAC-SHA256 secret of at least 32 characters that enables the websocket token below (unset = tokens disabled), and FF_CORS_ORIGINS, comma-separated exact origins (unset = permissive).

The header

A desk authenticates with one header on every request:

X-API-Key: <key>

The same key can instead be sent as Authorization: Bearer <key>. Both REST and MCP accept these headers; sending both together is refused with HTTP 400. Invited parties need no ForwardFlow account or login. See the connection guide to request a token and choose a supported client.

The engine compares the presented key against every configured key in constant time (access.rs::name_for_key). Only the key’s name ever leaves the engine: it appears in the tracing log and is attached to authenticated responses as X-Key-Name. The key is never logged, never echoed and never part of an error.

Getting, keeping and revoking a key

Email info@btcnow.com to request access. BTC Now issues one named token per invited person or organisation and delivers it privately. No ForwardFlow account is needed. Keep the token in your client’s credential settings or a private server configuration.

To replace or revoke a token, contact BTC Now. The operator updates FF_API_KEYS and restarts or rolls every engine instance; removal takes effect for new requests on each restarted instance. There is no guaranteed one-minute revocation window. Already running work is not retroactively cancelled. The short-lived websocket token has its own expiry. See operator steps.

The limits

Keyed callers share REST and MCP allowances under the key’s name. Anonymous callers are counted by IP; explicit MCP open mode has a separate anonymous rate state. These counters and the compute/memory budgets live in each server process, so multiple machines do not form a distributed quota.

A dropped request or disconnected websocket requests cooperative cancellation at the next engine checkpoint. A single simulation may finish its current unit before its compute resources are released. Simulation response memory remains reserved while its serialized or compressed response bytes are still owned by the transport. The websocket also watches for disconnects while a chunk is running; a socket that sends no request within ten seconds of upgrading is closed.

Every estimate checks the configuration before computation. Requests must fit both their per-request peak ceiling (FF_MAX_ESTIMATED_BYTES, default 1.5 GB) and the free part of the shared process budget (FF_MEMORY_BUDGET_BYTES, 1.2 GB in fly.toml). A request that does not fit beside current work returns 503 with Retry-After: 5 and does not calculate. Detailed simulation flags add Agreement rows, postings and bounded response buffers to the estimate.

LimitValueOn breach
Requests per rolling minute120429, Retry-After: <seconds> until the oldest request leaves the window
Concurrent heavy-route requests, including every MCP request4429 too many concurrent runs for this key, Retry-After: 5

The per-key concurrency classification includes /mcp, montecarlo and its websocket, heatmap, backtest, risk, fair_value, hedge, hedge_research, hedge_series, rebalance_policies and coin_seat. It also includes simulate, solve_price and placement when their complete estimate exceeds 1,000,000 Agreement-runs or 128,000,000 bytes peak memory. MCP protocol calls consume a per-key slot without necessarily running a calculation.

The four per-key slots do not override the shared three-calculation compute limit, its eight waiting places, or the memory budget. Each route checks its own work formula against the 24,000,000 Agreement-run ceiling; MCP adds the tighter tool limits. A refusal names the route and inputs to reduce. See Errors and caps for the underlying configuration limits.

401 and 429

Both are JSON, both name what went wrong, and neither repeats a value it was sent.

HTTP/1.1 401 Unauthorized
{"error":"missing X-API-Key header"}

HTTP/1.1 401 Unauthorized
{"error":"invalid X-API-Key"}

HTTP/1.1 429 Too Many Requests
Retry-After: 37
{"error":"rate limit exceeded: 120 requests per minute for this key"}

The websocket route’s 401s add missing X-API-Key header or token query parameter, token expired, token signature invalid, token expiry is more than 10 minutes ahead and websocket tokens are disabled on this engine (FF_TOKEN_SECRET unset).

The version headers

Every response, open or keyed, success or error, carries

X-Engine-Spec: v1.14
X-Engine-Version: 0.7.0
X-Engine-Build: 0.7.0+v1.14+<sha>
X-Engine-Data: <sha256>

X-Engine-Spec is the spec the engine implements (access.rs::ENGINE_SPEC); X-Engine-Version is the crate version from backend/Cargo.toml; X-Engine-Build is the three joined (access.rs::ENGINE_BUILD): crate version, spec and the source revision backend/build.rs stamps at build time — GIT_SHA from the environment (the deploy workflow passes the commit), else git rev-parse --short=12 HEAD on the build host, suffixed -dirty when that host’s backend/ tree had uncommitted changes, else unknown — identifying the deployed source revision. Local edits made under the same -dirty revision can share that marker; use an immutable committed build for reproducible shared results. X-Engine-Data is the SHA-256 of the compiled-in price series (data/btc_historical_monthly.csv), the data half of the identity: a data refresh changes the header without anyone bumping a version. The browser WebSocket API does not expose handshake headers, so Monte Carlo also puts the identity on its Complete frame as engine (spec, version, build, data_sha256), and /api/forwardflow/health reports them as engine_spec, version, build, git_sha and data_sha256. A desk that stamps its own exhibits should record the build and the data digest beside the configuration hash, off the answer that produced them.

The websocket token

A browser cannot send a header on a WebSocket upgrade, so GET /api/forwardflow/montecarlo/ws alone accepts a second credential, the query parameter ?token=<t> (access.rs::mint_token / verify_token):

t       = base64url(payload) + "." + base64url(HMAC-SHA256(FF_TOKEN_SECRET, payload))
payload = "<key-name>|<expiry unix seconds>"        expiry at most 10 minutes ahead

The token names a key rather than carrying one, so the socket is counted against that key’s limits and answered with its X-Key-Name. It is minted server-side by whoever holds a key and the secret — for the cockpit, by the web app’s own GET /api/forwardflow/token route — and the browser learns only the token and the socket URL. A desk’s own code needs no token: it sends X-API-Key on the upgrade request.

The health route and the OpenAPI document

GET /api/forwardflow/health is open in both modes. The following illustrates a 0.7.0 response; read the live response for the actual deployed version and build:

{"status":"ok","engine_spec":"v1.14","version":"0.7.0","build":"0.7.0+v1.14+1a2b3c4d5e6f","git_sha":"1a2b3c4d5e6f","data_sha256":"8846a81b803001d885b0cc3b830f26bfeba35405b91d1843c6376eeca5d8a1bf","mode":"keyed"}

GET /api/forwardflow/openapi.json is the OpenAPI 3 document for every route, compiled into the binary from backend/openapi/openapi.json and also open: https://btcnow-forwardflow.fly.dev/api/forwardflow/openapi.json. The next chapter, Try it, loads it into a Swagger UI.

A first call

Against the hosted engine, the base configuration, one run, scalars only:

$ curl -s -i -X POST https://btcnow-forwardflow.fly.dev/api/forwardflow/simulate \
    -H 'X-API-Key: REPLACE_ME_desk_alpha_key_0001' \
    -H 'Content-Type: application/json' \
    -d '{"config": <BASE_CONFIG>, "include_agreements": false}'
HTTP/2 200
content-type: application/json
x-key-name: desk-alpha
x-engine-spec: v1.14
x-engine-version: 0.6.0
{"outputs":{"irr_monthly":…},"agreements":[],"agreement_count":240,…}

REPLACE_ME_desk_alpha_key_0001 is a placeholder, never a key; <BASE_CONFIG> is the configuration object from Every input.

The cockpit at ff.btcnow.com holds its own key server-side: the browser calls same-origin /api/forwardflow/<path>, the cockpit’s server adds the key and forwards, and the websocket carries a token minted the same way. Nothing a desk can see in the page is a credential.

Paired hedge research

POST /api/forwardflow/hedge_research accepts config, 0–8 structures, 1–32 seeds, optional objective (dollar or coin, default dollar for legacy callers), optional surface, option_rate, put_skew_points, exec_cost_bps (default 50), discount_rate_annual (default 0), valuation_seed (default 42), and include_monthly (default false; at most 8 seeds when true). Surface warning acceptance follows the other hedge endpoints. Cross-book collars and wrapping seed ranges are refused.

The response includes surface_note and any warnings alongside the calculation fields. samples contains ordered per-seed unhedged and strategy outcomes, dollar and coin net gain, NPV, cash needs, horizon marks and paper exposure peaks. Optional monthly arrays support replay inspection. See the comparison guide for definitions. The served OpenAPI describes every field.

Keep valuation_seed fixed: it controls estimation noise in the coin-delta trading rule independently of realized paths. To scale a run, make sequential requests with disjoint consecutive seed ranges and identical assumptions. Check engine build/data identity and merge raw samples before calculating statistics. Do not average separate batches’ percentiles. The endpoint uses the shared heavy queue, per-key limits, cancellation and memory budget. Larger browser runs stop when the page closes.

MCP exposes the same bounded calculation as hedge_risk_samples, with replay inputs and engine identity. Small individual tool calls do not establish precise tail risk.

The additive workspace contract retains response schema: 1 and returns objective plus conventions version 2. The objective changes interpretation only: the same inputs return identical raw samples for Dollar and Coin. Present invalid objectives are refused. A price path that produces a non-finite BTC conversion is refused with HTTP 400 instead of returning a null numeric result. discount_rate_annual discounts USD only; no BTC NPV is implied.

Each outcome now carries coin_holding: contributed_coins, recovered_coins, holding_benchmark_coins, surplus_coins, realized_cash_contributed_coins, realized_cash_recovered_coins and signed horizon_mark_coins. Fund each Agreement purchase at its actual entry price, then combine that BTC outlay with the month’s USD receipts and hedge economic flows converted at monthly spot. Negative net BTC amounts are contributions; positive amounts are recoveries. Holding retains exactly the same dated contributed BTC, so the benchmark equals contributions and surplus equals existing net_gain_coins (up to floating-point tolerance when subtracting totals). These are strategy-specific net contributions, not a common initial budget. Gross acquisition at par is exactly one BTC per Agreement, before receipt offsets and hedge needs. Purchase prices different from strike and phased purchases follow their actual dated net flows. Reserves, borrowed capital, external free BTC and conversion trading costs are excluded.

Economic totals include surviving derivative marks at the horizon; realized-only fields exclude them. Negative marks may create a hypothetical terminal contribution. monthly.spot gives the spot used for each replay month. paper_cash + hedge_cash is realized net USD cash; add horizon_mark_usd in the final month to reconcile USD economic totals. For BTC, use monthly.btc_book.net_realized_cash_btc and add coin_holding.horizon_mark_coins once; purchases use their own entry prices, so dividing the total USD flow by monthly spot does not reconstruct the BTC result. The USD cash-requirement screen retains receipts in dollars and reserves futures margin. It is separate from monthly BTC conversion: the two readings do not establish one jointly funded portfolio or wallet. Full conventions.

Coin measurement fields in 0.6.0

simulate retains its legacy scalar fields and adds coin_irr_unavailable, coin_irr_ambiguous and coin_irr_root_count; USD diagnostics describe USD only. Set include_research: true for the complete unhedged observation and convention metadata.

hedge_research accepts structures: [] for unhedged-only sampling. Coin holding outcomes provide paper_purchase_coins, paper_receipt_coins, economic_recovery_multiple, economic_total_return, realized_cash_recovery_multiple and realized_cash_total_return. Nullable ratios are unavailable when their denominator is not positive and finite or the calculated ratio is non-finite. BTC cash-flow IRR is annualized; total return is a dimensionless recovery ratio minus one, not an annual rate.

Engine 0.6.0 and ResearchConventions.version: 2 fund each Agreement purchase at its actual entry price: one BTC at par. agreement_notional_coins records actual originated Agreements times one BTC; paper_receipt_offset_coins records same-month receipt offsets against Agreement purchases before hedge cash. The old monthly-purchase conversion method remains part of saved pre-0.6.0 evidence and must not be mixed with current results.

Detailed unhedged observations expose coin_cohorts, with count, contractual BTC, funded purchases, fee-net receipts, gain and dated Coin return per origination month. Monthly also returns these unhedged rows on median_seed. Shared hedge costs are not silently allocated. Gross cohort amounts and gains reconcile to the book; cohort IRRs and net-contribution ratios are not added.

When monthly observations are requested, monthly.btc_book contains the engine’s BTC-equivalent component ledger, with modeled USD originals and the conversion spot available beside it. Sum flow components; use end observations for value stocks. Realized cash excludes horizon marks and the economic bridge adds the signed mark once. Fees already netted from receipts must not be deducted twice. The measurement guide defines the denominators and reporting/settlement boundary.

Keep valuation_seed fixed when comparing hedges or replaying a path. Legacy hedge, coin-seat and monthly endpoints disclose their optional valuation-seed convention; research’s fixed valuation seed is independent of the realized config.seed. A matched market seed alone is insufficient if the estimated hedge rule changes.

hedge_series accepts objective (legacy default dollar) and optional selected_seed. Its response records objective, valuation_seed and selection: { metric, seed }. Coin selects the lower-median economic BTC gain; Dollar selects the lower-median finite USD IRR, or USD multiple if every IRR is unavailable. selected_seed must belong to the requested sample. A replay keeps both the market seed and the valuation seed. Explicit Coin monthly and Coin drift requests reject the incomplete legacy cross-book collar.

Monthly btc_book includes flow components and separate open derivative/Agreement value stocks for the selected path and bands. Research’s optional monthly ledger does not compute monthly valuation stocks: those two fields are null, not zero. Its signed horizon derivative value remains available separately in coin_holding.

The hedge response separates realized_settlement_mean_usd from horizon_mark_mean_usd; their sum is payoff_mean_usd. A remaining mark is economic value, not already received cash. Monthly delta_series.price_exposed_agreements gives the population matching total delta, including stopped collateral awaiting sale. Raw delta equivalents and actual strategy positions are different quantities.