preview
Honest Backtesting of Swing Strategies on Index CFDs: Financing Costs, Swap Modes, and What the Strategy Tester Cannot Model

Honest Backtesting of Swing Strategies on Index CFDs: Financing Costs, Swap Modes, and What the Strategy Tester Cannot Model

MetaTrader 5Tester |
134 0
Jan Kahlert
Jan Kahlert

You are testing and optimizing a swing strategy on index CFDs. The equity curve looks clean, the profit factor is solid, and the final net figure is positive. What is not visible is the cost that funded every night those positions were open: overnight financing. Every multi-day index-CFD position carries a nightly swap charge that is economically a financing rate on the position notional — and in MetaTrader 5, that cost is handled inconsistently across brokers (POINTS vs. INTEREST modes), invisible inside optimizer result caches, and applied to the entire backtest history from a single present-day snapshot.

The result is that you can select parameters, symbols, or even a broker based on figures where financing either eats a large share of the edge or is modeled incorrectly for most of the test period. This article measures that effect with real data, explains the four structural reasons the tester makes it hard to see, and provides read-only tools to convert swap into a comparable annualized rate so you can judge the true economics before you trade.


How an index CFD position is actually financed

Holding an index CFD overnight means holding a leveraged claim on the index notional. The broker finances that notional and charges (or credits) a nightly amount — the "swap". MetaTrader 5 exposes this via SYMBOL_SWAP_MODE, SYMBOL_SWAP_LONG, and SYMBOL_SWAP_SHORT. The triple-swap day is set by SYMBOL_SWAP_ROLLOVER3DAYS.

The catch is the mode. Among real brokers, index CFDs come in (at least) two fundamentally different financing worlds:

  • POINTS mode: the swap is a fixed number of symbol points per night. It does not know or care what the index level is — the same points are charged at 6,000 as at 30,000.
  • INTEREST_CURRENT mode: the swap is an annual percentage of the current price (banking convention, 360 days). It scales with the index level automatically — it is a financing rate, stated as one.

Both are configured through the same two numbers, SWAP_LONG and SWAP_SHORT, so at first glance the specification window looks the same. Economically they behave completely differently over time — and the Strategy Tester treats both the same way, as we will see.


Reading the specs: one script, every broker

To compare financing across brokers you cannot compare the raw swap values. A raw "−701 points" at one broker and "−6.15" at another are meaningless side by side: contract sizes differ, account currencies differ, and one of the two numbers is not even in points. The only fair yardstick is the implied annualized percentage of the position notional — the swap converted into what it economically is, a financing rate.

The attached script does exactly that, read-only, for every index CFD it finds in a terminal:

//--- Index families: canonical name + broker nomenclatures.
//--- Matching is anchored at the START of the cleaned symbol name.
string g_famName[] = {"DE40",  "NAS100", "US500", "US30"};
string g_famPat[]  = {"DE40|GER40|DE30|GER30|DAX|GERMANY40|GRXEUR",
                      "NAS100|USTEC|USTECH|US100|NDX100|NDX|TECH100|NASDAQ100",
                      "US500|SPX500|SP500|SPX|USA500|SPXUSD",
                      "US30|DJ30|DOW30|DOWJONES|WS30|USA30|DJIUSD"};

//+------------------------------------------------------------------+
//| MatchesFamily                                                    |
//+------------------------------------------------------------------+
//--- The anchor is not a complete filter: prefixed tickers like
//--- "NDX1.ETR" still get in, so the output table is eyeballed once.
bool MatchesFamily(string symUpper, string pattern)
  {
   string toks[];
   int n = StringSplit(pattern, '|', toks);
   string s = StripLead(symUpper);
   for(int i = 0; i < n; i++)
      if(StringFind(s, toks[i]) == 0) return true;
   return false;
  }

//+------------------------------------------------------------------+
//| PointValuePerLot                                                 |
//+------------------------------------------------------------------+
//--- Value of one point, for 1.0 lot, in the account currency.
double PointValuePerLot(string sym)
  {
   double point     = SymbolInfoDouble(sym, SYMBOL_POINT);
   double tickSize  = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
   double tickValue = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
   if(tickSize <= 0)
      return 0.0;
   return tickValue * (point / tickSize);
  }

