Optimization

Submit portfolio optimization jobs for Indian equities, US equities, or Indian open-ended equity mutual funds across 31 classical and modern allocation strategies.

Overview

The optimization endpoint accepts Indian equity tickers (NSE/BSE), US equity tickers (NYSE/NASDAQ and other supported US venues), or Indian mutual fund scheme codes. Send equities through stocks and mutual funds through funds. The API queues the job and returns a run_id for polling. Completed jobs expose a chart data bundle and Excel reports, and render PDF reports on request.

Each request must contain one asset group and one market. Stocks and funds cannot be mixed, and US stocks cannot be mixed with Indian stocks. See the Mutual Funds (Beta) section for the MF schema and the Asset Discovery section for both search endpoints.

Indian requests default to the 10-year India government bond seriesINDIRLTLT01STM from FRED. US requests must use a US benchmark and set risk_free.region to "US", with an optional US tenor. If live yield data is unavailable, the backend uses its configured fallback rate.

Plan access: US equities, mutual funds, starting from uploaded holdings, and the complete 31-method catalog require Pro or Enterprise. Free accounts can submit up to 10 Indian equities per run using MVO, MinVol, CriticalLineAlgorithm, HRP, or EquiWeighted. Pro accounts can submit up to 100 equities per run. Constraints and fund-manager mode require Enterprise. The global request ceiling is 150 equities or 100 mutual funds, and 31 methods.

All optimization requests are processed asynchronously. The API immediately returns a 200 OK response with a unique run identifier. The run consumes one unit of the monthly quota at submission, whatever the number of methods. Use the Jobs API to poll for completion and retrieve results.

POST
/jobs

Submit a new portfolio optimization job for asynchronous processing.

Authentication

Required - JWT Bearer token or API Key in the Authorization header.

Request Body

