Skip to content
Pro feature

Signals API

Pull any strategy's monthly signal, plus your portfolio's allocations and backtest, as JSON or CSV with a read-only API key. Use it in your research scripts or a spreadsheet, or connect your AI assistant over MCP.

Read the guide to the API, webhooks and MCP

What it is

A read-only HTTP API that returns a strategy variant's monthly rebalancing signal in machine-readable form, so you can automate your own workflow around the signal. It is the same signal you see on the site and in the monthly email, just in a format your code can read.

Authentication

Every request needs a BestFolio API key. Keys start with bf_live_ and are tied to your Pro subscription (access stops if the subscription lapses). Pass it either way:

  • Header: X-API-Key: bf_live_...
  • Or: Authorization: Bearer bf_live_...

Keys are read-only and should be treated like a password. Pro members generate and revoke keys in Settings, under API Keys.

API key scopes

full is the default scope for new keys and the scope of all existing keys. It keeps the current read-only access, including your own portfolio data.

An administrator can create a strategies key for a research or review worker. It can read the explicitly enabled strategy and variant lists and details, signals, backtest NAV and metrics, strategy performance series, and benchmark data. It inherits the owner's Pro eligibility for that data, without access to their user identity, private strategies, portfolios, billing, broker connections, settings or admin tools. Portfolio entities in shared performance endpoints are also forbidden.

All portfolio endpoints below require a full key. A strategies key receives 403 outside its scope, with the detail below. Both scopes use the same headers, quota, rate limits and revocation controls.

{"detail":"This API key is limited to strategy data"}

Endpoint

GET /api/variants/{variant_id}/signal/export

Query parameters:

  • format: json or csv. CSV columns are signal_date, regime, then one column per ETF holding the target weight as a decimal (0.25 = 25%). The first line of a CSV is a comment starting with # that carries the data notice (the price credit and the terms under Data sources and terms); the header row follows it. Skip it when you parse, for example with pandas.read_csv(..., comment="#"). JSON carries the same notice under data_notice.
  • limit: number of most recent signals to return (1 to 1000, default 120). Returned oldest first.

Active signal vs next-month preview

Read this before you automate your own workflow around the signal

Signals are dated at their effective date. The newest signal effective on or before the response's UTC as_of_date is current. A signal with a later effective date is a preview and is not actionable until that date.

Do not blindly take the most recent row (limit=1), which may be the preview. Use the declared current signal and check its holding period and data cutoff before acting.

To make this explicit, the JSON response includes top-level current_signal (the active allocation) and next_signal (the preview) dates. Each JSON row also includes status, as_of_date, and effective_date. Strategy summary and detail responses use the same fields inside current latest_signal and nullable preview_signal.

Each row also carries a period object with the four dates that matter: data_cutoff is the close the signal is computed from (equal to signal_date and effective_date, which keep their meaning for compatibility), holding_start and holding_end are the first and last sessions the allocation is held (US market holidays applied), and next_review is the first session after that, at the open (weekly strategies trade market-on-close, so theirs is the following Friday close). A preview has provisional: true: it is recomputed every trading morning until its cutoff close. CSV exports append holding_start, holding_end and next_review after the price columns.

Finding a variant_id

Resolve GET /api/strategies/slug/{slug} first. Inspect its variants, choose your intended variant by slug, and use that active variant's returned id for the export. Stop if the slug is absent or ambiguous. Do not copy numeric IDs from examples.

Resolve a variant by slug with curl and jq

# Set KEY and VARIANT_SLUG to your API key and chosen variant slug.
# A slug is stable; numeric IDs must come from the live response.
SLUG=haa
DETAIL=$(curl --fail --silent --show-error \
  -H "X-API-Key: $KEY" \
  "https://bestfolio.app/api/strategies/slug/$SLUG") || exit 1