//+------------------------------------------------------------------+
//| DailySwapAccountCcy                                              |
//+------------------------------------------------------------------+
//--- Nightly swap in the account currency. Covers every SWAP_MODE.
double DailySwapAccountCcy(string sym, double lots, bool isLong,
                           bool &convOk, bool &supported)
  {
   double swap = isLong ? SymbolInfoDouble(sym, SYMBOL_SWAP_LONG)
                        : SymbolInfoDouble(sym, SYMBOL_SWAP_SHORT);
   long   mode     = SymbolInfoInteger(sym, SYMBOL_SWAP_MODE);
   double contract = SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE);

   MqlTick t;
   if(!SymbolInfoTick(sym, t))
     {
      supported = false;
      return 0.0;
     }
   double price = (t.bid > 0) ? t.bid : t.last;

   switch((ENUM_SYMBOL_SWAP_MODE)mode)
     {
      case SYMBOL_SWAP_MODE_DISABLED:
         return 0.0;                                  // the futures CFDs land here

      case SYMBOL_SWAP_MODE_POINTS:
        {
         //--- A fixed number of points per night, turned into money.
         double pv = PointValuePerLot(sym);
         if(pv <= 0)
           {
            supported = false;
            return 0.0;
           }
         return swap * pv * lots;
        }

      case SYMBOL_SWAP_MODE_INTEREST_CURRENT:
      case SYMBOL_SWAP_MODE_INTEREST_OPEN:
        {
         //--- The swap IS an annual percentage of notional, banking basis 360.
         double notional = price * contract * lots;
         double daily    = notional * swap / 100.0 / 360.0;
         return ConvertToAccountCurrency(daily,
                   SymbolInfoString(sym, SYMBOL_CURRENCY_PROFIT), convOk);
        }

      case SYMBOL_SWAP_MODE_CURRENCY_SYMBOL:
         return ConvertToAccountCurrency(swap * lots,
                   SymbolInfoString(sym, SYMBOL_CURRENCY_BASE), convOk);

      default:
         supported = false;                           // REOPEN_CURRENT / REOPEN_BID
         return 0.0;                                  // flagged for manual review
     }
  }

//+------------------------------------------------------------------+
//| Comparable yardstick: annualized % of notional                   |
//+------------------------------------------------------------------+
//--- The one cross-broker comparable figure, computed identically for every mode.
double swapAcctL = DailySwapAccountCcy(sym, InpTestLots, true, okL, supL);
double notional  = NotionalAccountCcy(sym, InpTestLots, okN);
double pctNight  = (notional > 0) ? (swapAcctL / notional * 100.0) : 0.0;
double pctYear   = pctNight * 360.0;
//--- For INTEREST_* symbols pctYear round-trips exactly back to SYMBOL_SWAP_LONG.
//--- That is the point: it is the same number a POINTS symbol never states.

//--- The script also records SYMBOL_SWAP_ROLLOVER3DAYS (triple-swap day),
//--- restores the Market Watch to the state it found, and never touches an order.

Run against four live terminals (anonymized as Brokers A–D), here is the long-side financing for the same four index families, as an annualized percentage of notional:

Index Broker A (POINTS)  Broker B (POINTS)   Broker C (INTEREST) Broker D (INTEREST) 
NAS100 -8.5% -7.9%  -8.6% -6.2%
DE40 -9.9% -6.1%  -7.2% -4.7%
US30 -7.8% -5.2%  -8.6% -6.2%
US500 -9.8% -6.9%  -8.6% -6.2%
Three things stand out. First, the two financing worlds coexist in the market: A and B charge fixed points, C and D charge a percentage. Second, the spread is wide — the same DE40 long position costs more than twice as much per year at Broker A (−9.9%) as at Broker D (−4.7%). Third, none of this is exotic: these are four ordinary, regulated MetaTrader 5 brokers, scanned on the same day with the same script.

