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

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

All methods completed. Artifacts are ready for download.

failed

An error occurred. Check error_code and error_detail for diagnostics.

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",
  "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",
  "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": 50002,
  "error_message": "DATA_FETCH_ERROR",
  "error_detail": "Unable to fetch price data for ticker INVALIDTICKER.NSE"
}

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_message']} - {job['error_detail']}")
GET
/jobs/history

List your optimization job history with cursor-based pagination.

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. Useful for quickly finding a specific job.

Response

200 OK
json
{
  "items": [
    {
      "run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "status": "succeeded",
      "created_at": "2025-01-15T10:30:00Z",
      "methods": ["MVO", "HRP"],
      "stock_count": 5
    },
    {
      "run_id": "f9e8d7c6-b5a4-3210-fedc-ba9876543210",
      "status": "running",
      "created_at": "2025-01-15T11:00:00Z",
      "methods": ["MinVol"],
      "stock_count": 8
    }
  ],
  "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}
    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. Job must have status succeeded.

Response

200 OK
json
{
  "artifacts": [
    {
      "artifact_id": "art_001",
      "method": "MVO",
      "kind": "returns_dist",
      "label": "MVO Returns Distribution",
      "signed_url": "<presigned-url:art_001 - expires in 1h>",
      "content_type": "image/png"
    },
    {
      "artifact_id": "art_002",
      "method": "MVO",
      "kind": "max_drawdown",
      "label": "MVO Maximum Drawdown",
      "signed_url": "<presigned-url:art_002 - expires in 1h>",
      "content_type": "image/png"
    },
    {
      "artifact_id": "art_global_001",
      "method": null,
      "kind": "covariance_heatmap",
      "label": "Covariance Heatmap",
      "signed_url": "<presigned-url:art_global_001 - expires in 1h>",
      "content_type": "image/png"
    },
    {
      "artifact_id": "art_global_002",
      "method": null,
      "kind": "optimization_report",
      "label": "Optimization Report (Excel)",
      "signed_url": "<presigned-url:art_global_002 - expires in 1h>",
      "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    }
  ],
  "data_parquets": {
    "benchmark_returns": "<presigned-url:benchmark_returns - expires in 1h>",
    "cumulative_returns": "<presigned-url:cumulative_returns - expires in 1h>",
    "stock_yearly_returns": "<presigned-url:stock_yearly_returns - expires in 1h>"
  },
  "data_files": {
    "chart_bundle": "<presigned-url:chart_bundle - expires in 1h>"
  }
}

Artifact Types

KindScopeContent TypeDescription
data
Global
application/vnd.openxmlformats-officedocument.spreadsheetml.sheetExcel report (label: "optimization_report")
data
Global
application/jsonPre-computed chart data bundle - gzip-compressed JSON (label: "chart_bundle")
report_pdf
Global
application/pdfPDF report (current format). Legacy runs may use kind="data" + label="optimization_report_pdf"
data
Global
application/parquetBenchmark daily returns (label: "benchmark_returns")
data
Global
application/parquetCumulative portfolio returns (label: "cumulative_returns")
data
Global
application/parquetYearly returns per stock (label: "stock_yearly_returns")

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)

for artifact in data["artifacts"]:
    ext = "png" if "png" in artifact["content_type"] else "xlsx"
    filename = f"{artifact['kind']}_{artifact['method'] or 'global'}.{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)

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

Job-specific error codes returned in the error_code and error_message fields.

CodeNameDescription
40401NOT_FOUNDJob not found or not owned by the authenticated user
50001OPTIMIZATION_FAILEDOptimization computation failed during processing
50002DATA_FETCH_ERRORCould not fetch market data for one or more tickers
50099UNEXPECTED_ERRORAn unexpected server error occurred

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. 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), rebalance_frequency, rebalance_mode with its walk_forward block, paired_static_baseline, 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 yet passed out-of-sample validation against the others, so none is published as more accurate. 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. Politis-Romano stationary block bootstrap over the parent run's daily returns. Retains observed marginal shocks, complete same-day cross-asset rows, and local ordering conditional on the selected block length. Expected block length uses Politis-White automatic selection with a sqrt(T) fallback; override with expected_block_length_days. This is the only process that replays real calendar windows, so the 2008 and COVID stresses require it.Pro, 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 three log-domain processes (fhs_ewma, fhs_gjr_garch, 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. 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 is 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.