Jobs

Track optimization status, browse job history, and download result artifacts.

Job Lifecycle

Every optimization job progresses through a fixed set of states.

queued
→
running
→
uploading
→
succeeded
or
failed
or
canceled

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.

GET
/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

ParameterTypeDescription
run_idUUIDThe unique identifier returned when the job was submitted.

Response

200 OK

Successful job:

json
{
  "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:

json
{
  "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

bash
curl https://api.foliolab.ai/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer <YOUR_TOKEN>"

Python

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']}")
GET
/jobs/history

List 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

ParameterTypeDefaultDescription
limitinteger20Number of items per page. Maximum 100.
cursorstring-Opaque pagination cursor. Pass the next_cursor value from the previous response to fetch the next page.
qstring-Search by run_id prefix or by a substring of the run name.
statusstring-Keep only runs in one state: queued, running, uploading, succeeded, failed or canceled. Applied before pagination, so pages stay full.
summarybooleanfalseAdd 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_eligibilitybooleanfalseAdd whether each run's request can seed a Monte Carlo simulation.
tagstring-Keep only runs that carry this exact tag name.
market / mode / constraintsstring-Keep only runs with that facet: market is IN or US, mode is retail or fund_manager, and constraints is with or without.

Response

200 OK
json
{
  "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

bash
# 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

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)")
GET
/jobs/{run_id}/artifacts

Retrieve downloadable artifacts (charts, data files, reports) for a completed job.

Authentication

Required - you must own the run.

Path Parameters

ParameterTypeDescription
run_idUUIDThe job identifier. Artifacts exist once the job has succeeded; the response carries the job's status beside them.

Response

200 OK
json
{
  "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

KindScopeContent TypeDescription
data
Global
application/json (gzip)Pre-computed chart data bundle (label: "chart_bundle")
data
Global
application/vnd.openxmlformats-officedocument.spreadsheetml.sheetExcel report (label: "optimization_report")
data
Global
application/vnd.openxmlformats-officedocument.spreadsheetml.sheetBacktest Excel report, present only when the run carried an inline rolling backtest (label: "backtest_report")
report_pdf
Global
application/pdfPDF 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.parquetLegacy 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

bash
curl https://api.foliolab.ai/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/artifacts \
  -H "Authorization: Bearer <YOUR_TOKEN>"

Python - Download All Artifacts

python
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.

CodeNameDescription
INVALID_TICKERInvalid tickerOne or more requested tickers could not be resolved
DATA_FETCH_FAILEDData unavailablePrice or benchmark data was missing, or too few aligned observations remained
SECURITY_RETURNS_MISSINGReturns missingPrice history is missing for named instruments. error_detail.missing_tickers lists them, and the monthly run is returned
CONSTRAINTS_UNIVERSE_SHRANKConstraints lost an instrumentData filtering removed an instrument that a constraint needs. The monthly run is returned
CONSTRAINTS_UNIVERSE_SHRANK_INFEASIBLEConstraints infeasible after filteringAfter data filtering removed instruments, no portfolio satisfies the constraints. The monthly run is returned
INVALID_INPUTInvalid inputThe request could not be processed as given
OPTIMIZER_INVALID_OUTPUTInvalid optimizer outputA method returned weights that failed validation
UPSTREAM_TIMEOUTUpstream timeoutA data source timed out
QUEUE_ENQUEUE_FAILEDNot queuedThe run was accepted but could not be queued
EXCEPTIONUnexpected errorAn 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.

MethodPathWhat it returns
GET/jobs/activeEvery 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}/configThe 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}/cancelCancel 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/pdfRequest 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/statusWhere 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_backtestOne 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}/backtestsEvery 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/requirementsWhat 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/availabilityThe 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/universeThe live list of supported Indian stocks, with an ETag for caching. No authentication required.
GET/constraints/capabilityWhich constraint families each method supports, as one server-side table. Read this rather than hard-coding a method list.
POST/constraints/preflightCheck 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/importRead 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.

EndpointPurpose
POST /runs/{run_id}/monte_carloSubmit 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_carloList 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 /mcCursor-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.

ValueModelPlan
fhs_gjr_garch_vtDefault. 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_regimeThe variance-targeted GJR filter under a slow-moving variance-regime layer.Enterprise
fhs_gjr_regime_uv_fallbackAs fhs_gjr_regime, but an asset whose GJR fit fails admission falls back to its unconditional variance instead of EWMA.Enterprise
regime_bootstrapHamilton 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_ewmaFiltered 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_garchFiltered 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_lognormalDaily 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_tMultivariate 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
gaussianMultivariate 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 that rgp is 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 is feature_flags.mc_allowed_processes on GET /billing/me, where "*" means every process.
  • 422 mc_insufficient_history_for_process means 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_UNAVAILABLE means 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 to POST /jobs requests containing a chained monte_carlo block.

Not investment advice. Past performance is not indicative of future results.