VARIANT_ID=$(printf '%s' "$DETAIL" | jq -er --arg slug "$VARIANT_SLUG" '
  [.variants[] | select(.slug == $slug and .active == true)]
  | if length == 1 then .[0].id else error("Choose one active variant slug") end
') || exit 1

Examples

JSON, inspect the live recent history

# After resolving VARIANT_ID above:
curl --fail --silent --show-error -H "X-API-Key: $KEY" \
  "https://bestfolio.app/api/variants/$VARIANT_ID/signal/export?format=json&limit=6"

CSV, for a spreadsheet

# After resolving VARIANT_ID above:
curl --fail --silent --show-error -H "X-API-Key: $KEY" \
  "https://bestfolio.app/api/variants/$VARIANT_ID/signal/export?format=csv&limit=24"

Python, resolve the variant before reading signals

import os
import requests

BASE = "https://bestfolio.app"
headers = {"X-API-Key": os.environ["BESTFOLIO_API_KEY"]}
slug = os.environ["BESTFOLIO_STRATEGY_SLUG"]
variant_slug = os.environ["BESTFOLIO_VARIANT_SLUG"]

def read_json(path, **params):
    response = requests.get(BASE + path, headers=headers, params=params, timeout=30)
    response.raise_for_status()
    return response.json()

detail = read_json(f"/api/strategies/slug/{slug}")
matches = [v for v in detail["variants"]
           if v["slug"] == variant_slug and v["active"] is True]
if len(matches) == 1:
    variant = matches[0]
else:
    raise SystemExit("Choose exactly one active variant slug")

history = read_json(f"/api/variants/{variant['id']}/signal/export",
                    format="json", limit=6)
# Strategy detail supplies the stable signal ID; the export omits it.
signal = variant.get("latest_signal")
print(signal)  # Inspect the live response; apply every safety check below.

Automation safety

Automate your own workflow around the signal. Before any action, fail closed: if a required check is missing, stale or uncertain, do nothing. The API is read-only and does not place orders.

  • Verify status is current, as_of_date is today in UTC, and the methodology version matches a version you have reviewed.
  • Preview signals are provisional. Check period.data_cutoff against the expected cutoff for that strategy's frequency and calendar; if it is older than expected, do nothing.
  • Deduplicate by signal id from strategy detail's latest_signal, using durable storage and an atomic claim across retries and workers. The JSON export does not include signal IDs.
  • Reconcile actual positions and open orders before ordering; an allocation is a target, not a record of what you hold.
  • Cap each order size in your chosen units, reject missing or non-finite sizes, and require human approval of the exact proposed orders.
  • Recheck after delays or changed inputs. An unknown order outcome needs reconciliation before any retry.

The public status page reports the deployed engine's methodology version. It is not a version stamp on each historical signal, nor proof that all data is fresh. Review its corrections alongside the signal's own dates.

Python, fail closed on signal and data checks

from datetime import date, datetime, timedelta, timezone

def checked_signal(variant, status_payload, approved_version, expected_cutoff):
    # expected_cutoff comes from your reviewed strategy calendar, not the
    # newest row returned by the API. A monthly cutoff need not be yesterday.
    try:
        now = datetime.now(timezone.utc)
        today = now.date()
        verified = datetime.fromisoformat(status_payload["generated_at"].replace("Z", "+00:00"))
        if not timedelta(0) <= now - verified <= timedelta(minutes=10):
            return None
        if not approved_version or status_payload["methodology_version"] != approved_version:
            return None
        signal = variant["latest_signal"]
        if variant["active"] is not True or signal["status"] != "current":
            return None
        if date.fromisoformat(signal["as_of_date"]) != today:
            return None
        period = signal["period"]
        cutoff = date.fromisoformat(period["data_cutoff"])
        if cutoff != expected_cutoff or cutoff > today:
            return None  # Stale, unexpected or future data: do nothing.
        if period["provisional"] is not False:
            return None
        if not date.fromisoformat(period["holding_start"]) <= today <= date.fromisoformat(period["holding_end"]):
            return None
        if type(signal["id"]) is not int or signal["id"] <= 0:
            return None
        return signal
    except (KeyError, TypeError, ValueError, AttributeError):
        return None  # Missing or malformed fields: do nothing.

# Pin this value only after reviewing the methodology and corrections.
# Do not automatically approve the version the status endpoint returns.
status_payload = read_json("/api/system/status-public")
safe = checked_signal(variant, status_payload,
                      os.environ["REVIEWED_METHODOLOGY_VERSION"],
                      date.fromisoformat(os.environ["EXPECTED_DATA_CUTOFF"]))
if safe is None:
    raise SystemExit("No workflow action: safety checks did not pass")

Your workflow, reconcile and require human approval

# Pseudocode: these adapters belong to your own workflow, not BestFolio.
# Any adapter failure stops the workflow. This example submits no orders.
def prepare_reviewed_workflow(signal, proposed_orders, max_order_size):
    signal_id = signal["id"]
    if already_processed(signal_id):
        return None
    if reconcile_actual_positions_and_open_orders() is not True:
        return None
    if not proposed_orders or not valid_positive_cap(max_order_size):
        return None
    if not all(finite_order_size_within_cap(order, max_order_size)
               for order in proposed_orders):
        return None
    if human_approved(signal_id, proposed_orders) is not True:
        return None
    # Recheck freshness and positions if approval took time or inputs changed.
    if inputs_still_match_review(signal, proposed_orders) is not True:
        return None
    # Atomic durable claim, shared by workers, scoped to this workflow/account.
    # Keep uncertain outcomes claimed until a human reconciles them.
    if claim_signal_once(signal_id) is not True:
        return None
    return proposed_orders  # Reviewed handoff only; no broker call here.

More endpoints

The same key also reaches your portfolio analytics and richer strategy data. Portfolio endpoints return only portfolios you own. All are read-only GET requests using the same authentication.

A machine-readable OpenAPI spec for these endpoints lives at /api/v1/openapi.json, for generating a client or importing into your tooling.

Units in the metrics these endpoints return: returns, CAGR, drawdowns and volatility are fractions (0.12 means 12%), unless the field name ends in _pct. The Ulcer Index (ulcer_index), SWR and PWR are percentage points (6.79 means 6.79%), the figure the pages show with a percent sign.

Trade list fields: each row's change is INCREASE, DECREASE or UNCHANGED, the direction of the model's target change. action repeats it as BUY, SELL or HOLD and stays for now; it will be deprecated with advance notice, so read change in new code. dollar_amount is that change applied to the portfolio_size you pass, and the response's disclaimer applies to those amounts. Signal webhooks carry the same pair for each ticker: change (Increase or Decrease) next to action (Buy or Sell), plus a disclaimer field. Tickers follow your saved ETF preferences (VOO in place of SPY, USFR in place of BIL), as your signal emails do, and execution_substitutions lists each swap; pass etf_preferences=false for the strategies' default tickers.

methodpathreturns
GET/api/strategies/summaryAll strategies with the current latest_signal and nullable future preview_signal, in your tier's view. Add include_published_result=true to name the stored run behind each primary_metrics, or published_result=slim for only its engine version, data version and publication date.
GET/api/strategies/slug/{slug}Full detail for one strategy: variants, metrics, signals.
GET/api/strategies/slug/{slug}/variantsVariant IDs and details for one strategy. Resolve IDs here before requesting signals or NAV.
GET/api/backtest/{variant_id}/navStored daily NAV for a strategy variant. Params: currency.
GET/api/backtest/{variant_id}/contributionReturn contribution by asset for a strategy variant, as on the strategy page: percentage points and share of the total per holding, additive over the latest stored backtest. Params: start, end (optional window).
GET/api/backtest/{variant_id}/windowLatest stored backtest reframed to a custom window: metrics plus daily NAV. Params: start, end, currency.
GET/api/performance/entitiesResolve variant and benchmark IDs. A strategies key receives no portfolio entities.
GET/api/performance/navComparison NAV series for entities like variant:{resolved_id},benchmark:{resolved_id}. Params: entity_ids, resample, start_date, end_date, currency. Benchmark series start on 6 January 1987 (the Golden Butterfly on 26 February 1993), or on the benchmark's own first price when that is later.
GET/api/performance/comparisonStrategy and benchmark metrics for a date window. Also available at /api/performance/metrics. Params: entity_ids, start_date, end_date, currency. Strategies keys accept only variant and benchmark entities.
GET/api/performance/correlationCorrelation matrix and rolling correlations for two or more entities. Params: entity_ids, start_date, end_date, window (trading days). Strategies keys accept only variant and benchmark entities.
GET/api/performance/correlation/screenThe most and least correlated strategies and benchmarks for one entity: monthly returns, each pair over the months it shares (at least 36). Params: entity (e.g. variant:17), n (1 to 25), start_date (a common start for every pair), include_same_strategy. Each row also has the Sharpe hurdle over the same months (sharpe, query_sharpe, hurdle = correlation x query_sharpe, clears_hurdle, lift = Sharpe change from a 10% slice), and best_additions lists the rows that clear it with the largest lift. Strategies keys accept only variant and benchmark entities.
GET/api/portfolios/{id}/rollupYour portfolio's net exposure: asset-class, regional, turnover.
GET/api/portfolios/{id}/rollup/executionExecution-ready allocation after small-position merging.
GET/api/portfolios/{id}/processed-allocationFinal ticker-to-weight map after UCITS and rollup.
GET/api/portfolios/{id}/driftTarget vs actual drift per sleeve for your portfolio.
GET/api/portfolios/{id}/trade-listModel target changes per ticker (change, action, delta_pct, dollar_amount) with amounts sized to the portfolio_size you pass, plus a disclaimer. See trade list fields below. Params: portfolio_size, period (current or next), etf_preferences (default true).
GET/api/portfolios/{id}/backtestBlended NAV, returns, metrics, drawdown. Params: start, end, currency. With an API key, each benchmark line starts where /api/performance/nav starts it (series_start says where); its metrics still cover your portfolio's whole window.
GET/api/portfolios/{id}/wf-navCached walk-forward NAV series and metrics.
GET/api/performance/leaderboardPublic ranking of every released variant: trailing returns and risk metrics, each entry with its published_result. No key needed. Params: period, currency, include_portfolios, start_year.

Published results

Every strategy figure these endpoints return comes from a stored backtest run. A published_result object next to the figures names that run, read from the same run as the figures, so you can tell which engine, data and assumptions produced a number and notice when a later run replaces it:

  • strategy_slug, variant_id, variant_slug and params: the strategy, the variant and the exact parameter set of the run.
  • engine_version (code_sha, methodology_version) and data_version, the hash of the price data the run read.
  • observation_period: the run's first and last dates, which a window or a leaderboard period can narrow.
  • currency: USD, the currency the engine computes in. A euro view converts the run's figures and says so in its own currency field.
  • cost_assumptions, tax_assumptions (null unless a tax model was applied) and execution_convention: how trades were costed and filled.
  • proxy_coverage: per ticker, from when its prices are real and until when its history rests on proxies.
  • publication (published_at, publish_source): when and by which process the run was published.

Where it appears: each variant of /api/strategies/slug/{slug} and of its /api/strategies/slug/{slug}/stress-windows, /api/backtest/{variant_id} with its /nav, /rebalance-sensitivity and /rebalance-phase, /api/backtest/{variant_id}/window (for the whole run, not only the window), each entry of /api/performance/leaderboard, each row of /api/strategies/annual-returns and /api/strategies/stress-windows, each variant series of /api/performance/nav, each card of /api/strategies/catalog and /api/strategies/free-cards, each row of the public /api/strategies/seo-data, /api/strategies/{slug}/seo and /api/strategies/{slug}/share-data feeds, and primary_published_result on /api/strategies/summary when you pass include_published_result=true (with published_result=slim instead, only primary_published_result_slim: the methodology version, the first 8 characters of the data version and the publication date). The MCP tools get_strategy_detail and list_strategies with full=true return it too. CSV downloads on the leaderboard, the annual returns and stress tables and the risk and return chart end each row with the same fields as columns.

Blog posts that quote a strategy's figures list, in figure_sources on /api/blog/posts/{slug}, the published_result of the run they read and the figures as quoted. It is the record of that run when the post was written, so a later run can show different numbers; the strategy endpoints above always name the latest one.

A value that was not recorded when the run was stored is null, never filled in. A model portfolio blends several runs, so its leaderboard entry carries published_result: null, as do benchmark series. Signals and trade lists are not backtest results, and your portfolio analytics blend several runs without recording which, so none of them carries published_result.

Connect your AI assistant (MCP)

BestFolio runs a remote Model Context Protocol server, so you can ask Claude or ChatGPT about your strategies, signals, and portfolios directly. Add it as a custom connector and sign in with your BestFolio account.

MCP server URL

https://bestfolio.app/mcp

Add the URL above as a custom connector and sign in with your BestFolio account when prompted. There is no key to copy: the connector is authorised against your account directly, and you can disconnect it at any time. The server is read-only and Pro-gated, exactly like the HTTP API.

In ChatGPT: turn on Developer mode (Settings, Apps, Advanced settings), create a new connector, paste the URL above, choose OAuth as the authentication, and sign in with your BestFolio account when ChatGPT opens the sign-in window. Developer mode needs a paid ChatGPT plan, and on Business and Enterprise workspaces an admin has to allow it first. Claude has no such gate.

Claude Code, Codex and other terminal clients can skip the sign-in and present an API key instead:

Claude Code

claude mcp add --transport http bestfolio \
  https://bestfolio.app/mcp \
  --header "Authorization: Bearer bf_live_..."

Codex

export BESTFOLIO_API_KEY=bf_live_...
codex mcp add bestfolio --url https://bestfolio.app/mcp \
  --bearer-token-env-var BESTFOLIO_API_KEY

If you prefer to sign in with your account in Codex, add the server without the last line, then run codex mcp login bestfolio. Codex does not start the sign-in on its own: until you run that command, every call is refused.

Available tools:

list_strategiesget_strategy_signalget_strategy_detailget_strategy_contributionscreen_correlationsget_portfolio_rollupget_portfolio_executionget_portfolio_allocationget_portfolio_driftget_portfolio_trade_listget_portfolio_backtestget_portfolio_walkforward

Errors

401Missing or invalid key.
403The subscription is not Pro, or the key's scope does not allow this request.
404Variant does not exist, or is not visible to you.

Fair use

Signals update at most once per trading day, after the daily scan. Polling once a day is plenty. Please do not hammer the endpoint.

Data sources and terms

Third-party data (including prices, index and economic data) is provided for your own use within the Service. You may not redistribute it, or use it to build a database or a competing service. Some providers require their credit to be kept on any permitted use; for OECD data the credit must be passed on to anyone you share it with.

The signal export and the price ledger carry these terms and the price credit with the data: as the first line of a CSV, and under data_notice in JSON.

Prices of US stocks, ETFs and mutual funds: Data sourced by Tiingo.

This product uses the FRED® API but is not endorsed or certified by the Federal Reserve Bank of St. Louis.

Series retrieved from FRED, Federal Reserve Bank of St. Louis:

  • Source: U.S. Bureau of Labor Statistics via FRED: the unemployment rate (UNRATE) and US consumer prices (CPIAUCSL, CPIAUCNS).
  • Source: Board of Governors of the Federal Reserve System via FRED: the federal funds rate (DFF, FEDFUNDS), Treasury yields (DGS1, DGS2, DGS10, GS1, GS10) and the dollar-euro exchange rate (DEXUSEU).
  • Source: Federal Reserve Bank of St. Louis via FRED: breakeven inflation (T5YIE, T5YIFR).
  • Source: Moody's via FRED: the Baa corporate bond yield (DBAA, BAA), used as a signal input. © 2017, Moody's Corporation, Moody's Investors Service, Inc., Moody's Analytics, Inc. and/or their licensors and affiliates (collectively, "Moody's"). All rights reserved.
  • Source: Chicago Board Options Exchange via FRED: the VIX (VIXCLS), used as a signal input. Copyright, 2016, Chicago Board Options Exchange, Inc. Reprinted with permission.
  • Source: OECD via FRED: the euro exchange rate before 1999 (CCUSMA02EZM618N), national consumer price indices (DEUCPIALLMINMEI, FRACPIALLMINMEI, ITACPIALLMINMEI, ESPCPIALLMINMEI) and, when the OECD's own service is down, its composite leading indicators.
  • Source: Eurostat via FRED: euro-area consumer prices (CP0000EZ19M086NEST). Copyright, European Union.

Other sources:

  • OECD (2026), Composite leading indicators, OECD Data Explorer, https://data-explorer.oecd.org/ (accessed on the date of each data refresh).
  • OECD (2026), Main Economic Indicators, OECD Data Explorer, https://data-explorer.oecd.org/ (accessed on the date of each data refresh).
  • Fama-French factors and portfolios: Ken French Data Library, Tuck School of Business at Dartmouth.
  • Gold price, June 1968 to December 1998: Source: Deutsche Bundesbank (Frankfurt Stock Exchange gold fixing, converted to US dollars per ounce at the official US dollar fixing).
  • Gold price, other months: World Bank Commodity Price Data (The Pink Sheet), licensed under CC BY 4.0.

S&P 500 is a registered trademark of S&P Dow Jones Indices LLC or its affiliates, and Russell 2000 is a trademark of FTSE Russell. Other index names are trademarks of their owners. BestFolio is not affiliated with or endorsed by them.

The same terms are in section 3.5 of the Terms of Service.

Automate your own workflow around the signal

The Signals API is included with BestFolio Pro.

BestFolio is an information and research tool. Nothing on this platform constitutes investment advice. Past performance does not guarantee future results.