Jobs
Track optimization status, browse job history, and download result artifacts.
Job Lifecycle
Every optimization job progresses through a fixed set of states.
queued
Job is accepted and waiting for a worker to pick it up.
running
Optimization is actively being computed. Market data has been fetched.
uploading
Computation complete. Results and artifacts are being uploaded to storage.
succeeded
The run finished and its artifacts are ready for download. A method that failed on its own is left out of the results; the other methods are kept.
failed
An error occurred. Check error_code and error_detail for diagnostics.
canceled
The job was cancelled with POST /jobs/{run_id}/cancel before it finished. Quota is not refunded.
/jobs/{run_id}Check the current status of an optimization job.
Authentication
Required - you must own the run (the job must have been submitted under your account).
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| run_id | UUID | The unique identifier returned when the job was submitted. |
Response
Successful job:
{
"run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": null,
"status": "succeeded",
"created_at": "2025-01-15T10:30:00Z",
"started_at": "2025-01-15T10:30:02Z",
"finished_at": "2025-01-15T10:31:14Z",
"error_code": null,
"error_message": null,
"error_detail": null
}Failed job:
{
"run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": null,
"status": "failed",
"created_at": "2025-01-15T10:30:00Z",
"started_at": "2025-01-15T10:30:02Z",
"finished_at": "2025-01-15T10:30:08Z",
"error_code": "SECURITY_RETURNS_MISSING",
"error_message": "We are sorry. We could not find price data for INVALIDTICKER.NS in any of our sources, so we could not optimize this portfolio. Remove it and run it again.",
"error_detail": {
"type": "SecurityReturnsMissingError",
"missing_tickers": ["INVALIDTICKER.NS"]
}
}curl
curl https://api.foliolab.ai/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer <YOUR_TOKEN>"
Python
import requests BASE_URL = "https://api.foliolab.ai" TOKEN = "<YOUR_TOKEN>" RUN_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" headers = {"Authorization": f"Bearer {TOKEN}"} response = requests.get(f"{BASE_URL}/jobs/{RUN_ID}", headers=headers) response.raise_for_status() job = response.json() print(f"Status: {job['status']}") if job["status"] == "failed": print(f"Error: {job['error_code']}: {job['error_message']}")
/jobs/historyList your optimization job history, newest first, with cursor-based pagination. Each item carries run_id, name, created_at, status and has_pdf. The example below is a summary=true response.
Authentication
Required - returns only jobs belonging to the authenticated user.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| limit | integer | 20 | Number of items per page. Maximum 100. |
| cursor | string | - | Opaque pagination cursor. Pass the next_cursor value from the previous response to fetch the next page. |
| q | string | - | Search by run_id prefix or by a substring of the run name. |
| status | string | - | Keep only runs in one state: queued, running, uploading, succeeded, failed or canceled. Applied before pagination, so pages stay full. |
| summary | boolean | false | Add each run's tags, requested methods, asset counts, constraint flag, mode, best Sharpe, best CAGR, lowest volatility, and a facets object. Metrics are null on a run that has not succeeded. |
| mc_eligibility | boolean | false | Add whether each run's request can seed a Monte Carlo simulation. |
| tag | string | - | Keep only runs that carry this exact tag name. |
| market / mode / constraints | string | - | Keep only runs with that facet: market is IN or US, mode is retail or fund_manager, and constraints is with or without. |
Response
{
"items": [
{
"run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Large caps, Q1",
"created_at": "2025-01-15T10:30:00Z",
"status": "succeeded",
"has_pdf": false,
"tags": [{ "name": "production", "color": "#22c55e" }],
"methods": ["MVO", "HRP"],
"method_count": 2,
"stock_count": 5,
"fund_count": 0,
"has_constraints": false,
"mode": "retail",
"metrics": { "sharpe": 0.87, "cagr": 0.162, "volatility": 0.213 },
"facets": { "...": "..." }
},
{
"run_id": "f9e8d7c6-b5a4-3210-fedc-ba9876543210",
"name": null,
"created_at": "2025-01-15T11:00:00Z",
"status": "running",
"has_pdf": false,
"tags": [],
"methods": ["MinVol"],
"method_count": 1,
"stock_count": 8,
"fund_count": 0,
"has_constraints": false,
"mode": "retail",
"metrics": { "sharpe": null, "cagr": null, "volatility": null },
"facets": { "...": "..." }
}
],
"next_cursor": "eyJjIjoiMjAyNS0wMS0xNVQxMDozMDowMFoiLCJpIjoiYTFiMmMzZDQifQ=="
}Pagination note
This endpoint uses cursor-based pagination. When next_cursor is null, you have reached the last page. Do not attempt to construct or decode cursor values - treat them as opaque strings.
curl
# First page curl "https://api.foliolab.ai/jobs/history?limit=10" \ -H "Authorization: Bearer <YOUR_TOKEN>" # Next page (use next_cursor from previous response) curl "https://api.foliolab.ai/jobs/history?limit=10&cursor=eyJjIjoiMjAyNS0wMS..." \ -H "Authorization: Bearer <YOUR_TOKEN>"
Python
import requests BASE_URL = "https://api.foliolab.ai" TOKEN = "<YOUR_TOKEN>" headers = {"Authorization": f"Bearer {TOKEN}"} # Fetch all jobs using cursor-based pagination all_jobs = [] cursor = None while True: params = {"limit": 20, "summary": "true"} if cursor: params["cursor"] = cursor resp = requests.get(f"{BASE_URL}/jobs/history", params=params, headers=headers) resp.raise_for_status() data = resp.json() all_jobs.extend(data["items"]) cursor = data.get("next_cursor") if not cursor: break print(f"Total jobs: {len(all_jobs)}") for job in all_jobs: print(f" {job['run_id'][:8]}... {job['status']} ({job['stock_count']} stocks)")
/jobs/{run_id}/artifactsRetrieve downloadable artifacts (charts, data files, reports) for a completed job.
Authentication
Required - you must own the run.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| run_id | UUID | The job identifier. Artifacts exist once the job has succeeded; the response carries the job's status beside them. |
Response
{
"run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "succeeded",
"artifacts": [
{
"artifact_id": "5b0c9e2a-...",
"method": null,
"kind": "data",
"label": "chart_bundle",
"content_type": "application/json",
"bytes": 759495,
"sha256": "...",
"created_at": "2025-01-15T10:31:12Z",
"signed_url": "<presigned-url - expires in 1h>"
},
{
"artifact_id": "7d4e1f3b-...",
"method": null,
"kind": "data",
"label": "optimization_report",
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"bytes": 214880,
"sha256": "...",
"created_at": "2025-01-15T10:31:12Z",
"signed_url": "<presigned-url - expires in 1h>"
}
],
"data_parquets": {},
"data_files": {
"chart_bundle": "<presigned-url - expires in 1h>",
"optimization_report": "<presigned-url - expires in 1h>"
}
}Artifact Types
| Kind | Scope | Content Type | Description |
|---|---|---|---|
| data | Global | application/json (gzip) | Pre-computed chart data bundle (label: "chart_bundle") |
| data | Global | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | Excel report (label: "optimization_report") |
| data | Global | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | Backtest Excel report, present only when the run carried an inline rolling backtest (label: "backtest_report") |
| report_pdf | Global | application/pdf | PDF reports, present after a request to POST /jobs/{run_id}/report/pdf. Labels: "optimization_report_pdf", "monte_carlo_report_pdf", "backtest_report_pdf". Legacy runs may use kind="data" + label="optimization_report_pdf" |
| data | Global | application/vnd.apache.parquet | Legacy runs only: benchmark returns, cumulative returns and yearly returns per stock as Parquet. Current runs carry this data in the chart bundle |
Signed URL expiry & storage access
Artifacts are always retrieved through the Folio Lab API - there is no separate public storage hostname you can browse. Each call to /jobs/{run_id}/artifacts returns short-lived presigned URLs that expire after 1 hour and are opaque (you cannot construct or re-sign them yourself). When a URL expires, call the endpoint again to receive fresh ones; the underlying artifact is unchanged.
curl
curl https://api.foliolab.ai/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/artifacts \ -H "Authorization: Bearer <YOUR_TOKEN>"
Python - Download All Artifacts
import requests import os BASE_URL = "https://api.foliolab.ai" TOKEN = "<YOUR_TOKEN>" RUN_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" headers = {"Authorization": f"Bearer {TOKEN}"} # Fetch artifact metadata resp = requests.get(f"{BASE_URL}/jobs/{RUN_ID}/artifacts", headers=headers) resp.raise_for_status() data = resp.json() # Download each artifact using its signed URL output_dir = f"./results/{RUN_ID[:8]}" os.makedirs(output_dir, exist_ok=True) EXTENSIONS = { "application/json": "json", "application/pdf": "pdf", "application/vnd.apache.parquet": "parquet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", } for artifact in data["artifacts"]: ext = EXTENSIONS.get(artifact["content_type"], "bin") filename = f"{artifact['label']}.{ext}" filepath = os.path.join(output_dir, filename) print(f"Downloading {artifact['label']}...") download = requests.get(artifact["signed_url"]) download.raise_for_status() with open(filepath, "wb") as f: f.write(download.content) # Legacy runs only: Parquet data files for name, url in data["data_parquets"].items(): filepath = os.path.join(output_dir, f"{name}.parquet") print(f"Downloading {name}.parquet...") download = requests.get(url) with open(filepath, "wb") as f: f.write(download.content) print(f"\nAll artifacts saved to {output_dir}/")
Error Codes
The error_code values a failed optimization run can carry. error_message holds a readable sentence, and error_detail an object with the error type and, where it applies, the affected tickers. Errors at submission are HTTP responses instead, listed on the Optimization page.
| Code | Name | Description |
|---|---|---|
| INVALID_TICKER | Invalid ticker | One or more requested tickers could not be resolved |
| DATA_FETCH_FAILED | Data unavailable | Price or benchmark data was missing, or too few aligned observations remained |
| SECURITY_RETURNS_MISSING | Returns missing | Price history is missing for named instruments. error_detail.missing_tickers lists them, and the monthly run is returned |
| CONSTRAINTS_UNIVERSE_SHRANK | Constraints lost an instrument | Data filtering removed an instrument that a constraint needs. The monthly run is returned |
| CONSTRAINTS_UNIVERSE_SHRANK_INFEASIBLE | Constraints infeasible after filtering | After data filtering removed instruments, no portfolio satisfies the constraints. The monthly run is returned |
| INVALID_INPUT | Invalid input | The request could not be processed as given |
| OPTIMIZER_INVALID_OUTPUT | Invalid optimizer output | A method returned weights that failed validation |
| UPSTREAM_TIMEOUT | Upstream timeout | A data source timed out |
| QUEUE_ENQUEUE_FAILED | Not queued | The run was accepted but could not be queued |
| EXCEPTION | Unexpected error | An unexpected server error occurred |
Further Job Endpoints
These ship today and authenticate the same way as the endpoints above. They are listed here so the reference covers the whole surface rather than the common path alone.
| Method | Path | What it returns |
|---|---|---|
| GET | /jobs/active | Every job of yours that is still queued, running or uploading, across all three kinds. Each item carries kind and job_id, so it can be passed straight to cancel. |
| GET | /jobs/{run_id}/config | The exact request a run was submitted with, including any defaults the server wrote into it, so a run can be reproduced or amended. |
| PATCH | /jobs/{run_id} | Rename a run. The body is { "name": "..." }. |
| DELETE | /jobs/{run_id} | Permanently delete one run with its Monte Carlo runs, backtests, artifacts and tags. A queued, running or uploading run returns 409: cancel it first. Quota is not refunded. |
| POST | /jobs/{run_id}/cancel | Cancel one in-flight job. The optional kind (optimization, monte_carlo or backtest) selects which table the id names; omitted, the id is resolved against each in turn. A job that already finished returns 409. Quota is not refunded. |
| POST | /jobs/{run_id}/report/pdf | Request one PDF report of a succeeded run. kind is optimization (default), monte_carlo or backtest. Returns 202 with status generating, or 200 with status ready when the current report already exists. A run that has not succeeded returns 409. The PDF then appears in the run's artifacts. |
| GET | /jobs/{run_id}/report/status | Where a PDF report is: absent, queued, rendering, ready or failed. A failed response carries the reason in error, so a caller can offer a retry instead of polling until a timeout. |
| GET | /jobs/{run_id}/rolling_backtest | One run's rolling backtest section alone, with the run period and a source of inline, artifact or unavailable. A run with no rolling backtest returns 200 with rolling_backtest null. |
| GET | /runs/{run_id}/backtests | Every standalone backtest run against one optimization run. |
| GET | /backtests/{backtest_run_id} | One standalone backtest run, by its own id rather than the parent run id. |
| GET | /methods/requirements | What every optimization method needs before it can run: minimum assets, minimum observations, and whether it needs a benchmark. When a minimum asset count needs an explanation, min_assets_reason gives it, and it is null otherwise. The answer is the same for every caller, so fetch it once and re-check eligibility locally as the ticker list changes. No authentication required. |
| POST | /price-history/availability | The stored price history of each asset before a run: the first and last stored day, the observation count, and a state of ok, dropped or missing with a reason. The body takes stocks or funds (not both) and an optional start_date and end_date. With a window, the counts and states are for the rows inside it. It is a prediction from stored prices, and the run applies the same rules again. Consumes no run. API key jobs:read, or session. |
| GET | /universe | The live list of supported Indian stocks, with an ETag for caching. No authentication required. |
| GET | /constraints/capability | Which constraint families each method supports, as one server-side table. Read this rather than hard-coding a method list. |
| POST | /constraints/preflight | Check a constraints object against a list of instruments before a run is spent: whether the rules can be stated, whether a portfolio satisfies them together, and what each group can reach. |
| POST | /holdings/import | Read a Zerodha or Groww holdings export, sent as base64 in content_base64, into stock and mutual fund portfolios with current_weights. The file is not stored. Session only. |
Monte Carlo Simulation Endpoints
Run a forward Monte Carlo wealth simulation on a succeeded optimization run and poll its result. These endpoints authenticate with a web session or an MCP OAuth grant; API keys currently receive 403 mc_api_key_not_supported. Every result is conditional on the selected model, the frozen optimizer-time inputs, the effective configuration, the engine version and the seed. It is not a calibrated forecast. The complete request schema lives on the dedicated Monte Carlo API page.
| Endpoint | Purpose |
|---|---|
| POST /runs/{run_id}/monte_carlo | Submit a simulation on a succeeded run. Body is the Monte Carlo config; method is required and must be one of the parent run's methods. Returns 202 with mc_run_id. |
| GET /runs/{run_id}/monte_carlo | List all simulations for a parent run, newest first. |
| GET /mc/{mc_run_id} | Status, config echo, inline summary, and a one-hour signed artifact URL once succeeded. |
| GET /mc | Cursor-paginated list of all your simulations across runs. |
Key config fields
method (required), horizon_years (1-40, default 15), path_count (1,000-100,000, default 10,000), starting_wealth, contributions / withdrawals (SIP / SWP), goal_amount + goal_basis, rgp (return process, see below), scenarios (2008_repeat, covid_repeat, single_name_blowup, bear_regime_start, permanent_loss, liquidity_freeze, multi_session_price_band, correlation_spike, fast_crisis_entry; the two calendar-window scenarios run only on the retired block bootstrap, so they are normally reported as skipped), rebalance_frequency, rebalance_mode with its walk_forward block, paired_static_baseline, policy_comparison (needs rebalance_mode: reoptimize and the paired baseline; a third arm on simulated paths, not a realized policy test), risk_free, precision, parameter_uncertainty, and seed for reproducibility. Unknown fields are rejected rather than silently ignored.
Return processes (rgp)
The return process decides how simulated paths are generated. None of these has passed out-of-sample validation, so none is published as more accurate. block_bootstrap is retired and no plan can select it. Running two and reporting the range is more honest than trusting one; the spread across processes is model risk, not noise.
| Value | Model | Plan |
|---|---|---|
| fhs_gjr_garch_vt | Default. Filtered historical simulation with a per-asset variance-targeted GJR-GARCH(1,1) volatility model: the long-run variance is pinned to the estimation-window sample variance. Needs about 5 years of jointly aligned history. When rgp is omitted and the history is shorter, the run falls back to the stationary block bootstrap and the result reports generator_fallback. That bootstrap was rejected by the rolling-origin study, and its dispersion is materially wider than realized. | Pro, Enterprise |
| fhs_gjr_regime | The variance-targeted GJR filter under a slow-moving variance-regime layer. | Enterprise |
| fhs_gjr_regime_uv_fallback | As fhs_gjr_regime, but an asset whose GJR fit fails admission falls back to its unconditional variance instead of EWMA. | Enterprise |
| regime_bootstrap | Hamilton Markov regime switching fitted on portfolio returns, resampling within the active regime's labelled days. Configure with regime.n_regimes (2-3) and regime.start. A failed fit falls back to block_bootstrap with the reason reported in the result's regime block. Required by the bear_regime_start scenario. | Pro, Enterprise |
| fhs_ewma | Filtered historical simulation. Per-asset EWMA conditional volatility is estimated on drift-removed log returns, complete cross-asset rows of standardized residuals are resampled, and future volatility is rebuilt recursively. Unlike the bootstrap it conditions on the current volatility level. Tune with fhs.ewma_lambda (default 0.94, a benchmark value, not a validated one) and fhs.long_run_blend_half_life_days. Needs about 3 years of shared history. | Pro, Enterprise |
| fhs_gjr_garch | Filtered historical simulation with a per-asset GJR-GARCH(1,1) volatility model, so losses raise future volatility more than equivalent gains. Each asset's fit must pass convergence, parameter validity, a persistence guard, an unconditional-variance sanity band and a residual Ljung-Box check; any failure falls back to EWMA for that asset with the reason recorded per asset in return_generator.per_asset. Needs about 5 years of shared history. | Enterprise |
| gbm_lognormal | Daily lognormal GBM: IID Gaussian log returns with a Ledoit-Wolf shrinkage covariance. An analytical and implementation benchmark, deliberately not a realism upgrade, since it has neither volatility clustering nor fat tails. | Pro, Enterprise |
| student_t | Multivariate Student-t on simple returns, covariance-matched to history; student_t_dof defaults to 5. A comparison process: it emits unbounded simple returns and relies on the disclosed gross-return clip. | Pro, Enterprise |
| gaussian | Multivariate normal on simple returns. Understates tail risk; kept for comparison only, with the same unbounded support caveat as student_t. | Pro, Enterprise |
The log-domain processes (the five filtered historical simulation processes and gbm_lognormal) build gross returns as expm1 of a log return, so returns are positive by construction and no clipping correction applies. They reconstruct returns rather than replaying historical rows, so they expose no sampled calendar window: inflation uses the disclosed constant model instead of calendar-aligned CPI replay. Runs using them publish a return_generator block carrying the fitted model, residual-pool size, ACF diagnostics, admission thresholds and any per-asset fallbacks.
Every process has a methodology page covering its mathematics, calibration and admission rules: Monte Carlo Engine Methodology.
Drift (drift.mode)
historical (default) simulates the portfolio's own historical arithmetic mean. override recentres to annual_return_override, an annualized daily arithmetic mean (divided by 252), not a CAGR target. shrunk blends the two using an explicit shrink_weight. forward_shrunk instead derives the weight on history from precision: you declare the anchor and its uncertainty via anchor_uncertainty_annual (one annualized standard deviation), and the weight on history becomes var_prior / (var_prior + var_hac), where var_hac is the Newey-West HAC sampling variance of the portfolio's daily mean. A confident anchor pulls toward the anchor; a vague one defers to history. The resolved weight is reported in calibration.drift. That forward anchor is a user-declared assumption. market_forward_shrunk is the fifth mode: it applies the same precision weighting toward a versioned, audited market-level Indian equity anchor resolved by policy, and it accepts no user-supplied anchor fields at all. No plan includes it today. Drift modes are plan-gated by mc_allowed_drift_modes, where a "*" wildcard deliberately excludes the house-view modes and a missing key degrades to the four caller-supplied modes (403 mc_drift_mode_not_allowed:<mode>). Separately, drift.uncertainty (default on) gives each path a HAC mean perturbation; that is sampling uncertainty around a fixed estimate, not forward-assumption or structural uncertainty.
Plan limits and quota errors
- Free: no access (
403 mc_not_allowed). - Pro: 20 simulations / month and up to 50,000 paths per run (
429 mc_monthly_limit_exceeded,403 mc_path_limit_exceeded). - Enterprise: unlimited simulations, up to 100,000 paths per run.
- All plans: at most 2 simulations queued or running at once (
429 mc_inflight_limit_exceeded); the parent run must be succeeded (409 parent_not_ready); the portfolio must be long-only Indian cash equity (422 with a stable eligibility code otherwise). - Return processes are plan-gated:
403 mc_process_not_allowed:<process>means thatrgpis not on your plan (fhs_gjr_garch, fhs_gjr_regime and fhs_gjr_regime_uv_fallback are Enterprise only). The authoritative list for the signed-in user isfeature_flags.mc_allowed_processesonGET /billing/me, where"*"means every process. 422 mc_insufficient_history_for_processmeans the chosen process needs more jointly aligned daily history than the portfolio has (about 3 years for fhs_ewma, 5 for the GJR family). Omit rgp and the default falls back on its own, or retry with a longer shared history.503 MC_GOVERNANCE_UNAVAILABLEmeans the service could not load or apply its model-risk governance record. This is a service-side problem, not a request error: keep the configuration unchanged and try again later. It applies to direct simulation submission and toPOST /jobsrequests containing a chainedmonte_carloblock.
Not investment advice. Past performance is not indicative of future results.