Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Polygon day fix #446

Open
wants to merge 85 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
54aa55a
Fixed day polygon backtesting
Canx May 3, 2024
31032c9
get timestep from data source if not available
Canx May 6, 2024
bce22b4
Use get_timestep() instead of MIN_TIMESTEP
Canx May 6, 2024
1d75d65
timestep should be mandatory in get_historical_prices
Canx May 6, 2024
7313808
make timestep mandatory in _pull_source_symbol_bars
Canx May 6, 2024
2ae2410
Fail fast if timestep not specified in polygon data source
Canx May 6, 2024
9786405
Do not ask for timestep, minute is safer
Canx May 6, 2024
aee1c64
This must be a typo!
Canx May 6, 2024
b2674ad
Was not starting from the first date in 'day' timestep
Canx May 6, 2024
04c497e
In 'day' timestep we need to add one more day
Canx May 6, 2024
cd6ccd6
check correctly empty bars
Canx May 8, 2024
9be2df5
add case where timestep and self.timestep is 'day'
Canx May 8, 2024
95fdc8d
remove UTC as is added by default and set to LUMIBOT_DEFAULT_PYTZ if …
Canx May 8, 2024
01ba540
Added data logs and changed UTC to LUMIBOT_DEFAULT_PYTZ on fetched po…
Canx May 8, 2024
5483aa7
Was breaking things
Canx May 8, 2024
9688d7d
Added full backtesting tests with fixtures
Canx May 9, 2024
bb82868
Removed comments
Canx May 9, 2024
cd63579
populate avg_fill_price
May 6, 2024
38b71e2
orders can have zero or negative values
grzesir May 8, 2024
6a0fc1e
deploy 3.4.0
grzesir May 8, 2024
64f519a
fix empty tearsheet_file for trader.run_all
minfawang May 8, 2024
5509f6a
generate test data with decimals
Canx May 9, 2024
b004cf2
Raise exception if data generation for options is asked
Canx May 10, 2024
2f3d836
Reduce test length and exception message
Canx May 10, 2024
e94b874
better TestStrategy class
Canx May 10, 2024
511adb4
Improved tests
Canx May 10, 2024
b20f7df
added is_holiday and no special case for day timestep for waiting mar…
Canx May 10, 2024
c5ffaaf
Make sure timestep is correct
Canx May 10, 2024
86a5572
is_holiday should be implemented by all brokers
Canx May 10, 2024
0e4a9d6
No need for special case, added complexity having different timesteps
Canx May 10, 2024
ed5be6c
Wait till open only if it's not is holiday
Canx May 10, 2024
5fad217
Moved is_holiday to Broker class
Canx May 10, 2024
60e7620
Checking positions before checking orders
Canx May 10, 2024
f83d481
Removed exception when no timestep specified, just for tests to pass
Canx May 10, 2024
eca260b
Undoing change in localize
Canx May 10, 2024
a80c9bc
We should end before datetime_end
Canx May 10, 2024
87f190b
Added datetime asserts
Canx May 10, 2024
ab35a10
Added intraday 30m polygon test
Canx May 10, 2024
5351e6b
added ability to backtest index options
grzesir May 10, 2024
8ac3b97
Cleaned tests
Canx May 11, 2024
a89cb7e
Renamed strategy class, was giving a warning in pytest
Canx May 11, 2024
5123d12
Make sure we return a datetime object
Canx May 11, 2024
4e7ae61
Use datetime broker property
Canx May 11, 2024
6ae517b
Undoing zoneinfo
Canx May 11, 2024
8ad6b9a
Added get_historical_prices call in tests
Canx May 11, 2024
1e7f3c9
Give more historic data to cache as get_historical_prices is called
Canx May 11, 2024
d07b427
options have the same dataframe data
Canx May 12, 2024
071a780
Undo ohlc empty test
Canx May 12, 2024
5212ac6
Undo removing UTC
Canx May 12, 2024
78518d6
Refactored for testing
Canx May 12, 2024
5f3ab12
fixed cache mock
Canx May 12, 2024
2387336
Undo timestep raise exception
Canx May 13, 2024
aed7f15
Renaming is_holiday to is_market_day and inverting logic
Canx May 13, 2024
6ac70fb
Undoing timestep parameter
Canx May 13, 2024
09b405d
Add market option, still not implemented
Canx May 13, 2024
0cfb309
Fixed day polygon backtesting
Canx May 3, 2024
2044d4c
Add portfolio value assert
Canx May 14, 2024
000f46a
Improved tests to check positions
Canx May 15, 2024
65758a7
Same values for tests
Canx May 15, 2024
3fdb43b
Remove UTC timezone
Canx May 15, 2024
f8dcb4e
Undo timezone condition
Canx May 15, 2024
e080b07
generate asset data with multiplier, for bigger test numbers
Canx May 16, 2024
854a47c
Undo UTC removal
Canx May 17, 2024
180538b
Modified test values
Canx May 17, 2024
4f2e220
Update cicd.yaml
grzesir May 16, 2024
a43d9a8
Fixed indentation
Canx May 17, 2024
d1adc69
Remove spanish comments
Canx May 17, 2024
6b05586
Merged from dev
Canx May 24, 2024
d7017ed
Merge branch 'dev' into polygon_day_fix
Canx Jun 1, 2024
e402fe3
Undoing datetime casting
Canx Jun 1, 2024
f3781ea
remove timestep parameter and fix tab
Canx Jun 1, 2024
4278330
One more test: test_polygon_1D_minute_stock
Canx Jun 2, 2024
ab9cbe0
Add new test
Canx Jun 4, 2024
023284b
also await market close in day backtesting
Canx Jun 8, 2024
ca0e01e
only process trade iteration if it's market day in day backtesting
Canx Jun 8, 2024
f00fa9a
Added test_polygon_1M_day_stock
Canx Jun 8, 2024
8e2d3ea
Added test_polygon_1M_minute_stock
Canx Jun 8, 2024
1397507
Merge branch 'dev' into polygon_day_fix
Canx Jun 11, 2024
e6a78ea
Suboptimal fix in is_market_day due to index change in _trading_days …
Canx Jun 11, 2024
6e335e5
Merge branch 'dev' into polygon_day_fix
Canx Jun 12, 2024
a558513
Merge branch 'dev' into polygon_day_fix
Canx Jun 15, 2024
df62b1a
Integrated day with intraday logic
Canx Jun 15, 2024
1b94e74
Improved market hours checks
Canx Jun 16, 2024
05ba56e
Merge branch 'dev' into polygon_day_fix
Canx Jun 17, 2024
0c3ce73
fixed log message
Canx Jul 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 62 additions & 42 deletions lumibot/backtesting/backtesting_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from datetime import timedelta
from decimal import Decimal
from functools import wraps