FieldTypeRequiredDescription
stocksArray<object>
Required if no funds
Array of stock objects. Each object has ticker (string symbol, e.g. "RELIANCE" or "AAPL") and exchange (string, "NSE", "BSE" or "US" for NYSE/NASDAQ listings). Minimum 2 stocks, maximum 150 per request (Free 10, Pro 100). A request must stay within one market: US and Indian stocks can't be mixed, and a US portfolio requires a US benchmark plus risk_free.region: "US". Mutually exclusive with funds.
funds
Beta
Array<object>
Required if no stocks
Array of mutual fund objects. Each object has scheme_code (positive integer, MFAPI scheme code) and an optional label (string, used in reports / PDFs). Minimum 2 schemes, maximum 100. A scheme that AMFI classifies as anything other than equity is rejected. A scheme code that AMFI's current scheme master does not hold is refused with 422 UNKNOWN_FUND_SCHEME. Mutually exclusive with stocks. See the Mutual Funds (Beta) section below.
methodsArray<string>Default: ["MVO"]One or more optimization method enums. See Supported Methods below for the full list of 31 strategies. Duplicate method values are collapsed while preserving their first-seen order. A method that needs more assets than the request names is refused with 422 METHOD_REQUIREMENTS_UNMET; GET /methods/requirements lists each method's minimum asset count, minimum history and benchmark need.
benchmarkstringDefault: "nifty" (retail), "nifty_500" (fund manager)Benchmark index for relative metrics (alpha, beta, tracking error, IR, capture ratios). One of 17 values (14 Indian + 3 US) - see Supported Benchmarks below. Benchmarks other than "nifty", "sensex", and "bank_nifty" have shorter history; the optimizer truncates the asset price series to align with the benchmark window. A fund-manager request that names no benchmark measures against "nifty_500"; a retail request measures against "nifty".
benchmark_basisstringOptionalWhich series of the benchmark to measure against. "price_return" is the index level and is the default for every Indian benchmark. "total_return" uses NSE Indices' Total Return Index, which reinvests dividends, so Jensen's alpha and the information ratio are not overstated by the dividend yield. Omit it to keep the benchmark's own basis. A fund-manager request that names no basis gets "total_return" wherever the benchmark has a Total Return Index, so a fund-manager request with neither field measures against NIFTY 500 TRI. The default is written into the stored request. "total_return" is rejected for "sensex", and "price_return" is rejected for the US benchmarks, which are total-return ETFs. A backtest or Monte Carlo run on this run uses the same basis. See Price return or Total Return Index.
cla_methodstringOptionalSub-method for Critical Line Algorithm. One of "MVO", "MinVol", "Both". Only used when "CriticalLineAlgorithm" is in methods.
rolling_backtestobjectOptionalConfiguration for rolling walk-forward backtesting. When enabled: true, the job also runs a walk-forward evaluation over the history, beside the single in-sample fit. See the Rolling Backtest section below for sub-fields.
risk_freeobjectDefault: IndiaRisk-free rate source. region is "IN" (default, Indian 10Y government bond series via FRED) or "US". For "US", optionally choose a tenor - "FedFunds" (overnight effective fed funds rate) or a Treasury constant-maturity tenor ("1M", "3M", "6M", "1Y" … "30Y") - sourced from FRED. Omitting the tenor defaults to "10Y". Required to be "US" for US portfolios.
modestringDefault: "retail""retail" or "fund_manager" (Enterprise). A fund-manager request must carry constraints, every requested method must solve every constraint family it uses, and some portfolio must satisfy the constraints on the requested instruments. A request that fails either check is refused with 422 FUND_MANAGER_NOT_ADMITTED before it is queued, and spends no monthly run. The mode adds no benchmark-relative measurement beyond what a retail run reports. See Fund Manager Mode.
constraintsobjectOptionalHard limits on the finished weights (Enterprise). An object that sets no limit is treated as absent. See Portfolio Constraints below.
current_weightsobjectOptionalThe holdings you start from, as a map of instrument to weight (for example "RELIANCE.NS": 0.25). Stored with the run and returned by GET /jobs/{run_id}/config so a client can show the change from current to optimized weights. It does not change the optimization. Negative and non-numeric entries are dropped; values are not renormalized. Requires Pro or Enterprise. POST /holdings/import builds this map from a Zerodha or Groww holdings export.
ignore_short_historybooleanDefault: falseWhen true, an instrument with too little price history is dropped and the run continues on the rest, instead of failing. Dropped instruments are listed in the result's dropped_tickers. With constraints, a drop that leaves a rule unenforceable fails the run.
start_date, end_datestring (YYYY-MM-DD)OptionalThe history window of the run. Both dates are inclusive, and each one is optional. With no dates, the run uses the longest window that the assets share. The fields apply to stock, US and fund runs. A backtest or Monte Carlo run on this run uses the window of its parent. A window that ends after today, that starts on or after its end, or that holds fewer than 150 weekdays is refused with 422 HISTORY_WINDOW_REFUSED. The result records the requested window in history_window.requested. Call POST /price-history/availability first to see the stored history of each asset.
monte_carloobjectOptionalA chained Monte Carlo simulation, queued on the optimized weights after the run succeeds. The body is the Monte Carlo config, and its method is required and must be one of the requested methods. Indian equities only, Pro or Enterprise, and a signed-in session (not an API key). Both the optimization and the Monte Carlo quota are consumed at submission. Results are conditional on the selected model, the frozen optimizer-time inputs, the effective configuration, the engine version and the seed; they are not calibrated forecasts.

Request Example - Standard Optimization

json
{
  "stocks": [
    { "ticker": "RELIANCE", "exchange": "NSE" },
    { "ticker": "TCS", "exchange": "NSE" },
    { "ticker": "HDFCBANK", "exchange": "NSE" },
    { "ticker": "INFY", "exchange": "NSE" },
    { "ticker": "ICICIBANK", "exchange": "NSE" }
  ],
  "methods": ["MVO", "MinVol", "HRP", "BlackLitterman"],
  "benchmark": "nifty"
}

Request Example - US Stocks

json
{
  "stocks": [
    { "ticker": "AAPL", "exchange": "US" },
    { "ticker": "MSFT", "exchange": "US" },
    { "ticker": "NVDA", "exchange": "US" },
    { "ticker": "JPM", "exchange": "US" }
  ],
  "methods": ["MVO", "MinVol", "HRP"],
  "benchmark": "sp500",
  "risk_free": { "region": "US", "tenor": "3M" }
}

Request Example - Rolling Walk-Forward Backtest

