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

Fix: Restore dev branch regression with Ticker.history() #2211

Merged
merged 2 commits into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 50 additions & 1 deletion tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
python -m unittest tests.ticker.TestTicker

"""
from datetime import datetime, timedelta

import pandas as pd

from tests.context import yfinance as yf
Expand Down Expand Up @@ -133,8 +135,55 @@ def test_invalid_period(self):
with self.assertRaises(YFInvalidPeriodError):
dat.history(period="2wks", interval="1d", raise_errors=True)
with self.assertRaises(YFInvalidPeriodError):
dat.history(period="2mo", interval="1d", raise_errors=True)
dat.history(period="2mos", interval="1d", raise_errors=True)

def test_valid_custom_periods(self):
valid_periods = [
# Yahoo provided periods
("1d", "1m"), ("5d", "15m"), ("1mo", "1d"), ("3mo", "1wk"),
("6mo", "1d"), ("1y", "1mo"), ("5y", "1wk"), ("max", "1mo"),

# Custom periods
("2d", "30m"), ("10mo", "1d"), ("1y", "1d"), ("3y", "1d"),
("2wk", "15m"), ("6mo", "5d"), ("10y", "1wk")
]

tkr = "AAPL"
dat = yf.Ticker(tkr, session=self.session)

for period, interval in valid_periods:
with self.subTest(period=period, interval=interval):
df = dat.history(period=period, interval=interval, raise_errors=True)
self.assertIsInstance(df, pd.DataFrame)
self.assertFalse(df.empty, f"No data returned for period={period}, interval={interval}")
self.assertIn("Close", df.columns, f"'Close' column missing for period={period}, interval={interval}")

# Validate date range
now = datetime.now()
if period != "max": # Difficult to assert for "max", therefore we skip
if period.endswith("d"):
days = int(period[:-1])
expected_start = now - timedelta(days=days)
elif period.endswith("mo"):
months = int(period[:-2])
expected_start = now - timedelta(days=30 * months)
elif period.endswith("y"):
years = int(period[:-1])
expected_start = now - timedelta(days=365 * years)
elif period.endswith("wk"):
weeks = int(period[:-2])
expected_start = now - timedelta(weeks=weeks)
else:
continue

actual_start = df.index[0].to_pydatetime().replace(tzinfo=None)
expected_start = expected_start.replace(hour=0, minute=0, second=0, microsecond=0)

# leeway added because of weekends
self.assertGreaterEqual(actual_start, expected_start - timedelta(days=7),
f"Start date {actual_start} out of range for period={period}")
self.assertLessEqual(df.index[-1].to_pydatetime().replace(tzinfo=None), now,
f"End date {df.index[-1]} out of range for period={period}")

def test_prices_missing(self):
# this test will need to be updated every time someone wants to run a test
Expand Down
23 changes: 23 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import tempfile
import os

from yfinance.utils import is_valid_period_format


class TestCache(unittest.TestCase):
@classmethod
Expand Down Expand Up @@ -105,11 +107,32 @@ def test_mixed_timezones_to_datetime(self):
i += 1


class TestUtils(unittest.TestCase):
def test_is_valid_period_format_valid(self):
self.assertTrue(is_valid_period_format("1d"))
self.assertTrue(is_valid_period_format("5wk"))
self.assertTrue(is_valid_period_format("12mo"))
self.assertTrue(is_valid_period_format("2y"))

def test_is_valid_period_format_invalid(self):
self.assertFalse(is_valid_period_format("1m")) # Incorrect suffix
self.assertFalse(is_valid_period_format("2wks")) # Incorrect suffix
self.assertFalse(is_valid_period_format("10")) # Missing suffix
self.assertFalse(is_valid_period_format("abc")) # Invalid string
self.assertFalse(is_valid_period_format("")) # Empty string

def test_is_valid_period_format_edge_cases(self):
self.assertFalse(is_valid_period_format(None)) # None input
self.assertFalse(is_valid_period_format("0d")) # Zero is invalid
self.assertTrue(is_valid_period_format("999mo")) # Large number valid


def suite():
ts: TestSuite = unittest.TestSuite()
ts.addTest(TestCache('Test cache'))
ts.addTest(TestCacheNoPermission('Test cache no permission'))
ts.addTest(TestPandas("Test pandas"))
ts.addTest(TestUtils("Test utils"))
return ts


Expand Down
3 changes: 2 additions & 1 deletion yfinance/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def __init__(self, ticker, invalid_period, valid_ranges):
self.ticker = ticker
self.invalid_period = invalid_period
self.valid_ranges = valid_ranges
super().__init__(f"{self.ticker}: Period '{invalid_period}' is invalid, must be one of {valid_ranges}")
super().__init__(f"{self.ticker}: Period '{invalid_period}' is invalid, "
f"must be of the format {valid_ranges}, etc.")


class YFRateLimitError(YFException):
Expand Down
19 changes: 10 additions & 9 deletions yfinance/scrapers/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,6 @@ def history(self, period="1mo", interval="1d",
period_user = period
period = None
interval = '1d'
else:
end = utils._parse_user_dt(end, self.tz)
start = _datetime.date.fromordinal(end)
start -= utils._interval_to_timedelta(period)
start -= _datetime.timedelta(days=4)

start_user = start
end_user = end
Expand Down Expand Up @@ -237,10 +232,9 @@ def history(self, period="1mo", interval="1d",
elif "chart" not in data or data["chart"]["result"] is None or not data["chart"]["result"] or not data["chart"]["result"][0]["indicators"]["quote"][0]:
_exception = YFPricesMissingError(self.ticker, _price_data_debug)
fail = True
elif period is not None and period not in self._history_metadata["validRanges"]:
# even if timestamp is in the data, the data doesn't encompass the period requested
# User provided a bad period. The minimum should be '1d', but sometimes Yahoo accepts '1h'.
_exception = YFInvalidPeriodError(self.ticker, period, self._history_metadata['validRanges'])
elif period and period not in self._history_metadata['validRanges'] and not utils.is_valid_period_format(period):
# User provided a bad period
_exception = YFInvalidPeriodError(self.ticker, period, ", ".join(self._history_metadata['validRanges']))
fail = True

if fail:
Expand All @@ -255,6 +249,13 @@ def history(self, period="1mo", interval="1d",
self._reconstruct_start_interval = None
return utils.empty_df()

# Process custom periods
if period and period not in self._history_metadata.get("validRanges", []):
end = int(_time.time())
start = _datetime.date.fromtimestamp(end)
start -= utils._interval_to_timedelta(period)
start -= _datetime.timedelta(days=4)

# parse quotes
quotes = utils.parse_quotes(data["chart"]["result"][0])
# Yahoo bug fix - it often appends latest price even if after end date
Expand Down
13 changes: 12 additions & 1 deletion yfinance/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import datetime as _datetime
import logging
import re
import re as _re
import sys as _sys
import threading
Expand Down Expand Up @@ -431,7 +432,7 @@ def _parse_user_dt(dt, exchange_tz):
def _interval_to_timedelta(interval):
if interval[-1] == "d":
return relativedelta(days=int(interval[:-1]))
elif interval[-2:] == "wk":
elif interval[-2:] == "wk":
return relativedelta(weeks=int(interval[:-2]))
elif interval[-2:] == "mo":
return relativedelta(months=int(interval[:-2]))
Expand All @@ -441,6 +442,16 @@ def _interval_to_timedelta(interval):
return _pd.Timedelta(interval)


def is_valid_period_format(period):
"""Check if the provided period has a valid format."""
if period is None:
return False

# Regex pattern to match valid period formats like '1d', '2wk', '3mo', '1y'
valid_pattern = r"^[1-9]\d*(d|wk|mo|y)$"
return bool(re.match(valid_pattern, period))


def auto_adjust(data):
col_order = data.columns
df = data.copy()
Expand Down
Loading