from lumibot import LUMIBOT_DEFAULT_TIMEZONE
import pandas as pd


from lumibot.brokers import Broker
from lumibot.data_sources import DataSourceBacktesting
from lumibot.entities import Asset, Order, Position, TradingFee
Expand Down Expand Up @@ -111,24 +112,12 @@ def should_continue(self):

def is_market_open(self):
"""Return True if market is open else false"""
now = self.datetime

# As the index is sorted, use searchsorted to find the relevant day
idx = self._trading_days.index.searchsorted(now, side='right')

# Check that the index is not out of bounds
if idx >= len(self._trading_days):
logging.error("Cannot predict future")
market_open, market_close = self._get_market_hours(self.datetime)

if market_open is None:
return False

# The index of the trading_day is used as the market close time
market_close = self._trading_days.index[idx]

# Retrieve market open time using .at since idx is a valid datetime index
market_open = self._trading_days.at[market_close, 'market_open']

# Check if 'now' is within the trading hours of the located day
return market_open <= now < market_close

return market_open <= self.datetime < market_close

def _get_next_trading_day(self):
now = self.datetime
Expand All @@ -141,66 +130,97 @@ def _get_next_trading_day(self):
def get_time_to_open(self):
"""Return the remaining time for the market to open in seconds"""
now = self.datetime
today = pd.Timestamp(now.date())

