Dynamic Assets Selection

This chapter covers methods for dynamic stock selection in trading strategies. You can adapt these methods to your needs, using the examples below to develop your own stock selection criteria. Try to include many stocks in your portfolio to keep it diversified.

Example strategy in Python

Here is a basic strategy that uses moving averages to decide when to buy and sell stocks:

import xarray as xr
import qnt.data as qndata
import qnt.ta as qnta
import qnt.stats as qnstats
import qnt.filter as qnfilter

# Load stock data
data = qndata.stocks.load_ndx_data(min_date="2005-01-01")


def strategy(data):
    close = data.sel(field="close")
    ma_slow = qnta.sma(close, 30)
    ma_fast = qnta.sma(close, 10)
    return xr.where(ma_fast > ma_slow, 1, 0)


weights = strategy(data)

Applying to liquid assets

This liquidity filter targets Nasdaq 100 assets and is the main filter you should use, especially if you plan to enter competitions. It multiplies the strategy weights by a liquidity indicator, which removes non-liquid stocks:

weights = weights * data.sel(field="is_liquid")

Trading stocks with different volatilities

You can choose stocks with different levels of volatility:

  • Low Volatility: Trade 150 stocks with the lowest volatility over the past 60 days.

  • High Volatility: Focus on the 150 most volatile stocks.

import qnt.filter as qnfilter

# Low Volatility
low_volatility = qnfilter.filter_volatility(data=data, rolling_window=60, top_assets=150, metric="std", ascending=True)
weights = weights * low_volatility

# High Volatility
high_volatility = qnfilter.filter_volatility(data=data, rolling_window=60, top_assets=150, metric="std",
                                             ascending=False)
weights = weights * high_volatility

Selecting stocks by Sharpe ratio

Select stocks that show the best results by Sharpe ratio:

import qnt.stats as qnstats
import qnt.filter as qnfilter


def filter_sharpe_ratio(data, weights, top_assets):
    stats_per_asset = qnstats.calc_stat(data, weights, per_asset=True)
    sharpe_ratio = stats_per_asset.sel(field="sharpe_ratio")
    return qnfilter.rank_assets_by(data, sharpe_ratio, top_assets, ascending=False)


asset_filter = filter_sharpe_ratio(data, weights, 150)
weights = weights * asset_filter

# weights = weights * qnfilter.filter_sharpe_ratio(data, weights, 150) # this can be done in one line

Volatility using a rolling window

This method filters stocks by volatility calculated over a specified time window:

import qnt.stats as qnstats
import qnt.filter as qnfilter


def filter_volatility_rolling(data, weights, top_assets, rolling_window, metric="std", ascending=True):
    stats_per_asset = qnstats.calc_stat(data, weights, per_asset=True)
    volatility = stats_per_asset.sel(field="volatility")
    volatility = qnfilter.calc_rolling_metric(volatility, rolling_window, metric)
    return qnfilter.rank_assets_by(data, volatility, top_assets, ascending)


asset_filter = filter_volatility_rolling(data, weights, 150, 60, "std", True)
weights = weights * asset_filter

# Same way to calculate Low Volatility
# weights = weights * qnfilter.filter_volatility_rolling(data=data,
#                                                     weights=strategy(data),
#                                                     top_assets=150,
#                                                     rolling_window=60,
#                                                     metric="std",
#                                                     ascending=True)

Filtering stocks by normalized average true range (NATR)

The Normalized Average True Range (NATR) adjusts the Average True Range (ATR) for the price level of the asset. This gives a percentage-based measure, making it easier to compare volatility across stocks at different price levels.

import qnt.filter as qnfilter
import qnt.ta as qnta