broker_financing

The same instrument, four brokers, financing rates from −4.7% to −9.9% per year. The raw swap values behind these bars are not comparable at all — only the annualized percentage of notional is.


The short side is a lottery

The long side is at least consistently negative. The short side is not even that:

  • Broker A pays you +2.04 per night for shorting NAS100 (and +4.21 for US30) — a genuine credit.
  • Broker B credits NAS100 shorts a token +0.28, but charges US30 shorts −3.47.
  • Broker C charges shorts on every index (−1.8% p.a. across the board — both directions cost money).
  • Broker D credits shorts a small +1.15% on the US indices — but charges −0.32% on DE40, so even within one broker the sign is not uniform across symbols.

A strategy that trades both directions therefore has a materially different economics profile at each broker — and a long-only strategy, like the swing system used as the case study below, structurally pays the expensive side every single night it holds.


What the tester actually booked: a real case

The case study is a simple long-only daily swing system on four index CFDs (rank-based entry, time-based exit, average hold ≈ 4 days) tested at a POINTS-mode broker. This is deliberately not presented as a profitable strategy — with the trade counts involved, no performance claim would survive scrutiny, and that is not the point. The point is what the deal records show about costs.

Extracting the swap from the deal records of a full-history run (424 round-trips, 2020–2026):


Amount
Gross trading profit (sum of deal profits) +15,630
Booked swap
−6,902
Net (as reported by the tester)
+8,728

Financing consumed 44.2% of the gross trading profit — on a system whose average hold is only about four nights. Per symbol, the picture is even sharper:

Symbol  Round-trips   Swap Deal Profit  Swap as % of profit 
NAS100 127 -2,022 +8,257
24%
US30 131 -1,907 +4,191
46%
US500 85 -1,452 +3,161
46%
DE40 73 -1,521 +21
≈ 71× gross

The DE40 line deserves a pause. Seventy-three trades produced a gross profit of 21 — and paid 1,521 in financing. The symbol did not trade for the account; it traded for the broker. Nothing in the standard tester report headline draws attention to this: the strategy-level net figure simply absorbs it.

A verification note, because numbers like these should be checked: gross (+15,630) plus swap (−6,902) reconstructs the tester's reported net (+8,728) exactly. The swap extraction is arithmetically closed against the official report — and it also confirms that the profit factors the tester shows do include swap. The problem is not that the tester ignores financing; it is everything below.

swap_share

The same strategy, four symbols, one broker. On DE40, financing consumed the entire gross profit — a fact the aggregate report absorbs without comment.


Problem 1: the optimizer never shows you the swap

Here is a structural detail that surprised me. The single-run tester report contains the swap (it is in the deal list). But the optimization cache — the .opt file — stores only the ENUM_STATISTICS values per pass, and there is no swap entry among them. When you optimize a parameter grid and sort the passes by profit factor, the per-pass swap does not exist anywhere. You are selecting parameters on net numbers whose cost composition is invisible.

Why does that matter? Because parameters change holding behavior. In the case-study grid, the entry-rank parameter shifted the average trade count by a factor of two between passes — and with it, the number of financed nights. Two passes with similar net PF can hide very different financing exposures: one robust to a swap increase, one not. The optimizer gives you no way to see the difference; you would have to re-run each pass individually and read its deal list.


Problem 2: one snapshot, applied to all of history

This is the deepest issue, and it is quantifiable. The Strategy Tester takes the current swap specification — a single present-day snapshot — and applies it to the entire backtest history.

For an INTEREST-mode symbol that is already questionable (the rate itself changed over the years: money was near-free in 2020 and expensive in 2023). For a POINTS-mode symbol it is structurally worse, because a fixed points charge means a different percentage at every price level. NAS100 traded near 6,000 in late 2018 and near 30,000 today. The same −701 points per night that equals −8.5% p.a. today equalled, at historical price levels:

 Year Avg. close  Implied financing, % pa  Illustrative real rate*   Factor
