Skip to the page
EXAMPLES/STRESS TEST A PORTFOLIO

Stress test a portfolio

For risk teams. Your firm runs an 80/20 portfolio, 80% in shares and 20% in cash, and the board has set a limit: it should never fall more than 15% from its peak. The board wants to know which kinds of market trouble would break that limit, and how often. If a likely kind of trouble breaks it, the firm needs to know now, while it can still hedge or change the mix. This script runs the portfolio through each kind of trouble the library ships, on ten markets, each once with the trouble and once without.

Hedge against a recession and a liquidity crisis first. Either one broke the 15% limit in most markets, the recession in all 10 of 10. Rate and geopolitical shocks hurt but mostly stayed under the limit, and an oil spike or a policy shift made no difference.

7%worst fall in a normal market
27%worst fall in a recession
24%worst fall in a liquidity crisis
10 of 10recession markets over the limit

The same markets with and without trouble

A fire drill tells you whether people get out of the building, but only if you run the drill in the same building you are worried about. Most risk teams drill on one old crisis, such as 2008, which happened to a different market at a different time. This script runs the portfolio on ten markets, each once with the stress and once without.

The question

Does our portfolio stay inside the 15% loss limit the board signed off, and which kinds of trouble break it?

The fair test

Each kind of trouble meets the same ten markets as a run with no trouble. The markets are identical until the trouble starts, so the extra loss belongs to the trouble.

The result

A recession or a liquidity crisis breaks the limit in most markets. Rate and geopolitical shocks hurt, but mostly stay under it. An oil spike and a policy shift made no difference the test could find.

tradefloor against a backtest

A backtest replays one history, so a stress test on it is one crisis that happened once, to a market that was doing other things at the same time. tradefloor builds the market, so it can play each stress into the same markets as a run without it, ten times over, and keep the past identical until the stress begins.

Needed for this testBacktest on price historytradefloor
Stresses that have not happened yetNo. Only the crises in the data.Yes. Every shipped scenario, or one you write.
The loss caused by the stress aloneNo. The crisis and the rest of the market are mixed together.Yes. Each stress meets the same markets as a run without it.
More than one example of each stressOne 2008, one 2020Ten markets per stress, with a paired test
A count of how often the limit breaksOne yes or noOut of ten, for every stress

The script

Install with pip install tradefloor. The script needs no API key and runs in under a minute.

from concurrent.futures import ThreadPoolExecutor
from math import comb
from statistics import median

import tradefloor as tf

EQUITY = 0.80           # the rest stays in cash, which earns the policy rate
DRAWDOWN_LIMIT = 15.0   # the largest fall from peak the mandate allows, in %
SEEDS = range(1, 11)    # ten markets; each scenario meets the same ten
DAYS = 90               # the shipped scenarios start on day 50 or earlier
universe = tf.Universe.random(8, seed=101)
scenarios = ["none"] + list(tf.Scenario.available())


class EightyTwenty:
    """80% in equities, equally weighted, rebalanced every 21 trading days."""

    def act(self, obs):
        if not (obs.is_first_step_of_day and obs.day % 21 == 0):
            return None
        per_name = EQUITY * obs.portfolio.net_worth() / len(obs.tickers)
        return {t: int(per_name / obs.price(t)) - obs.position(t)
                for t in obs.tickers}


def run(job):
    name, seed = job
    scenario = None if name == "none" else tf.Scenario.load(name)
    return tf.evaluate({"80/20": EightyTwenty()}, seed=seed, universe=universe,
                       days=DAYS, scenario=scenario,
                       cash_interest=True)["80/20"]


def sign_test(k, n):
    """Two-sided p-value for k of n paired markets moving the same way."""
    tail = sum(comb(n, i) for i in range(max(k, n - k), n + 1))
    return min(1.0, 2 * tail / 2 ** n)


# every market is independent, so spread them over threads as rank(workers=) does
jobs = [(name, seed) for name in scenarios for seed in SEEDS]
with ThreadPoolExecutor() as pool:
    cards = dict(zip(jobs, pool.map(run, jobs)))

# chart data: median net worth per day as % of start, and the table below
paths = {name: [round(median(cards[name, s].equity_curve[d] for s in SEEDS)
                      / 10_000, 2) for d in range(DAYS)] for name in scenarios}