def filter_by_normalized_atr(data, top_assets, ma_period=90, ascending=True):
    high = data.sel(field='high')
    low = data.sel(field='low')
    close = data.sel(field='close')

    # Calculating ATR and then Normalized ATR
    atr = qnta.atr(high=high, low=low, close=close, ma=ma_period)
    natr = 100 * (atr / close)  # Normalized ATR

    # Ranking assets based on NATR
    natr_ranks = qnfilter.rank_assets_by(data=data, criterion=natr, top_assets=top_assets, ascending=ascending)

    return natr_ranks


asset_filter = filter_by_normalized_atr(data, top_assets=150, ma_period=90, ascending=True)
weights = weights * asset_filter

# Same way calculate Low Volatility
# weights = weights * qnfilter.filter_by_normalized_atr(data, top_assets=50, ma_period=90, ascending=True)

These methods give you more flexibility when building stock trading strategies and can improve your portfolio’s performance.

Risk Management

This section covers risk management techniques for keeping your trading outcomes stable and controlled. These approaches help limit financial exposure and improve portfolio performance through systematic checks.

Exposure check

Managing each asset’s exposure in a portfolio is a key part of risk management. Exposure is the absolute value of each asset relative to the total portfolio value, which matters for assessing how much risk each asset adds.

Below is an example Python function that calculates the exposure for each position within a portfolio and checks if the exposure exceeds predefined limits:

import qnt.stats as qnstats
import qnt.exposure as qnexp

qnstats.check_exposure(portfolio_history=weights,
                       soft_limit=0.05, hard_limit=0.1,
                       days_tolerance=0.02, excess_tolerance=0.02,
                       avg_period=252, check_period=252 * 5
                       )

How it works

  • Exposure Calculation: Computes the ratio of the absolute value of each position to the total portfolio value.

  • Soft and Hard Limits: Defines thresholds that should not be exceeded to adequately control risk.

    • Soft Limit (0.05): This is a threshold indicating a cautionary level of exposure. If the exposure of any asset exceeds this limit, it may warrant attention but isn’t critical yet.

    • Hard Limit (0.1): This threshold indicates a critical level of exposure. Exceeding this limit is considered risky and requires immediate action.

  • Tolerance Levels: Specifies the acceptable levels for brief periods of increased risk.

    • Days Tolerance (0.02): Represents the proportion of the checking period that can tolerate exposures between the soft and hard limits.

    • Excess Tolerance (0.02): The maximum allowable average excess exposure above the soft limit during the check period.

  • Historical Analysis: Uses historical data to evaluate the risk profile over time and ensure compliance with set limits, considering the past data specified by avg_period (252 days) and check_period (1260 days, or 252 days multiplied by 5).

Detailed function explanation

The check_exposure function evaluates portfolio risk by comparing each asset’s exposure against set risk thresholds. It performs several checks:

  • Maximum Exposure Analysis: Identifies periods where the exposure of any asset exceeds the soft limit and logs these occurrences.

  • Compliance Checks:

    • Checks if the proportion of days with exposures exceeding the soft limit is within the days tolerance.

    • Analyzes if the average excess exposure is within the excess tolerance.

    • Ensures that no asset’s exposure ever exceeds the hard limit during the check period.

This approach keeps the portfolio’s risk profile balanced according to the predefined risk parameters.

Applying exposure filters

Adjusting weights based on exposure checks is important for maintaining the desired risk profile. Here are functions that manage exposure by modifying the investment weights:

normalize_by_max_exposure

Helper function which normalizes weights based on the highest daily exposure, ensuring that the exposure of each asset does not exceed a specified maximum exposure limit, while keeping daily weights allocation ratio among assets.

def normalize_by_max_exposure(weights, max_exposure=0.1):
    daily_max = abs(weights).max("asset")
    normalizer = xr.where(daily_max > max_exposure, daily_max / max_exposure, 1)
    return weights / normalizer

weights.to_pandas().tail(2)
    asset 	NAS:AAPL 	NAS:GOOG 	NAS:AMGN