2019 7,633 −33.2%
≈ −5.2%
6.4x
2020 10,283
−25.1%
≈ −3.4%
7.4x
2021 14,487
−17.5%
≈ −3.1%
5.7x
2022 12,750
−20.0%
≈ −4.7%
4.3x
2023 14,194
−18.0%
≈ −8.0%
2.3x
2024 19,100
−13.3%
≈ −8.1%
1.6x
2025 22,544
−11.3%
≈ −7.4%
1.5x
2026 27,079
−9.4%
≈ −7.0%
1.3x

Illustrative reference: Fed Funds yearly average plus a typical 3% broker markup — an order-of-magnitude yardstick, not a claim about any specific broker's historical pricing.*

At the COVID low (March 20, 2020, close 6,980) the implied rate reaches −36% per year — charged for a period when actual short-term rates were near zero. The deepest point of the whole series is earlier still: −43% on December 24, 2018 (close 5,871), right at the start of the available history. In other words, the −6,902 swap booked in the case-study backtest is not only large but also systematically mispriced. The error increases the further back the test goes. A backtest that looks financing-realistic for 2025 silently charged 2020 at seven times any plausible real rate.

# Implied p.a. financing rate of a fixed points swap, per trading day.
# The conversion chain mirrors PointValuePerLot() from the MQL5 scanner.
import json, pandas as pd
from pathlib import Path

DAY_BASIS = 360.0
SPEC_KEY  = "NAS100"        # the broker's symbol name in the real export
swap_points = -701.167      # SYMBOL_SWAP_LONG, points per night (spec snapshot)

s = json.loads(Path("symbol_specs.json").read_text())[SPEC_KEY]
point_value = s["trade_tick_value"] * (s["point"] / s["trade_tick_size"])
swap_acct   = swap_points * point_value                  # per night, 1.00 lot
fx          = point_value / (s["point"] * s["trade_contract_size"])

df = pd.read_csv("nas100_d1.csv", parse_dates=["date"])  # D1 export, close per day
df["notional_acct_ccy"] = df["close"] * s["trade_contract_size"] * fx
df["implied_pa_pct"]    = swap_acct / df["notional_acct_ccy"] * DAY_BASIS * 100.0

# fx enters swap_acct and the notional with the same factor, so it cancels:
# the whole computation reduces to points * POINT / close, i.e. FX-independent.
assert abs(df["implied_pa_pct"].iloc[-1]
           - swap_points * s["point"] / df["close"].iloc[-1] * DAY_BASIS * 100) < 1e-9

# Sanity anchor against the live spec scan: at the price the scanner saw
# (29,740.26) this must reproduce its ~ -8.5% p.a. -- if it does not,
# the units are wrong and nothing downstream can be trusted.
check = swap_acct / (29740.26 * s["trade_contract_size"] * fx) * DAY_BASIS * 100.0
assert abs(check - (-8.5)) < 0.15, f"sanity check failed: {check:.4f}"

implied_financing

A fixed points swap re-expressed as an annual financing rate against the actual price history. The same specification that costs −8.5% per year today implied −25% in 2020 and −33% in 2019 — while real short-term rates were near zero. The dashed reference is illustrative (Fed Funds + 3% markup).

Two smaller observations reinforce the point. First, the implied-rate formula is exchange-rate independent — the currency conversion cancels between swap and notional — so this distortion is purely about price level and specification, not FX modeling. Second, specifications drift: between two scans one month apart, the DE40 long swap moved from −688.91 to −721.04 points — 4.7% more expensive, with no announcement and no trace in the platform. (NAS100 drifted too over the same month, −701.17 to −702.93, but only by a quarter of a percent — the size of the move is itself unpredictable.) A backtest started today and the identical backtest started next month book different historical financing. There is no "historical swap series" anywhere in the platform to anchor either of them.


Problem 3: the swap-free variant next door

