Table of Contents

Strategies

With a few important building blocks out of the way, let's revisit the simple moving average system from the Introduction to understand a few key concepts.

using Balsam;

class SimpleSma : Strategy
{
    protected override void OnStrategyStart()
    {
        Col1 = Sma(Close, 50);
        Col2 = Sma(Close, 200);
    }

    protected override void OnBarClose()
    {
        if (Col1.Last > Col2.Last)
        {
            Buy();
        }
        else
        {
            Sell();
        }
    }
}

Strategy code discussion

  • On the first line we import the namespace Balsam. This is the primary namespace containing key classes for creating and working with strategies. Other important namespaces include Balsam.DataServers and Balsam.MoneyManagement.

  • Next we create a class called SimpleSma which inherits from Strategy, an abstract base class containing core backtesting functionality. All strategies must inherit from Strategy (or a derivative of it.)

  • Within the new SimpleSma class, we override the virtual method OnStrategyStart(). This is where strategy initialization is typically performed. Here we calculate two simple moving averages on lines 7 and 8 and assign them to the built-in TimeSeries variables Col1 and Col2. Col is short for column. Think of it just like a spreadsheet column. For convenience, there are 20 predefined column variables. You can also declare your own TimeSeries variables if you prefer and it will work just the same.

  • Finally we override the OnBarClose() method. Like the name suggests, this is called after the close of every new bar and is typically where trading logic is implemented. Here we reference the moving average values that were assigned to the Col1 and Col2 variables in OnStrategyStart(). Col1.Last is an alias for Col1[0]. Using Last can make code a little easier to understand at a glance. But you might be wondering, if Last is equivalent to [0], aren't we always referring to the same observation? If we were using simple arrays the answer would be yes; however, TimeSeries have some additional functionality under the hood which brings up a key point you must understand when coding strategies.

Concurrency

The backtester automatically maintains concurrency, or the notion that all data is aligned by date. In OnStrategyStart(), TimeSeries act like simple arrays where you can index them from zero to count - 1. But once the simulation starts running, this behavior changes. Indexing into a TimeSeries within the OnBarClose() method using .Last or [0] works relative to the current date that is being tested.

Here's a concrete example of this concept in action:

class ConcurrencyExample : Strategy
{
    protected override void OnStrategyStart()
    {
        //create some dummy data
        Col1 = new TimeSeries()
            {
                { new DateTime(2024, 1, 1), 1 },
                { new DateTime(2024, 1, 2), 2 },
                { new DateTime(2024, 1, 3), 3 },
                { new DateTime(2024, 1, 4), 4 },
                { new DateTime(2025, 1, 5), 5 }
            };

        Console.WriteLine($"Indexing in OnStrategyStart() is {Col1.Indexing}.");
        Console.WriteLine($"Date      Value");
        for (int x = 0; x < Col1.Count; x++)
        {
            Console.WriteLine($"{Col1.Date[x]:d}    {Col1[x]}");
        }

        PrimarySeries = Col1.ToBarSeries();
    }

    protected override void OnBarClose()
    {
        if (IsFirstBar)
        {
            Console.WriteLine($"Indexing in OnBarClose() is {Col1.Indexing}.");
            Console.WriteLine("Date      Last [0]  Prev [1]");
        }

        Console.WriteLine($"{CurrentDate:d}    {Col1.Last}   {Col1[0]}     {Col1.Previous}   {Col1[1]}");
    }
}

When running the code, we'll see the following printed to the console:

Indexing in OnStrategyStart() is Absolute.
Date      Value
1/1/2024    1
1/2/2024    2
1/3/2024    3
1/4/2024    4
1/5/2025    5
Indexing in OnBarClose() is RelativeToCurrent.
Date      Last [0]  Prev [1]
1/2/2024    2   2     1   1
1/3/2024    3   3     2   2
1/4/2024    4   4     3   3
1/5/2025    5   5     4   4

We added five values to the pre-defined TimeSeries variable Col1 and then looped through those values from 0 to count - 1. Note how the behavior of Col1[0] changes when called from within OnBarClose. Here [0] always refers to the latest value available relative to the current date being tested not the first value in the series. You can also see the equivalence of Col1.Last and Col1[0] as well as Col1.Previous and Col1[1].

Setback

The eagle-eyed among you may have also noticed that we are missing an observation. What happened to the observation on 1/1/2024 in OnBarClose()? This is due to the Setback property which allows for an additional period of initialization before OnBarClose is called for the first time. By default, the backtester uses a Setback of 1 so that we can always refer to the previous bar without encountering an error. You can modify this behavior by changing the Setback property from within OnStrategyStart() as needed.

MaxBarsBack and FirstValidDate

Now let's modify this code further by assigning a 2 period SMA to the Col2 variable:

class ConcurrencyExample2 : Strategy
{
    protected override void OnStrategyStart()
    {
        Col1 = new TimeSeries()
            {
                {new DateTime(2024, 1, 1), 1},
                {new DateTime(2024, 1, 2), 2},
                {new DateTime(2024, 1, 3), 3},
                {new DateTime(2024, 1, 4), 4},
                {new DateTime(2025, 1, 5), 5}
            };

        Col2 = Sma(Col1, 2);  //A simple moving average of length 2 isn't valid until at least 2 bars
        Setback = 1;  //this is the default value

        Console.WriteLine($"Indexing in OnStrategyStart() is {Col1.Indexing}.");
        Console.WriteLine($"Date      Value");
        for (int x = 0; x < Col1.Count; x++)
        {
            Console.WriteLine($"{Col1.Date[x]:d}    {Col1[x]}");
        }

        PrimarySeries = Col1.ToBarSeries();
    }