json
{
  "stocks": [
    { "ticker": "RELIANCE", "exchange": "NSE" },
    { "ticker": "TCS", "exchange": "NSE" },
    { "ticker": "HDFCBANK", "exchange": "NSE" },
    { "ticker": "INFY", "exchange": "NSE" },
    { "ticker": "ICICIBANK", "exchange": "NSE" }
  ],
  "methods": ["MVO", "HRP", "BlackLitterman"],
  "benchmark": "nifty",
  "rolling_backtest": {
    "enabled": true,
    "rebalance_frequency": "annual",
    "window_type": "expanding",
    "window_length_years": null,
    "shadow_account": { "enabled": true, "allocation_source": "first_fold" },
    "policy_comparison": { "enabled": true, "transaction_cost_bps": 10 },
    "evaluation": { "cutoff_date": "2020-01-01" }
  }
}

Request Example - Fund Manager Mode

json
{
  "stocks": [
    { "ticker": "TCS", "exchange": "NSE" },
    { "ticker": "INFY", "exchange": "NSE" },
    { "ticker": "HDFCBANK", "exchange": "NSE" },
    { "ticker": "ICICIBANK", "exchange": "NSE" },
    { "ticker": "ITC", "exchange": "NSE" }
  ],
  "methods": ["MVO", "MinVol"],
  "mode": "fund_manager",
  "constraints": {
    "every_asset": { "max_weight": 0.3 }
  }
}

This request names no benchmark, so it is stored and measured against "nifty_500" with "benchmark_basis": "total_return".

Response

200 OK
json
{
  "run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "queued"
}

A stock run can also return warnings. The field is present only when there is a warning. A METHOD_HISTORY_SHORT item names a requested method that the stored prices are too short for. The run is queued, and the other methods run. The warning is a prediction from stored prices. It is not a refusal, and the absence of a warning is not a promise that the method will run.

json
{
  "run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "queued",
  "warnings": [
    {
      "code": "METHOD_HISTORY_SHORT",
      "method": "HMMRegimeMVO",
      "required_observations": 252,
      "predicted_observations": 180,
      "unit": "return observations",
      "message": "HMMRegimeMVO needs at least 252 return observations, and the stored prices ..."
    }
  ]
}

curl

bash
curl -X POST https://api.foliolab.ai/jobs \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <YOUR_TOKEN>" \
  -d '{
    "stocks": [
      { "ticker": "RELIANCE", "exchange": "NSE" },
      { "ticker": "TCS", "exchange": "NSE" },
      { "ticker": "HDFCBANK", "exchange": "NSE" },
      { "ticker": "INFY", "exchange": "NSE" }
    ],
    "methods": ["MVO", "HRP", "BlackLitterman"],
    "benchmark": "nifty"
  }'

Python

python
import requests

BASE_URL = "https://api.foliolab.ai"
TOKEN = "<YOUR_TOKEN>"

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# Define the portfolio and optimization methods
payload = {
    "stocks": [
        {"ticker": "RELIANCE", "exchange": "NSE"},
        {"ticker": "TCS", "exchange": "NSE"},
        {"ticker": "HDFCBANK", "exchange": "NSE"},
        {"ticker": "INFY", "exchange": "NSE"},
    ],
    "methods": ["MVO", "MinVol", "HRP"],
    "benchmark": "nifty",
}

response = requests.post(f"{BASE_URL}/jobs", json=payload, headers=headers)
response.raise_for_status()

data = response.json()
print(f"Job submitted  run_id={data['run_id']}  status={data['status']}")

Error Codes

