Overall Statistics
Total Orders
758
Average Win
0.74%
Average Loss
-0.37%
Compounding Annual Return
141.886%
Drawdown
8.100%
Expectancy
0.637
Start Equity
100000
End Equity
242277.03
Net Profit
142.277%
Sharpe Ratio
4.117
Sortino Ratio
7.06
Probabilistic Sharpe Ratio
99.893%
Loss Rate
45%
Win Rate
55%
Profit-Loss Ratio
1.98
Alpha
0.764
Beta
0.505
Annual Standard Deviation
0.2
Annual Variance
0.04
Information Ratio
3.536
Tracking Error
0.2
Treynor Ratio
1.63
Total Fees
$14057.89
Estimated Strategy Capacity
$0
Lowest Capacity Asset
RVP S4EG8P2U49R9
Portfolio Turnover
12.68%
from AlgorithmImports import *
import json
import math
import pandas as pd
from datetime import timedelta
from io import StringIO
import os

class Form4TradingAlgorithm(QCAlgorithm):
    def Initialize(self):
        # self.csv_file_name = "StrategyA-2012-2023.csv"
        # self.csv_file_name = "StrategyA_2024.csv"
        # self.csv_file_name = "StrategyA_2024_2.csv"
        # self.csv_file_name = "StrategyA_2024_V2.csv"
        self.csv_file_name = "StrategyA_2024_V3.csv"
        # self.csv_file_name = "StrategyA_allInsiderTrades-2011-2023.csv"
        start_year = 2024
        end_year = 2024
        starting_capital = 100000
        maxSimultaneousOpenPositions = 10
        leverage = 1

        self.SetStartDate(start_year, 1, 1)  # Start backtesting period
        self.SetEndDate(end_year, 12, 31)  # End backtesting period
        
        self.SetCash(starting_capital)  # Initial capital for each year
        self.maxSimultaneousOpenPositions = maxSimultaneousOpenPositions
        self.maxInitialCapitalPerPosition = starting_capital / self.maxSimultaneousOpenPositions
        self.leverage = leverage # was 3: great results
        
        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.Margin)
        self.stopLossThreshold = -0.15  # -15% stop loss

        self.openPositions = []  # Track open positions

        self.allInsiderTrades = self.LoadAllInsiderTrades()

        self.AddEquity("SPY", Resolution.Daily)
        self.SetBenchmark("SPY")

        # Add ticker to universe to receive pricing data
        for _, insiderTrade in self.allInsiderTrades.iterrows():
            ticker = insiderTrade['ticker']
            # QC default leverage: 2
            # this means algo can use twice the value of portfolio (50% margin requirement) when trading equities.
            # self.AddEquity(ticker, Resolution.Daily, leverage=2)
            self.AddEquity(ticker, Resolution.Daily, leverage=self.leverage)


        self.Schedule.On(self.DateRules.EveryDay("SPY"), # Align with the trading calendar of SPY, excluding weekends and holidays
                        # SPY: Symbol of the security whose market open time is being used as a reference
                        # 0: This specifies how many minutes after the market opens the function should be triggered.
                        self.TimeRules.AfterMarketOpen("SPY", 1), 
                        self.OnMarketOpen)

        # realize all unrealized gains/losses at last trading day of the year
        # self.ScheduleLiquidateOnLastTradingDay()

        self.Debug(f"Starting year: {start_year}")


    # def OnData(self, data):
    def OnMarketOpen(self):
        # self.Debug(f"Market open logic executed at: {self.Time}")
        # Check open positions
        self.CheckOpenPositions()

        # Get filings for the previous trading day
        todaysInsiderTrades = self.GetTodaysInsiderTrades()

        if len(todaysInsiderTrades) == 0:
            return

        tickersToSkip = todaysInsiderTrades[todaysInsiderTrades['ticker'].apply(lambda ticker: self.Portfolio[ticker].Invested)]
        if len(tickersToSkip) > 0:
            tickersToSkip = tickersToSkip['ticker'].to_list()
            self.Debug(f"{self.Time} - Skipping tickers: {tickersToSkip}")

        # Filter insider trades to only include tickers that are not already 
        # included as long open position in portfolio
        todaysInsiderTrades = todaysInsiderTrades[~todaysInsiderTrades['ticker'].apply(lambda ticker: self.Portfolio[ticker].Invested)]

        if len(todaysInsiderTrades) == 0:
            return

        # only trade liquid stocks
        # todaysInsiderTrades = todaysInsiderTrades[todaysInsiderTrades['ticker'].apply(lambda ticker: self.IsVolumeAboveThreshold(ticker))]
        # todaysInsiderTrades = todaysInsiderTrades[todaysInsiderTrades['ticker'].apply(lambda ticker: self.IsTradeable(ticker))]
        # todaysInsiderTrades = todaysInsiderTrades[todaysInsiderTrades['ticker'].apply(lambda ticker: self.IsNotTooCheap(ticker))]

        # de-duplicate on ticker, keep first occurence of ticker
        # NOTE: performance drops with de-duplication is applied
        # todaysInsiderTrades = todaysInsiderTrades.drop_duplicates(subset='ticker', keep='first')

        if len(todaysInsiderTrades) == 0:
            return

        # Calculate available slots and capital allocation
        availableSlots = self.maxSimultaneousOpenPositions - len(self.openPositions)

        if availableSlots <= 0:
            return

        availableCash = self.Portfolio.Cash
        availableBuyingPower = availableCash + (self.Portfolio.TotalPortfolioValue - availableCash) * (self.leverage - 1)
        positionSize = availableBuyingPower / availableSlots
        positionSize = min(positionSize, self.maxInitialCapitalPerPosition)

        for _, insiderTrade in todaysInsiderTrades[:availableSlots].iterrows():
            # Fetch current stock price
            ticker = insiderTrade['ticker']
            openPrice = self.Securities[ticker].Open

            if openPrice is None or openPrice <= 0:
                continue

            numShares = math.floor(positionSize / openPrice)
            
            if numShares > 0:
                # self.Debug(f"Buying {ticker} at {self.Time} for ${openPrice} ({numShares} shares)")
                # self.MarketOnOpenOrder(ticker, numShares)
                self.MarketOrder(ticker, numShares)

                self.openPositions.append({
                    "Symbol": ticker,
                    "EntryDate": self.Time,
                    "EntryPrice": openPrice,
                    "NumShares": numShares,
                    "StopLossPrice": openPrice * (1 + self.stopLossThreshold)
                })


    def CheckOpenPositions(self):
        for position in self.openPositions[:]:
            symbol = position["Symbol"]
            stopLossPrice = position["StopLossPrice"]
            entryDate = position["EntryDate"]
            numShares = position["NumShares"]

            currentPrice = self.Securities[symbol].Open

            if currentPrice is None:
                continue

            # calender days    
            # daysHeld = (self.Time - entryDate).days
            # business days
            daysHeld = len(pd.date_range(start=entryDate, end=self.Time, freq='B')) - 1

            if currentPrice <= stopLossPrice or daysHeld >= 5:
                # self.Liquidate(symbol) # Market order at the date/time this function runs
                self.MarketOrder(symbol, -1 * numShares)
                self.openPositions.remove(position)


    def GetTodaysInsiderTrades(self):
        today = self.Time.date()
        return self.allInsiderTrades[self.allInsiderTrades['date'] == pd.Timestamp(today)]


    def LoadAllInsiderTrades(self):
        try:
            # data = self.ObjectStore.ReadBytes(self.objectStoreKey)
            # if data:
                # filings = json.loads(data)
                # return [f for f in filings if f['Date'] == (self.Time - timedelta(days=1)).strftime('%Y-%m-%d')]
            csv_data = self.ObjectStore.Read(self.csv_file_name)
            # df = pd.read_csv(StringIO(csv_data), usecols=['index', 'issuerTicker', 'filedAt'], sep=",")
            df = pd.read_csv(StringIO(csv_data), sep=",")
            df.rename(columns={'index': 'date', 'issuerTicker': 'ticker'}, inplace=True)
            df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d')
            if "filedAt" in df.columns:
                self.Debug("Sorting insiders by 'filedAt'")
                df.sort_values(by='filedAt', inplace=True)
            else:
                self.Debug("Sorting insiders by 'date'")
                df.sort_values(by='date', inplace=True)

            self.Debug(f"Loaded {len(df):,.0f} insider trades")
            return df
        except Exception as e:
            self.Debug(f"Error reading CSV: {e}")
            return


    def ScheduleLiquidateOnLastTradingDay(self):
        # Get the last calendar day of the year
        lastCalendarDay = datetime(self.Time.year, 12, 31)

        # Find the last trading day
        lastTradingDay = self.Securities["SPY"].Exchange.Hours.GetPreviousTradingDay(lastCalendarDay)

        # Schedule the liquidation at market open on the last trading day
        self.Schedule.On(
            self.DateRules.On(lastTradingDay.year, lastTradingDay.month, lastTradingDay.day),
            # self.TimeRules.AfterMarketOpen("SPY", 2),
            self.TimeRules.BeforeMarketClose("SPY", 10), # 10 min before market close
            self.LiquidateAllPositions
        )

    def LiquidateAllPositions(self):
        self.Liquidate()  # Liquidates all positions
        self.Debug(f"All positions liquidated on {self.Time}.")


    def IsTradeable(self, ticker):
        return self.IsVolumeAboveThreshold(ticker) and self.IsNotTooCheap(ticker)

    
    def IsNotTooCheap(self, ticker, limit_price=5):
        return self.Securities[ticker].Open >= limit_price

    
    def IsVolumeAboveThreshold(self, ticker, volumeThreshold=100000):
        try:
            symbol = Symbol.Create(ticker, SecurityType.Equity, Market.USA)
            history = self.History(symbol, 20, Resolution.Daily)  # Fetch 20 days of historical data
            if history.empty:
                return False
            # Convert history to a DataFrame
            history_df = history.loc[symbol]
            avgVolume = history_df['volume'].mean()
            return avgVolume >= volumeThreshold
        except Exception as e:
            self.Debug(f"Error checking volume for {ticker}: {e}")
            return False