    protected override void OnBarClose()
    {
        if (IsFirstBar)
        {
            Console.WriteLine($"Indexing in OnBarClose() {Col1.Indexing}.");
            Console.WriteLine("Date      Last [0]  Prev [1]  Sma Sma[1]");
        }

        Console.WriteLine($"{CurrentDate:d}    {Col1.Last}   {Col1[0]}     {Col1.Previous}   {Col1[1]}   {Col2.Last} {Col2.Previous}");
    }
}

If we re-run we get the following output:

Indexing in OnStrategyStart() is Absolute.
Date      Value
1/1/2024    1
1/2/2024    2
1/3/2024    3
1/4/2024    4
1/5/2025    5
Indexing in OnBarClose() is RelativeToCurrent.
Date      Last [0]  Prev [1]  Sma Sma[1]
1/3/2024    3   3     2   2   2.5 1.5
1/4/2024    4   4     3   3   3.5 2.5
1/5/2025    5   5     4   4   4.5 3.5

Note how in OnBarClose we have "lost" another observation. This is because the two period SMA requires two observations to initialize the calculation. With the default Setback of 1 to allow for referencing the prior bar, we therefore start calling OnBarClose on day 3.

TimeSeries expose a property called MaxBarsBack which indicates the index at which valid data is first available. For a two period moving average it takes periods 0 and 1 to calculate a value, so MaxBarsBack would be 1. A related property is FirstValidDate, which returns the first date on which a TimeSeries is valid (in this case it would return 1/2/2024.)

The backtester looks across all indicators initialized in OnStrategyStart() to determine how much data is needed to "warmup" the system. This generally requires no intervention by the user unless you want to index further back in time than one period. For example, if you wanted to reference the value of the 200 period Sma 10 bars ago you would need to change the Setback to 10. If however you wanted to reference the 50 period Sma 10 periods ago, this would be fine since the 200 period Sma requires even more data to initialize than the 60 periods required to reference a 50 period Sma 10 bars ago.

Mixing periodicities

Concurrency works automatically and across periodicities so you can mix and match daily and weekly data and the backtester will ensure you are always using the latest value that could have been known at that point in time. In the example below, we buy if the close of the most recent bar is below its 5 period SMA and the weekly SMA is greater than the prior week's reading. If today is Thursday, Col2.Last would refer to the prior Friday (i.e. four trading days ago) and Col2.Previous would be two Fridays ago. If today is Friday, Close.Last, Col1.Last, and Col2.Last would all share Friday's date since the week just ended. You can observe concurrency in action by setting a breakpoint in OnBarClose() and examining the CurrentDate property of each TimeSeries.

class MixedPeriodicities : Strategy
{
    protected override void OnStrategyStart()
    {
        var weekly = PrimarySeries.ToWeekly();

        Col1 = Sma(Close, 5);
        Col2 = Sma(weekly.Close, 52);
    }

    protected override void OnBarClose()
    {
        if (Close.Last < Col1.Last && Col2.Last > Col2.Previous)
        {
            Buy();
        }
    }
}

Order sides

Equity traders already think in terms of four different order sides: Buy/Sell, and SellShort/Cover. Futures traders however generally only think in terms of Buy/Sell since short selling is an integral part of the futures market (for every long there is naturally an offsetting short). The backtester operates more like an equity trader, and indeed, it can keep track of Reg-T margin requirements and debit/credit interest on cash and short balances for equity strategies. The backtester does require the use of four different order sides to keep track of long and short positions separately.

This means a call to SellShort will be ignored if you currently have an open long position. You would need to explictly close this long position by calling Sell first before issuing an opening SellShort order. Alternatively, you can set the AutoReverseOnOppositeEntry property to true and the backtester will do that work for you. Methods like Sell or Cover can safely be called when there is an opposite position or no position at all. If the orders to be generated aren't appropriate for the current position, they will be ignored. This reduces the need for writing extra code around order placement.

Order placement

So far our examples have only used Buy() and Sell() which generate market orders for execution on the open of the next bar. But rest assured there are other order methods including:

  • BuyLimit
  • BuyStop
  • BuyClose
Note

BuyClose generates a market order for execution on the current bar's close for backtesting purposes only. You should be cognizant of the subtle look-ahead bias here. We are potentially making calculations and trading on a price that is only known after the market has actually closed. The backtester has safeguards built-in to prevent look-ahead bias but this is one area where the benefits for testing outweigh the risks as long as you are aware of the tradeoff.

Of course there are corresponding methods for Sell, SellShort, and Cover as well. All these methods are convenience methods that create and submit the appropriate order object under the hood. If for some reason you need more control you can always submit orders directly. For example, below we check if there is a long position and then submit a sellstop order directly to the SubmitOrder method.

if (MarketPosition == PositionSide.Long)
{
    SubmitOrder(new StopOrder(OrderSide.Sell, CurrentPosition.Quantity, Symbol, CurrentPosition.Stop) { SignalName = "Protective stop" });
}

But there is rarely a reason to do this. After all, abstracting away the underlying complexity to speed the testing process, while still preserving the ability to respond to events as needed, is a major part of the backtester's appeal.

Time in force

By default all orders are good for the next bar only, whether that is a daily, weekly or even a one minute bar. If you prefer, you can submit orders directly and set TimeInForce equal to TimeInForce.GTC but then it would be incumbent on you to manage these orders. For efficiency in backtesting, it's generally advisable to use the built-in convenience methods and the default TimeInForce rather than creating and managing your own orders.

var pending = GetPendingOrders().OfType<StopOrder>().Where(x => x.Side == OrderSide.Sell);

foreach (var sellStop in pending)
{
    CancelOrder(sellStop);
}