Artur Sepp Blog on Quantitative Investment Strategies

  • Blog
  • Research
  • About
  • All-weather or 60/40? A portfolio backtest with qis

    Posted at 8:58 pm by artursepp, on November 11, 2026

    Artur Sepp · 11 November 2026

    My paper qis: reproducible performance analytics and portfolio backtesting in Python was published in the Journal of Open Source Software on 18 October 2026. qis is the Python package behind the analytics of my research papers, and it has been downloaded 244K times from PyPI as of 11 November 2026. Download history

    Contents:

    1. Why qis: the problem I built it for, and what it covers today.
    2. An example: is an all-weather portfolio better than the classic 60/40, and when?
    3. qis in the era of Gen AI: what a package is for when an AI writes the code.

    Why qis

    I started to develop qis and made it open source around 2022, for the reason that many times I faced a simple research problem. I was using public and private packages for exploring and developing quant strategies that generate position weights for me. What I found missing was a simple portfolio backtester that will reconstruct the P&L from the weights and prices I provide. That backtester should work uniformly whether the underlying strategies cover a universe of several thousand stocks with long-short self-financing weights, a universe of a dozen self-financing futures positions, DeFi staking strategies with cash flows, or simple long-only ETFs.

    One function for generating the net asset value or the equity curve of the portfolio serves the four cases because it assumes little. The weights do not have to sum to one, and the cash balance is explicit. Between rebalancings the backtester holds units, not weights, so the realised weights drift with prices and the reported turnover is the traded one. Its accounting is 460 lines of code.

    For all these cases I wanted to have a simple mechanism:

    • My algo produces position weights for the provided universe along with estimated trading costs at rebalancing times
    • The universe has valid prices accounting for total returns
    • Leverage / balances are funded / accrued at the specified rate

    Each requirement is an argument of qis.backtest_model_portfolio:

    RequirementArgument
    Position weightsweights: a dict for a static allocation, a dated DataFrame for a strategy; weight_implementation_lag delays the trade of a dated weight
    Trading costsrebalancing_costs: one number, a series by instrument, or a DataFrame of dates by instruments
    Total-return pricesprices; instruments_carry for a carry that is not in the price, such as a staking yield or borrow costs
    Fundingfunding_rate on positive and negative cash, management_fee on the net asset value

    The backtester then provides the net asset value of the trading account along with the different performance measures, turnover and risk analysis, comparison to benchmarks, regime-conditional performance etc.

    The output is a PortfolioData object with the net asset value, the units, the realised weights, the P&L of each instrument and the realised costs. The tables and charts of the example below are read from it.

    Along with that I wanted to have different visualisation methods. For example, a scatter plot of strategy returns vs benchmark returns. It may be a trivial task to generate the code by prompting an AI these days, but in the pre-AI days you had to know the matplotlib settings at length to make a proper scatter plot and to display the estimated regression coefficient on it. What I did is to create API-like interfaces for the core plots that I need for reporting of financial data: time series, stack plots, scatter plots, box plots, bar charts with uniform styling and labelling. These APIs are linked to factsheet generation.

    The functions are qis.plot_time_series, qis.plot_stack, qis.plot_scatter, qis.df_boxplot_by_classification_var, qis.plot_bars and qis.plot_heatmap. Each draws on a matplotlib axis passed as ax=. The scatter plot of my example is the right panel of Figure 3 below: its legend prints the fitted quadratic and its R2.

    With time I also developed backtest reporting factsheets focused on core analyses such as:

    • strategy factsheet: when I think of presenting the strategy as a stand-alone: its risk, turnover, performance heatmap, diagnostics of the Sharpe ratio and alpha, etc.
    • strategy-benchmark factsheet: when I analyse a new strategy feature vs the base strategy: what this feature provides marginally, Brinson decomposition, etc.
    • multi-strategy factsheet: when I analyse a block of strategies or the sensitivity to a specific parameter
    • multi-asset factsheet: a simple report for a multi-asset universe where strategy weights are not analysed and only a cross-sectional measure of performance is requested.

    Each report answers one question:

    FactsheetThe question it answersWhat it shows
    StrategyHow did I earn this return?Exposures, turnover and trading costs next to the net asset value. A page of inference adds intervals for the Sharpe ratio and for the alpha to the benchmark
    Strategy-benchmarkWhat did my change contribute?The two pages of the example below: the outcomes of both portfolios, the paired Sharpe difference and the marginal alpha
    Multi-strategyDoes the conclusion survive a change of parameters?Several variants against one base portfolio, with p-values adjusted for multiple comparisons
    Multi-assetWhat can I learn before choosing weights?From a price panel alone: performance, drawdowns, correlations and the behaviour in the regimes of a benchmark

    Tracking error is reported before and after the fact. Before trading, the active weights and a dated covariance matrix give an ex-ante tracking error, through qis.RiskModel. After trading, the realised return differences show how far the strategy moved from its benchmark, through qis.compute_ewma_realised_tracking_error.

    The four reports share one entry point. qis.factsheet(...) selects the report from the type of its input and sets the rolling windows and the annualisation from the reporting frequency, and each panel prints the frequency it was computed at. The reports also cover risk contributions, value at risk, stress tests and signal diagnostics. I list them with their calls in the section “What else the reports cover” below.

    With time I also added some features to qis that I constantly re-use in signal and portfolio construction and for which I wanted to have a common interface: exponentially weighted moving average (EWMA) filters, risk model, bootstrap, and FX adjustments among the key features.

    The entry points are qis.compute_ewm_vol and qis.estimate_rolling_ewma_covar for the EWMA estimators, qis.RiskModel for exposures and tracking error from dated covariance matrices, qis.bootstrap_price_data for stationary and block bootstraps, and qis.FxRatesData for returns and hedges in a reference currency. The same group holds the unsmoothing of appraisal-based returns and the regime classifier that shades the charts of this post.

    This makes qis a building tool for designing and reporting of quant strategies by delegating the reporting and tooling to a public method while keeping the focus on developing proprietary features for signal and portfolio construction that ultimately matter for the performance.

    The boundary is deliberate: qis ships no optimiser and no signal library. The weights come from the researcher’s own code or from the other packages of my stack, such as optimalportfolios.

    Example of using qis for all-weather portfolio analysis

    I do not summarise the paper further – read the paper further for my motivation, comparison to peer packages and other details. I use the package the way I use it every day: to answer a question about portfolios. For this post I choose an interesting one: is an all-weather portfolio better than the classic 60/40, and if so, when? I will not dwell on the details of how to construct an all-weather portfolio – let’s pick a proxy using a static weights portfolio. Mine is a simplification with equal capital in each ETF: the all-weather portfolio holds 25% each in US equities (SPY), long-term Treasuries (TLT), inflation-linked Treasuries (TIP) and gold (GLD). It follows the idea of Bridgewater’s All Weather approach, to hold assets for rising and falling growth and inflation (The All Weather Story), without its risk balancing and its leverage. The 60/40 portfolio holds 60% in SPY and 40% in 7-10 year Treasuries (IEF). Both are rebalanced quarterly and pay 10 basis points on each trade.

    This is a typical problem an investor or an advisor faces. Good luck solving it with AI and getting all the details right.

    The details are the launch dates of the ETFs, the drift of the weights between rebalancings, the trading costs, the return frequency behind a volatility and the definition of a Sharpe ratio.

    Here, every panel below comes from one line of code, printed above it. Changing the question costs one line. I use 10-point text for the standalone charts.

    Two portfolios in fifteen lines

    import matplotlib.pyplot as plt
    import yfinance as yf
    import qis
    
    tickers = ['SPY', 'TLT', 'TIP', 'GLD', 'IEF']
    prices = yf.download(tickers, start='2002-07-31', end='2026-10-01', ignore_tz=True, auto_adjust=True)['Close'][tickers]
    prices = prices.asfreq('B', method='ffill')  # an ETF stays empty before its launch
    
    def backtest(weights, ticker, funding_rate=None):
        px = prices[list(weights)]
        schedule = qis.generate_static_weights_schedule(prices=px, weights=weights, rebalancing_freq='QE')
        return qis.backtest_model_portfolio(prices=px, weights=schedule, rebalancing_freq=None,
                                            rebalancing_costs=0.0010,  # fractional units: 10 bp
                                            funding_rate=funding_rate, ticker=ticker)
    
    all_weather = backtest({'SPY': 0.25, 'TLT': 0.25, 'TIP': 0.25, 'GLD': 0.25}, 'All weather')
    balanced = backtest({'SPY': 0.60, 'IEF': 0.40}, '60/40')
    portfolios = qis.MultiPortfolioData(portfolio_datas=[all_weather, balanced], benchmark_prices=prices['SPY'])
    perf_params = qis.PerfParams(freq='ME')  # statistics on monthly returns
    

    TIP started trading in December 2003 and GLD in November 2004. I do not cut the history to the date when all four ETFs exist. generate_static_weights_schedule spreads the target over the ETFs that have a price at each rebalancing date: half each in SPY and TLT from July 2002, a third in each of three ETFs from December 2003, and a quarter in each of four from December 2004. Both portfolios start on 31 July 2002.

    The last two lines matter most. MultiPortfolioData holds the two backtests with their prices, weights, trades and costs, and each question below is one call on this object.

    The backtester holds units between the rebalancing dates, so the trades are the ones that return the drifted weights to their targets. Over the 21 complete years from 2005 through 2025, the annual sum of buys and sells averaged 16.20% of NAV for all weather and 14.30% for 60/40. At 10 basis points per dollar traded, that cost 1.62 and 1.43 basis points of NAV a year. I sum each trade relative to that day’s NAV and then average the calendar-year totals. Annual turnover and costs

    fig, ax = plt.subplots()
    qis.plot_stack(df=all_weather.get_weights(freq='ME'), use_bar_plot=True,
                   var_format='{:.0%}', fontsize=10, ax=ax)
    
    Realised month-end weights in SPY, TLT, TIP and GLD
    Opening allocation. Monthly holdings drift between quarterly trades. TIP enters in December 2003 and GLD in December 2004.

    Which one did better?

    portfolios.plot_nav(regime_benchmark='SPY', perf_params=perf_params, fontsize=10, ax=ax)
    portfolios.plot_drawdowns(regime_benchmark='SPY', fontsize=10, ax=ax)
    
    Net asset values and daily drawdowns of all weather and 60/40
    Figure 1. Net asset values and running drawdowns, 31 July 2002 to 30 September 2026. The pink and the dark green bands are the bear and the bull quarters of SPY.

    Over 24 years the 60/40 portfolio returns 8.5% p.a. and all weather 7.8%, with volatilities of 8.9% and 8.2%. The Sharpe ratio is 0.95 for both. The deepest drawdown is -32% for 60/40 and -21% for all weather.

    So the two portfolios earn the same return per unit of risk. Figure 1 already shows that they do not earn it in the same years: all weather is ahead in cumulative wealth at every year-end from 2005 through 2016, and 60/40 pulls away after 2020.

    The same Sharpe ratio does not make them the same portfolio. Regressed on SPY with monthly returns, the 60/40 portfolio has a beta of 0.58 and an R2 of 91%, and all weather a beta of 0.29 and an R2 of 27%. Both are columns of qis.compute_ra_perf_table_with_benchmark.

    In which years?

    portfolios.plot_periodic_returns(fontsize=10, ax=ax)
    
    Calendar-year return heatmap for both portfolios
    Figure 2. Calendar-year returns. 2002 starts on 31 July and 2026 ends on 30 September.

    All weather is ahead in 10 of the 23 complete years, and in two of them, 2018 and 2022, by less than one point. The large gaps go both ways. In 2008 all weather loses 1% and 60/40 loses 17%. In 2013 all weather loses 6% while 60/40 gains 16%. In 2025 it gains 22% against 14%.

    The years cluster. A window of dates is a qis.TimePeriod, and the same performance table can be computed on any window:

    qis.compute_ra_perf_table(prices=qis.TimePeriod('31Dec2012', '31Dec2021').locate(portfolios.get_navs()),
                              perf_params=perf_params)
    
    Return p.a.All weather60/40
    July 2002 to 201210.2%7.2%
    2013 to 20216.5%11.2%
    2022 to September 20265.0%6.3%

    An investor who looked at these two portfolios at the end of 2012 saw all weather three points a year ahead, with a deepest drawdown of 18% against 32%. The next nine years went the other way by almost five points a year.

    What kind of market favours it?

    portfolios.plot_regime_data(benchmark='SPY', regime_data_to_plot=qis.RegimeData.REGIME_AVG,
                                perf_params=perf_params, yvar_format='{:.1%}', fontsize=10, ax=ax)
    portfolios.plot_returns_scatter(benchmark='SPY', freq='QE', fontsize=10, ax=ax)
    
    Quarterly portfolio returns by SPY regime and against SPY
    Figure 3. Left: average quarterly return in the bear, normal and bull quarters of SPY, which are the worst 16%, the middle and the best 16% of its quarterly returns. The bars are stacked and the number on top is their sum. Right: quarterly returns against SPY with a quadratic fit.

    In the 16 worst quarters of SPY, equities lose 10.2% on average, 60/40 loses 4.8% and all weather is flat. In the 16 best quarters all weather gains 4.8% and 60/40 gains 8.1%. The fitted curve on the right is flat for all weather when SPY falls, and close to a straight line for 60/40.

    The same holds month by month. All weather is ahead in 70% of the months in which 60/40 falls, and in 34% of the months in which it rises. It is a portfolio for the quarters that an investor would rather forget, and it pays for this in the other quarters.

    The same classification splits the Sharpe ratio, with regime_data_to_plot=qis.RegimeData.REGIME_SHARPE in the call above. The 0.95 of the 60/40 portfolio is 0.60 from the bull quarters, 0.70 from the normal quarters and -0.36 from the bear quarters, up to rounding. For all weather it is 0.38, 0.57 and -0.01: it earns less in the good quarters and gives nothing back in the bad ones.

    Which ETF did the work?

    all_weather.plot_performance_attribution(time_period=qis.TimePeriod('31Dec2021', '31Dec2022'), fontsize=10, ax=ax)
    
    Wealth-linked gross contributions of each ETF in four calendar years
    Figure 4. Contribution of each ETF to the return of the all-weather portfolio in four years. The bars of a year add up to its return before trading costs. Each day’s contribution is scaled by the gross portfolio wealth at the preceding close, starting from one dollar at the opening of that calendar year. Data behind the bars

    Each of the four years has a different story.

    • 2008. SPY costs 10.5 points and TLT gives back 8.0. The portfolio ends the year at -1%.
    • 2013. SPY adds 7.1 points, and gold takes 7.5 away. The two bond ETFs lose another 5.6.
    • 2022. TLT loses 8.3 points, SPY 4.6 and TIP 3.0. Gold is flat. Three of the four ETFs fall together, and nothing offsets them. In this year all weather loses 16% and 60/40 loses 17%.
    • 2025. Gold adds 14.2 percentage points to the gross return. The portfolio finishes the year up 21.8% after trading costs.

    Over the full sample SPY and GLD contribute 247 and 190 of the 516 points of return before costs. TLT and TIP hold at least half of the capital at every rebalancing and contribute 79. The risk is not equal either: with qis.compute_portfolio_risk_contribution_ratios, gold has 25% of the capital and 39% of the volatility.

    How bad did it get?

    qis.plot_top_drawdowns_paths(all_weather.get_portfolio_nav(), max_num=5, fontsize=10, ax=ax)
    
    Five largest peak-to-recovery drawdown paths in each portfolio
    Figure 5. Paths of the five deepest drawdowns of each portfolio, from the peak to the recovery.

    The deepest drawdown of all weather is not 2008. It starts on 9 November 2021, reaches -20.9% on 20 October 2022 and ends on 5 July 2024, after 969 days. The 60/40 portfolio falls 21.3% in the same episode and recovers in 794 days. Its own deepest drawdown is the financial crisis: -31.6% and 1,085 days. March 2020 looks different: all weather loses 13.5% in 12 days and recovers within a month of the trough, 41 calendar days after its previous peak.

    What changed after 2021?

    span = 36
    pair = prices[['SPY', 'TLT']]
    paired_returns = qis.to_returns(pair, freq='ME', is_log_returns=True).dropna()
    # The first 36 paired returns warm up the estimates. Display starts with return 37.
    window = qis.TimePeriod(start=paired_returns.index[span], end=paired_returns.index[-1])
    
    fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharex=True, constrained_layout=True)
    qis.plot_returns_corr_matrix_time_series(
        prices=pair, freq='ME', span=span, time_period=window, fontsize=10, ax=axes[0],
        title='36-month EW correlation: SPY and TLT',
    )
    portfolios.plot_factor_betas(
        benchmark_prices=prices[['SPY']], freq_beta='ME', factor_beta_span=span,
        time_period=window, fontsize=10, axs=[axes[1]],
    )
    axes[0].set_xlim(window.start, window.end)
    
    Monthly SPY–TLT EW correlation and holdings-weighted equity betas
    Figure 6. Left: correlation of the monthly returns of SPY and TLT, exponentially weighted with a span of 36 months. Right: beta of each portfolio to SPY, computed from the betas of its holdings. The first portfolio betas appear on 31 August 2005. The correlation begins on the same date, after 36 paired monthly returns, and both panels show every month from then on. Each holding needs 36 paired monthly returns with SPY for warm-up and first contributes a beta on its 37th paired return. During the early warm-up, the panel uses the available instrument betas at their actual weights. All held instruments have coverage by December 2007.

    In 2022 three of the four ETFs fell together, and the attribution does not say why. The reason is the correlation of bonds with equities. Using monthly log returns, the ordinary sample correlation of SPY and TLT is -0.31 from 2005 to 2012, of -0.26 from 2013 to 2021, and of +0.58 from 2022 to September 2026.

    The portfolio changes with it. The beta of all weather to SPY is 0.28 at the end of 2021, reaches 0.53 in December 2023 and stands at 0.46 in September 2026.

    Bonds helped cushion equity losses in 2008. From 2013 to 2021, their correlation with equities remained negative, but the higher equity weight favoured 60/40. In 2022, stocks and long Treasuries fell together.

    The three correlations above use 96, 108 and 57 monthly returns. The rolling line in Figure 6 gives greater weight to recent months and ends at +0.31. The shift in the sample average describes the period as a whole; individual months still varied. Period correlations · Full monthly series, including warm-up

    Is the difference real?

    portfolios.get_sharpe_difference_table()
    
    PortfolioBase SharpePortfolio SharpeSharpe differenceHAC SEHAC zp-valueObservationsHAC lags
    60/400.9557900.9606660.0048760.1677730.0290640.9768142903

    The table tests the Sharpe ratio of 60/40 against all weather on their 290 common monthly returns. The difference is 0.005 with a standard error of 0.17 and a p-value of 0.98. Twenty-four years of data cannot tell the two portfolios apart by their Sharpe ratios. They differ in the years in which they disappoint.

    The test and Figure 1 do not use the same Sharpe ratio. Figure 1 divides the compound return p.a. by the volatility: 0.946 for all weather and 0.948 for 60/40. The test uses the arithmetic mean of the monthly returns: 0.956 and 0.961. qis names its Sharpe conventions, and each table states the one it uses.

    The second page of the factsheet below adds the path. At every year-end from 2007 to 2012, the trailing five-year arithmetic Sharpe favours all weather. The monthly difference briefly crosses zero in June and July 2013. It then favours 60/40 at every month-end from September 2013 through February 2020, before turning positive in March 2020. In September 2026 it ends at −0.083, with a pointwise 95% interval of −0.559 to +0.393. The sign here is all weather minus 60/40. Five-year rolling estimates and intervals With the difference in the betas to SPY taken out, all weather is ahead by 2.1% p.a. over the 225 eligible months from January 2008, with a 95% interval from -0.3% to 4.5%, which includes zero.

    What if I add leverage?

    ust_3m = yf.download('^IRX', start='2002-07-01', end='2026-10-01', ignore_tz=True, auto_adjust=True)['Close']['^IRX'].dropna() / 100.0
    levered = backtest({'SPY': 0.50, 'TLT': 0.50, 'TIP': 0.50, 'GLD': 0.50}, 'All weather 2x', funding_rate=ust_3m)
    perf_params_excess = qis.PerfParams(freq='ME', rates_data=ust_3m)  # Sharpe ratios in excess of the bill rate
    

    All weather has about the volatility of the 60/40 portfolio and a lower return. Bridgewater’s All Weather uses leverage, so I add it. The same four ETFs now hold 50% of the capital each, which is 200% in total, and the borrowed 100% pays the rate of the 3-month Treasury bill. This is the funding_rate of the backtester: it accrues on the cash balance, which is now negative. I use the last annual bill quote available at the previous portfolio date and accrue interest over the actual calendar days on a 365-day basis. The QIS reference script runs this comparison with --with-leverage and can reuse the saved annual rates.

    navs = portfolios.get_navs(benchmark='SPY').copy()
    navs['All weather 2x'] = levered.get_portfolio_nav()
    stat = qis.PerfStat
    fig, ra_table = qis.plot_ra_perf_table_benchmark(
        prices=navs, benchmark='SPY', perf_params=perf_params_excess, fontsize=10,
        perf_columns=[stat.PA_RETURN, stat.VOL, stat.SHARPE_EXCESS, stat.MAX_DD,
                      stat.ALPHA_AN, stat.BETA, stat.R2],
    )
    ra_table
    
    AssetP.a. returnVolEx SharpeMax DDAn AlphaBetaR2
    SPY11.20%14.75%0.63-55%-0.00%1.00100%
    All weather7.79%8.24%0.73-21%3.24%0.2927%
    60/408.47%8.94%0.74-32%1.03%0.5891%
    All weather 2x13.48%16.52%0.70-40%6.56%0.5827%

    The last three columns use monthly simple returns in excess of the Treasury bill rate. Beta measures sensitivity to SPY, R² gives the share of return variance explained by SPY, and annualised alpha is 12 times the monthly regression intercept.

    comparison = navs[['All weather 2x', 'SPY']]
    fig, axes = plt.subplots(2, 1, figsize=(12, 9), constrained_layout=True)
    qis.plot_prices(prices=comparison, perf_params=perf_params_excess, fontsize=10,
                    perf_stats_labels=[stat.PA_RETURN, stat.VOL, stat.SHARPE_EXCESS],
                    title='Growth of one dollar', ax=axes[0])
    qis.plot_rolling_drawdowns(prices=comparison, dd_legend_type=qis.DdLegendType.SIMPLE,
                               title='Drawdown from the previous peak', fontsize=10, ax=axes[1])
    
    Two panels showing the growth of one dollar and daily drawdowns for all weather 2x and SPY
    Figure 7. The funded 2× portfolio and SPY from 31 July 2002 to 30 September 2026. Both series start at one dollar. Drawdowns are measured from each series’ previous peak.

    With leverage, the comparison I find more informative is SPY. The 2× portfolio earned 13.48% a year at 16.52% volatility, against 11.20% at 14.75% for SPY. Within the all-weather allocation, doubling the target exposure lifted the annualised return from 7.79% to 13.48%. Its excess Sharpe fell from 0.726 to 0.701 after financing and trading costs.

    The price of the extra exposure was clearest in 2022. The funded portfolio lost 32.45% that calendar year, against 16.01% for unlevered all weather and 18.18% for SPY. Its deepest drawdown began on 9 November 2021, reached 39.58% on 20 October 2022 and recovered on 31 March 2025. That was 1,238 calendar days from peak to recovery.

    With a funding rate in the backtest, a Sharpe ratio with a zero rate is no longer the right statistic. The Sharpe ratios of this section are in excess of the bill rate, which PerfParams takes as rates_data. On this convention the unlevered Sharpe ratios are 0.726 for all weather and 0.744 for 60/40. Funding at the bill rate with no spread is a lower bound on the cost of leverage.

    The whole report in one call

    figs = qis.factsheet(portfolios, kind='strategy_benchmark', add_brinson_attribution=False)
    qis.save_figs_to_pdf(figs=figs, file_name='aw6040_factsheet', orientation='portrait',
                         local_path=None, add_current_date=False)
    
    First page of the two-page strategy and benchmark factsheet
    Download the two-page factsheet (PDF) · Figure 8. First page of the factsheet of all weather against 60/40. For both portfolios, rolling alpha starts on 30 September 2005. The first observation is September’s portfolio log return less the 31 August beta times September’s SPY log return. This residual seeds the 36-span average, which is annualised by multiplying by 12. Earlier months are blank. The marginal-alpha comparison on page two begins in January 2008, once both portfolios have a fully covered opening beta.

    Most figures above are panels of this report. The first page shows the rolling statistics, the exposures, the turnover and the costs of both portfolios. The second reports the paired test of the Sharpe ratios and the difference in alpha against SPY. I start an analysis from this report and then pull out single panels, as in this post.

    What else the reports cover

    The factsheets are a part of what qis reports. The table lists the rest: performance and risk after the fact (ex post), and risk before it (ex ante). Some function names abbreviate tracking error to TRE.

    QuestionCallContent
    Ex-post performanceqis.compute_ra_perf_table, the factsheetsReturn, volatility, Sharpe ratio under three named conventions, drawdowns, alpha and beta to a benchmark, regime contributions, attribution, Brinson, turnover and costs
    Is a difference significant?MultiPortfolioData.get_sharpe_difference_table, the second page of the strategy-benchmark factsheetPaired Sharpe difference and marginal alpha with autocorrelation-consistent intervals
    Ex-post tracking errorqis.compute_ewma_realised_tracking_error, qis.compute_te_ir_errorsA series for monitoring; tracking error and information ratio of the whole sample
    Ex-ante riskqis.RiskModel, PortfolioData.plot_current_varTracking error by group, factor exposures, beta, marginal contributions and value at risk, from weights and dated covariance matrices
    Tracking-error reportqis.weights_tracking_error_report_by_ac_subacStrategy against benchmark by asset class: weights, tracking error, risk contributions, turnover
    Stress testsqis.generate_portfolio_stress_reportFactor shocks applied to the current holdings
    Signalsqis.generate_weight_change_reportWhat moved the weights: momentum, carry, target volatility, leverage
    Look-through and FXqis.generate_lookthrough_exposures_factsheet, qis.run_asset_fx_hedging_reportExposures through fund holdings; hedged returns in a reference currency

    Conventions

    Prices are Yahoo adjusted closes downloaded on 11 November 2026. For this edition I reran the calculations and charts with qis==6.0.4 using the saved ETF prices and Treasury bill rates. Returns, volatilities and Sharpe ratios use monthly returns; the Sharpe ratio is the compound return p.a. over the volatility with a zero risk-free rate. Drawdowns use daily values. Trading costs are 10 basis points of the traded notional. There are no taxes and no interest on cash. The QIS reference script produces the unlevered ETF comparison by default. The --with-leverage option also produces the funded table and Figure 7. Save the script as qis_reference_604.py. In a data/ folder beside it, save the ETF prices as prices.csv and the Treasury bill rates as ust_3m_irx.csv. Run python qis_reference_604.py --with-leverage --output-dir <output-folder> in an environment with qis==6.0.4.

    The test of the Sharpe difference departs from this and uses the arithmetic Sharpe ratio. The section on leverage funds the cash balance at the 3-month Treasury bill rate and reports Sharpe ratios in excess of it.

    Try it yourself

    Each experiment is a new dict of weights in the setup and the same calls.

    • Replace TLT with IEF, and see what shorter bonds do to 2022 and to 2008.
    • Move to 40% gold and 20% in each of the others, and look at 2013.
    • Add the variant to MultiPortfolioData and read the Sharpe difference table before you believe the improvement.
    • Change 'QE' to 'ME' or 'YE' in backtest, and see how little the rebalancing frequency matters.
    • Add a spread to the funding of the levered portfolio, funding_rate=ust_3m + 0.005, and compare it with SPY again.

    qis in the era of Gen AI

    I understand that in the era of AI most of the analysis that qis provides can be generated quickly. What makes qis viable is the harness: consistent treatment of NaN data, all conventions are documented (Sharpe ratio – arithmetic, compounded, excess, etc., volatility frequency, alpha and beta versus the benchmark, start and end dates) and synchronised across the reporting. I see qis as a harness for agentic work. All my repositories have an explicit setting in SKILL.md or AGENTS.md to always use qis for any type of backtesting and reporting, and never to invent something that is not available there or to re-create something that is.

    The specification is the point. Every line of it is a convention that qis implements once and documents, so the chart, the table and the factsheet of one strategy show one number under one stated convention.

    An agent also has to find the function. The documented core is CORE_API in qis/api.py, 183 symbols in 14 groups, and a test fails when a documented argument is not in the signature. The rule in my agent instructions is a table. Four of its rows:

    You need toCallDo not write
    Turn weights into a portfolioqis.backtest_model_portfolioa date loop accumulating P&L
    Performance statisticsqis.compute_ra_perf_tableSharpe, vol or drawdown by hand
    Returns to NAVqis.returns_to_nav(1 + r).cumprod()
    Covariance on a ragged panelqis.compute_masked_covar_corr.dropna().cov()

    You are welcome to use qis for your research and agentic work. You can send me feedback at artursepp@gmail.com, report an issue on GitHub, or submit a pull request to propose a new feature.

    Links

    • Paper: Sepp, A. (2026), qis: reproducible performance analytics and portfolio backtesting in Python, Journal of Open Source Software, 11(126), 11400, doi:10.21105/joss.11400
    • Code: github.com/ArturSepp/QuantInvestStrats
    • Documentation: quantinveststrats.readthedocs.io
    • PyPI: pypi.org/project/qis

    I thank the editor Daniel S. Katz and the reviewers @alex09x, @robertmartin8 and @tschm for the review, and John D. Reynolds for his contributions to qis.

    Disclosure

    The views in this post are my own and do not represent the views of my current or former employers. The post and the software are not investment advice in any form.

    Download the reproduction guide and run commands

    Share this:

    • Share on LinkedIn (Opens in new window) LinkedIn
    • Share on X (Opens in new window) X
    • Share on WhatsApp (Opens in new window) WhatsApp
    • Email a link to a friend (Opens in new window) Email
    • Print (Opens in new window) Print

    Like this:

    Like Loading…
    • ← The Science and Practice of Trend-following Systems: paper and presentation
    •  

    Author: artursepp

    Posted in Asset Allocation, Python, Quantitative Strategies, Uncategorized | 0 Comments |

    Leave a Reply Cancel reply

    • Receive notifications of new posts by email

      Join 348 other subscribers
    • Recent Posts

      • All-weather or 60/40? A portfolio backtest with qis
      • The Science and Practice of Trend-following Systems: paper and presentation
      • Lognormal Stochastic Volatility – Youtube Seminar and Slides
      • Optimal allocation to cryptocurrencies in diversified portfolios – update on research paper
      • Unified Approach for Hedging Impermanent Loss of Liquidity Provision – Research paper
      • Log-normal stochastic volatility with quadratic drift – open access publication
      • Stochastic Volatility for Factor Heath-Jarrow-Morton Framework – research paper
      • AD Derivatives podcast on volatility modeling and DeFi
      • What is a robust stochastic volatility model – research paper
      • Robust Log-normal Stochastic Volatility for Interest Rate Dynamics – research paper
      • Optimal Allocation to Cryptocurrencies in Diversified Portfolios – research paper
      • Log-normal Stochastic Volatility Model for Assets with Positive Return-Volatility Correlation – research paper
      • Developing systematic smart beta strategies for crypto assets – QuantMinds Presentation
      • Toward an efficient hybrid method for pricing barrier options on assets with stochastic volatility – research paper
      • Paper on Automated Market Making for DeFi: arbitrage-fee exchange between on-chain and traditional markets
      • Tail risk of systematic investment strategies and risk-premia alpha
      • Trend-Following CTAs vs Alternative Risk-Premia (ARP) products: crisis beta vs risk-premia alpha
      • My talk on Machine Learning in Finance: why Alternative Risk Premia (ARP) products failed
      • Why Python for quantitative trading?
      • Machine Learning for Volatility Trading
    • Categories

      • Asset Allocation (11)
      • Crypto (8)
      • Decentralized Finance (5)
      • Python (5)
      • Quantitative Strategies (18)
      • Trend-following (4)
      • Uncategorized (21)
      • Volatility Modeling (18)
      • Volatility Trading (13)

Loading Comments...
%d