results = {}
for name in scenarios:
    drawdowns = [cards[name, s].max_drawdown_pct for s in SEEDS]
    extra = [cards[name, s].max_drawdown_pct - cards["none", s].max_drawdown_pct
             for s in SEEDS]
    results[name] = {
        "return_pct": round(median(cards[name, s].return_pct for s in SEEDS), 1),
        "max_drawdown_pct": round(median(drawdowns), 1),
        "extra_drawdown_pct": round(median(extra), 1),
        "markets_worse": sum(x > 0 for x in extra),
        "markets_over_limit": sum(d > DRAWDOWN_LIMIT for d in drawdowns),
    }

n = len(SEEDS)
print(f"80/20 portfolio, {DAYS} days, median of {n} markets, "
      f"drawdown limit {DRAWDOWN_LIMIT:.0f}%")
print(f"{'scenario':<22}{'return':>8}{'max dd':>8}{'dd vs none':>12}"
      f"{'worse':>7}{'over limit':>12}")
for name, r in results.items():
    print(f"{name:<22}{r['return_pct']:>+7.1f}%{r['max_drawdown_pct']:>7.1f}%"
          f"{r['extra_drawdown_pct']:>+11.1f}%{r['markets_worse']:>4}/{n}"
          f"{r['markets_over_limit']:>9}/{n}")

worst = max(results, key=lambda k: results[k]["max_drawdown_pct"])
failing = [k for k, r in results.items() if r["markets_over_limit"] > n / 2]
unclear = [k for k, r in results.items()
           if k != "none" and sign_test(r["markets_worse"], n) >= 0.05]
print(f"Verdict: worst is {worst} at a {results[worst]['max_drawdown_pct']:.1f}%"
      f" median drawdown; the {DRAWDOWN_LIMIT:.0f}% limit breaks in most"
      f" markets under {', '.join(failing) or 'no scenario'}.")
print("No clear change from no scenario (sign test p >= 0.05): "
      + (", ".join(unclear) or "none") + ".")
80/20 portfolio, 90 days, median of 10 markets, drawdown limit 15%
scenario                return  max dd  dd vs none  worse  over limit
none                     +3.1%    7.0%       +0.0%   0/10        1/10
curve_shock              -3.4%   10.9%       +4.6%  10/10        2/10
geopolitical_conflict    -1.6%   11.6%       +6.1%  10/10        3/10
liquidity_crisis        -12.3%   23.9%      +18.0%  10/10        7/10
oil_price_spike          +1.4%    7.0%       +0.2%   5/10        1/10
policy_regime_shift      +1.5%    7.5%       +0.3%   7/10        1/10
rate_shock               -3.4%   10.9%       +4.6%  10/10        2/10
recession               -16.4%   26.6%      +21.4%  10/10       10/10
Verdict: worst is recession at a 26.6% median drawdown; the 15% limit breaks in most markets under liquidity_crisis, recession.
No clear change from no scenario (sign test p >= 0.05): oil_price_spike, policy_regime_shift.

return and max dd are medians over the ten markets. dd vs none is the median, market by market, of the drawdown with the scenario less the drawdown without it. worse counts the markets where the scenario made the drawdown deeper, and over limit counts those that passed 15%.

The rate shock and the curve shock print the same row. Both raise the policy rate and corporate yields by 200 basis points, and the curve shock's extra moves reach only the rate indices, which this portfolio does not hold.

The path through a stress

Every line is the same until day 50, because each scenario runs on the same ten markets as the run without one.

Adapting it

Change EQUITY and DRAWDOWN_LIMIT for your own mandate, or replace EightyTwenty.act with your own rebalancing rule. A scenario of your own goes in the scenarios list; Run a scenario shows how to write one.

Limits

  • Eight simulated companies on the default preset. They are made up and are not an index.
  • No bonds: the 20% is cash. With rate indices in the roster the rate and curve shocks would read differently.
  • 90 days. Most scenarios start on day 50, so the run sees their first 40 days. The recession scenario keeps cutting earnings after that, so its full depth is not shown.
  • A scenario is a named set of stated assumptions, not a forecast. The run says what those assumptions do to this portfolio.
  • Ten markets per scenario, so the medians are coarse, and the sign test needs nine or ten of ten to call a difference.
  • One million in capital, so the portfolio's own trades barely move prices. A much larger fund would also pay more to rebalance in a thin book.

Reference

tf.evaluate and the Scorecard fields are in Agents and evaluation. The shipped scenarios and their assumptions are in Scenarios.