Overall Statistics
Total Orders
219
Average Win
0.54%
Average Loss
-0.39%
Compounding Annual Return
19.353%
Drawdown
15.600%
Expectancy
0.502
Start Equity
1000000
End Equity
1382226.13
Net Profit
38.223%
Sharpe Ratio
0.724
Sortino Ratio
1.071
Probabilistic Sharpe Ratio
37.727%
Loss Rate
37%
Win Rate
63%
Profit-Loss Ratio
1.40
Alpha
0
Beta
0
Annual Standard Deviation
0.168
Annual Variance
0.028
Information Ratio
0.868
Tracking Error
0.168
Treynor Ratio
0
Total Fees
$948.05
Estimated Strategy Capacity
$120000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
9.73%
# region imports
from AlgorithmImports import *

from etf_by_country import etf_by_country
from country_etf import CountryETF
# endregion

class CountryRotationAlphaModel(AlphaModel):

    security_by_country = {}

    def __init__(self, algorithm, lookback_days):
        self.algorithm = algorithm
        self.LOOKBACK_PERIOD = timedelta(lookback_days)

        # Subscribe to Reg Alerts dataset
        self.dataset_symbol = algorithm.add_data(RegalyticsRegulatoryArticles, "REG").symbol

        # Schedule model training sessions
        algorithm.train(algorithm.date_rules.month_end(), algorithm.time_rules.at(23,0), self.train_model)

    def train_model(self):
        reg_history = self.algorithm.history[RegalyticsRegulatoryArticles](self.dataset_symbol, self.LOOKBACK_PERIOD)

        for security in self.security_by_country.values():
            # Get daily returns of country ETF
            etf_history = self.algorithm.history(security.symbol, self.LOOKBACK_PERIOD, Resolution.DAILY, data_normalization_mode=DataNormalizationMode.SCALED_RAW)
            if etf_history.empty:
                continue
            security.etf.update_word_sentiments(reg_history, etf_history)

    def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        # Only emit insights when we get a new Regalytics alert
        if not data.contains_key(self.dataset_symbol):
            return []
        
        # Calculate sentiment for each country in the universe
        sentiments_by_symbol = {}
        for article in data[self.dataset_symbol]:
            countries = [kvp.key for kvp in article.states]
            for country in countries:
                if country not in self.security_by_country:
                    continue
                security = self.security_by_country[country]
                if security.symbol not in sentiments_by_symbol:
                    sentiments_by_symbol[security.symbol] = []
                
                article_sentiment = security.etf.get_sentiment(article)
                sentiments_by_symbol[security.symbol].append(article_sentiment)
        if not sentiments_by_symbol:
            return []
        
        # Aggregate the sentiment of each country across all of the articles that reference the country
        sentiment_by_symbol = {
            symbol: sum(sentiments)/len(sentiments)
                for symbol, sentiments in sentiments_by_symbol.items()
        }

        # Calculate portfolio weight of each country ETF
        base_weight = 1 / len(sentiment_by_symbol) 
        weight_by_symbol = {}
        for security in self.security_by_country.values():
            symbol = security.symbol
            # Check if there is material news
            if symbol not in sentiment_by_symbol or sentiment_by_symbol[symbol] == 0:
                weight_by_symbol[symbol] = 0
                continue

            # Check if the security is liquid enough to trade
            if not security.avg_liquidity.is_ready or base_weight * algorithm.portfolio.total_portfolio_value > 0.01 * security.avg_liquidity.current.value:
                weight_by_symbol[symbol] = 0
                continue

            long_short_bias = 1 if sentiment_by_symbol[symbol] > 0 else -1
            weight_by_symbol[symbol] = long_short_bias * base_weight

        # Calculate a factor to scale portfolio weights so the algorithm uses 1x leverage
        positive_weight_sum = sum([weight for weight in weight_by_symbol.values() if weight > 0])
        negative_weight_sum = sum([weight for weight in weight_by_symbol.values() if weight < 0])
        scale_factor = positive_weight_sum + abs(negative_weight_sum)
        if scale_factor == 0:
            return []

        # Place orders to rebalance portfolio
        insights = []
        for symbol, weight in weight_by_symbol.items():
            if weight == 0:
                algorithm.insights.cancel([symbol])
                continue
            direction = InsightDirection.UP if weight > 0 else InsightDirection.DOWN
            insights.append(Insight.price(symbol, timedelta(7), direction, weight=weight/scale_factor))
                
        return insights

    def on_securities_changed(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        for security in changes.added_securities:
            countries = [country for country, ticker in etf_by_country.items() if ticker == security.symbol.value]
            if countries:
                country = countries[0]
                security.etf = CountryETF(country, security.symbol)

                # Create indicator to track average liquidity
                security.avg_liquidity = SimpleMovingAverage(21*3)
                bars = algorithm.history[TradeBar](security.symbol, security.avg_liquidity.period, Resolution.DAILY, data_normalization_mode=DataNormalizationMode.SCALED_RAW)
                for bar in bars:
                    security.avg_liquidity.update(bar.end_time, bar.close*bar.volume)
                # Create a consolidator to update to the indicator
                consolidator = TradeBarConsolidator(timedelta(days=1))
                consolidator.data_consolidated += lambda _, consolidated_bar: algorithm.securities[consolidated_bar.symbol].avg_liquidity.update(consolidated_bar.end_time, consolidated_bar.close*consolidated_bar.volume)
                algorithm.subscription_manager.add_consolidator(security.symbol, consolidator)

                self.security_by_country[country] = security
        
        self.train_model()
#region imports
from AlgorithmImports import *
#endregion
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize

class CountryETF:
    STOP_WORDS = set(stopwords.words('english'))

    def __init__(self, country, symbol):
        self.country = country
        self.symbol = symbol
        self.future_returns_by_word = {}

    def update_word_sentiments(self, reg_history, etf_history):
        daily_returns = etf_history.loc[self.symbol]['open'].pct_change()[1:]

        # Get articles that are tagged with this country
        future_returns_by_word = {}
        for articles in reg_history:
            future_returns = daily_returns.loc[daily_returns.index > articles.time]
            if len(future_returns) < 2:
                continue
            future_return = future_returns.iloc[1]

            for article in articles:
                if self.country not in [kvp.key for kvp in article.states]:
                    continue
                filtered_words = self.filter_words(article)
                for word in filtered_words:
                    if word not in future_returns_by_word:
                        future_returns_by_word[word] = []
                    future_returns_by_word[word].append(future_return)

        self.future_returns_by_word = {
            word: sum(future_returns)/len(future_returns) 
                for word, future_returns in future_returns_by_word.items()
        }
    
    def filter_words(self, article):
        word_tokens = word_tokenize(article.title)
        return list(set([w for w in word_tokens if not w.lower() in self.STOP_WORDS and w not in [",", ".", "-"]]))

    def get_sentiment(self, article):
        if len(self.future_returns_by_word) == 0:
            return 0
        filtered_words = self.filter_words(article)
        future_returns = []
        for word in filtered_words:
            if word not in self.future_returns_by_word:
                continue
            future_returns.append(self.future_returns_by_word[word])
        if len(future_returns) == 0:
            return 0
        return sum(future_returns)/len(future_returns)
#region imports
from AlgorithmImports import *
#endregion
etf_by_country = {
    "Croatia" : "FM",
    "Singapore" : "EWS",
    "Hungary" : "CRAK",
    "Denmark" : "EDEN",
    "Norway" : "NORW",
    "Saudi Arabia" : "FLSA",
    "United Kingdom" : "EWUS",
    "Republic of the Philippines" : "EPHE",
    "Romania" : "FM",
    "Sweden" : "EWD",
    "France" : "FLFR",
    "Brazil" : "EWZS",
    "Luxembourg" : "SLX",
    "Japan" : "DFJ",
    "China" : "CHIU",
    "Libya" : "BRF",
    "Pakistan" : "PAK",
    "Germany" : "FLGR",
    "Sri Lanka" : "FM",
    "Greece" : "GREK",
    "Finland" : "EFNL",
    "Ireland" : "EIRL",
    "United Arab Emirates" : "UAE",
    "Qatar" : "QAT",
    "The Bahamas" : "RNSC",
    "Vietnam" : "VNAM",
    "Czech Republic" : "NLR",
    "Portugal" : "PGAL",
    "Austria" : "EWO",
    #"Russia" : "FLRU",
    "Turkey" : "TUR",
    "South Korea" : "FLKR",
    "Hong Kong" : "EWH",
    "Belgium" : "EWK",
    "Cyprus" : "SIL",
    "Switzerland" : "EWL",
    "Thailand" : "THD",
    "United States": "SPY",
    "Colombia" : "GXG",
    "Malta" : "BETZ",
    "Canada" : "FLCA",
    "Israel" : "EIS",
    "Indonesia" : "EIDO",
    "Spain" : "EWP",
    "Malaysia" : "EWM",
    "Ukraine" : "TLTE",
    "Poland" : "EPOL",
    "Argentina" : "ARGT",
    "Estonia" : "FM",
    "Taiwan" : "FLTW",
    "Netherlands" : "EWN",
    "Mexico" : "FLMX",
    "Australia" : "EWA",
    "Italy" : "FLIY",
    "Egypt" : "EGPT"
}


# Croatia: https://etfdb.com/country/croatia/ (FM 0.37%)
# Singapore: https://etfdb.com/country/singapore/ (EWS 91.83%)
# Afghanistan : None
# Hungary: https://etfdb.com/country/hungary/ (CRAK 4.55%)
# Denmark: https://etfdb.com/country/denmark/ (EDEN 97.84%)
# Norway: https://etfdb.com/country/norway/ (NORW 90.11%)
# Saudi Arabia: https://etfdb.com/country/saudi-arabia/ (FLSA 100%)
# United Kingdom: https://etfdb.com/country/united-kingdom/ (EWUS 95.65%)
# Republic of the Philippines: https://etfdb.com/country/philippines/ (EPHE 99.59%)
# Romania: https://etfdb.com/country/romania/ (FM 5.78%)
# Sweden: https://etfdb.com/country/sweden/ (EWD 91.48%)
# France: https://etfdb.com/country/france/ (FLFR 94.29%)
# Brazil: https://etfdb.com/country/brazil/ (EWZS 96.91%)
# Luxembourg: https://etfdb.com/country/luxembourg/ (SLX 5.65%)
# Japan: https://etfdb.com/country/japan/ (DFJ 99.93%)
# China: https://etfdb.com/country/china/ (CHIU 100%)
# Libya: https://etfdb.com/country/libya/ (BRF 0.4%)
# Pakistan: https://etfdb.com/country/pakistan/ (PAK 85.82%)
# Germany: https://etfdb.com/country/germany/ (FLGR 97.7%)
# Sri Lanka: https://etfdb.com/country/sri-lanka/ (FM 1.11%)
# Greece: https://etfdb.com/country/greece/ (GREK 94.75%)
# Tajikistan: None
# Finland: https://etfdb.com/country/finland/ (EFNL 99.42%)
# Ireland: https://etfdb.com/country/ireland/ (EIRL 72.84%)
# United Arab Emirates: https://etfdb.com/country/united-arab-emirates/ (UAE 96.32%)
# Qatar : https://etfdb.com/country/qatar/ (QAT 99.96%)
# The Bahamas : https://etfdb.com/country/bahamas/ (RNSC 0.35%)
# Iceland : None
# Vietnam : https://etfdb.com/country/vietnam/ (VNAM 100%; VNM has more history but 65.55% weight)
# Republic of Kosovo : None
# Latvia : None
# Czech Republic : https://etfdb.com/country/czech-republic/ (NLR 4.29%)
# Slovakia : None
# Portugal : https://etfdb.com/country/portugal/ (PGAL 95.59%)
# Austria : https://etfdb.com/country/austria/ (EWO 92.18%)
# Russia : https://etfdb.com/country/russia/ (FLRU 93.65%; Missing data for last year)
# Turkey : https://etfdb.com/country/turkey/ (TUR 92.79%)
# South Korea : https://etfdb.com/country/south-korea/ (FLKR 99.44%)
# Slovenia : None
# Hong Kong : https://etfdb.com/country/hong-kong/ (EWH 85.66%)
# Belgium : https://etfdb.com/country/belgium/ (EWK 83.52%)
# Cyprus : https://etfdb.com/country/cyprus/ (SIL 12.88%)
# Switzerland : https://etfdb.com/country/switzerland/ (EWL 99.27%)
# Bulgaria : None
# Thailand : https://etfdb.com/country/thailand/ (THD 98.14%)
# United States : https://etfdb.com/country/united-states/  (SPY???)
# Colombia : https://etfdb.com/country/colombia/ (GXG 92.79%)
# Botswana : None
# Malta : https://etfdb.com/country/malta/ (BETZ 10.29%)
# Canada : https://etfdb.com/country/canada/ (FLCA 89.45%; There is also EWC iShares MSCI Canada)
# Barbados : None
# Israel : https://etfdb.com/country/israel/ (EIS 79.68%)
# Indonesia : https://etfdb.com/country/indonesia/ (EIDO 73.15%)
# Albania : None
# Spain : https://etfdb.com/country/spain/ (EWP 93.33%)
# Malaysia : https://etfdb.com/country/malaysia/ (EWM 95.62%)
# Ukraine : https://etfdb.com/country/ukraine/ (TLTE 0.11%)
# Poland : https://etfdb.com/country/poland/ (EPOL 87.85%)
# Argentina : https://etfdb.com/country/argentina/ (ARGT 52.07%)
# Estonia : https://etfdb.com/country/estonia/ (FM 0.65%)
# Taiwan : https://etfdb.com/country/taiwan/ (FLTW 98.04%; There is also EWT iShares MSCI Taiwan)
# Solomon Islands : None
# Netherlands : https://etfdb.com/country/netherlands/ (EWN 90.03%)
# Mexico : https://etfdb.com/country/mexico/ (FLMX 87.86%; There is also EWW iShares MSCI Mexico)
# Australia : https://etfdb.com/country/australia/ (EWA 94.71%)
# Italy : https://etfdb.com/country/italy/ (FLIY 88.50%; There is also EWI iShares MSCI Italy)
# Lithuania : None
# Egypt : https://etfdb.com/country/egypt/ (EGPT 93.56%)
# region imports
from AlgorithmImports import *

from universe import CountryEtfUniverse
from alpha import CountryRotationAlphaModel
from etf_by_country import etf_by_country
# endregion

class CountryRotationAlgorithm(QCAlgorithm):

    undesired_symbols_from_previous_deployment = []
    checked_symbols_from_previous_deployment = False

    def initialize(self):
        self.set_start_date(2021, 9, 1)
        self.set_end_date(2023, 7, 1)
        self.set_cash(1_000_000)

        # Configure algorithm settings
        self.settings.minimum_order_margin_portfolio_percentage = 0

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        
        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        self.add_universe_selection(CountryEtfUniverse())

        self.add_alpha(CountryRotationAlphaModel(self, self.get_parameter("lookback_days", 90)))
        
        self.settings.rebalance_portfolio_on_security_changes = False
        self.settings.rebalance_portfolio_on_insight_changes = False
        self.set_portfolio_construction(InsightWeightingPortfolioConstructionModel(self.rebalance_func))

        self.add_risk_management(NullRiskManagementModel())

        self.set_execution(ImmediateExecutionModel())

        self.set_warm_up(timedelta(31))
    
    def rebalance_func(self, time):
        if self.is_warming_up:
            return None

        # Rebalance when a Regalytics article references one of the countries in the universe
        for kvp in self.current_slice.get[RegalyticsRegulatoryArticles]():
            articles = kvp.value
            for article in articles:
                countries = [kvp.key for kvp in article.states]
                for country in countries:
                    if country in etf_by_country:
                        return time
        return None

    def on_data(self, data):
        # Exit positions that aren't backed by existing insights.
        # If you don't want this behavior, delete this method definition.
        if not self.is_warming_up and not self.checked_symbols_from_previous_deployment:
            for security_holding in self.portfolio.Values:
                if not security_holding.invested:
                    continue
                symbol = security_holding.symbol
                if not self.insights.has_active_insights(symbol, self.utc_time):
                    self.undesired_symbols_from_previous_deployment.append(symbol)
            self.checked_symbols_from_previous_deployment = True
        
        for symbol in self.undesired_symbols_from_previous_deployment[:]:
            if self.is_market_open(symbol):
                self.liquidate(symbol, tag="Holding from previous deployment that's no longer desired")
                self.undesired_symbols_from_previous_deployment.remove(symbol)
# region imports
from AlgorithmImports import *

from etf_by_country import etf_by_country
# endregion

class CountryEtfUniverse(ManualUniverseSelectionModel):
    def __init__(self):
        symbols = [Symbol.create(ticker, SecurityType.EQUITY, Market.USA) for ticker in etf_by_country.values()]
        super().__init__(symbols)