CodeResponse detailDescription
400Invalid optimization request payload: <reasons>Schema or market validation failed, including mixed asset groups, an invalid enum, a mismatched benchmark / risk-free region, a benchmark_basis the benchmark does not have, or a fund-manager request with no constraints. Up to five reasons follow the colon, each as field: message
400Invalid Monte Carlo config: <reasons>The chained monte_carlo block failed validation. The reasons follow the colon
401UnauthorizedThe bearer token or API key is missing, invalid, or expired
403email_not_verifiedThe signed-in account has not confirmed its email address
403API key lacks required scopeThe API key does not carry jobs:write
403funds_not_allowedMutual-fund optimization requires Pro or Enterprise
403us_stocks_not_allowedUS-stock optimization requires Pro or Enterprise
403holdings_upload_not_allowedA request that carries current_weights requires Pro or Enterprise
403algo_not_allowed:<method>The selected plan does not include one of the requested methods
403ticker_limit_exceeded:<limit>The request exceeds the plan's per-run equity limit (Free 10, Pro 100)
403constraints_not_allowed:<plan>The request carries constraints and the plan does not include them
403fund_manager_not_allowed:<plan>The request sets mode to fund_manager and the plan does not include it
403mc_api_key_not_supportedA chained monte_carlo block was sent with an API key. Chained simulations need a signed-in session
404<saved classification message>A saved_classifications entry names a map that does not exist on the account
422METHOD_REQUIREMENTS_UNMET: ...A requested method needs more assets than the request names, for example HERC with two stocks or HERC2 with three. The detail names the method, the bound and the count
422UNKNOWN_TICKER: ...An Indian stock symbol is not listed on the exchange that the request names. The detail names each symbol and, when it is listed elsewhere, that exchange. No monthly run is spent
422UNKNOWN_FUND_SCHEME: ...AMFI's current scheme master does not hold a fund scheme_code, for example a closed or merged scheme. The check does not refuse when the master cannot be read. No monthly run is spent
422INSUFFICIENT_HISTORY: ... Insufficient: TICKER(n), ...A stock has fewer than 150 stored daily prices in the window and ignore_short_history is false, or too few stocks would remain with it set. Refused before the run is queued, so no monthly run is spent
422HISTORY_WINDOW_REFUSED: <reason>start_date or end_date is after today, start_date is not before end_date, or the window holds fewer than 150 weekdays
422REOPTIMIZE_FREQUENCY_REFUSED: <reason>rolling_backtest.reoptimize_frequency is finer than rolling_backtest.rebalance_frequency
422FUND_MANAGER_NOT_ADMITTED: ...A fund-manager request failed admission: a requested method cannot solve every constraint family, or no portfolio satisfies the constraints. The detail names the method or the conflict. No monthly run is spent
422MC_METHOD_REQUIREDA chained monte_carlo block does not name its method
422METHOD_NOT_REQUESTED: ...The chained monte_carlo method is not among the requested methods
429monthly_limit_exceededThe account has used its monthly optimization quota
503MC_GOVERNANCE_UNAVAILABLEA chained monte_carlo block was sent and the service could not load its model-risk governance record. Retry later with the same request
503Unable to enqueue run. Please retry.The validated job could not be queued for asynchronous processing

Supported Methods

All 31 optimization strategies available through the API. Pass one or more enum values in the methods array.

MVOMean-Variance Optimization (Markowitz)
MinVolMinimum Volatility portfolio
MaxQuadraticUtilityMaximum quadratic utility with risk aversion
EquiWeightedEqual weight (1/N) baseline
CriticalLineAlgorithmMarkowitz Critical Line Algorithm
HRPHierarchical Risk Parity (Lopez de Prado)
MinCVaRMinimum Conditional Value at Risk
MinCDaRMinimum Conditional Drawdown at Risk
HERCHierarchical Equal Risk Contribution
NCONested Clustered Optimization
HERC2Enhanced HERC with CVaR risk measure. Needs at least 4 assets: with 3 it always returns equal weights, so use EquiWeighted for 1/N
BenchmarkTrackerMinimize tracking error to a benchmark index
MaximumDiversificationMaximize the diversification ratio
RiskBudgetingTarget equal or custom risk contribution per asset
DistributionallyRobustCVaRWorst-case CVaR over a Wasserstein ball (robust to distribution uncertainty)
StackingOptimizationEnsemble meta-optimizer: stacks InverseVolatility, MaximumDiversification, and RiskBudgeting sub-models
InverseVolatilityWeights inversely proportional to each asset's volatility
SparseMarkowitzL1L1-regularized Markowitz for sparse, concentrated portfolios
HMMRegimeMVOHidden Markov Model regime-switching MVO (regime-weighted μ and Σ)
BlackLittermanBlack-Litterman with market-implied equilibrium returns and momentum views
EWMAMVOEWMA moments that adapt MVO to recent volatility regimes
MinSemivarianceMinimum downside semivariance
MinEVaRMinimum Entropic Value-at-Risk
MinEDaRMinimum Entropic Drawdown-at-Risk
MaxDecorrelationMaximum decorrelation through the asset correlation matrix
RobustMVOWorst-case MVO under expected-return uncertainty
ResampledMVOBootstrap-resampled MVO with averaged weights
SparseIndexTrackingSparse benchmark replication with a reduced asset subset
QuintileMomentumEqual-weight allocation to the highest-momentum quintile
KellyLong-only growth-optimal expected log-wealth allocation
KalmanPairsTradingLong/short pairs allocation on a Kalman-filtered time-varying hedge ratio. Needs at least 2 assets and 120 observations, is absent from the web method picker, and cannot seed a Monte Carlo run

