Strategy

The Strategy class is the basis for all backtesting in the SigTech platform. Functionality embedded within the Strategy class is also accessible via the classes inheriting from it.

Since all building blocks inherit from Strategy, using the full functionality of the SigTech platform’s building blocks requires a familiarity with the base class.

This page is relevant to all users of the SigTech platform—learn how to:

  • Build and backtest a strategy: Plot the value of a strategy through time.

  • Name a strategy: Specify a name for a strategy via the ticker parameter.

  • Retrieve a strategy: Fetch strategy objects from your cache via the sig.obj.get API.

  • Clone a strategy: Create a copy of a strategy and change its parameters.

  • Fix the parameters of a strategy: Set the initial cash held by a strategy, determine if that cash accrues interest, and decide whether or not transaction costs are included in the backtest.

Agenda

Environment

Set up your environment using the following three steps:

  • Import the relevant internal and external libraries

  • Configure the environment parameters

  • Initialise the environment

import pandas as pd
import datetime as dtm
from uuid import uuid4
import seaborn as sns
sns.set(rc={'figure.figsize': (18, 6)})

import sigtech.framework as sig

currency = 'USD'
start_date = dtm.date(2020, 1, 2)
end_date = dtm.date(2021, 11, 30)

sig.init(env_date=end_date)

Backtesting

Backtests are conducted by creating an instance of an object that inherits from the Strategy class and calling build on it.

The following code block cycles through some commonly used building blocks and checks that they inherit from Strategy:

example_building_blocks = [
    sig.RollingFutureStrategy, 
    sig.RollingFXForwardStrategy, 
    sig.DynamicOptionsStrategy,
    sig.RollingSwapStrategy,
    sig.ReinvestmentStrategy, 
    sig.RollingBondStrategy,  
    sig.BasketStrategy, 
    sig.SignalStrategy, 
]
for building_block in example_building_blocks:
    print(f'{building_block.__name__}: {issubclass(building_block, sig.Strategy)}')

Once a strategy has been built, the details of the backtest are stored within it and are accessible for further analysis. This includes the value of the strategy and holdings through time, and lower level details, such as the generation and execution of orders.

Note: calling build on a strategy that has already been built has no additional effect.

The following instance of a RollingFutureStrategy object is an example:

Note: strategies generally require a currency and start_date. If an end_date isn't used, the asofdate for the configured environment is used.

rfs = sig.RollingFutureStrategy(
    currency=currency,
    start_date=start_date,
    contract_code='ES',
    contract_sector='INDEX',
)

Working through the build cycle:

rfs.built
rfs.build()
rfs.built

Once a strategy has been built, calling history retrieves a pd.Series of its values or prices:

Note: this is the equivalent of calling history on an instrument to return its value through time.

rfs.history().head()

Tip: it is unnecessary to call build and history separately. Calling history on an unbuilt strategy calls build automatically.

Strategy tickers

When a strategy object is constructed you can assign a name to it, using the ticker parameter. Once constructed, it is added to the cache of the environment.

If a ticker is not provided on construction, the strategy name is determined using the strategy details:

  • Strategy type: such as S for Strategy or BS for BasketStrategy.

  • Currency.

  • A random, eight-digit, alphanumeric combination.

To create a Strategy and display its automatically generated name:

s = sig.Strategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date
)
s.name

To create a Strategy called TEST STRATEGY:

t = sig.Strategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date,
    ticker='test'
)
t.name

Note: strategy names are always written in upper case, withSTRATEGYadded to the end.

In Backtesting, we built a RollingFutureStrategy called rfs. But we did not assign a ticker value. To display the automatically generated name of rfs:

rfs.name

Cache

When a strategy object is created, its name is added to the cache. You can access the cache directly via the configured environment:

env_cache = sig.env().object.cache

Any object that has been accessed is also added to the cache. We can check the size of the cache with the following call:

print(len(env_cache))

You can filter the objects in the cache for those whose name ends with STRATEGY:

sorted([o for o in env_cache.keys() if o.endswith('STRATEGY')])

Note: The results of the previous two cells will vary at different points as you progress through the notebook and add more items to the cache.

Any object listed in the cache is retrieved using the sig.obj.get API. This includes strategies created by users:

test_strategy = sig.obj.get('TEST STRATEGY')
test_strategy

Check this with the original version:

test_strategy == t

Examples

To rebuild rfs and specify a ticker:

rfs_ticker = sig.RollingFutureStrategy(
    currency=currency,
    start_date=start_date,
    contract_code='ES',
    contract_sector='INDEX',

    # specify ticker upon construction
    ticker='ES RFS'
)
rfs_ticker.name

Object name clashes result in an ObjectError, unless the new strategy has identical characteristics to the existing strategy.

In the following code block, despite rfs_duplicate having the same ticker as rfs_ticker, there is no ObjectError as the characteristics of both strategies are identical:

rfs_duplicate = sig.RollingFutureStrategy(
    currency=currency,
    start_date=start_date,
    contract_code='ES',
    contract_sector='INDEX',
    ticker='ES RFS'
)
rfs_duplicate.name