One more thing the tester will never volunteer: at the POINTS-mode brokers in the scan, the same indices are also offered as expiring futures CFDs — and their specification says swap: none . No overnight financing at all. The trade-offs are real (wider spreads, an expiry date, larger contract sizes), but for a multi-week swing position the arithmetic can easily favor the futures variant. A strategy that pays −40 per lot in financing over four nights on the cash CFD pays zero on the future. The tester will happily backtest either symbol; it just gives you no hint that the comparison is worth making.

What to do about it — a practical checklist

  1. Convert your swap into % p.a. of notional before you trust any multi-day backtest. Raw points are not interpretable. The attached script does it for every index CFD in your terminal; the number it prints is a financing rate you can compare against anything.
  2. Read the deal list of your best pass, not just its PF. The optimizer cache contains no swap. Re-run the chosen parameter set as a single test and check what share of gross profit the swap consumed — per symbol, not in aggregate. A DE40-style line (all profit eaten) hides comfortably inside a healthy-looking total.
  3. Distrust the early years of any long backtest on a POINTS-mode symbol. The current snapshot applied to old price levels can imply absurd financing rates (−25% to −36% p.a. in this data). If the strategy's edge concentrates in those years, part of what you see is cost mismodeling, in either direction.
  4. Check whether your broker lists a swap-free futures variant of the same index, and whether your holding period makes it the cheaper instrument — the tester will not make that comparison for you.
  5. Long-only means paying the expensive side every night. If the short side of your symbol is credited (it is, at two of the four brokers scanned), a symmetric strategy has structurally different economics than a long-only one — before any signal quality enters the picture.



Takeaways

After reading this article you will have three practical things:

First, a reproducible audit script that reads every index-CFD swap specification in your terminal and expresses it as an annualized percentage of notional — one number that is directly comparable across brokers, symbols, and swap modes.

Second, a workflow for optimizer output: re-run the chosen parameter set as a single test, open the deal list, and compute the swap share of gross profit per symbol. A DE40-style result — where financing consumed the entire gross profit — hides comfortably inside a healthy-looking aggregate. The optimizer will never surface it; you have to look.

Third, two heuristics worth keeping: distrust the early years of any long backtest on a POINTS-mode symbol, where a current snapshot can imply financing rates of −25% to −36% per year against near-zero real rates; and always check whether your broker lists a swap-free futures variant of the same index, because for a multi-week swing hold the arithmetic can easily favor it.

Financing is not a rounding error on index-CFD swing systems. In one real multi-year test it consumed 44% of gross profit overall and 100% on one symbol. The fix is methodological: treat swaps as financing rates, audit deal-level costs before trusting optimizer outputs, and include instrument and specification checks in your pre-deployment checklist. The attached scripts implement the conversions and checks so you can reproduce the analysis on your own broker and history.

How to run this yourself (no prior Python experience required)

Everything attached is read-only — the scanner reads symbol specifications and the Python part reads price history; nothing places, modifies, or closes a trade. The full beginner walkthrough (with troubleshooting) is included as how_to_run.md inside the archive; the short version is here.