Supported Benchmarks

Pass one of these 17 values (14 Indian and 3 US) as the benchmark field. See Supported Benchmarks for inception dates and selection guidance.

ValueIndexExchangeApprox. startHistory
niftyNIFTY 50NSE~1990Full
sensexBSE SENSEX 30BSE~1997Full
bank_niftyNIFTY BankNSE~2000Full
nifty_100NIFTY 100NSE~2003Limited
nifty_200NIFTY 200NSE~2004Limited
nifty_500NIFTY 500NSE~1995Limited
nifty_midcapNIFTY Midcap 150 (alias)NSE~2005Limited
nifty_midcap_50NIFTY Midcap 50NSE~2004Limited
nifty_midcap_100NIFTY Midcap 100NSE~2003Limited
nifty_midcap_150NIFTY Midcap 150NSE~2005Limited
nifty_smallcapNIFTY Smallcap 250 (alias)NSE~2005Limited
nifty_smallcap_50NIFTY Smallcap 50NSE~2005Limited
nifty_smallcap_100NIFTY Smallcap 100NSE~2004Limited
nifty_smallcap_250NIFTY Smallcap 250NSE~2005Limited
sp500S&P 500 (SPY, total return)US~1993Full
nasdaq_100Nasdaq-100 (QQQ, total return)US~1999Full
russell_3000Russell 3000 (IWV, total return)US~2000Full

Limited-history behaviour

When you pick any benchmark flagged Limited, the asset price series is cut to the dates the benchmark covers. The run keeps only the dates on which every asset and the benchmark have a price, so the optimization and every metric (alpha, beta, tracking error, information ratio, etc.) use the same overlap. This applies to both equity and mutual fund runs. A Total Return Index basis can start later than the price index: NIFTY 50 TRI starts in June 1999.

Asset Discovery

Public search endpoints for resolving mutual-fund scheme codes and US equity tickers before building a POST /jobs payload. Authentication is not required for either search endpoint.

GET
/mutual-funds/search
Beta

Search the mutual fund scheme list. Use asset_class=equity when selecting schemes for optimization because non-equity schemes can be discovered but are rejected by POST /jobs.

Query parameterRulesDescription
qRequired, 2 to 100 charactersScheme, AMC, or category search text
limitDefault 20, range 1-50Flat-response result cap when pagination is not requested
asset_classequity, debt, hybrid, or otherOptional AMFI asset-class filter
page / page_sizePage starts at 1; size defaults to 20 and caps at 50Supplying either parameter enables pagination and adds page, page_size, total, and has_more to the response
bash
curl "https://api.foliolab.ai/mutual-funds/search?q=parag%20parikh&asset_class=equity&page=1&page_size=20"
json
{
  "items": [
    {
      "scheme_code": 122639,
      "scheme_name": "Parag Parikh Flexi Cap Fund - Direct Plan - Growth",
      "isin_growth": "INF879O01027",
      "isin_div_reinvestment": null,
      "category": "Flexi Cap Fund",
      "asset_class": "equity"
    }
  ],
  "page": 1,
  "page_size": 20,
  "total": 1,
  "has_more": false
}

Returns HTTP 400 for invalid query parameters and HTTP 503 when the scheme catalog is temporarily unavailable.

GET
/us-stocks/search

Search Yahoo Finance for US-listed equities. Results exclude ETFs, indices, futures, foreign-exchange pairs, and non-US listings.

Query parameterRulesDescription
qRequired, 1 to 100 charactersCompany name or ticker search text
limitDefault 20, range 1-50Maximum number of filtered US equities to return
bash
curl "https://api.foliolab.ai/us-stocks/search?q=apple&limit=10"
json
{
  "items": [
    {
      "ticker": "AAPL",
      "name": "Apple Inc.",
      "exchange": "NASDAQ",
      "type": "EQUITY"
    }
  ]
}
The search response's exchange is a display venue such as NASDAQ or NYSE. In a job request, always send { "ticker": "AAPL", "exchange": "US" }.