In contrast, the following example displays an error being produced due to rfs_error and rfs_ticker sharing the same ticker, but not the same start_date:

from sigtech.framework.internal.infra.utils.exceptions import ObjectError

try:
    rfs_error = sig.RollingFutureStrategy(
        currency=currency,
        # start date is altered
        start_date=start_date + dtm.timedelta(days=1),
        contract_code='ES',
        contract_sector='INDEX',
        ticker='ES RFS'
    )
except ObjectError as e:
    print(e)

Cloning strategies

The clone_strategy method allows you to create a copy of an existing strategy and vary some of its parameters.

A dictionary of {parameter: new_value} pairs should be supplied. The following example creates a version of rfs in 'EUR' with an updated ticker:

eur_rfs = rfs.clone_strategy({'currency': 'EUR', 'ticker': 'EUR RFS'})
eur_rfs
print(f'Currency: {eur_rfs.currency}')
print(f'Name: {eur_rfs.name}')

Strategy details & parameters

Initial cash holdings

A strategy begins by holding a number of cash units, denominated in the currency of the strategy. The number of units is determined by the initial_cash parameter, with a default value of 1000.

Using the RollingFutureStrategy in Backtesting, plotting the history of rfs results in an initial value of 1000:

rfs.history().plot();

The portfolio table also records an initial 1000 units of USD CASH:

rfs.plot.portfolio_table(
    dts='TOP_ORDER_PTS',
    end_dt=dtm.date(2020, 1, 3)
)

To create a variant of rfs whose initial_cash level is 100:

rfs_initial_cash = rfs.clone_strategy({'ticker':'ES RFS $100', 'initial_cash': 100})
rfs_initial_cash.name

As expected, the history series begins at 100:

rfs_initial_cash.history().head()

Assuming that the transaction cost model is AUM independent, varying initial_cash in this way is purely a matter of scaling and will not impact performance characteristics:

from sigtech.framework.analytics.performance.metrics import annualised_return

rfs_perf = annualised_return(rfs.history().dropna())
rfs_ic_perf = annualised_return(rfs_initial_cash.history().dropna())

print(f'{round(100 * rfs_perf, 3)}%')
print(f'{round(100 * rfs_ic_perf, 3)}%')

An exception to this rule is when initial_cash is set to zero. In this case no positions are opened. It is not possible to compute common metrics, such as return, for such a strategy.

rfs_zero_cash =  rfs.clone_strategy({'ticker':'ES RFS ZERO', 'initial_cash': 0})
rfs_zero_cash.name
rfs_zero_cash.history().plot();

There are multiple ways to remove cash from a strategy whilst still investing in underlyings. In the case of the RollingFutureStrategy, you will need to provide a fixed_contracts parameter and set the initial_cash to zero.

Similar functionality is available for ReinvestmentStrategy objects, using the initial_shares parameter.

Learn more: examples of these cases are provided in Further examples.

Total return

Accruing interest on the cash held within a strategy is optional and can be controlled with the boolean input total_return.

To create a variant of rfs that does not accrue interest on cash held:

rfs_er = rfs.clone_strategy({'ticker':'ES RFS ER', 'total_return': False})
rfs_er.build()

df = pd.DataFrame({'TotalReturn': rfs.history(),
                   'ExcessReturn': rfs_er.history()}).dropna()
df.plot();

The difference in annualised return between the TotalReturn version of the strategy and the ExcessReturn version is approximately 32 basis points:

round(10000 * (rfs_perf - annualised_return(df['ExcessReturn'])))

As a further example, a custom strategy that simply holds its initial cash is created:

Learn more: Custom strategies.

class ExampleStrategy(sig.Strategy):
    def strategy_initialization(self, dt):
        pass

example_strategy = ExampleStrategy(
    currency=currency,
    start_date=dtm.date(2000, 1, 4),
    initial_cash=100
)

To create an alternative version with total_return set to False and plot the performance of both versions of the strategy:

er_example_strategy = example_strategy.clone_strategy({
    'total_return': False
})

pd.DataFrame({
    'TR': example_strategy.history(),
    'ER': er_example_strategy.history()
}).plot();

To switch off total_return at the global level when configuring the environment, use the command: sig.env()[sig.config.EXCESS_RETURN_ONLY] = True.

Transaction costs

The inclusion of transaction costs in a backtest can be controlled with the include_trading_costs parameter.

You can create a variant of rfs that does not assume any trading costs:

rfs_no_tc = rfs.clone_strategy({
    'ticker': 'RFS NO TRADING COSTS', 
    'include_trading_costs': False
})

To compute the impact of the transaction cost assumptions, you can compare the performance of the two strategy variants:

rfs_no_tc_perf = annualised_return(rfs_no_tc.history().dropna())

The impact here is 2bp annualised. This can vary considerably, depending on the underlying and the transaction cost model.

round(10000 * (rfs_no_tc_perf - rfs_perf))