search = self._trading_days[now < self._trading_days.index]
if search.empty:
idx = self._trading_days.index.searchsorted(today)

if idx >= len(self._trading_days):
logging.error("Cannot predict future")
return 0

trading_day = search.iloc[0]
open_time = trading_day.market_open

market_open = self._trading_days.market_open.iloc[idx]

#search = self._trading_days[now < self._trading_days.index]

# For Backtesting, sometimes the user can just pass in dates (i.e. 2023-08-01) and not datetimes
# In this case the "now" variable is starting at midnight, so we need to adjust the open_time to be actual
# market open time. In the case where the user passes in a time inside a valid trading day, use that time
# as the start of trading instead of market open.
if self.IS_BACKTESTING_BROKER and now > open_time:
open_time = self.data_source.datetime_start
if self.IS_BACKTESTING_BROKER and now > market_open:
market_open = self.data_source.datetime_start

if now >= open_time:
if now >= market_open:
return 0

delta = open_time - now
delta = market_open - now
return delta.total_seconds()

def get_time_to_close(self):
"""Return the remaining time for the market to close in seconds"""
now = self.datetime
today = pd.Timestamp(now.date())

# Use searchsorted for efficient searching and reduce unnecessary DataFrame access
idx = self._trading_days.index.searchsorted(now, side='left')
idx = self._trading_days.index.searchsorted(today)

if idx >= len(self._trading_days):
logging.error("Cannot predict future")
return 0

# Directly access the data needed using more efficient methods
market_close_time = self._trading_days.index[idx]
market_open = self._trading_days.at[market_close_time, 'market_open']
market_close = market_close_time # Assuming this is a scalar value directly from the index
market_open = self._trading_days.market_open.iloc[idx]
market_close = self._trading_days.market_close.iloc[idx]

if now < market_open:
return None

delta = market_close - now
return delta.total_seconds()

def _await_market_to_open(self, timedelta=None, strategy=None):
if self.data_source.SOURCE == "PANDAS" and self.data_source._timestep == "day":
return
def _get_market_hours(self, dt):
today = pd.Timestamp(dt.date())

try:
idx = self._trading_days.index.get_loc(today)
except KeyError:
return [None, None]

idx = self._trading_days.index.get_loc(today)

market_open = self._trading_days.market_open.iloc[idx]
market_close = self._trading_days.market_close.iloc[idx]

return [market_open, market_close]

def _await_market_to_open(self, minutes_delta=None, strategy=None):
# Process outstanding orders first before waiting for market to open
# or else they don't get processed until the next day
self.process_pending_orders(strategy=strategy)

time_to_open = self.get_time_to_open()
if timedelta:
time_to_open -= 60 * timedelta
self._update_datetime(time_to_open)

is_market_closed = False
market_open, market_close = self._get_market_hours(self.datetime)

if market_open is None:
is_market_closed = True
elif self.datetime >= market_close:
is_market_closed = True

# If market closed get next day!
while is_market_closed:
today = pd.Timestamp(self.datetime.date())
tomorrow = (today + timedelta(days=1)).tz_localize(LUMIBOT_DEFAULT_TIMEZONE)
self._update_datetime(tomorrow)
market_open, market_close = self._get_market_hours(self.datetime)
if market_open:
is_market_closed = False

if minutes_delta:
market_open -= timedelta(seconds = 60 * minutes_delta)
self._update_datetime(market_open)

def _await_market_to_close(self, timedelta=None, strategy=None):
if self.data_source.SOURCE == "PANDAS" and self.data_source._timestep == "day":
return
#if self.data_source.SOURCE == "PANDAS" and self.data_source._timestep == "day":
# return