time 			
2024-04-23 	0.319466 	0.095525 	0.022927
2024-04-24 	0.317989 	0.095365 	0.022855
normalize_by_max_exposure(weights).to_pandas().tail(2)
    asset 	NAS:AAPL 	NAS:GOOG 	NAS:AMGN
time 			
2024-04-23 	0.1 	0.029902 	0.007177
2024-04-24 	0.1 	0.029990 	0.007187

drop_bad_days

Removes positions exceeding the maximum weight for any asset, thus reducing exposure.

import qnt.exposure as qnexp

weights_filtered = qnexp.drop_bad_days(weights=weights, max_weight=0.049)

mix_weights

Combines two sets of weights (primary and secondary) while ensuring the maximum exposure does not exceed a specific threshold.

import qnt.exposure as qnexp

weights_filtered = qnexp.mix_weights(primary=weights1, secondary=weights2, max_weight=0.049)

cut_big_positions

Caps the weights of positions that exceed the maximum allowable weight to limit exposure.

import qnt.exposure as qnexp

weights_filtered = qnexp.cut_big_positions(weights=weights, max_weight=0.049)

These methods are part of a solid risk management system and help protect against market volatility while keeping the portfolio stable.

Full code example

import xarray as xr
import qnt.data as qndata
import qnt.output as qnout
import qnt.ta as qnta
import qnt.stats as qnstats
import qnt.filter as qnfilter
import qnt.exposure as qnexp

# Load stock data
data = qndata.stocks.load_ndx_data(min_date="2005-01-01")


def strategy(data):
    close = data.sel(field="close")
    ma_slow = qnta.sma(close, 30)
    ma_fast = qnta.sma(close, 10)
    return xr.where(ma_fast > ma_slow, 1, 0)


weights = strategy(data)

# Applying to Liquid Assets

weights = weights * data.sel(field="is_liquid")

# Trading Stocks with Different Volatilities

# Low Volatility
# low_volatility = qnfilter.filter_volatility(data=data, rolling_window=60, top_assets=150, metric="std", ascending=True)
# weights_low_volatility = weights * low_volatility

# High Volatility
# high_volatility = qnfilter.filter_volatility(data=data, rolling_window=60, top_assets=150, metric="std",
#                                              ascending=False)
# weights_high_volatility = weights * high_volatility

# Selecting Stocks by Sharpe Ratio
# weights_filter_sharpe_ratio = weights * qnfilter.filter_sharpe_ratio(data, weights, 150)

# Same way calculate Low Volatility
# weights_roll = weights * qnfilter.filter_volatility_rolling(data=data,
#                                                     weights=strategy(data),
#                                                     top_assets=150,
#                                                     rolling_window=60,
#                                                     metric="std",
#                                                     ascending=True)

# Same way calculate Low Volatility
# weights = weights * qnfilter.filter_by_normalized_atr(data, top_assets=50, ma_period=90, ascending=True)

qnstats.check_exposure(portfolio_history=weights,
                       soft_limit=0.05, hard_limit=0.1,
                       days_tolerance=0.02, excess_tolerance=0.02,
                       avg_period=252, check_period=252 * 5
                       )

# Removes positions exceeding the maximum weight for any asset, thus reducing exposure.
# weights_filtered = qnexp.drop_bad_days(weights=weights, max_weight=0.049)

# Combines two sets of weights (primary and secondary) while ensuring the maximum exposure does not exceed a specific
# threshold.
# weights_filtered = qnexp.mix_weights(primary=weights1, secondary=weights2, max_weight=0.049)

# Caps the weights of positions that exceed the maximum allowable weight to limit exposure.
weights_filtered = qnexp.cut_big_positions(weights=weights, max_weight=0.049)

stats = qnstats.calc_stat(data, weights.sel(time=slice("2006-01-01", None)))
display(stats.to_pandas().tail())

qnout.check(weights_filtered, data, "stocks_nasdaq100")
qnout.write(weights_filtered)  # to participate in the competition. run this line in a separate cell