To switch off transaction costs at the global level when configuring the environment, use the following command: sig.env()[sig.config.IGNORE_T_COSTS] = True.

Not reinvesting P&L

You can fix the amount of cash used to determine the position of a strategy at its inception.

Example: signify a fixed capital allocation for a PM regardless of performance.

rfs_initial = sig.RollingFutureStrategy(
    currency=currency,
    start_date=dtm.date(2016, 1, 4),
    contract_code='ES',
    contract_sector='INDEX',
    
    # relevant parameters
    initial_cash=1e6,
    set_weight_from_initial_cash=True,
    ticker='ES RFS FIXED INITIAL CASH'
)
rfs_initial.build()

rfs_varying = rfs_initial.clone_strategy({
    'set_weight_from_initial_cash': False
})

pd.DataFrame({
    'VARYING CASH': rfs_varying.history(),
    'FIXED CASH': rfs_initial.history()
}).plot();

The following portfolio table displays a position of USD 1MM targeted at rebalance, despite the strategy holding close to USD 2MM in cash:

rfs_initial.plot.portfolio_table(
    dts='TOP_ORDER_PTS',
    start_dt=dtm.date(2021, 3, 1),
    end_dt=dtm.date(2021, 4, 1)
)

Long & short positions

Short versions of strategies are created by passing the direction parameter with a value of 'short', as opposed to its default of 'long':

rfs_short = sig.RollingFutureStrategy(
    currency=currency,
    start_date=start_date,
    contract_code='ES',
    contract_sector='INDEX',
    
    # specify 'short' version
    direction='short'
)
rfs_short.build()

In the case of a RollingFutureStrategy, the 'short' version coincides with the negative of the long version:

pd.DataFrame({
    'LONG': rfs.history(),
    'SHORT': -rfs_short.history()
}).plot();

Going short on the long version of the RollingFutureStrategy through a basket delivers the same result as going long on the short version of the RollingFutureStrategy through a basket:

basket_short_long_rfs = sig.BasketStrategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date,
    weights=[-1.0],
    constituent_names=[rfs.name],
    rebalance_frequency='EOM'
)
basket_short_long_rfs.build()

basket_long_short_rfs = sig.BasketStrategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date,
    weights=[1.0],
    constituent_names=[rfs_short.name],
    rebalance_frequency='EOM'
)
basket_long_short_rfs.build()

pd.DataFrame({
    'LONG SHORT-RFS': basket_long_short_rfs.history(),
    'SHORT LONG-RFS': basket_short_long_rfs.history(),
}).plot();

For certain assets there are expected differences caused by market realities. An example is the treatment of dividends when creating a ReinvestmentStrategy:

rs_long = sig.ReinvestmentStrategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date,
    underlyer='1000045.SINGLE_STOCK.TRADABLE',
    ticker='AAPL RS LONG'
)
rs_long.build()

rs_short = rs_long.clone_strategy({'direction': 'short'})

pd.DataFrame({
    'RS LONG': rs_long.history(),
    'RS SHORT': -rs_short.history()
}).plot();

Further examples

Note: These examples illustrate how to specify a specific number of units of underlying to trade whilst using a Building Block wrapper. The precise syntax can vary by asset class.

Rolling Future Strategy

By default the number of contracts held by a RollingFutureStrategy will be determined by the strategy's value and the value of each futures contract. It is possible to specify that a fixed number of contracts be traded by using the fixed_contracts parameter.

rfs_ticker = sig.RollingFutureStrategy(
    currency=currency,
    start_date=start_date,
    contract_code='ES',
    contract_sector='INDEX',
    ticker='ES RFS'
)
rfs_ticker.name
rfs_contract_only = sig.RollingFutureStrategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date,
    contract_code='ES',
    contract_sector='INDEX',
    
    # specific parameters
    initial_cash=0,
    include_trading_costs=False,
    fixed_contracts=1,
    ticker='ES RFS CONTRACT ONLY'
)
rfs_contract_only.build()

rfs_contract_only.history().plot();
rfs_contract_only.plot.portfolio_table(
    dts='VALUATION_PTS',
    start_dt=start_date,
    end_dt=dtm.date(2020, 1, 14),
    unit_type='TRADE'
)

Reinvestment Strategy

To create a ReinvestmentStrategy that holds precisely one unit of the underlying stock the initial_shares parameter can be used. You can also choose whether to include an initial cash balance on the strategy.

rs = sig.ReinvestmentStrategy(
    currency=currency,
    start_date=start_date,
    end_date=end_date,
    underlyer='1000045.SINGLE_STOCK.TRADABLE',
    
    # specific parameters
    initial_cash=0,
    include_trading_costs=False,
    initial_shares=1,
    ticker='AAPL RS STOCK ONLY'
)
rs.build()

rs.history().plot();
rs.plot.portfolio_table(
    dts='VALUATION_PTS',
    start_dt=start_date,
    end_dt=dtm.date(2020, 1, 14),
    unit_type='TRADE'
)

Last updated

© 2023 SIG Technologies Limited