# Process outstanding orders first before waiting for market to close
# or else they don't get processed until the next day
Expand Down Expand Up @@ -611,7 +631,7 @@ def process_pending_orders(self, strategy):
2,
quote=order.quote,
timeshift=-2,
timestep=self.data_source._timestep,
timestep=self.data_source._timestep
)
# Check if we got any ohlc data
if ohlc is None:
Expand Down
10 changes: 8 additions & 2 deletions lumibot/backtesting/polygon_backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ def __init__(
pandas_data=None,
api_key=None,
has_paid_subscription=False,
timestep='minute',
**kwargs,
):
# This should be better in Data class
timestep = self._parse_source_timestep(timestep)

super().__init__(
datetime_start=datetime_start, datetime_end=datetime_end, pandas_data=pandas_data, api_key=api_key, **kwargs
datetime_start=datetime_start, datetime_end=datetime_end, timestep=timestep, pandas_data=pandas_data, api_key=api_key, **kwargs
)
self.has_paid_subscription = has_paid_subscription

Expand Down Expand Up @@ -209,6 +213,7 @@ def _pull_source_symbol_bars(
exchange: str = None,
include_after_hours: bool = True,
):

# Get the current datetime and calculate the start datetime
current_dt = self.get_datetime()

Expand Down Expand Up @@ -242,7 +247,8 @@ def get_historical_prices_between_dates(
bars = self._parse_source_symbol_bars(response, asset, quote=quote)
return bars

def get_last_price(self, asset, timestep="minute", quote=None, exchange=None, **kwargs):
def get_last_price(self, asset, timestep=None, quote=None, exchange=None, **kwargs):
timestep = timestep or self.get_timestep()
try:
dt = self.get_datetime()
self._update_pandas_data(asset, quote, 1, timestep, dt)
Expand Down
12 changes: 12 additions & 0 deletions lumibot/brokers/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from dateutil import tz
from termcolor import colored


from lumibot import LUMIBOT_DEFAULT_TIMEZONE
from lumibot.data_sources import DataSource
from lumibot.entities import Asset, Order, Position
from lumibot.trading_builtins import SafeList
Expand Down Expand Up @@ -691,6 +693,16 @@ def should_continue(self):

def market_close_time(self):
return self.utc_to_local(self.market_hours(close=True))

def is_market_day(self):
if self._trading_days is None:
return True

trading_days_dates = self._trading_days.index.normalize()

today = pd.Timestamp(self.datetime.date()).tz_localize(LUMIBOT_DEFAULT_TIMEZONE)

return today in trading_days_dates

def is_market_open(self):
"""Determines if the market is open.
Expand Down
3 changes: 0 additions & 3 deletions lumibot/data_sources/data_source_backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ def __init__(
self._iter_count = None
self.backtesting_started = _backtesting_started

# Subtract one minute from the datetime_end so that the strategy stops right before the datetime_end
self.datetime_end -= timedelta(minutes=1)

# Legacy strategy.backtest code will always pass in a config even for DataSources that don't need it, so
# catch it here and ignore it in this class. Child classes that need it should error check it themselves.
self._config = config
Expand Down
17 changes: 12 additions & 5 deletions lumibot/data_sources/pandas_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ class PandasData(DataSourceBacktesting):
{"timestep": "minute", "representations": ["1M", "minute"]},
]

def __init__(self, *args, pandas_data=None, auto_adjust=True, **kwargs):
def __init__(self, *args, pandas_data=None, auto_adjust=True, timestep="minute", **kwargs):
super().__init__(*args, **kwargs)
self.name = "pandas"
self.pandas_data = self._set_pandas_data_keys(pandas_data)
self.auto_adjust = auto_adjust
self._data_store = self.pandas_data
self._date_index = None
self._date_supply = None
self._timestep = "minute"
self._timestep = timestep

@staticmethod
def _set_pandas_data_keys(pandas_data):
Expand Down Expand Up @@ -158,9 +158,16 @@ def update_date_index(self):
dt_index = dt_index.join(data.df.index, how="outer")

if dt_index is None:
# Build a dummy index
freq = "1min" if self._timestep == "minute" else "1D"
dt_index = pd.date_range(start=self.datetime_start, end=self.datetime_end, freq=freq)
# Determine the frequency and adjust the end date accordingly
if self._timestep == "minute":
freq = "1min"
adjusted_end = self.datetime_end
else: # Assumes the only other option is daily
freq = "1D"
adjusted_end = self.datetime_end + pd.Timedelta(days=1)

# Generate date range with the determined frequency and adjusted end date
dt_index = pd.date_range(start=self.datetime_start, end=adjusted_end, freq=freq)

else:
if self.datetime_end < dt_index[0]:
Expand Down
8 changes: 7 additions & 1 deletion lumibot/entities/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,12 @@ def columns(self, df):
def set_date_format(self, df):
df.index.name = "datetime"
df.index = pd.to_datetime(df.index)

if not df.index.tzinfo:
df.index = df.index.tz_localize(DEFAULT_PYTZ)
elif df.index.tzinfo != DEFAULT_PYTZ:
df.index = df.index.tz_convert(DEFAULT_PYTZ)

return df

def set_dates(self, date_start, date_end):
Expand Down Expand Up @@ -517,6 +519,10 @@ def get_bars(self, dt, length=1, timestep=MIN_TIMESTEP, timeshift=0):
unit = "D"
data = self._get_bars_dict(dt, length=length, timestep="minute", timeshift=timeshift)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Readability and Maintainability

I noticed that you're using 'dict' as a variable name in the '_get_bars_dict', '_get_bars_between_dates_dict', 'get_bars', and 'get_bars_between_dates' methods. This can lead to confusion as 'dict' is a built-in Python function name. Please consider renaming this variable to something more descriptive and not a built-in function name.


elif timestep == "day" and self.timestep == "day":
length = length
unit = "D"
data = self._get_bars_dict(dt, length=length, timestep="day", timeshift=timeshift)
else:
unit = "min" # Guaranteed to be minute timestep at this point
length = length * quantity
Expand All @@ -532,7 +538,7 @@ def get_bars(self, dt, length=1, timestep=MIN_TIMESTEP, timeshift=0):
df_result = df_result.dropna()

# Remove partial day data from the current day, which can happen if the data is in minute timestep.
if timestep == "day":
if timestep == "day" and self.timestep == "minute":
df_result = df_result[df_result.index < dt.replace(hour=0, minute=0, second=0, microsecond=0)]

# The original df_result may include more rows when timestep is day and self.timestep is minute.
Expand Down
18 changes: 14 additions & 4 deletions lumibot/strategies/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,17 @@ def set_market(self, market):

self.broker.market = market

def is_market_day(self):
"""
Determine if the current day is a market day.

Returns
-------
bool
True if today is a market day, otherwise False.
"""
return self.broker.is_market_day()

def await_market_to_open(self, timedelta=None):
"""Executes infinite loop until market opens

Expand Down Expand Up @@ -2988,7 +2999,7 @@ def get_historical_prices(
self,
asset: Union[Asset, str],
length: int,
timestep: str = "",
timestep: str = None,
timeshift: datetime.timedelta = None,
quote: Asset = None,
exchange: str = None,
Expand Down Expand Up @@ -3080,13 +3091,12 @@ def get_historical_prices(
if quote is None:
quote = self.quote_asset

self.logger.info(f"Getting historical prices for {asset}, {length} bars, {timestep}")
self.logger.info(f"Getting historical prices for {asset}, {length} bars, {timestep if timestep is not None else ''}")

asset = self._sanitize_user_asset(asset)

asset = self.crypto_assets_to_tuple(asset, quote)
if not timestep:
timestep = self.broker.data_source.MIN_TIMESTEP

return self.broker.data_source.get_historical_prices(
asset,
length,
Expand Down
Loading