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 includeBalsam.DataServers
andBalsam.MoneyManagement
.Next we create a class called
SimpleSma
which inherits from Strategy, an abstract base class containing core backtesting functionality. All strategies must inherit fromStrategy
(or a derivative of it.)Within the new
SimpleSma
class, we override the virtual methodOnStrategyStart()
. 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-inTimeSeries
variablesCol1
andCol2
. 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 ownTimeSeries
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 theCol1
andCol2
variables inOnStrategyStart()
.Col1.Last
is an alias forCol1[0]
. UsingLast
can make code a little easier to understand at a glance. But you might be wondering, ifLast
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);
}