Returns HTTP 400 for invalid query parameters and HTTP 503 when Yahoo Finance search is temporarily unavailable.

Mutual Funds

Beta

Submit optimization across Indian open-ended equity mutual funds by sending afunds array instead of stocks. Daily NAV history comes from AMFI's published NAVs, with MFAPI as a fallback; category classification is sourced from AMFI.

Mutual fund optimization is in beta

The MF endpoint is new and still being hardened. Coverage is limited to equity-oriented schemes (large/mid/small/multi/flexi cap, ELSS, sectoral, thematic, equity index funds). Debt, hybrid, liquid, arbitrage, gilt, gold, and solution-oriented schemes are rejected. Treat outputs as exploratory and verify before using for live decisions. Full documentation: Mutual Funds (Beta).

Funds object schema

FieldTypeRequiredDescription
scheme_codeinteger (> 0)
Required
Numeric scheme code as published by AMFI / MFAPI (e.g. 120503). Must resolve to an equity-oriented open-ended scheme.
labelstringOptionalHuman-readable scheme name, at most 512 characters. Used in result tables, charts, and PDF reports. Falls back to the canonical scheme name if omitted.

Constraints

  • Minimum 2 unique schemes, maximum 100 submitted schemes per run.
  • Duplicate scheme_code values are deduplicated before optimization.
  • stocks and funds cannot both be present in the same request.
  • Use an Indian benchmark and the Indian risk-free region. US benchmarks are rejected for fund runs.
  • Available NAV history varies by scheme and may shorten the common analysis window.
  • NAVs are growth-option, end-of-day, net of expense ratio. There is no intra-day series.

Request Example - Mutual Funds

json
{
  "funds": [
    { "scheme_code": 120503, "label": "Parag Parikh Flexi Cap Fund - Direct Growth" },
    { "scheme_code": 118989, "label": "Mirae Asset Large Cap Fund - Direct Growth" },
    { "scheme_code": 120586, "label": "Axis Midcap Fund - Direct Growth" },
    { "scheme_code": 119598, "label": "Nippon India Small Cap Fund - Direct Growth" }
  ],
  "methods": ["MVO", "HRP", "RiskBudgeting"],
  "benchmark": "nifty_500"
}

curl

bash
curl -X POST https://api.foliolab.ai/jobs \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <YOUR_TOKEN>" \
  -d '{
    "funds": [
      { "scheme_code": 120503, "label": "Parag Parikh Flexi Cap" },
      { "scheme_code": 118989, "label": "Mirae Asset Large Cap" },
      { "scheme_code": 120586, "label": "Axis Midcap" },
      { "scheme_code": 119598, "label": "Nippon Small Cap" }
    ],
    "methods": ["MVO", "HRP", "RiskBudgeting"],
    "benchmark": "nifty_500"
  }'

Python

python
import requests

BASE_URL = "https://api.foliolab.ai"
TOKEN = "<YOUR_TOKEN>"

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# Submit a mutual fund optimization (BETA)
# Mutual fund runs use scheme_code (integer from MFAPI / AMFI)
# instead of {ticker, exchange}. Stocks and funds cannot be mixed.
payload = {
    "funds": [
        {"scheme_code": 120503, "label": "Parag Parikh Flexi Cap"},
        {"scheme_code": 118989, "label": "Mirae Asset Large Cap"},
        {"scheme_code": 120586, "label": "Axis Midcap"},
        {"scheme_code": 119598, "label": "Nippon Small Cap"},
    ],
    "methods": ["MVO", "HRP", "RiskBudgeting"],
    "benchmark": "nifty_500",
}

response = requests.post(f"{BASE_URL}/jobs", json=payload, headers=headers)
response.raise_for_status()

data = response.json()
print(f"MF job submitted  run_id={data['run_id']}  status={data['status']}")

Portfolio Constraints

Send a constraints object to put hard limits on the finished weights. Every limit binds: a limit that cannot be met makes the request infeasible and is never widened to fit. Constraints require an Enterprise plan, and a request that carries them on any other plan is refused with 403 constraints_not_allowed:<plan> before the run is queued and before it spends a monthly run.

The object takes nine keys. Send at least one limit from the first four.

