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.

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/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%).
  • 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.

methodpathreturns
GET/api/strategies/summaryAll 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}/windowLatest stored backtest reframed to a custom window: metrics plus daily NAV. Params: start, end, currency.
GET/api/performance/navComparison NAV series for entities like variant:{resolved_id},benchmark:{resolved_id}. Params: entity_ids, resample, start_date, end_date, currency.
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-listBuy/sell/hold list. Params: portfolio_size, period (current or next).
GET/api/portfolios/{id}/backtestBlended NAV, returns, metrics, drawdown. Params: start, end, currency.
GET/api/portfolios/{id}/wf-navCached 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/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.

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_walkforward

Errors

401Missing or invalid key.
403Key valid but the subscription is not Pro.
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.

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.