Part 1 — the spec scanner (MQL5, no Python needed). Unzip MQL5.zip into your MetaTrader 5 data folder (File → Open Data Folder — the archive's root MQL5\ merges into the existing one). In the terminal: Navigator → Scripts → IndexSwapAudit → double-click IndexSwapCompare. Run it during market hours — with the market closed there is no price, and the %-of-notional column (the whole point) cannot be computed. The result CSV lands in the shared Common\Files folder, so if you run the script in several terminals, all results collect in one place. To merge them into one comparison table:

powershell -ExecutionPolicy Bypass -File Merge-SwapCompare.ps1

(The -ExecutionPolicy Bypass part is needed on most Windows machines, where running PowerShell scripts is disabled by default; it applies to this one call only.)

Part 2 — the historical implied-rate analysis (Python, optional). Install Python 3.12 from python.org (tick "Add python.exe to PATH"; 3.13/3.14 do not work — the MetaTrader5 package cannot be installed on them yet). Open a command window in MQL5\Python\IndexSwapAudit\ , then:

py -3.12 -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python export_d1_history.py
python implied_financing.py

The export needs a running, logged-in terminal (a demo account is fine) and the terminal path entered at the top of the script; the analysis then runs offline and reproduces the implied-rate chart and yearly table from your own broker's history and swap specification. If you have no terminal at hand, the shipped reference CSV lets you render the chart directly.


Attached files

All files are packed into MQL5.zip, whose root is a single MQL5\ folder.

When unpacked into the MetaTrader 5 data folder, all files go to their intended locations. There are no executable .ex5 files; every script is plain text you can read before running.
File  Purpose 
MQL5\Scripts\IndexSwapAudit\IndexSwapCompare.mq5
Read-only spec scanner: finds every index CFD in the terminal, reads swap mode and values, converts them into an annualized %-of-notional financing rate, restores the Market Watch state it found.
MQL5\Files\IndexSwapAudit\Merge-SwapCompare.ps1
Merges the per-terminal result CSVs from Common\Files into one cross-broker comparison table.
MQL5\Files\IndexSwapAudit\Python\export_d1_history.py
Read-only D1 history export from a running terminal (with terminal-identity verification), cached locally.
MQL5\Files\IndexSwapAudit\Python\implied_financing.py
Computes the implied p.a. financing rate of a fixed points swap against the price history and renders the article chart; sanity-anchored against the spec scanner's output.
MQL5\Files\IndexSwapAudit\Python\requirements.txt
Python package list for pip install -r requirements.txt .
MQL5.zip
The complete archive of all files above, root MQL5\ , ready to unpack into the terminal data directory.

Disclosure: The research question and the read-only constraint were defined up front; the implementation and the draft of this article were built with Claude (Anthropic) as a coding and writing assistant, working in stages with manual review. Verification relied on mechanisms rather than trust: terminal identity checks before every export, an arithmetic closure check (gross + swap reconstructs the tester's reported net exactly), a sanity anchor between the spec scan and the historical computation, and the full pipeline reproduced on a second run.

Attached files |
MQL5.zip (14.99 KB)
From Basic to Intermediate: Classes (III) From Basic to Intermediate: Classes (III)
In this article, we will explore the best ways to manage code when working with object-oriented programming. Although we are just beginning to learn about object-oriented programming, what we will cover here will help you understand its various aspects. This will also help dispel any doubts that may arise later.
How to Create and Adapt an RL Agent with an LLM and Quantum Encoding for Algorithmic Trading in MQL5 How to Create and Adapt an RL Agent with an LLM and Quantum Encoding for Algorithmic Trading in MQL5
The article proposes a hybrid approach to algorithmic trading based on quantum encoding of market states, Double DQN with a prioritized experience replay buffer, and an LLM acting as a contextual EA. The SEAL methodology enables asynchronous continued training of the agent without halting trading. A lightweight Q-learning filter (USE/SKIP/REDUCE) controls signal execution at the meta-level. Practical details are provided on integrating the system with the MetaTrader 5 trading platform, along with a scheme for adapting it to market regime shifts.
Market Simulation: Position View (XV) Market Simulation: Position View (XV)
In this article, I will try to explain as simply as possible how messaging between applications can be used. The goal is to enable you to create something workable in the simplest and most efficient way possible whenever you can. I am not sure if I will be able to convey the idea behind this concept, since it is not that easy to understand for someone encountering it for the first time. In addition, I will take this opportunity to show you how to modify the replay/simulation system so you can debug an Expert Advisor or any other code you are developing. And all of this is just as simple and straightforward.
Market Simulation: Position View (VI) Market Simulation: Position View (VI)
In this article, we will implement a number of improvements to ensure that the position indicator accurately reflects the actual state on the trading server in terms of open positions and their current state. I should point out that the applications shown here are in no way intended to replace any of the elements available in MetaTrader 5. They should also not be used without due caution and a balanced approach, since their purpose is to provide educational code—that is, code intended solely for learning how the system works. The reason I call this code “educational” is that, in some cases, using messages is not the best way to implement certain functions.