KeyWhat it does
every_assetOne min_weight and max_weight applied to every stock in the run.
assetsA min_weight and max_weight on one named stock at a time, given as asset. A bare symbol such as TCS binds that stock on its exchange.
excluded_assetsStocks held at exactly zero.
groupsA min_weight and max_weight on the total held in one group of a classification scheme, such as a sector or mktcap band. Takes two further keys: each_member bounds each stock inside the band rather than their total, and denominator measures the percentages against another band instead of the whole portfolio.
classificationsThe sector list the group limits read, sent inline with a scheme, a vintage, a source and the assignments.
saved_classificationsA stored sector list referenced by map_id. The run expands it at submission and records the revision it read.
derived_classificationsAsks the server to build the mktcap bands from a market capitalisation snapshot of this run's own stocks.
basisWhat the limits are checked against. One value, final_weights, which is also the default.
report_constraint_costAlso measure how far each constrained portfolio sits from the one that method builds with no limits. Costs a second solve per method, so it is false by default.

Every bound is a fraction between 0 and 1, and each bound needs at least one of min_weight and max_weight. A min_weight forces ownership: it is not conditional on the stock being held. Each list takes at most 200 rules, and at most 8 classification schemes. Not every method accepts every kind of limit. In a retail run, a method that cannot apply one produces no portfolio in that run; in a fund-manager run, the request is refused. Portfolio Constraints gives the per-method table and the mathematics behind it.

json
{
  "stocks": [
    { "ticker": "TCS", "exchange": "NSE" },
    { "ticker": "INFY", "exchange": "NSE" },
    { "ticker": "HDFCBANK", "exchange": "NSE" },
    { "ticker": "ITC", "exchange": "NSE" }
  ],
  "methods": ["MVO", "MinVol"],
  "benchmark": "nifty",
  "constraints": {
    "every_asset": { "max_weight": 0.25 },
    "assets": [
      { "asset": "TCS", "min_weight": 0.08, "max_weight": 0.2 }
    ],
    "excluded_assets": ["ITC"],
    "groups": [
      { "scheme": "sector", "group": "IT", "max_weight": 0.35 }
    ],
    "classifications": [
      {
        "scheme": "sector",
        "vintage": "2026-06-30",
        "source": "desk list",
        "assignments": {
          "TCS": ["IT"],
          "INFY": ["IT"],
          "HDFCBANK": ["Financial Services"],
          "ITC": ["FMCG"]
        }
      }
    ],
    "report_constraint_cost": true
  }
}

Use POST /constraints/preflight to check the limits against a list of stocks before a run is spent. It reports whether the limits can be stated at all, whether any portfolio satisfies them together, and what each sector or mktcap band can reach under the other limits. GET /constraints/capability returns the per-method table. Both routes accept a web session only: an API key receives 403.

Rolling Walk-Forward Backtest

Set rolling_backtest.enabled = true to add a walk-forward evaluation to the run, beside the single in-sample fit. The job re-optimizes each method on an expanding or rolling training window at every rebalance date and holds the weights out of sample until the next one. The out-of-sample track carries the same metric suite as the run, with Sharpe inference (PSR, MinTRL, 95% CI) that accounts for non-Normality and serial correlation. The result is one realized historical path, not a forecast. GET /jobs/{run_id}/rolling_backtest returns this section alone. To backtest a run that already exists, use the Backtests API instead of submitting the portfolio again.

