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.
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.
Endpoint
GET /api/variants/{variant_id}/signal/exportQuery parameters:
format:jsonorcsv. CSV columns aresignal_date,regime, then one column per ETF holding the target weight as a decimal (0.25 = 25%).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 1Examples
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.
| method | path | returns |
|---|---|---|
| GET | /api/strategies/summary | All strategies with the current latest_signal and nullable future preview_signal, in your tier's view. |
| GET | /api/strategies/slug/{slug} | Full detail for one strategy: variants, metrics, signals. |
| GET | /api/backtest/{variant_id}/window | Latest stored backtest reframed to a custom window: metrics plus daily NAV. Params: start, end, currency. |
| GET | /api/performance/nav | Comparison NAV series for entities like variant:{resolved_id},benchmark:{resolved_id}. Params: entity_ids, resample, start_date, end_date, currency. |
| GET | /api/portfolios/{id}/rollup | Your portfolio's net exposure: asset-class, regional, turnover. |
| GET | /api/portfolios/{id}/rollup/execution | Execution-ready allocation after small-position merging. |
| GET | /api/portfolios/{id}/processed-allocation | Final ticker-to-weight map after UCITS and rollup. |
| GET | /api/portfolios/{id}/drift | Target vs actual drift per sleeve for your portfolio. |
| GET | /api/portfolios/{id}/trade-list | Buy/sell/hold list. Params: portfolio_size, period (current or next). |
| GET | /api/portfolios/{id}/backtest | Blended NAV, returns, metrics, drawdown. Params: start, end, currency. |
| GET | /api/portfolios/{id}/wf-nav | Cached walk-forward NAV series and metrics. |
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/mcpAdd 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.
A note on ChatGPT: custom connectors sit behind Developer mode (Settings, Apps, Advanced settings), which needs a paid ChatGPT plan. On Business and Enterprise workspaces an admin has to allow it first. Claude has no such gate.
Claude Code 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_..."Available tools:
list_strategiesget_strategy_signalget_strategy_detailget_portfolio_rollupget_portfolio_executionget_portfolio_allocationget_portfolio_driftget_portfolio_trade_listget_portfolio_backtestget_portfolio_walkforwardErrors
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.
Automate your own workflow around the signal
The Signals API is included with BestFolio Pro.