FieldTypeDefaultDescription
enabledbooleanfalseMust be true to activate rolling backtesting.
rebalance_frequencystring"annual"How often the portfolio is rebalanced between walk-forward periods. One of "annual", "semi_annual", "quarterly".
reoptimize_frequencystring | nullnullHow often the optimizer fits a new target. Same values as rebalance_frequency, and it must be equal to it or coarser. A finer value is refused with 422 REOPTIMIZE_FREQUENCY_REFUSED. A rebalance between two refits trades back to the last fitted target. With null, the optimizer refits at every rebalance date, as before. The field makes no claim that one policy performs better.
window_typestring"expanding"Training window strategy. "expanding" grows from the start of history (all available data up to the rebalance date); "rolling" uses a fixed-length lookback window specified by window_length_years.
window_length_yearsnumber | nullnullLength of the rolling training window in years (e.g. 3 for 3-year lookback). Required when window_type = "rolling"; ignored for "expanding".
shadow_accountobjectenabled: trueA never-rebalanced control arm held beside the walk-forward track. allocation_source is "first_fold" (default, the method's first allocation), "equal_weight", or "supplied_allocation", which requires weights. Set enabled: false to omit it.
policy_comparisonobjectenabled: trueCompares buy and hold, rebalance to the original target, and walk-forward re-optimization on one matched evidence set. transaction_cost_bps (0 to 500, default 0) is a flat cost per unit of notional traded.
evaluationobject{}Optional cutoff_date (the first evaluation date) and end_date (the last). Both resolve to trading sessions, and the resolved dates are stored with the result. end_date must be after cutoff_date.

Python - Rolling Backtest Workflow

python
import requests
import time

BASE_URL = "https://api.foliolab.ai"
TOKEN = "<YOUR_TOKEN>"

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# Submit a rolling walk-forward backtest job
payload = {
    "stocks": [
        {"ticker": "RELIANCE", "exchange": "NSE"},
        {"ticker": "TCS", "exchange": "NSE"},
        {"ticker": "HDFCBANK", "exchange": "NSE"},
        {"ticker": "INFY", "exchange": "NSE"},
        {"ticker": "ICICIBANK", "exchange": "NSE"},
    ],
    "methods": ["MVO", "HRP", "BlackLitterman"],
    "benchmark": "nifty",
    "rolling_backtest": {
        "enabled": True,
        "rebalance_frequency": "annual",   # "annual" | "semi_annual" | "quarterly"
        "window_type": "expanding",        # "expanding" | "rolling"
        "window_length_years": None,       # Required if window_type == "rolling"
    },
}

job = requests.post(f"{BASE_URL}/jobs", json=payload, headers=headers)
job.raise_for_status()
run_id = job.json()["run_id"]
print(f"Submitted rolling backtest: {run_id}")

# Poll for completion
while True:
    resp = requests.get(f"{BASE_URL}/jobs/{run_id}", headers=headers)
    resp.raise_for_status()
    status = resp.json()["status"]
    print(f"  Status: {status}")
    if status in ("succeeded", "failed", "canceled"):
        break
    time.sleep(3)

print("Rolling backtest complete!")

Example: Full Optimization Workflow

A complete Python script that authenticates, submits an optimization job, polls for completion, and fetches the downloadable artifacts.

python
import requests
import time

BASE_URL = "https://api.foliolab.ai"

# ---- Step 1: Authenticate ----
auth = requests.post(f"{BASE_URL}/auth/login", json={
    "email": "user@example.com",
    "password": "your-password",
})
auth.raise_for_status()
token = auth.json()["access_token"]

headers = {
    "Authorization": f"Bearer {token}",
    "Content-Type": "application/json",
}

# ---- Step 2: Submit optimization job ----
job = requests.post(f"{BASE_URL}/jobs", json={
    "stocks": [
        {"ticker": "RELIANCE", "exchange": "NSE"},
        {"ticker": "TCS", "exchange": "NSE"},
        {"ticker": "HDFCBANK", "exchange": "NSE"},
        {"ticker": "INFY", "exchange": "NSE"},
        {"ticker": "ICICIBANK", "exchange": "NSE"},
    ],
    "methods": ["MVO", "MinVol", "HRP", "HERC"],
    "benchmark": "nifty",
}, headers=headers)
job.raise_for_status()

run_id = job.json()["run_id"]
print(f"Submitted job: {run_id}")

# ---- Step 3: Poll until completion ----
while True:
    status_resp = requests.get(f"{BASE_URL}/jobs/{run_id}", headers=headers)
    status_resp.raise_for_status()
    status = status_resp.json()

    print(f"  Status: {status['status']}")

    if status["status"] in ("succeeded", "failed", "canceled"):
        break

    time.sleep(3)  # poll every 3 seconds

if status["status"] == "failed":
    print(f"Job failed: {status.get('error_message', 'Unknown error')}")
    exit(1)

# ---- Step 4: Fetch artifacts ----
artifacts = requests.get(f"{BASE_URL}/jobs/{run_id}/artifacts", headers=headers)
artifacts.raise_for_status()

for artifact in artifacts.json()["artifacts"]:
    print(f"  {artifact['label']}  ->  {artifact['signed_url'][:60]}...")

# PDF reports are rendered on request: POST /jobs/{run_id}/report/pdf

print("\nOptimization complete!")

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