diff --git a/docs/index.html b/docs/index.html index b2798da..4a82438 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,11 +4,11 @@ -yfinance_cache API documentation +yfinance_cache.yfc_ticker API documentation - - - + + + @@ -22,83 +22,18 @@
-

Package yfinance_cache

+

Module yfinance_cache.yfc_ticker

-

Sub-modules

-
-
yfinance_cache.yfc_cache_manager
-
-
-
-
yfinance_cache.yfc_dat
-
-
-
-
yfinance_cache.yfc_financials_manager
-
-
-
-
yfinance_cache.yfc_logging
-
-
-
-
yfinance_cache.yfc_multi
-
-
-
-
yfinance_cache.yfc_options
-
-
-
-
yfinance_cache.yfc_prices_manager
-
-
-
-
yfinance_cache.yfc_ticker
-
-
-
-
yfinance_cache.yfc_time
-
-
-
-
yfinance_cache.yfc_upgrade
-
-
-
-
yfinance_cache.yfc_utils
-
-
-
-

Functions

-
-def DisableLogging() -
-
-
-
-
-def EnableLogging(mode=20) -
-
-
-
-
-def download(tickers, threads=True, ignore_tz=None, progress=True, interval='1d', group_by='column', max_age=None, period=None, start=None, end=None, prepost=False, actions=True, adjust_splits=True, adjust_divs=True, keepna=False, proxy=None, rounding=False, debug=True, quiet=False, trigger_at_market_close=False, session=None) -
-
-
-
-
+
def verify_cached_tickers_prices(session=None, rtol=0.0001, vol_rtol=0.005, correct=False, halt_on_fail=True, resume_from_tkr=None, debug_tkr=None, debug_interval=None)
@@ -120,279 +55,7 @@

Functions

Classes

-
-class AmbiguousComparisonException -(value1, value2, operation, true_prob=None) -
-
-

Common base class for all non-exit exceptions.

-
- -Expand source code - -
class AmbiguousComparisonException(Exception):
-    def __init__(self, value1, value2, operation, true_prob=None):
-        if not isinstance(operation, str):
-            raise TypeError(f"operation must be a string not {type(operation)}")
-        if true_prob is not None and not isinstance(true_prob, (int, float)):
-            raise TypeError(f"true_prob must be numeric not {type(true_prob)}")
-
-        self.value1 = value1
-        self.value2 = value2
-        self.operation = operation
-        self.true_prob = true_prob
-
-    def __str__(self):
-        msg = f"Ambiguous whether {self.value1} {self.operation} {self.value2}"
-        if self.true_prob is not None:
-            msg += f" (true with probability {self.true_prob*100:.1f}%)"
-        return msg
-
-

Ancestors

-
    -
  • builtins.Exception
  • -
  • builtins.BaseException
  • -
-
-
-class Interval -(*args, **kwds) -
-
-

Create a collection of name/value pairs.

-

Example enumeration:

-
>>> class Color(Enum):
-...     RED = 1
-...     BLUE = 2
-...     GREEN = 3
-
-

Access them by:

-
    -
  • attribute access:
  • -
-
-
-
-

Color.RED -

-
-
-
-
    -
  • value lookup:
  • -
-
-
-
-

Color(1) -

-
-
-
-
    -
  • name lookup:
  • -
-
-
-
-

Color['RED'] -

-
-
-
-

Enumerations can be iterated over, and know how many members they have:

-
>>> len(Color)
-3
-
-
>>> list(Color)
-[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
-
-

Methods can be added to enumerations, and members can have their own -attributes – see the documentation for details.

-
- -Expand source code - -
class Interval(Enum):
-    Week = 5
-    Days1 = 10
-    Hours1 = 20
-    Mins90 = 21
-    Mins60 = 22
-    Mins30 = 23
-    Mins15 = 24
-    Mins5 = 25
-    Mins2 = 26
-    Mins1 = 27
-
-

Ancestors

-
    -
  • enum.Enum
  • -
-

Class variables

-
-
var Days1
-
-
-
-
var Hours1
-
-
-
-
var Mins1
-
-
-
-
var Mins15
-
-
-
-
var Mins2
-
-
-
-
var Mins30
-
-
-
-
var Mins5
-
-
-
-
var Mins60
-
-
-
-
var Mins90
-
-
-
-
var Week
-
-
-
-
-
-
-class Period -(*args, **kwds) -
-
-

Create a collection of name/value pairs.

-

Example enumeration:

-
>>> class Color(Enum):
-...     RED = 1
-...     BLUE = 2
-...     GREEN = 3
-
-

Access them by:

-
    -
  • attribute access:
  • -
-
-
-
-

Color.RED -

-
-
-
-
    -
  • value lookup:
  • -
-
-
-
-

Color(1) -

-
-
-
-
    -
  • name lookup:
  • -
-
-
-
-

Color['RED'] -

-
-
-
-

Enumerations can be iterated over, and know how many members they have:

-
>>> len(Color)
-3
-
-
>>> list(Color)
-[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
-
-

Methods can be added to enumerations, and members can have their own -attributes – see the documentation for details.

-
- -Expand source code - -
class Period(Enum):
-    Days1 = 0
-    Days5 = 1
-    Months1 = 10
-    Months3 = 11
-    Months6 = 12
-    Years1 = 20
-    Years2 = 21
-    Years5 = 22
-    Ytd = 24
-    Max = 30
-
-

Ancestors

-
    -
  • enum.Enum
  • -
-

Class variables

-
-
var Days1
-
-
-
-
var Days5
-
-
-
-
var Max
-
-
-
-
var Months1
-
-
-
-
var Months3
-
-
-
-
var Months6
-
-
-
-
var Years1
-
-
-
-
var Years2
-
-
-
-
var Years5
-
-
-
-
var Ytd
-
-
-
-
-
-
+
class Ticker (ticker, session=None)
@@ -1345,7 +1008,7 @@

Class variables

Instance variables

-
prop balance_sheet
+
prop balance_sheet
@@ -1357,7 +1020,7 @@

Instance variables

return self._financials_manager.get_balance_sheet()
-
prop calendar
+
prop calendar
@@ -1369,7 +1032,7 @@

Instance variables

return self._financials_manager.get_calendar()
-
prop cashflow
+
prop cashflow
@@ -1381,7 +1044,7 @@

Instance variables

return self._financials_manager.get_cashflow()
-
prop earnings
+
prop earnings
@@ -1393,7 +1056,7 @@

Instance variables

return self._financials_manager.get_earnings()
-
prop fast_info
+
prop fast_info
@@ -1431,7 +1094,7 @@

Instance variables

return self._fast_info
-
prop financials
+
prop financials
@@ -1443,7 +1106,7 @@

Instance variables

return self._financials_manager.get_income_stmt()
-
prop income_stmt
+
prop income_stmt
@@ -1455,7 +1118,7 @@

Instance variables

return self._financials_manager.get_income_stmt()
-
prop info
+
prop info
@@ -1467,7 +1130,7 @@

Instance variables

return self.get_info()
-
prop inin
+
prop inin
@@ -1488,7 +1151,7 @@

Instance variables

return self._inin
-
prop institutional_holders
+
prop institutional_holders
@@ -1509,7 +1172,7 @@

Instance variables

return self._institutional_holders
-
prop major_holders
+
prop major_holders
@@ -1530,7 +1193,7 @@

Instance variables

return self._major_holders
-
prop news
+
prop news
@@ -1551,7 +1214,7 @@

Instance variables

return self._news
-
prop options
+
prop options
@@ -1572,7 +1235,7 @@

Instance variables

return self._options
-
prop quarterly_balance_sheet
+
prop quarterly_balance_sheet
@@ -1584,7 +1247,7 @@

Instance variables

return self._financials_manager.get_quarterly_balance_sheet()
-
prop quarterly_cashflow
+
prop quarterly_cashflow
@@ -1596,7 +1259,7 @@

Instance variables

return self._financials_manager.get_quarterly_cashflow()
-
prop quarterly_earnings
+
prop quarterly_earnings
@@ -1608,7 +1271,7 @@

Instance variables

return self._financials_manager.get_quarterly_earnings()
-
prop quarterly_financials
+
prop quarterly_financials
@@ -1620,7 +1283,7 @@

Instance variables

return self._financials_manager.get_quarterly_income_stmt()
-
prop quarterly_income_stmt
+
prop quarterly_income_stmt
@@ -1632,7 +1295,7 @@

Instance variables

return self._financials_manager.get_quarterly_income_stmt()
-
prop recommendations
+
prop recommendations
@@ -1653,7 +1316,7 @@

Instance variables

return self._recommendations
-
prop splits
+
prop splits
@@ -1674,7 +1337,7 @@

Instance variables

return self._splits
-
prop sustainability
+
prop sustainability
@@ -1695,7 +1358,7 @@

Instance variables

return self._sustainability
-
prop yf_lag
+
prop yf_lag
@@ -1723,31 +1386,31 @@

Instance variables

Methods

-
+
def get_earnings_dates(self, limit=12)
-
+
def get_info(self, max_age=None)
-
+
def get_release_dates(self, period='quarterly', as_df=False, check=True)
-
+
def get_shares(self, start=None, end=None, max_age='30d')
-
+
def history(self, interval='1d', max_age=None, period=None, start=None, end=None, prepost=False, actions=True, adjust_splits=True, adjust_divs=True, keepna=False, proxy=None, rounding=False, debug=True, quiet=False, trigger_at_market_close=False)
@@ -1805,7 +1468,7 @@

Raises

Note

Either 'period' or 'start' and 'end' should be provided, but not both.

-
+
def verify_cached_prices(self, rtol=0.0001, vol_rtol=0.005, correct='none', discard_old=False, quiet=True, debug=False, debug_interval=None)
@@ -1821,95 +1484,49 @@

Note

    diff --git a/docs/yfinance_cache/index.html b/docs/yfinance_cache/index.html new file mode 100644 index 0000000..b2798da --- /dev/null +++ b/docs/yfinance_cache/index.html @@ -0,0 +1,1924 @@ + + + + + + +yfinance_cache API documentation + + + + + + + + + + + +
    +
    +
    +

    Package yfinance_cache

    +
    +
    +
    +
    +

    Sub-modules

    +
    +
    yfinance_cache.yfc_cache_manager
    +
    +
    +
    +
    yfinance_cache.yfc_dat
    +
    +
    +
    +
    yfinance_cache.yfc_financials_manager
    +
    +
    +
    +
    yfinance_cache.yfc_logging
    +
    +
    +
    +
    yfinance_cache.yfc_multi
    +
    +
    +
    +
    yfinance_cache.yfc_options
    +
    +
    +
    +
    yfinance_cache.yfc_prices_manager
    +
    +
    +
    +
    yfinance_cache.yfc_ticker
    +
    +
    +
    +
    yfinance_cache.yfc_time
    +
    +
    +
    +
    yfinance_cache.yfc_upgrade
    +
    +
    +
    +
    yfinance_cache.yfc_utils
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def DisableLogging() +
    +
    +
    +
    +
    +def EnableLogging(mode=20) +
    +
    +
    +
    +
    +def download(tickers, threads=True, ignore_tz=None, progress=True, interval='1d', group_by='column', max_age=None, period=None, start=None, end=None, prepost=False, actions=True, adjust_splits=True, adjust_divs=True, keepna=False, proxy=None, rounding=False, debug=True, quiet=False, trigger_at_market_close=False, session=None) +
    +
    +
    +
    +
    +def verify_cached_tickers_prices(session=None, rtol=0.0001, vol_rtol=0.005, correct=False, halt_on_fail=True, resume_from_tkr=None, debug_tkr=None, debug_interval=None) +
    +
    +

    :Parameters: +session: +Recommend providing a 'requests_cache' session, in case +you have to abort and resume verification (likely). +correct: +False, 'one', 'all' +resume_from_tkr: str +Resume verification from this ticker (alphabetical order). +Because maybe you had to abort verification partway. +debug_tkr: str +Only verify this ticker. +Because maybe you want to investigate a difference.

    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AmbiguousComparisonException +(value1, value2, operation, true_prob=None) +
    +
    +

    Common base class for all non-exit exceptions.

    +
    + +Expand source code + +
    class AmbiguousComparisonException(Exception):
    +    def __init__(self, value1, value2, operation, true_prob=None):
    +        if not isinstance(operation, str):
    +            raise TypeError(f"operation must be a string not {type(operation)}")
    +        if true_prob is not None and not isinstance(true_prob, (int, float)):
    +            raise TypeError(f"true_prob must be numeric not {type(true_prob)}")
    +
    +        self.value1 = value1
    +        self.value2 = value2
    +        self.operation = operation
    +        self.true_prob = true_prob
    +
    +    def __str__(self):
    +        msg = f"Ambiguous whether {self.value1} {self.operation} {self.value2}"
    +        if self.true_prob is not None:
    +            msg += f" (true with probability {self.true_prob*100:.1f}%)"
    +        return msg
    +
    +

    Ancestors

    +
      +
    • builtins.Exception
    • +
    • builtins.BaseException
    • +
    +
    +
    +class Interval +(*args, **kwds) +
    +
    +

    Create a collection of name/value pairs.

    +

    Example enumeration:

    +
    >>> class Color(Enum):
    +...     RED = 1
    +...     BLUE = 2
    +...     GREEN = 3
    +
    +

    Access them by:

    +
      +
    • attribute access:
    • +
    +
    +
    +
    +

    Color.RED +

    +
    +
    +
    +
      +
    • value lookup:
    • +
    +
    +
    +
    +

    Color(1) +

    +
    +
    +
    +
      +
    • name lookup:
    • +
    +
    +
    +
    +

    Color['RED'] +

    +
    +
    +
    +

    Enumerations can be iterated over, and know how many members they have:

    +
    >>> len(Color)
    +3
    +
    +
    >>> list(Color)
    +[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
    +
    +

    Methods can be added to enumerations, and members can have their own +attributes – see the documentation for details.

    +
    + +Expand source code + +
    class Interval(Enum):
    +    Week = 5
    +    Days1 = 10
    +    Hours1 = 20
    +    Mins90 = 21
    +    Mins60 = 22
    +    Mins30 = 23
    +    Mins15 = 24
    +    Mins5 = 25
    +    Mins2 = 26
    +    Mins1 = 27
    +
    +

    Ancestors

    +
      +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var Days1
    +
    +
    +
    +
    var Hours1
    +
    +
    +
    +
    var Mins1
    +
    +
    +
    +
    var Mins15
    +
    +
    +
    +
    var Mins2
    +
    +
    +
    +
    var Mins30
    +
    +
    +
    +
    var Mins5
    +
    +
    +
    +
    var Mins60
    +
    +
    +
    +
    var Mins90
    +
    +
    +
    +
    var Week
    +
    +
    +
    +
    +
    +
    +class Period +(*args, **kwds) +
    +
    +

    Create a collection of name/value pairs.

    +

    Example enumeration:

    +
    >>> class Color(Enum):
    +...     RED = 1
    +...     BLUE = 2
    +...     GREEN = 3
    +
    +

    Access them by:

    +
      +
    • attribute access:
    • +
    +
    +
    +
    +

    Color.RED +

    +
    +
    +
    +
      +
    • value lookup:
    • +
    +
    +
    +
    +

    Color(1) +

    +
    +
    +
    +
      +
    • name lookup:
    • +
    +
    +
    +
    +

    Color['RED'] +

    +
    +
    +
    +

    Enumerations can be iterated over, and know how many members they have:

    +
    >>> len(Color)
    +3
    +
    +
    >>> list(Color)
    +[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
    +
    +

    Methods can be added to enumerations, and members can have their own +attributes – see the documentation for details.

    +
    + +Expand source code + +
    class Period(Enum):
    +    Days1 = 0
    +    Days5 = 1
    +    Months1 = 10
    +    Months3 = 11
    +    Months6 = 12
    +    Years1 = 20
    +    Years2 = 21
    +    Years5 = 22
    +    Ytd = 24
    +    Max = 30
    +
    +

    Ancestors

    +
      +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var Days1
    +
    +
    +
    +
    var Days5
    +
    +
    +
    +
    var Max
    +
    +
    +
    +
    var Months1
    +
    +
    +
    +
    var Months3
    +
    +
    +
    +
    var Months6
    +
    +
    +
    +
    var Years1
    +
    +
    +
    +
    var Years2
    +
    +
    +
    +
    var Years5
    +
    +
    +
    +
    var Ytd
    +
    +
    +
    +
    +
    +
    +class Ticker +(ticker, session=None) +
    +
    +
    +
    + +Expand source code + +
    class Ticker:
    +    def __init__(self, ticker, session=None):
    +        self.ticker = ticker.upper()
    +
    +        self.session = session
    +        self.dat = yf.Ticker(self.ticker, session=self.session)
    +
    +        self._yf_lag = None
    +
    +        self._histories_manager = None
    +
    +        self._info = None
    +        self._fast_info = None
    +
    +        self._splits = None
    +
    +        self._shares = None
    +
    +        self._major_holders = None
    +
    +        self._institutional_holders = None
    +
    +        self._sustainability = None
    +
    +        self._recommendations = None
    +
    +        self._calendar = None
    +
    +        self._isin = None
    +
    +        self._options = None
    +
    +        self._news = None
    +
    +        self._debug = False
    +        # self._debug = True
    +
    +        self._tz = None
    +        self._exchange = None
    +
    +        exchange, tz_name = self._getExchangeAndTz()
    +        self._financials_manager = yfcf.FinancialsManager(ticker, exchange, tz_name, session=self.session)
    +
    +    def history(self,
    +                interval="1d",
    +                max_age=None,  # defaults to half of interval
    +                period=None,
    +                start=None, end=None, prepost=False, actions=True,
    +                adjust_splits=True, adjust_divs=True,
    +                keepna=False,
    +                proxy=None, rounding=False,
    +                debug=True, quiet=False,
    +                trigger_at_market_close=False):
    +        """
    +        Fetch historical market data for this ticker.
    +
    +        This method retrieves historical price and volume data, as well as information
    +        about stock splits and dividend payments if requested.
    +
    +        Args:
    +            interval (str, optional): Data interval. Defaults to "1d" (daily).
    +                Other options: "1m" (minute), "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo", "3mo".
    +            max_age (int, optional): Maximum age of cached data in seconds. Defaults to None (half of the interval).
    +            period (str, optional): Data period to download. Defaults to None.
    +                Options: "1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max".
    +            start (str, optional): Download start date string (YYYY-MM-DD) or datetime. Defaults to None.
    +            end (str, optional): Download end date string (YYYY-MM-DD) or datetime. Defaults to None.
    +            prepost (bool, optional): Include pre and post market data. Defaults to False.
    +            actions (bool, optional): Include stock splits and dividend data. Defaults to True.
    +            adjust_splits (bool, optional): Adjust data for stock splits. Defaults to True.
    +            adjust_divs (bool, optional): Adjust data for dividends. Defaults to True.
    +            keepna (bool, optional): Keep NaN values. Defaults to False.
    +            proxy (str, optional): Proxy URL. Defaults to None.
    +            rounding (bool, optional): Round values to 2 decimal places. Defaults to False.
    +            debug (bool, optional): Print debug messages. Defaults to True.
    +            quiet (bool, optional): Suppress output messages. Defaults to False.
    +            trigger_at_market_close (bool, optional): Trigger requests at market close. Defaults to False.
    +
    +        Returns:
    +            pandas.DataFrame: A DataFrame containing the historical data. Columns typically include:
    +                Date, Open, High, Low, Close, Volume, Dividends, Stock Splits.
    +
    +        Raises:
    +            ValueError: If invalid date parameters are provided.
    +            YFinanceException: If there's an error fetching the data from Yahoo Finance.
    +
    +        Note:
    +            Either 'period' or 'start' and 'end' should be provided, but not both.
    +        """
    +
    +        # t0 = perf_counter()
    +
    +        if prepost:
    +            raise Exception("pre and post-market caching currently not implemented. If you really need it raise an issue on Github")
    +
    +        debug_yfc = self._debug
    +        # debug_yfc = True
    +
    +        if start is not None or end is not None:
    +            log_msg = f"Ticker::history(tkr={self.ticker} interval={interval} start={start} end={end} max_age={max_age} trigger_at_market_close={trigger_at_market_close} adjust_splits={adjust_splits}, adjust_divs={adjust_divs})"
    +        else:
    +            log_msg = f"Ticker::history(tkr={self.ticker} interval={interval} period={period} max_age={max_age} trigger_at_market_close={trigger_at_market_close} adjust_splits={adjust_splits}, adjust_divs={adjust_divs})"
    +        yfcl.TraceEnter(log_msg)
    +
    +        td_1d = datetime.timedelta(days=1)
    +        exchange, tz_name = self._getExchangeAndTz()
    +        tz_exchange = ZoneInfo(tz_name)
    +        yfct.SetExchangeTzName(exchange, tz_name)
    +        dt_now = pd.Timestamp.utcnow()
    +
    +        # Type checks
    +        if max_age is not None:
    +            if isinstance(max_age, str):
    +                if max_age.endswith("wk"):
    +                    max_age = re.sub("wk$", "w", max_age)
    +                max_age = pd.Timedelta(max_age)
    +            if not isinstance(max_age, (datetime.timedelta, pd.Timedelta)):
    +                raise Exception("Argument 'max_age' must be Timedelta or equivalent string")
    +        if period is not None:
    +            if start is not None or end is not None:
    +                raise Exception("Don't set both 'period' and 'start'/'end'' arguments")
    +            if isinstance(period, str):
    +                if period in ["max", "ytd"]:
    +                    period = yfcd.periodStrToEnum[period]
    +                else:
    +                    if period.endswith("wk"):
    +                        period = re.sub("wk$", "w", period)
    +                    if period.endswith("y"):
    +                        period = relativedelta(years=int(re.sub("y$", "", period)))
    +                    elif period.endswith("mo"):
    +                        period = relativedelta(months=int(re.sub("mo", "", period)))
    +                    else:
    +                        period = pd.Timedelta(period)
    +            if not isinstance(period, (yfcd.Period, datetime.timedelta, pd.Timedelta, relativedelta)):
    +                raise Exception(f"Argument 'period' must be one of: 'max', 'ytd', Timedelta or equivalent string. Not {type(period)}")
    +        if isinstance(interval, str):
    +            if interval not in yfcd.intervalStrToEnum.keys():
    +                raise Exception("'interval' if str must be one of: {}".format(yfcd.intervalStrToEnum.keys()))
    +            interval = yfcd.intervalStrToEnum[interval]
    +        if not isinstance(interval, yfcd.Interval):
    +            raise Exception("'interval' must be yfcd.Interval")
    +
    +        start_d = None ; end_d = None
    +        start_dt = None ; end_dt = None
    +        interday = interval in [yfcd.Interval.Days1, yfcd.Interval.Week]#, yfcd.Interval.Months1, yfcd.Interval.Months3]
    +        if start is not None:
    +            start_dt, start_d = self._process_user_dt(start)
    +            if start_dt > dt_now:
    +                return None
    +            if interval == yfcd.Interval.Week:
    +                # Note: if start is on weekend then Yahoo can return weekly data starting
    +                #       on Saturday. This breaks YFC, start must be Monday! So fix here:
    +                if start_dt is None:
    +                    # Working with simple dates, easy
    +                    if start_d.weekday() in [5, 6]:
    +                        start_d += datetime.timedelta(days=7-start_d.weekday())
    +                else:
    +                    wd = start_d.weekday()
    +                    if wd in [5, 6]:
    +                        start_d += datetime.timedelta(days=7-wd)
    +                        start_dt = datetime.datetime.combine(start_d, datetime.time(0), tz_exchange)
    +
    +        if end is not None:
    +            end_dt, end_d = self._process_user_dt(end)
    +
    +        if start_dt is not None and end_dt is not None and start_dt >= end_dt:
    +            raise ValueError("start must be < end")
    +
    +        if debug_yfc:
    +            print("- start_dt={} , end_dt={}".format(start_dt, end_dt))
    +
    +        if (start_dt is not None) and start_dt == end_dt:
    +            return None
    +
    +        if max_age is None:
    +            if interval == yfcd.Interval.Days1:
    +                max_age = datetime.timedelta(hours=4)
    +            elif interval == yfcd.Interval.Week:
    +                max_age = datetime.timedelta(hours=60)
    +            # elif interval == yfcd.Interval.Months1:
    +            #     max_age = datetime.timedelta(days=15)
    +            # elif interval == yfcd.Interval.Months3:
    +            #     max_age = datetime.timedelta(days=45)
    +            else:
    +                max_age = 0.5*yfcd.intervalToTimedelta[interval]
    +            if start is not None:
    +                max_age = min(max_age, dt_now-start_dt)
    +
    +        if period is not None:
    +            if isinstance(period, (datetime.timedelta, pd.Timedelta)):
    +                if (dt_now - max_age) < (dt_now - period):
    +                    raise Exception(f"max_age={max_age} must be less than period={period}")
    +            elif period == yfcd.Period.Ytd:
    +                dt_now_ex = dt_now.tz_convert(tz_exchange)
    +                dt_year_start = pd.Timestamp(year=dt_now_ex.year, month=1, day=1).tz_localize(tz_exchange)
    +                if (dt_now - max_age) < dt_year_start:
    +                    raise Exception(f"max_age={max_age} must be less than days since this year start")
    +        elif start is not None:
    +            if (dt_now - max_age) < start_dt:
    +                raise Exception(f"max_age={max_age} must be closer to now than start={start}")
    +
    +
    +        if start_dt is not None:
    +            try:
    +                sched_14d = yfct.GetExchangeSchedule(exchange, start_dt.date(), start_dt.date()+14*td_1d)
    +            except Exception as e:
    +                if "Need to add mapping" in str(e):
    +                    raise Exception("Need to add mapping of exchange {} to xcal (ticker={})".format(exchange, self.ticker))
    +                else:
    +                    raise
    +            if sched_14d is None:
    +                raise Exception("sched_14d is None for date range {}->{} and ticker {}".format(start_dt.date(), start_dt.date()+14*td_1d, self.ticker))
    +            if sched_14d["open"].iloc[0] > dt_now:
    +                # Requested date range is in future
    +                return None
    +        else:
    +            sched_14d = None
    +
    +        # All date checks passed so can begin fetching
    +
    +        if ((start_d is None) or (end_d is None)) and (start_dt is not None) and (end_dt is not None):
    +            # if start_d/end_d not set then start/end are datetimes, so need to inspect
    +            # schedule opens/closes to determine days
    +            if sched_14d is not None:
    +                sched = sched_14d.iloc[0:1]
    +            else:
    +                sched = yfct.GetExchangeSchedule(exchange, start_dt.date(), end_dt.date()+td_1d)
    +            n = sched.shape[0]
    +            start_d = start_dt.date() if start_dt < sched["open"].iloc[0] else start_dt.date()+td_1d
    +            end_d = end_dt.date()+td_1d if end_dt >= sched["close"].iloc[n-1] else end_dt.date()
    +        else:
    +            if exchange not in yfcd.exchangeToXcalExchange:
    +                raise Exception("Need to add mapping of exchange {} to xcal (ticker={})".format(exchange, self.ticker))
    +
    +        if self._histories_manager is None:
    +            self._histories_manager = yfcp.HistoriesManager(self.ticker, exchange, tz_name, self.session, proxy)
    +
    +        # t1_setup = perf_counter()
    +
    +        hist = self._histories_manager.GetHistory(interval)
    +        if period is not None:
    +            h = hist.get(start=None, end=None, period=period, max_age=max_age, trigger_at_market_close=trigger_at_market_close, quiet=quiet)
    +        elif interday:
    +            h = hist.get(start_d, end_d, period=None, max_age=max_age, trigger_at_market_close=trigger_at_market_close, quiet=quiet)
    +        else:
    +            h = hist.get(start_dt, end_dt, period=None, max_age=max_age, trigger_at_market_close=trigger_at_market_close, quiet=quiet)
    +        if (h is None) or h.shape[0] == 0:
    +            msg = f"YFC: history() exiting without price data (tkr={self.ticker}"
    +            if start_dt is not None or end_dt is not None:
    +                msg += f" start_dt={start_dt} end_dt={end_dt}"
    +            else:
    +                msg += f" period={period}"
    +            msg += f" max_age={max_age}"
    +            msg += f" interval={yfcd.intervalToString[interval]})"
    +            raise Exception(msg)
    +
    +        # t2_sync = perf_counter()
    +
    +        f_dups = h.index.duplicated()
    +        if f_dups.any():
    +            raise Exception("{}: These timepoints have been duplicated: {}".format(self.ticker, h.index[f_dups]))
    +
    +        # Present table for user:
    +        h_copied = False
    +        if (start_dt is not None) and (end_dt is not None):
    +            h = h.loc[start_dt:end_dt-datetime.timedelta(milliseconds=1)].copy()
    +            h_copied = True
    +
    +        if not keepna:
    +            price_data_cols = [c for c in yfcd.yf_data_cols if c in h.columns]
    +            mask_nan_or_zero = (np.isnan(h[price_data_cols].to_numpy()) | (h[price_data_cols].to_numpy() == 0)).all(axis=1)
    +            if mask_nan_or_zero.any():
    +                h = h.drop(h.index[mask_nan_or_zero])
    +                h_copied = True
    +        # t3_filter = perf_counter()
    +
    +        if h.shape[0] == 0:
    +            h = None
    +        else:
    +            if adjust_splits:
    +                if not h_copied:
    +                    h = h.copy()
    +                for c in ["Open", "Close", "Low", "High", "Dividends"]:
    +                    h[c] = np.multiply(h[c].to_numpy(), h["CSF"].to_numpy())
    +                h["Volume"] = np.round(np.divide(h["Volume"].to_numpy(), h["CSF"].to_numpy()), 0).astype('int')
    +            if adjust_divs:
    +                if not h_copied:
    +                    h = h.copy()
    +                for c in ["Open", "Close", "Low", "High"]:
    +                    h[c] = np.multiply(h[c].to_numpy(), h["CDF"].to_numpy())
    +            else:
    +                if not h_copied:
    +                    h = h.copy()
    +                h["Adj Close"] = np.multiply(h["Close"].to_numpy(), h["CDF"].to_numpy())
    +            h = h.drop(["CSF", "CDF"], axis=1)
    +
    +            if rounding:
    +                # Round to 4 sig-figs
    +                if not h_copied:
    +                    h = h.copy()
    +                f_na = h["Close"].isna()
    +                na = f_na.any()
    +                if na:
    +                    f_nna = ~f_na
    +                    if not f_nna.any():
    +                        raise Exception(f"{self.ticker}: price table is entirely NaNs. Delisted?" +" \n" + log_msg)
    +                    last_close = h["Close"][f_nna].iloc[-1]
    +                else:
    +                    last_close = h["Close"].iloc[-1]
    +                rnd = yfcu.CalculateRounding(last_close, 4)
    +                for c in ["Open", "Close", "Low", "High"]:
    +                    if na:
    +                        h.loc[f_nna, c] = np.round(h.loc[f_nna, c].to_numpy(), rnd)
    +                    else:
    +                        h[c] = np.round(h[c].to_numpy(), rnd)
    +
    +            if debug_yfc:
    +                print("- h:")
    +                cols = [c for c in ["Close", "Dividends", "Volume", "CDF", "CSF"] if c in h.columns]
    +                print(h[cols])
    +                if "Dividends" in h.columns:
    +                    f = h["Dividends"] != 0.0
    +                    if f.any():
    +                        print("- dividends:")
    +                        print(h.loc[f, cols])
    +                print("")
    +            yfcl.TraceExit("Ticker::history() returning")
    +
    +        # t4_adjust = perf_counter()
    +        # t_setup = t1_setup - t0
    +        # t_sync = t2_sync - t1_setup
    +        # t_filter = t3_filter - t2_sync
    +        # t_adjust = t4_adjust - t3_filter
    +        # t_sum = t_setup + t_sync + t_filter + t_adjust
    +        # print("TIME: {:.4f}s: setup={:.4f} sync={:.4f} filter={:.4f} adjust={:.4f}".format(t_sum, t_setup, t_sync, t_filter, t_adjust))
    +        # t_setup *= 100/t_sum
    +        # t_sync *= 100/t_sum
    +        # t_cache *= 100/t_sum
    +        # t_filter *= 100/t_sum
    +        # t_adjust *= 100/t_sum
    +        # print("TIME %:        setup={:.1f}%  sync={:.1f}%  filter={:.1f}%  adjust={:.1f}%".format(t_setup, t_sync, t_filter, t_adju
    +
    +        return h
    +
    +    def _getCachedPrices(self, interval, proxy=None):
    +        if self._histories_manager is None:
    +            exchange, tz_name = self._getExchangeAndTz()
    +            self._histories_manager = yfcp.HistoriesManager(self.ticker, exchange, tz_name, self.session, proxy)
    +
    +        if isinstance(interval, str):
    +            if interval not in yfcd.intervalStrToEnum.keys():
    +                raise Exception("'interval' if str must be one of: {}".format(yfcd.intervalStrToEnum.keys()))
    +            interval = yfcd.intervalStrToEnum[interval]
    +
    +        return self._histories_manager.GetHistory(interval).h
    +
    +    def _getExchangeAndTz(self):
    +        if self._tz is not None and self._exchange is not None:
    +            return self._exchange, self._tz
    +
    +        exchange, tz_name = None, None
    +        try:
    +            exchange = self.get_info('9999d')['exchange']
    +            if "exchangeTimezoneName" in self.get_info('9999d'):
    +                tz_name = self.get_info('9999d')["exchangeTimezoneName"]
    +            else:
    +                tz_name = self.get_info('9999d')["timeZoneFullName"]
    +        except Exception:
    +            md = yf.Ticker(self.ticker, session=self.session).history_metadata
    +            if 'exchangeName' in md.keys():
    +                exchange = md['exchangeName']
    +            if 'exchangeTimezoneName' in md.keys():
    +                tz_name = md['exchangeTimezoneName']
    +
    +        if exchange is None or tz_name is None:
    +            raise Exception(f"{self.ticker}: exchange and timezone not available")
    +        self._tz = tz_name
    +        self._exchange = exchange
    +        return self._exchange, self._tz
    +
    +    def verify_cached_prices(self, rtol=0.0001, vol_rtol=0.005, correct='none', discard_old=False, quiet=True, debug=False, debug_interval=None):
    +        if debug:
    +            quiet = False
    +        if debug_interval is not None and isinstance(debug_interval, str):
    +            debug_interval = yfcd.intervalStrToEnum[debug_interval]
    +
    +        fn_locals = locals()
    +        del fn_locals["self"]
    +
    +        interval = yfcd.Interval.Days1
    +        cache_key = "history-"+yfcd.intervalToString[interval]
    +        if not yfcm.IsDatumCached(self.ticker, cache_key):
    +            return True
    +
    +        yfcl.TraceEnter(f"Ticker::verify_cached_prices(tkr={self.ticker} {fn_locals})")
    +
    +        if self._histories_manager is None:
    +            exchange, tz_name = self._getExchangeAndTz()
    +            self._histories_manager = yfcp.HistoriesManager(self.ticker, exchange, tz_name, self.session, proxy=None)
    +
    +        v = True
    +
    +        # First verify 1d
    +        dt0 = self._histories_manager.GetHistory(interval)._getCachedPrices().index[0]
    +        self.history(start=dt0.date(), quiet=quiet, trigger_at_market_close=True)  # ensure have all dividends
    +        v = self._verify_cached_prices_interval(interval, rtol, vol_rtol, correct, discard_old, quiet, debug)
    +        if debug_interval == yfcd.Interval.Days1:
    +            yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {v} (1st pass)")
    +            return v
    +        if not v:
    +            if debug or not correct:
    +                yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {v} (1st pass)")
    +                return v
    +        if correct in ['one', 'all']:
    +            # Rows were removed so re-fetch. Only do for 1d data
    +            self.history(start=dt0.date(), quiet=quiet)
    +
    +            # repeat verification, because 'fetch backporting' may be buggy
    +            v2 = self._verify_cached_prices_interval(interval, rtol, vol_rtol, correct, discard_old, quiet, debug)
    +            if not v2 and debug:
    +                yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {v2} (post-correction)")
    +                return v2
    +            if not v2:
    +                yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {v2} (post-correction)")
    +                return v2
    +
    +            if not v:
    +                # Stop after correcting first problem, because user won't have been shown the next problem yet
    +                yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {v} (corrected but user should review next problem)")
    +                return v
    +
    +        if debug_interval is not None:
    +            if debug_interval == yfcd.Interval.Days1:
    +                intervals = []
    +            else:
    +                intervals = [debug_interval]
    +            debug = True
    +        else:
    +            intervals = yfcd.Interval
    +        for interval in intervals:
    +            if interval == yfcd.Interval.Days1:
    +                continue
    +            istr = yfcd.intervalToString[interval]
    +            cache_key = "history-"+istr
    +            if not yfcm.IsDatumCached(self.ticker, cache_key):
    +                continue
    +            vi = self._verify_cached_prices_interval(interval, rtol, vol_rtol, correct, discard_old, quiet, debug)
    +            yfcl.TracePrint(f"{istr}: vi={vi}")
    +
    +            if not vi and correct != 'all':
    +                # Stop after correcting first problem, because user won't have been shown the next problem yet
    +                yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {vi}")
    +                return vi
    +
    +            v = v and vi
    +
    +        yfcl.TraceExit(f"Ticker::verify_cached_prices() returning {v}")
    +
    +        return v
    +
    +    def _verify_cached_prices_interval(self, interval, rtol=0.0001, vol_rtol=0.005, correct=False, discard_old=False, quiet=True, debug=False):
    +        if debug:
    +            quiet = False
    +
    +        fn_locals = locals()
    +        del fn_locals["self"]
    +
    +        if isinstance(interval, str):
    +            if interval not in yfcd.intervalStrToEnum.keys():
    +                raise Exception("'interval' if str must be one of: {}".format(yfcd.intervalStrToEnum.keys()))
    +            interval = yfcd.intervalStrToEnum[interval]
    +
    +        istr = yfcd.intervalToString[interval]
    +        cache_key = "history-"+istr
    +        if not yfcm.IsDatumCached(self.ticker, cache_key):
    +            return True
    +
    +        yfcl.TraceEnter(f"Ticker::_verify_cached_prices_interval(tkr={self.ticker}, {fn_locals})")
    +
    +        if self._histories_manager is None:
    +            exchange, tz_name = self._getExchangeAndTz()
    +            self._histories_manager = yfcp.HistoriesManager(self.ticker, exchange, tz_name, self.session, proxy=None)
    +
    +        v = self._histories_manager.GetHistory(interval)._verifyCachedPrices(rtol, vol_rtol, correct, discard_old, quiet, debug)
    +
    +        yfcl.TraceExit(f"Ticker::_verify_cached_prices_interval() returning {v}")
    +        return v
    +
    +    def _process_user_dt(self, dt):
    +        exchange, tz_name = self._getExchangeAndTz()
    +        return yfcu.ProcessUserDt(dt, tz_name)
    +
    +    @property
    +    def info(self):
    +        return self.get_info()
    +
    +    def get_info(self, max_age=None):
    +        if self._info is not None:
    +            return self._info
    +
    +        if max_age is None:
    +            max_age = pd.Timedelta(yfcm._option_manager.max_ages.info)
    +        elif not isinstance(max_age, (datetime.timedelta, pd.Timedelta)):
    +            max_age = pd.Timedelta(max_age)
    +        if max_age < pd.Timedelta(0):
    +            raise Exception(f"'max_age' must be positive timedelta not {max_age}")
    +
    +        md = None
    +        if yfcm.IsDatumCached(self.ticker, "info"):
    +            self._info, md = yfcm.ReadCacheDatum(self.ticker, "info", True)
    +            if 'FetchDate' not in self._info.keys():
    +                # Old bug meant this could happen
    +                fp = yfcm.GetFilepath(self.ticker, 'info')
    +                mod_dt = datetime.datetime.fromtimestamp(os.path.getmtime(fp))
    +                self._info['FetchDate'] = mod_dt
    +                md['LastCheck'] = mod_dt
    +                yfcm.StoreCacheDatum(self.ticker, "info", self._info, metadata=md)
    +
    +            if self._info is not None:
    +                if md is None:
    +                    md = {}
    +                if 'LastCheck' not in md.keys():
    +                    # Old bug meant this could happen
    +                    md['LastCheck'] = self._info['FetchDate']
    +                    yfcm.WriteCacheMetadata(self.ticker, "info", 'LastCheck', md['LastCheck'])
    +                if max(self._info['FetchDate'], md['LastCheck']) + max_age > pd.Timestamp.now():
    +                    return self._info
    +
    +        i = self.dat.info
    +        i['FetchDate'] = pd.Timestamp.now()
    +
    +        if self._info is not None:
    +            # Check new info is not downgrade
    +            diff = len(i) - len(self._info)
    +            diff_pct = float(diff) / float(len(self._info))
    +            if diff_pct < -0.1 and diff < -10:
    +                msg = 'When fetching new info, significant amount of data has disappeared\n'
    +                missing_keys = [k for k in self._info.keys() if k not in i.keys()]
    +                new_keys = [k for k in i.keys() if k not in self._info.keys()]
    +                msg += "- missing: "
    +                msg += str({k:self._info[k] for k in missing_keys}) + '\n'
    +                msg += "- new: "
    +                msg += str({k:i[k] for k in new_keys}) + '\n'
    +
    +                # msg += "\nKeep new data?"
    +                # keep = click.confirm(msg, default=False)
    +                # if not keep:
    +                #     return self._info
    +                #
    +                msg += "\nDiscarding fetched info."
    +                print(f'{self.ticker}: {msg}')
    +                yfcm.WriteCacheMetadata(self.ticker, "info", 'LastCheck', i['FetchDate'])
    +                return self._info
    +
    +        self._info = i
    +        if md is None:
    +            md = {}
    +        md['LastCheck'] = i['FetchDate']
    +        yfcm.StoreCacheDatum(self.ticker, "info", self._info, metadata=md)
    +
    +        exchange, tz_name = self._getExchangeAndTz()
    +        yfct.SetExchangeTzName(exchange, tz_name)
    +
    +        return self._info
    +
    +    @property
    +    def fast_info(self):
    +        if self._fast_info is not None:
    +            return self._fast_info
    +
    +        if yfcm.IsDatumCached(self.ticker, "fast_info"):
    +            try:
    +                self._fast_info = yfcm.ReadCacheDatum(self.ticker, "fast_info")
    +            except Exception:
    +                pass
    +            else:
    +                return self._fast_info
    +
    +        # self._fast_info = self.dat.fast_info
    +        self._fast_info = {}
    +        for k in self.dat.fast_info.keys():
    +            try:
    +                self._fast_info[k] = self.dat.fast_info[k]
    +            except Exception as e:
    +                if "decrypt" in str(e):
    +                    pass
    +                else:
    +                    print(f"TICKER = {self.ticker}")
    +                    raise
    +        yfcm.StoreCacheDatum(self.ticker, "fast_info", self._fast_info)
    +
    +        yfct.SetExchangeTzName(self._fast_info["exchange"], self._fast_info["timezone"])
    +
    +        return self._fast_info
    +
    +    @property
    +    def splits(self):
    +        if self._splits is not None:
    +            return self._splits
    +
    +        if yfcm.IsDatumCached(self.ticker, "splits"):
    +            self._splits = yfcm.ReadCacheDatum(self.ticker, "splits")
    +            return self._splits
    +
    +        self._splits = self.dat.splits
    +        yfcm.StoreCacheDatum(self.ticker, "splits", self._splits)
    +        return self._splits
    +
    +
    +    def get_shares(self, start=None, end=None, max_age='30d'):
    +        debug = False
    +        # debug = True
    +
    +        max_age = pd.Timedelta(max_age)
    +
    +        # Process dates
    +        exchange, tz = self._getExchangeAndTz()
    +        dt_now = pd.Timestamp.utcnow().tz_convert(tz)
    +        if start is not None:
    +            start_dt, start_d = self._process_user_dt(start)
    +            start = start_d
    +        if end is not None:
    +            end_dt, end_d = self._process_user_dt(end)
    +            end = end_d
    +        if end is None:
    +            end_dt = dt_now
    +            end = dt_now.date()
    +        if start is None:
    +            start = end - pd.Timedelta(days=548)  # 18 months
    +            start_dt = end_dt - pd.Timedelta(days=548)
    +        if start >= end:
    +            raise Exception("Start date must be before end")
    +        if debug:
    +            print("- start =", start, " end =", end)
    +
    +        if self._shares is None:
    +            if yfcm.IsDatumCached(self.ticker, "shares"):
    +                if debug:
    +                    print("- init shares from cache")
    +                self._shares = yfcm.ReadCacheDatum(self.ticker, "shares")
    +        if self._shares is None or self._shares.empty:
    +            # Loaded from cache. Either re-fetch or return None
    +            lastFetchDt = yfcm.ReadCacheMetadata(self.ticker, "shares", 'LastFetch')
    +            do_fetch = (lastFetchDt is None) or ((dt_now - lastFetchDt) > max_age)
    +            if do_fetch:
    +                self._shares = self._fetch_shares(start, end)
    +                if self._shares is None:
    +                    self._shares = pd.DataFrame()
    +                yfcm.StoreCacheDatum(self.ticker, "shares", self._shares, metadata={'LastFetch':pd.Timestamp.utcnow().tz_convert(tz)})
    +                if self._shares.empty:
    +                    return None
    +            else:
    +                return None
    +
    +        if debug:
    +            print("- self._shares:", self._shares.index[0], '->', self._shares.index[-1])
    +
    +        td_1d = datetime.timedelta(days=1)
    +        last_row = self._shares.iloc[-1]
    +        if pd.isna(last_row['Shares']):# and last_row['FetchDate'].date() == last_row.name:
    +            if debug:
    +                print("- dropping last row from cached")
    +            self._shares = self._shares.drop(self._shares.index[-1])
    +
    +        if not isinstance(self._shares.index, pd.DatetimeIndex):
    +            self._shares.index = pd.to_datetime(self._shares.index).tz_localize(tz)
    +        if self._shares['Shares'].dtype == 'float':
    +            # Convert to Int, and add a little to avoid rounding errors
    +            self._shares['Shares'] = (self._shares['Shares']+0.01).round().astype('Int64')
    +
    +        if start < self._shares.index[0].date():
    +            df_pre = self._fetch_shares(start, self._shares.index[0])
    +            yfcm.WriteCacheMetadata(self.ticker, "shares", 'LastFetch', pd.Timestamp.utcnow().tz_convert(tz))
    +            if df_pre is not None:
    +                self._shares = pd.concat([df_pre, self._shares])
    +        if (end-td_1d) > self._shares.index[-1].date() and \
    +            (end - self._shares.index[-1].date()) > max_age:
    +            df_post = self._fetch_shares(self._shares.index[-1] + td_1d, end)
    +            yfcm.WriteCacheMetadata(self.ticker, "shares", 'LastFetch', pd.Timestamp.utcnow().tz_convert(tz))
    +            if df_post is not None:
    +                self._shares = pd.concat([self._shares, df_post])
    +
    +        self._shares = self._shares
    +        yfcm.StoreCacheDatum(self.ticker, "shares", self._shares)
    +
    +        f_na = self._shares['Shares'].isna()
    +        shares = self._shares[~f_na]
    +        if start is not None:
    +            i0 = np.searchsorted(shares.index, start_dt)
    +        else:
    +            i0 = None
    +        if end is not None:
    +            i1 = np.searchsorted(shares.index, end_dt)
    +        else:
    +            i1 = None
    +        if i0 is not None and i1 is not None:
    +            return shares.iloc[i0:i1]
    +        elif i0 is not None:
    +            return shares[i0:]
    +        elif i1 is not None:
    +            return shares[:i1]
    +        return shares
    +
    +    def _fetch_shares(self, start, end):
    +        td_1d = datetime.timedelta(days=1)
    +
    +        exchange, tz = self._getExchangeAndTz()
    +        if isinstance(end, datetime.datetime):
    +            end_dt = end
    +            end_d = end.date()
    +        else:
    +            end_dt = pd.Timestamp(end).tz_localize(tz)
    +            end_d = end
    +        if isinstance(start, datetime.datetime):
    +            start_dt = start
    +            start_d = start.date()
    +        else:
    +            start_dt = pd.Timestamp(start).tz_localize(tz)
    +            start_d = start
    +
    +        end_d = min(end_d, datetime.date.today() + td_1d)
    +
    +        df = self.dat.get_shares_full(start_d, end_d)
    +        if df is None:
    +            return df
    +        if df.empty:
    +            return None
    +
    +        # Convert to Pandas Int for NaN support
    +        df = df.astype('Int64')
    +
    +        # Currently, yfinance uses ceil(end), so fix:
    +        if df.index[-1].date() == end_d:
    +            df.drop(df.index[-1])
    +            if df.empty:
    +                return None
    +
    +        fetch_dt = pd.Timestamp.utcnow().tz_convert(tz)
    +        df = pd.DataFrame(df, columns=['Shares'])
    +
    +        if start_d < df.index[0].date():
    +            df.loc[start_dt, 'Shares'] = np.nan
    +        if (end_d-td_1d) > df.index[-1].date():
    +            df.loc[end_dt, 'Shares'] = np.nan
    +        df = df.sort_index()
    +
    +        df['FetchDate'] = fetch_dt
    +
    +        return df
    +
    +    @property
    +    def major_holders(self):
    +        if self._major_holders is not None:
    +            return self._major_holders
    +
    +        if yfcm.IsDatumCached(self.ticker, "major_holders"):
    +            self._major_holders = yfcm.ReadCacheDatum(self.ticker, "major_holders")
    +            return self._major_holders
    +
    +        self._major_holders = self.dat.major_holders
    +        yfcm.StoreCacheDatum(self.ticker, "major_holders", self._major_holders)
    +        return self._major_holders
    +
    +    @property
    +    def institutional_holders(self):
    +        if self._institutional_holders is not None:
    +            return self._institutional_holders
    +
    +        if yfcm.IsDatumCached(self.ticker, "institutional_holders"):
    +            self._institutional_holders = yfcm.ReadCacheDatum(self.ticker, "institutional_holders")
    +            return self._institutional_holders
    +
    +        self._institutional_holders = self.dat.institutional_holders
    +        yfcm.StoreCacheDatum(self.ticker, "institutional_holders", self._institutional_holders)
    +        return self._institutional_holders
    +
    +    @property
    +    def earnings(self):
    +        return self._financials_manager.get_earnings()
    +
    +    @property
    +    def quarterly_earnings(self):
    +        return self._financials_manager.get_quarterly_earnings()
    +
    +    @property
    +    def income_stmt(self):
    +        return self._financials_manager.get_income_stmt()
    +
    +    @property
    +    def quarterly_income_stmt(self):
    +        return self._financials_manager.get_quarterly_income_stmt()
    +
    +    @property
    +    def financials(self):
    +        return self._financials_manager.get_income_stmt()
    +
    +    @property
    +    def quarterly_financials(self):
    +        return self._financials_manager.get_quarterly_income_stmt()
    +
    +    @property
    +    def balance_sheet(self):
    +        return self._financials_manager.get_balance_sheet()
    +
    +    @property
    +    def quarterly_balance_sheet(self):
    +        return self._financials_manager.get_quarterly_balance_sheet()
    +
    +    @property
    +    def cashflow(self):
    +        return self._financials_manager.get_cashflow()
    +
    +    @property
    +    def quarterly_cashflow(self):
    +        return self._financials_manager.get_quarterly_cashflow()
    +
    +    def get_earnings_dates(self, limit=12):
    +        return self._financials_manager.get_earnings_dates(limit)
    +
    +    def get_release_dates(self, period='quarterly', as_df=False, check=True):
    +        if period not in ['annual', 'quarterly']:
    +            raise ValueError(f'period argument must be "annual" or "quarterly", not "{period}"')
    +        if period == 'annual':
    +            period = yfcd.ReportingPeriod.Full
    +        else:
    +            period = yfcd.ReportingPeriod.Interim
    +
    +        releases = self._financials_manager.get_release_dates(period, as_df, refresh=True, check=check)
    +        if releases is None:
    +            return releases
    +
    +        if as_df:
    +            # Format:
    +            releases['Period end uncertainty'] = '0d'
    +            f = releases['PE confidence'] == yfcd.Confidence.Medium
    +            if f.any():
    +                releases.loc[f, 'Period end uncertainty'] = '+-7d'
    +            f = releases['PE confidence'] == yfcd.Confidence.Low
    +            if f.any():
    +                releases.loc[f, 'Period end uncertainty'] = '+-45d'
    +            releases = releases.drop('PE confidence', axis=1)
    +
    +            releases['Release date uncertainty'] = '0d'
    +            f = releases['RD confidence'] == yfcd.Confidence.Medium
    +            if f.any():
    +                releases.loc[f, 'Release date uncertainty'] = '+/-7d'
    +            f = releases['RD confidence'] == yfcd.Confidence.Low
    +            if f.any():
    +                releases.loc[f, 'Release date uncertainty'] = '+/-45d'
    +            releases = releases.drop('RD confidence', axis=1)
    +
    +            releases = releases.drop('Delay', axis=1)
    +
    +        return releases
    +
    +    @property
    +    def sustainability(self):
    +        if self._sustainability is not None:
    +            return self._sustainability
    +
    +        if yfcm.IsDatumCached(self.ticker, "sustainability"):
    +            self._sustainability = yfcm.ReadCacheDatum(self.ticker, "sustainability")
    +            return self._sustainability
    +
    +        self._sustainability = self.dat.sustainability
    +        yfcm.StoreCacheDatum(self.ticker, "sustainability", self._sustainability)
    +        return self._sustainability
    +
    +    @property
    +    def recommendations(self):
    +        if self._recommendations is not None:
    +            return self._recommendations
    +
    +        if yfcm.IsDatumCached(self.ticker, "recommendations"):
    +            self._recommendations = yfcm.ReadCacheDatum(self.ticker, "recommendations")
    +            return self._recommendations
    +
    +        self._recommendations = self.dat.recommendations
    +        yfcm.StoreCacheDatum(self.ticker, "recommendations", self._recommendations)
    +        return self._recommendations
    +
    +    @property
    +    def calendar(self):
    +        return self._financials_manager.get_calendar()
    +
    +    @property
    +    def inin(self):
    +        if self._inin is not None:
    +            return self._inin
    +
    +        if yfcm.IsDatumCached(self.ticker, "inin"):
    +            self._inin = yfcm.ReadCacheDatum(self.ticker, "inin")
    +            return self._inin
    +
    +        self._inin = self.dat.inin
    +        yfcm.StoreCacheDatum(self.ticker, "inin", self._inin)
    +        return self._inin
    +
    +    @property
    +    def options(self):
    +        if self._options is not None:
    +            return self._options
    +
    +        if yfcm.IsDatumCached(self.ticker, "options"):
    +            self._options = yfcm.ReadCacheDatum(self.ticker, "options")
    +            return self._options
    +
    +        self._options = self.dat.options
    +        yfcm.StoreCacheDatum(self.ticker, "options", self._options)
    +        return self._options
    +
    +    @property
    +    def news(self):
    +        if self._news is not None:
    +            return self._news
    +
    +        if yfcm.IsDatumCached(self.ticker, "news"):
    +            self._news = yfcm.ReadCacheDatum(self.ticker, "news")
    +            return self._news
    +
    +        self._news = self.dat.news
    +        yfcm.StoreCacheDatum(self.ticker, "news", self._news)
    +        return self._news
    +
    +    @property
    +    def yf_lag(self):
    +        if self._yf_lag is not None:
    +            return self._yf_lag
    +
    +        exchange, tz_name = self._getExchangeAndTz()
    +        exchange_str = "exchange-{0}".format(exchange)
    +        if yfcm.IsDatumCached(exchange_str, "yf_lag"):
    +            self._yf_lag = yfcm.ReadCacheDatum(exchange_str, "yf_lag")
    +            if self._yf_lag:
    +                return self._yf_lag
    +
    +        # Just use specified lag
    +        specified_lag = yfcd.exchangeToYfLag[exchange]
    +        self._yf_lag = specified_lag
    +        return self._yf_lag
    +
    +

    Instance variables

    +
    +
    prop balance_sheet
    +
    +
    +
    + +Expand source code + +
    @property
    +def balance_sheet(self):
    +    return self._financials_manager.get_balance_sheet()
    +
    +
    +
    prop calendar
    +
    +
    +
    + +Expand source code + +
    @property
    +def calendar(self):
    +    return self._financials_manager.get_calendar()
    +
    +
    +
    prop cashflow
    +
    +
    +
    + +Expand source code + +
    @property
    +def cashflow(self):
    +    return self._financials_manager.get_cashflow()
    +
    +
    +
    prop earnings
    +
    +
    +
    + +Expand source code + +
    @property
    +def earnings(self):
    +    return self._financials_manager.get_earnings()
    +
    +
    +
    prop fast_info
    +
    +
    +
    + +Expand source code + +
    @property
    +def fast_info(self):
    +    if self._fast_info is not None:
    +        return self._fast_info
    +
    +    if yfcm.IsDatumCached(self.ticker, "fast_info"):
    +        try:
    +            self._fast_info = yfcm.ReadCacheDatum(self.ticker, "fast_info")
    +        except Exception:
    +            pass
    +        else:
    +            return self._fast_info
    +
    +    # self._fast_info = self.dat.fast_info
    +    self._fast_info = {}
    +    for k in self.dat.fast_info.keys():
    +        try:
    +            self._fast_info[k] = self.dat.fast_info[k]
    +        except Exception as e:
    +            if "decrypt" in str(e):
    +                pass
    +            else:
    +                print(f"TICKER = {self.ticker}")
    +                raise
    +    yfcm.StoreCacheDatum(self.ticker, "fast_info", self._fast_info)
    +
    +    yfct.SetExchangeTzName(self._fast_info["exchange"], self._fast_info["timezone"])
    +
    +    return self._fast_info
    +
    +
    +
    prop financials
    +
    +
    +
    + +Expand source code + +
    @property
    +def financials(self):
    +    return self._financials_manager.get_income_stmt()
    +
    +
    +
    prop income_stmt
    +
    +
    +
    + +Expand source code + +
    @property
    +def income_stmt(self):
    +    return self._financials_manager.get_income_stmt()
    +
    +
    +
    prop info
    +
    +
    +
    + +Expand source code + +
    @property
    +def info(self):
    +    return self.get_info()
    +
    +
    +
    prop inin
    +
    +
    +
    + +Expand source code + +
    @property
    +def inin(self):
    +    if self._inin is not None:
    +        return self._inin
    +
    +    if yfcm.IsDatumCached(self.ticker, "inin"):
    +        self._inin = yfcm.ReadCacheDatum(self.ticker, "inin")
    +        return self._inin
    +
    +    self._inin = self.dat.inin
    +    yfcm.StoreCacheDatum(self.ticker, "inin", self._inin)
    +    return self._inin
    +
    +
    +
    prop institutional_holders
    +
    +
    +
    + +Expand source code + +
    @property
    +def institutional_holders(self):
    +    if self._institutional_holders is not None:
    +        return self._institutional_holders
    +
    +    if yfcm.IsDatumCached(self.ticker, "institutional_holders"):
    +        self._institutional_holders = yfcm.ReadCacheDatum(self.ticker, "institutional_holders")
    +        return self._institutional_holders
    +
    +    self._institutional_holders = self.dat.institutional_holders
    +    yfcm.StoreCacheDatum(self.ticker, "institutional_holders", self._institutional_holders)
    +    return self._institutional_holders
    +
    +
    +
    prop major_holders
    +
    +
    +
    + +Expand source code + +
    @property
    +def major_holders(self):
    +    if self._major_holders is not None:
    +        return self._major_holders
    +
    +    if yfcm.IsDatumCached(self.ticker, "major_holders"):
    +        self._major_holders = yfcm.ReadCacheDatum(self.ticker, "major_holders")
    +        return self._major_holders
    +
    +    self._major_holders = self.dat.major_holders
    +    yfcm.StoreCacheDatum(self.ticker, "major_holders", self._major_holders)
    +    return self._major_holders
    +
    +
    +
    prop news
    +
    +
    +
    + +Expand source code + +
    @property
    +def news(self):
    +    if self._news is not None:
    +        return self._news
    +
    +    if yfcm.IsDatumCached(self.ticker, "news"):
    +        self._news = yfcm.ReadCacheDatum(self.ticker, "news")
    +        return self._news
    +
    +    self._news = self.dat.news
    +    yfcm.StoreCacheDatum(self.ticker, "news", self._news)
    +    return self._news
    +
    +
    +
    prop options
    +
    +
    +
    + +Expand source code + +
    @property
    +def options(self):
    +    if self._options is not None:
    +        return self._options
    +
    +    if yfcm.IsDatumCached(self.ticker, "options"):
    +        self._options = yfcm.ReadCacheDatum(self.ticker, "options")
    +        return self._options
    +
    +    self._options = self.dat.options
    +    yfcm.StoreCacheDatum(self.ticker, "options", self._options)
    +    return self._options
    +
    +
    +
    prop quarterly_balance_sheet
    +
    +
    +
    + +Expand source code + +
    @property
    +def quarterly_balance_sheet(self):
    +    return self._financials_manager.get_quarterly_balance_sheet()
    +
    +
    +
    prop quarterly_cashflow
    +
    +
    +
    + +Expand source code + +
    @property
    +def quarterly_cashflow(self):
    +    return self._financials_manager.get_quarterly_cashflow()
    +
    +
    +
    prop quarterly_earnings
    +
    +
    +
    + +Expand source code + +
    @property
    +def quarterly_earnings(self):
    +    return self._financials_manager.get_quarterly_earnings()
    +
    +
    +
    prop quarterly_financials
    +
    +
    +
    + +Expand source code + +
    @property
    +def quarterly_financials(self):
    +    return self._financials_manager.get_quarterly_income_stmt()
    +
    +
    +
    prop quarterly_income_stmt
    +
    +
    +
    + +Expand source code + +
    @property
    +def quarterly_income_stmt(self):
    +    return self._financials_manager.get_quarterly_income_stmt()
    +
    +
    +
    prop recommendations
    +
    +
    +
    + +Expand source code + +
    @property
    +def recommendations(self):
    +    if self._recommendations is not None:
    +        return self._recommendations
    +
    +    if yfcm.IsDatumCached(self.ticker, "recommendations"):
    +        self._recommendations = yfcm.ReadCacheDatum(self.ticker, "recommendations")
    +        return self._recommendations
    +
    +    self._recommendations = self.dat.recommendations
    +    yfcm.StoreCacheDatum(self.ticker, "recommendations", self._recommendations)
    +    return self._recommendations
    +
    +
    +
    prop splits
    +
    +
    +
    + +Expand source code + +
    @property
    +def splits(self):
    +    if self._splits is not None:
    +        return self._splits
    +
    +    if yfcm.IsDatumCached(self.ticker, "splits"):
    +        self._splits = yfcm.ReadCacheDatum(self.ticker, "splits")
    +        return self._splits
    +
    +    self._splits = self.dat.splits
    +    yfcm.StoreCacheDatum(self.ticker, "splits", self._splits)
    +    return self._splits
    +
    +
    +
    prop sustainability
    +
    +
    +
    + +Expand source code + +
    @property
    +def sustainability(self):
    +    if self._sustainability is not None:
    +        return self._sustainability
    +
    +    if yfcm.IsDatumCached(self.ticker, "sustainability"):
    +        self._sustainability = yfcm.ReadCacheDatum(self.ticker, "sustainability")
    +        return self._sustainability
    +
    +    self._sustainability = self.dat.sustainability
    +    yfcm.StoreCacheDatum(self.ticker, "sustainability", self._sustainability)
    +    return self._sustainability
    +
    +
    +
    prop yf_lag
    +
    +
    +
    + +Expand source code + +
    @property
    +def yf_lag(self):
    +    if self._yf_lag is not None:
    +        return self._yf_lag
    +
    +    exchange, tz_name = self._getExchangeAndTz()
    +    exchange_str = "exchange-{0}".format(exchange)
    +    if yfcm.IsDatumCached(exchange_str, "yf_lag"):
    +        self._yf_lag = yfcm.ReadCacheDatum(exchange_str, "yf_lag")
    +        if self._yf_lag:
    +            return self._yf_lag
    +
    +    # Just use specified lag
    +    specified_lag = yfcd.exchangeToYfLag[exchange]
    +    self._yf_lag = specified_lag
    +    return self._yf_lag
    +
    +
    +
    +

    Methods

    +
    +
    +def get_earnings_dates(self, limit=12) +
    +
    +
    +
    +
    +def get_info(self, max_age=None) +
    +
    +
    +
    +
    +def get_release_dates(self, period='quarterly', as_df=False, check=True) +
    +
    +
    +
    +
    +def get_shares(self, start=None, end=None, max_age='30d') +
    +
    +
    +
    +
    +def history(self, interval='1d', max_age=None, period=None, start=None, end=None, prepost=False, actions=True, adjust_splits=True, adjust_divs=True, keepna=False, proxy=None, rounding=False, debug=True, quiet=False, trigger_at_market_close=False) +
    +
    +

    Fetch historical market data for this ticker.

    +

    This method retrieves historical price and volume data, as well as information +about stock splits and dividend payments if requested.

    +

    Args

    +
    +
    interval : str, optional
    +
    Data interval. Defaults to "1d" (daily). +Other options: "1m" (minute), "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo", "3mo".
    +
    max_age : int, optional
    +
    Maximum age of cached data in seconds. Defaults to None (half of the interval).
    +
    period : str, optional
    +
    Data period to download. Defaults to None. +Options: "1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max".
    +
    start : str, optional
    +
    Download start date string (YYYY-MM-DD) or datetime. Defaults to None.
    +
    end : str, optional
    +
    Download end date string (YYYY-MM-DD) or datetime. Defaults to None.
    +
    prepost : bool, optional
    +
    Include pre and post market data. Defaults to False.
    +
    actions : bool, optional
    +
    Include stock splits and dividend data. Defaults to True.
    +
    adjust_splits : bool, optional
    +
    Adjust data for stock splits. Defaults to True.
    +
    adjust_divs : bool, optional
    +
    Adjust data for dividends. Defaults to True.
    +
    keepna : bool, optional
    +
    Keep NaN values. Defaults to False.
    +
    proxy : str, optional
    +
    Proxy URL. Defaults to None.
    +
    rounding : bool, optional
    +
    Round values to 2 decimal places. Defaults to False.
    +
    debug : bool, optional
    +
    Print debug messages. Defaults to True.
    +
    quiet : bool, optional
    +
    Suppress output messages. Defaults to False.
    +
    trigger_at_market_close : bool, optional
    +
    Trigger requests at market close. Defaults to False.
    +
    +

    Returns

    +
    +
    pandas.DataFrame
    +
    A DataFrame containing the historical data. Columns typically include: +Date, Open, High, Low, Close, Volume, Dividends, Stock Splits.
    +
    +

    Raises

    +
    +
    ValueError
    +
    If invalid date parameters are provided.
    +
    YFinanceException
    +
    If there's an error fetching the data from Yahoo Finance.
    +
    +

    Note

    +

    Either 'period' or 'start' and 'end' should be provided, but not both.

    +
    +
    +def verify_cached_prices(self, rtol=0.0001, vol_rtol=0.005, correct='none', discard_old=False, quiet=True, debug=False, debug_interval=None) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_cache_manager.html b/docs/yfinance_cache/yfc_cache_manager.html new file mode 100644 index 0000000..a9b50e3 --- /dev/null +++ b/docs/yfinance_cache/yfc_cache_manager.html @@ -0,0 +1,259 @@ + + + + + + +yfinance_cache.yfc_cache_manager API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_cache_manager

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def GetCacheDirpath() +
    +
    +
    +
    +
    +def GetFilepath(ticker, objectName, obj=None, prune=False) +
    +
    +
    +
    +
    +def GetFilepathPacked(ticker, objectName) +
    +
    +
    +
    +
    +def GetPackedDataCat(objectName) +
    +
    +
    +
    +
    +def IsDatumCached(ticker, objectName) +
    +
    +
    +
    +
    +def IsObjectInPackedData(objectName) +
    +
    +
    +
    +
    +def ReadCacheDatum(ticker, objectName, return_metadata_too=False) +
    +
    +
    +
    +
    +def ReadCacheMetadata(ticker, objectName, key) +
    +
    +
    +
    +
    +def ReadCachePackedDatum(ticker, objectName, return_metadata_too=False) +
    +
    +
    +
    +
    +def ResetCacheDirpath() +
    +
    +
    +
    +
    +def SetCacheDirpath(dp) +
    +
    +
    +
    +
    +def StoreCacheDatum(ticker, objectName, datum, expiry=None, metadata=None) +
    +
    +
    +
    +
    +def StoreCachePackedDatum(ticker, objectName, datum, expiry=None, metadata=None) +
    +
    +
    +
    +
    +def WriteCacheMetadata(ticker, objectName, key, value) +
    +
    +
    +
    +
    +def WriteCachePackedMetadata(ticker, objectName, key, value) +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class NestedOptions +(name, data) +
    +
    +
    +
    + +Expand source code + +
    class NestedOptions:
    +    def __init__(self, name, data):
    +        self.__dict__['name'] = name
    +        self.__dict__['data'] = data
    +
    +    def __getattr__(self, key):
    +        return self.data.get(key)
    +
    +    def __setattr__(self, key, value):
    +        if self.name == 'max_ages':
    +            # Type-check value
    +            pd.Timedelta(value)
    +
    +        self.data[key] = value
    +        global _option_manager
    +        _option_manager._save_option()
    +
    +    def __len__(self):
    +        return len(self.__dict__['data'])
    +
    +    def __repr__(self):
    +        return json.dumps(self.data, indent=4)
    +
    +
    +
    +class OptionsManager +
    +
    +
    +
    + +Expand source code + +
    class OptionsManager:
    +    def __init__(self):
    +        self._initialised = False
    +
    +    def _load_option(self):
    +        self._initialised = True  # prevent infinite loop
    +        d = GetCacheDirpath()
    +        self.option_file = os.path.join(d, 'options.json')
    +        try:
    +            with open(self.option_file, 'r') as file:
    +                self.options = json.load(file)
    +        except (FileNotFoundError, json.JSONDecodeError):
    +            self.options = {}
    +            # Initialise
    +            self.__getattr__('max_ages').calendar = '7d'
    +            self.__getattr__('max_ages').info = '45d'
    +
    +    def _save_option(self):
    +        with open(self.option_file, 'w') as file:
    +            json.dump(self.options, file, indent=4)
    +
    +    def __getattr__(self, key):
    +        if not self._initialised:
    +            self._load_option()
    +
    +        if key not in self.options:
    +            self.options[key] = {}
    +        return NestedOptions(key, self.options[key])
    +
    +    def __repr__(self):
    +        if not self._initialised:
    +            self._load_option()
    +
    +        return json.dumps(self.options, indent=4)
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_dat.html b/docs/yfinance_cache/yfc_dat.html new file mode 100644 index 0000000..e577a06 --- /dev/null +++ b/docs/yfinance_cache/yfc_dat.html @@ -0,0 +1,2217 @@ + + + + + + +yfinance_cache.yfc_dat API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_dat

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def uniform_prob_lt(X, Y) +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AmbiguousComparisonException +(value1, value2, operation, true_prob=None) +
    +
    +

    Common base class for all non-exit exceptions.

    +
    + +Expand source code + +
    class AmbiguousComparisonException(Exception):
    +    def __init__(self, value1, value2, operation, true_prob=None):
    +        if not isinstance(operation, str):
    +            raise TypeError(f"operation must be a string not {type(operation)}")
    +        if true_prob is not None and not isinstance(true_prob, (int, float)):
    +            raise TypeError(f"true_prob must be numeric not {type(true_prob)}")
    +
    +        self.value1 = value1
    +        self.value2 = value2
    +        self.operation = operation
    +        self.true_prob = true_prob
    +
    +    def __str__(self):
    +        msg = f"Ambiguous whether {self.value1} {self.operation} {self.value2}"
    +        if self.true_prob is not None:
    +            msg += f" (true with probability {self.true_prob*100:.1f}%)"
    +        return msg
    +
    +

    Ancestors

    +
      +
    • builtins.Exception
    • +
    • builtins.BaseException
    • +
    +
    +
    +class ComparableRelativedelta +(dt1=None, dt2=None, years=0, months=0, days=0, leapdays=0, weeks=0, hours=0, minutes=0, seconds=0, microseconds=0, year=None, month=None, day=None, weekday=None, yearday=None, nlyearday=None, hour=None, minute=None, second=None, microsecond=None) +
    +
    +

    The relativedelta type is designed to be applied to an existing datetime and +can replace specific components of that datetime, or represents an interval +of time.

    +

    It is based on the specification of the excellent work done by M.-A. Lemburg +in his +mx.DateTime <https://www.egenix.com/products/python/mxBase/mxDateTime/>_ extension. +However, notice that this type does NOT implement the same algorithm as +his work. Do NOT expect it to behave like mx.DateTime's counterpart.

    +

    There are two different ways to build a relativedelta instance. The +first one is passing it two date/datetime classes::

    +
    relativedelta(datetime1, datetime2)
    +
    +

    The second one is passing it any number of the following keyword arguments::

    +
    relativedelta(arg1=x,arg2=y,arg3=z...)
    +
    +year, month, day, hour, minute, second, microsecond:
    +    Absolute information (argument is singular); adding or subtracting a
    +    relativedelta with absolute information does not perform an arithmetic
    +    operation, but rather REPLACES the corresponding value in the
    +    original datetime with the value(s) in relativedelta.
    +
    +years, months, weeks, days, hours, minutes, seconds, microseconds:
    +    Relative information, may be negative (argument is plural); adding
    +    or subtracting a relativedelta with relative information performs
    +    the corresponding arithmetic operation on the original datetime value
    +    with the information in the relativedelta.
    +
    +weekday: 
    +    One of the weekday instances (MO, TU, etc) available in the
    +    relativedelta module. These instances may receive a parameter N,
    +    specifying the Nth weekday, which could be positive or negative
    +    (like MO(+1) or MO(-2)). Not specifying it is the same as specifying
    +    +1. You can also use an integer, where 0=MO. This argument is always
    +    relative e.g. if the calculated date is already Monday, using MO(1)
    +    or MO(-1) won't change the day. To effectively make it absolute, use
    +    it in combination with the day argument (e.g. day=1, MO(1) for first
    +    Monday of the month).
    +
    +leapdays:
    +    Will add given days to the date found, if year is a leap
    +    year, and the date found is post 28 of february.
    +
    +yearday, nlyearday:
    +    Set the yearday or the non-leap year day (jump leap days).
    +    These are converted to day/month/leapdays information.
    +
    +

    There are relative and absolute forms of the keyword +arguments. The plural is relative, and the singular is +absolute. For each argument in the order below, the absolute form +is applied first (by setting each attribute to that value) and +then the relative form (by adding the value to the attribute).

    +

    The order of attributes considered when this relativedelta is +added to a datetime is:

    +
      +
    1. Year
    2. +
    3. Month
    4. +
    5. Day
    6. +
    7. Hours
    8. +
    9. Minutes
    10. +
    11. Seconds
    12. +
    13. Microseconds
    14. +
    +

    Finally, weekday is applied, using the rule described above.

    +

    For example

    +
    >>> from datetime import datetime
    +>>> from dateutil.relativedelta import relativedelta, MO
    +>>> dt = datetime(2018, 4, 9, 13, 37, 0)
    +>>> delta = relativedelta(hours=25, day=1, weekday=MO(1))
    +>>> dt + delta
    +datetime.datetime(2018, 4, 2, 14, 37)
    +
    +

    First, the day is set to 1 (the first of the month), then 25 hours +are added, to get to the 2nd day and 14th hour, finally the +weekday is applied, but since the 2nd is already a Monday there is +no effect.

    +
    + +Expand source code + +
    class ComparableRelativedelta(relativedelta):
    +    def _have_same_attributes(self, other):
    +        attrs = ['years', 'months', 'days', 'leapdays', 'hours', 'minutes', 'seconds', 'microseconds', 'year', 'month', 'day', 'weekday']
    +
    +        for a in attrs:
    +            if getattr(self, a, 0) == 0:
    +                if getattr(other, a, 0) != 0:
    +                    return False
    +        return True
    +
    +    def __str__(self):
    +        s = ''
    +
    +        a = 'years'
    +        x = getattr(self, a, 0)
    +        if x != 0:
    +            s += f'{x}y'
    +
    +        a = 'months'
    +        x = getattr(self, a, 0)
    +        if x != 0:
    +            s += f'{x}mo'
    +
    +        a = 'days'
    +        x = getattr(self, a, 0)
    +        if x != 0:
    +            s += f'{x}d'
    +
    +        a = 'hours'
    +        x = getattr(self, a, 0)
    +        if x != 0:
    +            s += f'{x}h'
    +
    +        a = 'minutes'
    +        x = getattr(self, a, 0)
    +        if x != 0:
    +            s += f'{x}m'
    +
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __eq__(self, other):
    +        if isinstance(other, relativedelta):
    +            attrs = ['years', 'months', 'days', 'leapdays', 'hours', 'minutes', 'seconds', 'microseconds', 'year', 'month', 'day', 'weekday']
    +            return all(getattr(self, attr, 0) == getattr(other, attr, 0) for attr in attrs)
    +
    +        raise NotImplementedError(f'Not implemented ComparableRelativedelta={self} == {type(other)}={other}')
    +
    +    def __lt__(self, other):
    +        if isinstance(other, (TimedeltaEstimate, TimedeltaRangeEstimate)):
    +            return other.__gt__(self)
    +
    +        elif isinstance(other, (relativedelta, timedelta)):
    +            reference_date = date(2000, 1, 1)
    +            result_date_self = reference_date + self
    +            result_date_other = reference_date + other
    +
    +            if not self._have_same_attributes(other):
    +                if abs(result_date_self - result_date_other) < timedelta(days=7):
    +                    raise AmbiguousComparisonException(self, other, '<')
    +
    +            return result_date_self < result_date_other
    +
    +        raise NotImplementedError(f'Not implemented ComparableRelativedelta={self} < {type(other)}={other}')
    +
    +    def __le__(self, other):
    +        return self < other
    +
    +    def __gt__(self, other):
    +        if isinstance(other, (TimedeltaEstimate, TimedeltaRangeEstimate)):
    +            return other.__lt__(self)
    +
    +        elif isinstance(other, (relativedelta, timedelta)):
    +            reference_date = date(2000, 1, 1)
    +            result_date_self = reference_date + self
    +            result_date_other = reference_date + other
    +
    +            if not self._have_same_attributes(other):
    +                if abs(result_date_self - result_date_other) < timedelta(days=7):
    +                    raise AmbiguousComparisonException(self, other, '>')
    +
    +            return result_date_self > result_date_other
    +        raise NotImplementedError(f'Not implemented ComparableRelativedelta={self} > {type(other)}={other}')
    +
    +    def __ge__(self, other):
    +        return self > other
    +
    +

    Ancestors

    +
      +
    • dateutil.relativedelta.relativedelta
    • +
    +
    +
    +class Confidence +(*args, **kwds) +
    +
    +

    Enum where members are also (and must be) ints

    +
    + +Expand source code + +
    class Confidence(IntEnum):
    +    Low = 0
    +    Medium = 1
    +    High = 2
    +
    +

    Ancestors

    +
      +
    • enum.IntEnum
    • +
    • builtins.int
    • +
    • enum.ReprEnum
    • +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var High
    +
    +
    +
    +
    var Low
    +
    +
    +
    +
    var Medium
    +
    +
    +
    +
    +
    +
    +class DateEstimate +(dt, confidence) +
    +
    +
    +
    + +Expand source code + +
    class DateEstimate():
    +    def __init__(self, dt, confidence):
    +        if not isinstance(confidence, Confidence):
    +            raise Exception("'confidence' must be a 'Confidence' object, not {0}".format(type(confidence)))
    +        if isinstance(dt, (datetime, pd.Timestamp)) or not isinstance(dt, (date, DateEstimate)):
    +            raise Exception("'dt' must be a 'date' object or None, not {0}".format(type(dt)))
    +        if isinstance(dt, DateEstimate):
    +            self.date = dt.date
    +            self.confidence = min(dt.confidence, confidence)
    +        else:
    +            self.date = dt
    +            self.confidence = confidence
    +        self.uncertainty = confidence_to_buffer[confidence]
    +
    +    def copy(self):
    +        return DateEstimate(self.date, self.confidence)
    +
    +    def __str__(self):
    +        s = f"DateEstimate {self.date} (conf={self.confidence}/2)"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def prob_lt(self, other):
    +        if isinstance(other, date):
    +            if self.date + self.uncertainty < other:
    +                return 1.0
    +            elif other <= self.date - self.uncertainty:
    +                return 0.0
    +            return uniform_prob_lt((self.date-self.uncertainty, self.date+self.uncertainty), other)
    +        elif isinstance(other, DateEstimate):
    +            if self.date + self.uncertainty < other.date - other.uncertainty:
    +                return 1.0
    +            elif other.date + other.uncertainty <= self.date - self.uncertainty:
    +                return 0.0
    +            return uniform_prob_lt((self.date-self.uncertainty, self.date+self.uncertainty), 
    +                                  (other.date-other.uncertainty, other.date+other.uncertainty))
    +        elif isinstance(other, DateRange):
    +            p = uniform_prob_lt((self.date-self.uncertainty, self.date+self.uncertainty), 
    +                               (other.start, other.end))
    +            return p
    +        elif isinstance(other, DateRangeEstimate):
    +            return other.__ge__(self)
    +
    +        raise NotImplementedError(f'Not implemented {self} < {type(other)}={other}')
    +    def __lt__(self, other):
    +        x = self.prob_lt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '<', x)
    +    def prob_gt(self, other):
    +        return 1.0 - self.prob_lt(other)
    +    def __gt__(self, other):
    +        x = self.prob_gt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '>', x)
    +    def prob_le(self, other):
    +        return self.prob_lt(other)
    +    def __le__(self, other):
    +        return self < other
    +    def prob_ge(self, other):
    +        return self.prob_gt(other)
    +    def __ge__(self, other):
    +        return self > other
    +
    +    def __abs__(self):
    +        raise NotImplementedError(f'Not implemented {self} abs')
    +
    +    def __eq__(self, other):
    +        if isinstance(other, DateEstimate):
    +            return self.date == other.date and self.confidence == other.confidence
    +        elif isinstance(other, date):
    +            if self.isclose(other):
    +                raise AmbiguousComparisonException(self, other, '==')
    +            else:
    +                return False
    +        raise NotImplementedError(f'Not implemented {self} == {type(other)}={other}')
    +
    +    def isclose(self, other):
    +        if isinstance(other, DateEstimate):
    +            return abs(self.date - other.date) <= min(self.uncertainty, other.uncertainty)
    +        else:
    +            return abs(self.date - other) <= self.uncertainty
    +        raise NotImplementedError(f'Not implemented {self} is-close-to {type(other)}={other}')
    +
    +    def __iadd__(self, other):
    +        if isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            self.date += other
    +            return self
    +        raise NotImplementedError(f'Not implemented {self} += {type(other)}={other}')
    +
    +    def __add__(self, other):
    +        if isinstance(other, TimedeltaEstimate):
    +            return DateEstimate(self.date+other.td, min(self.confidence, other.confidence))
    +        elif isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            return DateEstimate(self.date+other, self.confidence)
    +        elif isinstance(other, TimedeltaRange):
    +            return DateRangeEstimate(self.date + other.td1, self.date + other.td2, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} + {type(other)}={other}')
    +
    +    def __radd__(self, other):
    +        return self.__add__(other)
    +
    +    def __isub__(self, other):
    +        if isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            self.date -= other
    +            return self
    +        raise NotImplementedError(f'Not implemented {self} -= {type(other)}={other}')
    +
    +    def __sub__(self, other):
    +        if isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            return DateEstimate(self.date-other, self.confidence)
    +        elif isinstance(other, TimedeltaEstimate):
    +            return DateEstimate(self.date-other.td, min(self.confidence, other.confidence))
    +        elif isinstance(other, DateEstimate):
    +            td = self.date - other.date
    +            c0 = self.confidence
    +            c1 = other.confidence
    +            return TimedeltaEstimate(td, min(c0, c1))
    +        elif isinstance(other, date):
    +            return TimedeltaEstimate(self.date-other, self.confidence)
    +        elif isinstance(other, DateRange):
    +            return TimedeltaRangeEstimate(self.date - other.start, self.date - other.end, self.confidence)
    +        elif isinstance(other, DateRangeEstimate):
    +            return TimedeltaRangeEstimate(self.date - other.start, self.date - other.end, min(self.confidence, other.confidence))
    +        raise NotImplementedError(f'Not implemented {self} - {type(other)}={other}')
    +
    +    def __rsub__(self, other):
    +        if isinstance(other, DateEstimate):
    +            return other - self
    +        elif isinstance(other, date):
    +            return TimedeltaEstimate(other - self.date, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} rsub {type(other)}={other}')
    +
    +    def __neg__(self):
    +        raise NotImplementedError(f'Not implemented {self} negate')
    +
    +    def __invert__(self):
    +        raise NotImplementedError(f'Not implemented {self} invert')
    +
    +

    Methods

    +
    +
    +def copy(self) +
    +
    +
    +
    +
    +def isclose(self, other) +
    +
    +
    +
    +
    +def prob_ge(self, other) +
    +
    +
    +
    +
    +def prob_gt(self, other) +
    +
    +
    +
    +
    +def prob_le(self, other) +
    +
    +
    +
    +
    +def prob_lt(self, other) +
    +
    +
    +
    +
    +
    +
    +class DateInterval +(left, right, closed=None) +
    +
    +
    +
    + +Expand source code + +
    class DateInterval:
    +    def __init__(self, left, right, closed=None):
    +        if not isinstance(left, date) or isinstance(left, datetime):
    +            raise TypeError("'left' must be date object not datetime")
    +
    +        self.left = left
    +        self.right = right
    +
    +        if closed is None:
    +            self.closed = None
    +        else:
    +            if closed not in ["left", "right"]:
    +                raise Exception("closed must be left or right")
    +            self.closed = closed
    +
    +    def __eq__(self, other):
    +        return self.left == other.left and self.right == other.right and self.closed == other.closed
    +
    +    def __str__(self):
    +        s = ""
    +        if self.closed == "left":
    +            s += '['
    +        else:
    +            s += '('
    +        s += str(self.left) + ', ' + str(self.right)
    +        if self.closed == "right":
    +            s += ']'
    +        else:
    +            s += ')'
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +
    +
    +class DateIntervalIndex +(intervals) +
    +
    +
    +
    + +Expand source code + +
    class DateIntervalIndex:
    +    def __init__(self, intervals):
    +        if not isinstance(intervals, (list, np.ndarray, pd.Series)):
    +            raise TypeError(f"'intervals' must be iterable not '{type(intervals)}'")
    +        if not isinstance(intervals, np.ndarray):
    +            self.array = np.array(intervals)
    +        else:
    +            self.array = intervals
    +
    +        self._left = np.array([x.left for x in self.array])
    +        self._right = np.array([x.right for x in self.array])
    +        self._right_inc = self._right - timedelta(days=1)
    +
    +    @classmethod
    +    def from_arrays(cls, left, right, closed=None):
    +        if len(left) != len(right):
    +            raise Exception("left and right must be equal length")
    +        if isinstance(left, pd.Series):
    +            intervals = [DateInterval(left.iloc[i], right.iloc[i], closed) for i in range(len(left))]
    +        else:
    +            intervals = [DateInterval(left[i], right[i], closed) for i in range(len(left))]
    +        return cls(intervals)
    +
    +    @property
    +    def left(self):
    +        return self._left
    +
    +    @property
    +    def right(self):
    +        return self._right
    +
    +    @property
    +    def shape(self):
    +        return (len(self.array), 2)
    +
    +    @property
    +    def empty(self):
    +        return self.shape[0] == 0
    +
    +    def __len__(self):
    +        return self.shape[0]
    +
    +    def sort_values(self):
    +        return DateIntervalIndex(self.array[np.argsort(self._left)])
    +
    +    def get_indexer(self, values):
    +        idx_right = np.searchsorted(self._right_inc, values)
    +
    +        idx_left = np.searchsorted(self._left, values, side="right")
    +        idx_left -= 1
    +
    +        f_match = idx_right == idx_left
    +
    +        idx = idx_left
    +        idx[~f_match] = -1
    +        return idx
    +
    +    def __getitem__(self, i):
    +        v = self.array[i]
    +        if isinstance(v, np.ndarray):
    +            v = DateIntervalIndex(v)
    +        return v
    +
    +    def __setitem__(self, i, v):
    +        raise Exception("immutable")
    +
    +    def __eq__(self, other):
    +        if not isinstance(other, DateIntervalIndex):
    +            return False
    +        if len(self.array) != len(other.array):
    +            return False
    +        return np.equal(self.array, other.array)
    +
    +    def equals(self, other):
    +        e = self == other
    +        if isinstance(e, np.ndarray):
    +            e = e.all()
    +        return e
    +
    +    def __str__(self):
    +        s = "DateIntervalIndex([ "
    +        for x in self.array:
    +            s += x.__str__() + " , "
    +        s += "])"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +

    Static methods

    +
    +
    +def from_arrays(left, right, closed=None) +
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    prop empty
    +
    +
    +
    + +Expand source code + +
    @property
    +def empty(self):
    +    return self.shape[0] == 0
    +
    +
    +
    prop left
    +
    +
    +
    + +Expand source code + +
    @property
    +def left(self):
    +    return self._left
    +
    +
    +
    prop right
    +
    +
    +
    + +Expand source code + +
    @property
    +def right(self):
    +    return self._right
    +
    +
    +
    prop shape
    +
    +
    +
    + +Expand source code + +
    @property
    +def shape(self):
    +    return (len(self.array), 2)
    +
    +
    +
    +

    Methods

    +
    +
    +def equals(self, other) +
    +
    +
    +
    +
    +def get_indexer(self, values) +
    +
    +
    +
    +
    +def sort_values(self) +
    +
    +
    +
    +
    +
    +
    +class DateRange +(start, end) +
    +
    +
    +
    + +Expand source code + +
    class DateRange():
    +    def __init__(self, start, end):
    +        if (start is not None) and not isinstance(start, date):
    +            raise Exception("'start' must be a 'date' object or None, not {0}".format(type(start)))
    +        if (end is not None) and not isinstance(end, date):
    +            raise Exception("'end' must be a 'date' object or None, not {0}".format(type(end)))
    +        self.start = start
    +        self.end = end
    +
    +    def copy(self):
    +        return DateRange(self.start, self.end)
    +
    +    def __str__(self):
    +        s = f"DateRange {self.start} -> {self.end}"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __abs__(self):
    +        raise NotImplementedError(f'Not implemented {self} abs')
    +
    +    def __eq__(self, other):
    +        if isinstance(other, DateRange):
    +            return self.start == other.start and self.end == other.end
    +        raise NotImplementedError(f'Not implemented {self} == {type(other)}={other}')
    +
    +    def isclose(self, other):
    +        if isinstance(other, DateRange):
    +            return (other.start <= self.end) and (self.start <= other.end)
    +        elif isinstance(other, date):
    +            return self.start <= other and other <= self.end
    +        elif isinstance(other, DateEstimate):
    +            return self.start <= other.date+other.uncertainty and other.date-other.uncertainty <= self.end
    +        elif isinstance(other, DateRangeEstimate):
    +            return other.isclose(self)
    +        raise NotImplementedError(f'Not implemented {self} is-close-to {type(other)}={other}')
    +
    +    def prob_lt(self, other):
    +        if isinstance(other, date):
    +            if self.start >= other:
    +                return 0.0
    +            elif self.end < other:
    +                return 1.0
    +            else:
    +                return uniform_prob_lt((self.start, self.end), other)
    +        elif isinstance(other, DateRange):
    +            if other.end <= self.start:
    +                return 0.0
    +            elif self.end < other.start:
    +                return 1.0
    +            else:
    +                return uniform_prob_lt((self.start, self.end), (other.start, other.end))
    +        elif isinstance(other, DateEstimate):
    +            return other.prob_gt(self)
    +        raise NotImplementedError(f'Not implemented {self} < {type(other)}={other}')
    +    def __lt__(self, other):
    +        x = self.prob_lt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '<', x)
    +    def prob_gt(self, other):
    +        return 1.0 - self.prob_lt(other)
    +    def __gt__(self, other):
    +        x = self.prob_gt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '>', x)
    +    def prob_le(self, other):
    +        return self.prob_lt(other)
    +    def __le__(self, other):
    +        return self < other
    +    def prob_ge(self, other):
    +        return self.prob_gt(other)
    +    def __ge__(self, other):
    +        return self > other
    +
    +    def __iadd__(self, other):
    +        if isinstance(other, (timedelta, pd.Timedelta)):
    +            self.start += other
    +            self.end += other
    +            return self
    +        raise NotImplementedError(f'Not implemented {self} += {type(other)}={other}')
    +
    +    def __add__(self, other):
    +        if isinstance(other, timedelta):
    +            return DateRange(self.start + other, self.end + other)
    +        raise NotImplementedError(f'Not implemented {self} + {type(other)}={other}')
    +
    +    def __radd__(self, other):
    +        raise NotImplementedError(f'Not implemented {self} radd {type(other)}={other}')
    +
    +    def __isub__(self, other):
    +        raise NotImplementedError(f'Not implemented {self} -= {type(other)}={other}')
    +
    +    def __sub__(self, other):
    +        if isinstance(other, date):
    +            return TimedeltaRange(self.start - other, self.end - other)
    +        elif isinstance(other, DateRange):
    +            return TimedeltaRange(self.start - other.end, self.end - other.start)
    +        elif isinstance(other, DateEstimate):
    +            return TimedeltaRangeEstimate(self.start - other.date, self.end - other.date, other.confidence)
    +        elif isinstance(other, timedelta):
    +            return DateRange(self.start - other, self.end - other)
    +        raise NotImplementedError(f'Not implemented {self} - {type(other)}={other}')
    +
    +    def __rsub__(self, other):
    +        if isinstance(other, date):
    +            return TimedeltaRange(other - self.start, other - self.end)
    +        raise NotImplementedError(f'Not implemented {self} rsub {type(other)}={other}')
    +
    +    def __neg__(self):
    +        raise NotImplementedError(f'Not implemented {self} negate')
    +
    +    def __invert__(self):
    +        raise NotImplementedError(f'Not implemented {self} invert')
    +
    +

    Methods

    +
    +
    +def copy(self) +
    +
    +
    +
    +
    +def isclose(self, other) +
    +
    +
    +
    +
    +def prob_ge(self, other) +
    +
    +
    +
    +
    +def prob_gt(self, other) +
    +
    +
    +
    +
    +def prob_le(self, other) +
    +
    +
    +
    +
    +def prob_lt(self, other) +
    +
    +
    +
    +
    +
    +
    +class DateRangeEstimate +(start, end, confidence) +
    +
    +
    +
    + +Expand source code + +
    class DateRangeEstimate():
    +    def __init__(self, start, end, confidence):
    +        if (start is not None) and not isinstance(start, date):
    +            raise Exception("'start' must be a 'date' object or None, not {0}".format(type(start)))
    +        if (end is not None) and not isinstance(end, date):
    +            raise Exception("'end' must be a 'date' object or None, not {0}".format(type(end)))
    +        if not isinstance(confidence, Confidence):
    +            raise Exception("'confidence' must be a 'Confidence' object, not {0}".format(type(confidence)))
    +        self.start = start
    +        self.end = end
    +        self.confidence = confidence
    +        self.uncertainty = confidence_to_buffer[confidence]
    +
    +    def copy(self):
    +        return DateRangeEstimate(self.start, self.end, self.confidence)
    +
    +    def __str__(self):
    +        s = f"DateRangeEstimate {self.start} -> {self.end} (conf={self.confidence}/2)"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __abs__(self):
    +        raise NotImplementedError(f'Not implemented {self} abs')
    +
    +    def __eq__(self, other):
    +        if isinstance(other, DateRangeEstimate):
    +            return self.start == other.start and self.end == other.end and self.confidence == other.confidence
    +        raise NotImplementedError(f'Not implemented {self} == {type(other)}={other}')
    +
    +    def isclose(self, other):
    +        if isinstance(other, date):
    +            if (self.start - self.uncertainty) <= other and other <= (self.end + self.uncertainty):
    +                return True
    +            else:
    +                return False
    +        elif isinstance(other, DateRange):
    +            if ((self.start - self.uncertainty) <= other.start and other.start <= (self.end + self.uncertainty)) or\
    +               ((self.start - self.uncertainty) <= other.end and other.end <= (self.end + self.uncertainty)):
    +                return True
    +            else:
    +                return False
    +        elif isinstance(other, DateEstimate):
    +            if (self.start - self.uncertainty) <= (other.date+other.uncertainty) and (self.end + self.uncertainty) >= (other.date-other.uncertainty):
    +                return True
    +            else:
    +                return False
    +        elif isinstance(other, DateRangeEstimate):
    +            self_start_min = self.start - self.uncertainty
    +            self_end_max   = self.end + self.uncertainty
    +            other_start_min = other.start - other.uncertainty
    +            other_end_max   = other.end + other.uncertainty
    +            return (other_start_min <= self_end_max) and (self_start_min <= other_end_max)
    +
    +        raise NotImplementedError(f'Not implemented {self} is-close-to {type(other)}={other}')
    +
    +    def __neg__(self):
    +        raise NotImplementedError(f'Not implemented {self} negate')
    +
    +    def __invert__(self):
    +        raise NotImplementedError(f'Not implemented {self} invert')
    +
    +    def __iadd__(self, other):
    +        if isinstance(other, (timedelta, pd.Timedelta)):
    +            self.start += other
    +            self.end += other
    +            return self
    +        raise NotImplementedError(f'Not implemented {self} += {type(other)}={other}')
    +
    +    def __add__(self, other):
    +        if isinstance(other, timedelta):
    +            return DateRangeEstimate(self.start + other, self.end + other, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} + {type(other)}={other}')
    +
    +    def __radd__(self, other):
    +        raise NotImplementedError(f'Not implemented {self} radd {type(other)}={other}')
    +
    +    def __isub__(self, other):
    +        raise NotImplementedError(f'Not implemented {self} -= {type(other)}={other}')
    +
    +    def __sub__(self, other):
    +        if isinstance(other, date):
    +            return TimedeltaRangeEstimate(self.start - other, self.end - other, self.confidence)
    +        elif isinstance(other, DateEstimate):
    +            conf = min(self.confidence, other.confidence)
    +            return TimedeltaRangeEstimate(self.start - other.date, self.end - other.date, conf)
    +        elif isinstance(other, DateRange):
    +            return TimedeltaRangeEstimate(self.start - other.start, self.end - other.end, self.confidence)
    +        elif isinstance(other, DateRangeEstimate):
    +            conf = min(self.confidence, other.confidence)
    +            return TimedeltaRangeEstimate(self.start - other.start, self.end - other.end, conf)
    +        raise NotImplementedError(f'Not implemented {self} - {type(other)}={other}')
    +
    +    def __rsub__(self, other):
    +        return -self.__sub__(other)
    +
    +    def prob_lt(self, other):
    +        if isinstance(other, date):
    +            if self.end + self.uncertainty < other:
    +                return 1.0
    +            elif other <= self.start - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.start-self.uncertainty, self.end+self.uncertainty),
    +                                      other)
    +
    +        elif isinstance(other, DateEstimate):
    +            if self.end + self.uncertainty < other.date - other.uncertainty:
    +                return 1.0
    +            elif other.date + other.uncertainty <= self.start - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.start-self.uncertainty, self.end+self.uncertainty),
    +                                      (other.date-other.uncertainty, other.date+other.uncertainty))
    +
    +        elif isinstance(other, DateRangeEstimate):
    +            if self.end + self.uncertainty < other.start - other.uncertainty:
    +                return 1.0
    +            elif other.end + other.uncertainty <= self.start - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.start-self.uncertainty, self.end+self.uncertainty),
    +                                      (other.start-other.uncertainty, other.end+other.uncertainty))
    +
    +        raise NotImplementedError(f'Not implemented {self} < {type(other)}={other}')
    +    def __lt__(self, other):
    +        x = self.prob_lt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '<', x)
    +    def prob_gt(self, other):
    +        return 1.0 - self.prob_lt(other)
    +    def __gt__(self, other):
    +        x = self.prob_gt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '>', x)
    +    def prob_le(self, other):
    +        return self.prob_lt(other)
    +    def __le__(self, other):
    +        return self < other
    +    def prob_ge(self, other):
    +        return self.prob_gt(other)
    +    def __ge__(self, other):
    +        return self > other
    +
    +

    Methods

    +
    +
    +def copy(self) +
    +
    +
    +
    +
    +def isclose(self, other) +
    +
    +
    +
    +
    +def prob_ge(self, other) +
    +
    +
    +
    +
    +def prob_gt(self, other) +
    +
    +
    +
    +
    +def prob_le(self, other) +
    +
    +
    +
    +
    +def prob_lt(self, other) +
    +
    +
    +
    +
    +
    +
    +class Financials +(*args, **kwds) +
    +
    +

    Create a collection of name/value pairs.

    +

    Example enumeration:

    +
    >>> class Color(Enum):
    +...     RED = 1
    +...     BLUE = 2
    +...     GREEN = 3
    +
    +

    Access them by:

    +
      +
    • attribute access:
    • +
    +
    +
    +
    +

    Color.RED +

    +
    +
    +
    +
      +
    • value lookup:
    • +
    +
    +
    +
    +

    Color(1) +

    +
    +
    +
    +
      +
    • name lookup:
    • +
    +
    +
    +
    +

    Color['RED'] +

    +
    +
    +
    +

    Enumerations can be iterated over, and know how many members they have:

    +
    >>> len(Color)
    +3
    +
    +
    >>> list(Color)
    +[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
    +
    +

    Methods can be added to enumerations, and members can have their own +attributes – see the documentation for details.

    +
    + +Expand source code + +
    class Financials(Enum):
    +    IncomeStmt = 0
    +    BalanceSheet = 1
    +    CashFlow = 2
    +
    +

    Ancestors

    +
      +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var BalanceSheet
    +
    +
    +
    +
    var CashFlow
    +
    +
    +
    +
    var IncomeStmt
    +
    +
    +
    +
    +
    +
    +class Interval +(*args, **kwds) +
    +
    +

    Create a collection of name/value pairs.

    +

    Example enumeration:

    +
    >>> class Color(Enum):
    +...     RED = 1
    +...     BLUE = 2
    +...     GREEN = 3
    +
    +

    Access them by:

    +
      +
    • attribute access:
    • +
    +
    +
    +
    +

    Color.RED +

    +
    +
    +
    +
      +
    • value lookup:
    • +
    +
    +
    +
    +

    Color(1) +

    +
    +
    +
    +
      +
    • name lookup:
    • +
    +
    +
    +
    +

    Color['RED'] +

    +
    +
    +
    +

    Enumerations can be iterated over, and know how many members they have:

    +
    >>> len(Color)
    +3
    +
    +
    >>> list(Color)
    +[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
    +
    +

    Methods can be added to enumerations, and members can have their own +attributes – see the documentation for details.

    +
    + +Expand source code + +
    class Interval(Enum):
    +    Week = 5
    +    Days1 = 10
    +    Hours1 = 20
    +    Mins90 = 21
    +    Mins60 = 22
    +    Mins30 = 23
    +    Mins15 = 24
    +    Mins5 = 25
    +    Mins2 = 26
    +    Mins1 = 27
    +
    +

    Ancestors

    +
      +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var Days1
    +
    +
    +
    +
    var Hours1
    +
    +
    +
    +
    var Mins1
    +
    +
    +
    +
    var Mins15
    +
    +
    +
    +
    var Mins2
    +
    +
    +
    +
    var Mins30
    +
    +
    +
    +
    var Mins5
    +
    +
    +
    +
    var Mins60
    +
    +
    +
    +
    var Mins90
    +
    +
    +
    +
    var Week
    +
    +
    +
    +
    +
    +
    +class NoIntervalsInRangeException +(interval, start_dt, end_dt, *args) +
    +
    +

    Common base class for all non-exit exceptions.

    +
    + +Expand source code + +
    class NoIntervalsInRangeException(Exception):
    +    def __init__(self, interval, start_dt, end_dt, *args):
    +        super().__init__(args)
    +        self.interval = interval
    +        self.start_dt = start_dt
    +        self.end_dt = end_dt
    +
    +    def __str__(self):
    +        return ("No {} intervals found between {}->{}".format(self.interval, self.start_dt, self.end_dt))
    +
    +

    Ancestors

    +
      +
    • builtins.Exception
    • +
    • builtins.BaseException
    • +
    +
    +
    +class NoPriceDataInRangeException +(tkr, interval, start_dt, end_dt, *args) +
    +
    +

    Common base class for all non-exit exceptions.

    +
    + +Expand source code + +
    class NoPriceDataInRangeException(Exception):
    +    def __init__(self, tkr, interval, start_dt, end_dt, *args):
    +        super().__init__(args)
    +        self.tkr = tkr
    +        self.interval = interval
    +        self.start_dt = start_dt
    +        self.end_dt = end_dt
    +
    +    def __str__(self):
    +        return ("No {}-price data fetched for ticker {} between dates {} -> {}".format(self.interval, self.tkr, self.start_dt, self.end_dt))
    +
    +

    Ancestors

    +
      +
    • builtins.Exception
    • +
    • builtins.BaseException
    • +
    +
    +
    +class Period +(*args, **kwds) +
    +
    +

    Create a collection of name/value pairs.

    +

    Example enumeration:

    +
    >>> class Color(Enum):
    +...     RED = 1
    +...     BLUE = 2
    +...     GREEN = 3
    +
    +

    Access them by:

    +
      +
    • attribute access:
    • +
    +
    +
    +
    +

    Color.RED +

    +
    +
    +
    +
      +
    • value lookup:
    • +
    +
    +
    +
    +

    Color(1) +

    +
    +
    +
    +
      +
    • name lookup:
    • +
    +
    +
    +
    +

    Color['RED'] +

    +
    +
    +
    +

    Enumerations can be iterated over, and know how many members they have:

    +
    >>> len(Color)
    +3
    +
    +
    >>> list(Color)
    +[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
    +
    +

    Methods can be added to enumerations, and members can have their own +attributes – see the documentation for details.

    +
    + +Expand source code + +
    class Period(Enum):
    +    Days1 = 0
    +    Days5 = 1
    +    Months1 = 10
    +    Months3 = 11
    +    Months6 = 12
    +    Years1 = 20
    +    Years2 = 21
    +    Years5 = 22
    +    Ytd = 24
    +    Max = 30
    +
    +

    Ancestors

    +
      +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var Days1
    +
    +
    +
    +
    var Days5
    +
    +
    +
    +
    var Max
    +
    +
    +
    +
    var Months1
    +
    +
    +
    +
    var Months3
    +
    +
    +
    +
    var Months6
    +
    +
    +
    +
    var Years1
    +
    +
    +
    +
    var Years2
    +
    +
    +
    +
    var Years5
    +
    +
    +
    +
    var Ytd
    +
    +
    +
    +
    +
    +
    +class ReportingPeriod +(*args, **kwds) +
    +
    +

    Create a collection of name/value pairs.

    +

    Example enumeration:

    +
    >>> class Color(Enum):
    +...     RED = 1
    +...     BLUE = 2
    +...     GREEN = 3
    +
    +

    Access them by:

    +
      +
    • attribute access:
    • +
    +
    +
    +
    +

    Color.RED +

    +
    +
    +
    +
      +
    • value lookup:
    • +
    +
    +
    +
    +

    Color(1) +

    +
    +
    +
    +
      +
    • name lookup:
    • +
    +
    +
    +
    +

    Color['RED'] +

    +
    +
    +
    +

    Enumerations can be iterated over, and know how many members they have:

    +
    >>> len(Color)
    +3
    +
    +
    >>> list(Color)
    +[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
    +
    +

    Methods can be added to enumerations, and members can have their own +attributes – see the documentation for details.

    +
    + +Expand source code + +
    class ReportingPeriod(Enum):
    +    Interim = 0
    +    Full = 1
    +
    +

    Ancestors

    +
      +
    • enum.Enum
    • +
    +

    Class variables

    +
    +
    var Full
    +
    +
    +
    +
    var Interim
    +
    +
    +
    +
    +
    +
    +class TimedeltaEstimate +(td, confidence) +
    +
    +
    +
    + +Expand source code + +
    class TimedeltaEstimate():
    +    def __init__(self, td, confidence):
    +        if not isinstance(confidence, Confidence):
    +            raise Exception("'confidence' must be a 'Confidence' object, not {0}".format(type(confidence)))
    +        if not isinstance(td, (timedelta, pd.Timedelta, ComparableRelativedelta)):
    +            raise Exception("'td' must be a 'timedelta' object or None, not {0}".format(type(td)))
    +        if isinstance(td, ComparableRelativedelta) and confidence != Confidence.High:
    +            td = timedelta(days=td.years*365 +td.months*30 +td.days)
    +        self.td = td
    +        self.confidence = confidence
    +        self.uncertainty = confidence_to_buffer[confidence]
    +
    +    def copy(self):
    +        return TimedeltaEstimate(self.td, self.confidence)
    +
    +    def __str__(self):
    +        tdstr = ''
    +        if self.td.days != 0:
    +            tdstr += f'{self.td.days}d'
    +        s = f"{tdstr} (conf={self.confidence}/2)"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __abs__(self):
    +        return TimedeltaEstimate(abs(self.td), self.confidence)
    +
    +    def __neg__(self):
    +        return TimedeltaEstimate(-self.td, self.confidence)
    +
    +    def __eq__(self, other):
    +        if isinstance(other, TimedeltaEstimate):
    +            return self.td == other.td and self.confidence == other.confidence
    +        raise NotImplementedError(f'Not implemented {self} == {type(other)}={other}')
    +
    +    def isclose(self, other):
    +        if isinstance(other, TimedeltaEstimate):
    +            # return abs(self.td - other.td) <= max(self.uncertainty, other.uncertainty)
    +            return abs(self.td - other.td) <= (self.uncertainty + other.uncertainty)
    +
    +        elif isinstance(other, (timedelta, pd.Timedelta, ComparableRelativedelta)):
    +            return abs(self.td - other) <= self.uncertainty
    +        
    +        raise NotImplementedError(f'Not implemented {self} is-close-to {type(other)}={other}')
    +
    +    def __iadd__(self, other):
    +        if isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            self.td += other
    +            return self
    +        raise NotImplementedError(f'Not implemented {self} += {type(other)}={other}')
    +
    +    def __add__(self, other):
    +        if isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            return TimedeltaEstimate(self.td + other, self.confidence)
    +        elif isinstance(other, date):
    +            return DateEstimate(self.td + other, self.confidence)
    +        elif isinstance(other, DateEstimate):
    +            return DateEstimate(self.td + other.date, min(self.confidence, other.confidence))
    +        raise NotImplementedError(f'Not implemented {self} + {type(other)}={other}')
    +
    +    def __radd__(self, other):
    +        return self.__add__(other)
    +
    +    def __sub__(self, other):
    +        if isinstance(other, (timedelta, relativedelta, ComparableRelativedelta)):
    +            return TimedeltaEstimate(self.td - other, self.confidence)
    +        elif isinstance(other, date):
    +            return DateEstimate(self.td - other, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} - {type(other)}={other}')
    +
    +    def __rsub__(self, other):
    +        if isinstance(other, date):
    +            return DateEstimate(other - self.td, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} rsub {type(other)}={other}')
    +
    +    def __mul__(self, other):
    +        if isinstance(other, (int, float)):
    +            return TimedeltaEstimate(self.td * other, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} * {type(other)}={other}')
    +
    +    def __imul__(self, other):
    +        if isinstance(other, (int, float)):
    +            self.td *= other
    +            return self
    +        raise NotImplementedError(f'Not implemented {self} *= {type(other)}={other}')
    +
    +    def prob_lt(self, other):
    +        if isinstance(other, TimedeltaRangeEstimate):
    +            return other.prob_gt(self)
    +
    +        elif isinstance(other, TimedeltaRange):
    +            if self.td + self.uncertainty < other.td1:
    +                return 1.0
    +            elif other.td2 <= self.td - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td-self.uncertainty, self.td+self.uncertainty),
    +                                      (other.td1, other.td2))
    +
    +        elif isinstance(other, TimedeltaEstimate):
    +            if self.td + self.uncertainty < other.td - other.uncertainty:
    +                return 1.0
    +            elif other.td + other.uncertainty <= self.td - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td-self.uncertainty, self.td+self.uncertainty),
    +                                      (other.td-other.uncertainty, other.td+other.uncertainty))
    +
    +        elif isinstance(other, (relativedelta, timedelta, ComparableRelativedelta)):
    +            if self.td + self.uncertainty < other:
    +                return 1.0
    +            elif other <= self.td - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td-self.uncertainty, self.td+self.uncertainty),
    +                                       other)
    +
    +        raise NotImplementedError(f'Not implemented {self} < {type(other)}={other}')
    +    def __lt__(self, other):
    +        x = self.prob_lt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '<', x)
    +    def prob_gt(self, other):
    +        return 1.0 - self.prob_lt(other)
    +    def __gt__(self, other):
    +        x = self.prob_gt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '>', x)
    +    def prob_le(self, other):
    +        return self.prob_lt(other)
    +    def __le__(self, other):
    +        return self < other
    +    def prob_ge(self, other):
    +        return self.prob_gt(other)
    +    def __ge__(self, other):
    +        return self > other
    +
    +    def __mod__(self, other):
    +        if isinstance(other, timedelta):
    +            if self.td < timedelta(0):
    +                raise NotImplementedError('Not implemented modulus of negative TimedeltaEstimate')
    +            else:
    +                td = self.td
    +                while td > other:
    +                    td -= other
    +                return TimedeltaEstimate(td, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} modulus-of {type(other)}={other}')
    +
    +    def __truediv__(self, other):
    +        if isinstance(other, (int, float, np.int64)):
    +            return TimedeltaEstimate(self.td / other, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} / {type(other)}={other}')
    +
    +

    Methods

    +
    +
    +def copy(self) +
    +
    +
    +
    +
    +def isclose(self, other) +
    +
    +
    +
    +
    +def prob_ge(self, other) +
    +
    +
    +
    +
    +def prob_gt(self, other) +
    +
    +
    +
    +
    +def prob_le(self, other) +
    +
    +
    +
    +
    +def prob_lt(self, other) +
    +
    +
    +
    +
    +
    +
    +class TimedeltaRange +(td1, td2) +
    +
    +
    +
    + +Expand source code + +
    class TimedeltaRange():
    +    def __init__(self, td1, td2):
    +        if (td1 is not None) and not isinstance(td1, (timedelta, pd.Timedelta)):
    +            raise Exception("'td1' must be a 'timedelta' object or None, not {0}".format(type(td1)))
    +        if (td2 is not None) and not isinstance(td2, (timedelta, pd.Timedelta)):
    +            raise Exception("'td2' must be a 'timedelta' object or None, not {0}".format(type(td2)))
    +        if td2 <= td1:
    +            swap = td1 ; td1 = td2 ; td2 = swap
    +        self.td1 = td1
    +        self.td2 = td2
    +
    +    def __str__(self):
    +        s = f"TimedeltaRange {self.td1} -> {self.td2}"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __abs__(self):
    +        if self.td2 <= timedelta(0):
    +            tdr = TimedeltaRange(-self.td1, -self.td2)
    +            return tdr
    +        elif self.td1 >= timedelta(0):
    +            return self
    +        else:
    +            raise AmbiguousComparisonException(self, None, "abs")
    +
    +    def __add__(self, other):
    +        if isinstance(other, date):
    +            return DateRange(other+self.td1, other+self.td2)
    +        elif isinstance(other, DateEstimate):
    +            return DateRangeEstimate(other.date+self.td1, other.date+self.td2, other.confidence)
    +        elif isinstance(other, timedelta):
    +            return TimedeltaRange(self.td1 + other, self.td2 + other)
    +        raise NotImplementedError(f'Not implemented {self} + {type(other)}={other}')
    +
    +    def __mul__(self, other):
    +        if isinstance(other, int):
    +            return TimedeltaRange(self.td1 * other, self.td2 * other)
    +        raise NotImplementedError(f'Not implemented {self} * {type(other)}={other}')
    +
    +    def prob_lt(self, other):
    +        if isinstance(other, (timedelta, pd.Timedelta, ComparableRelativedelta)):
    +            if self.td2 < other:
    +                return 1.0
    +            else:
    +                return 0.0
    +        elif isinstance(other, TimedeltaRange):
    +            if self.td2 < other.td1:
    +                return 1.0
    +            elif other.td2 <= self.td1:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td1, self.td2), 
    +                                      (other.td1, other.td2))
    +        elif isinstance(other, TimedeltaEstimate):
    +            return other.prob_gt(self)
    +        raise NotImplementedError(f'Not implemented {self} < {type(other)}={other}')
    +    def __lt__(self, other):
    +        x = self.prob_lt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '<', x)
    +    def prob_gt(self, other):
    +        return 1.0 - self.prob_lt(other)
    +    def __gt__(self, other):
    +        x = self.prob_gt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '>', x)
    +    def prob_le(self, other):
    +        return self.prob_lt(other)
    +    def __le__(self, other):
    +        return self < other
    +    def prob_ge(self, other):
    +        return self.prob_gt(other)
    +    def __ge__(self, other):
    +        return self > other
    +
    +

    Methods

    +
    +
    +def prob_ge(self, other) +
    +
    +
    +
    +
    +def prob_gt(self, other) +
    +
    +
    +
    +
    +def prob_le(self, other) +
    +
    +
    +
    +
    +def prob_lt(self, other) +
    +
    +
    +
    +
    +
    +
    +class TimedeltaRangeEstimate +(td1, td2, confidence) +
    +
    +
    +
    + +Expand source code + +
    class TimedeltaRangeEstimate():
    +    def __init__(self, td1, td2, confidence):
    +        if not isinstance(confidence, Confidence):
    +            raise Exception("'confidence' must be a 'Confidence' object, not {0}".format(type(confidence)))
    +        if (td1 is not None) and not isinstance(td1, (timedelta, pd.Timedelta)):
    +            raise Exception("'td1' must be a 'timedelta' object or None, not {0}".format(type(td1)))
    +        if (td2 is not None) and not isinstance(td2, (timedelta, pd.Timedelta)):
    +            raise Exception("'td2' must be a 'timedelta' object or None, not {0}".format(type(td2)))
    +        if td2 <= td1:
    +            swap = td1 ; td1 = td2 ; td2 = swap
    +        self.td1 = td1
    +        self.td2 = td2
    +        self.confidence = confidence
    +        self.uncertainty = confidence_to_buffer[confidence]
    +
    +    def __str__(self):
    +        s = f"TimedeltaRangeEstimate {self.td1} -> {self.td2} (conf={self.confidence}/2)"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __abs__(self):
    +        return TimedeltaRangeEstimate(abs(self.td1), abs(self.td2), self.confidence)
    +
    +    def isclose(self, other):
    +        raise NotImplementedError(f'Not implemented {self} is-close-to {type(other)}={other}')
    +
    +    def __neg__(self):
    +        return TimedeltaRangeEstimate(-self.td2, -self.td1, self.confidence)
    +
    +    def __invert__(self):
    +        raise NotImplementedError(f'Not implemented {self} invert')
    +
    +    def __eq__(self, other):
    +        if isinstance(other, TimedeltaRangeEstimate):
    +            return self.td1 == other.td1 and self.td2 == other.td2 and self.confidence == other.confidence
    +        raise NotImplementedError(f'Not implemented {self} == {type(other)}={other}')
    +
    +    def __add__(self, other):
    +        if isinstance(other, date):
    +            return DateRangeEstimate(self.td1 + other, self.td2 + other, self.confidence)
    +        elif isinstance(other, DateEstimate):
    +            return DateRangeEstimate(self.td1 + other.date, self.td2 + other.date, min(self.confidence, other.confidence))
    +        elif isinstance(other, timedelta):
    +            return TimedeltaRangeEstimate(self.td1 + other, self.td2 + other, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} + {type(other)}={other}')
    +
    +    def __mul__(self, other):
    +        if isinstance(other, int):
    +            return TimedeltaRangeEstimate(self.td1 * other, self.td2 * other, self.confidence)
    +        raise NotImplementedError(f'Not implemented {self} * {type(other)}={other}')
    +
    +    def prob_lt(self, other):
    +        if isinstance(other, (timedelta, pd.Timedelta, ComparableRelativedelta)):
    +            if self.td2 + self.uncertainty < other:
    +                return 1.0
    +            elif other <= self.td1 - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td1-self.uncertainty, self.td2+self.uncertainty), 
    +                                      other)
    +
    +        elif isinstance(other, TimedeltaEstimate):
    +            if self.td2 + self.uncertainty < other.td - other.uncertainty:
    +                return 1.0
    +            elif other.td + other.uncertainty <= self.td1 - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td1-self.uncertainty, self.td2+self.uncertainty), 
    +                                      (other.td-other.uncertainty, other.td+other.uncertainty))
    +
    +        elif isinstance(other, TimedeltaRangeEstimate):
    +            if self.td2 + self.uncertainty < other.td1 - other.uncertainty:
    +                return 1.0
    +            elif other.td2 + other.uncertainty <= self.td1 - self.uncertainty:
    +                return 0.0
    +            else:
    +                return uniform_prob_lt((self.td1-self.uncertainty, self.td2+self.uncertainty), 
    +                                      (other.td1-other.uncertainty, other.td2+other.uncertainty))
    +
    +        raise NotImplementedError(f'Not implemented {self} < {type(other)}={other}')
    +    def __lt__(self, other):
    +        x = self.prob_lt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '<', x)
    +    def prob_gt(self, other):
    +        return 1.0 - self.prob_lt(other)
    +    def __gt__(self, other):
    +        x = self.prob_gt(other)
    +        if x in [0.0, 1.0]:
    +            return x == 1.0
    +        else:
    +            raise AmbiguousComparisonException(self, other, '>', x)
    +    def prob_le(self, other):
    +        return self.prob_lt(other)
    +    def __le__(self, other):
    +        return self < other
    +    def prob_ge(self, other):
    +        return self.prob_gt(other)
    +    def __ge__(self, other):
    +        return self > other
    +
    +

    Methods

    +
    +
    +def isclose(self, other) +
    +
    +
    +
    +
    +def prob_ge(self, other) +
    +
    +
    +
    +
    +def prob_gt(self, other) +
    +
    +
    +
    +
    +def prob_le(self, other) +
    +
    +
    +
    +
    +def prob_lt(self, other) +
    +
    +
    +
    +
    +
    +
    +class TimestampOutsideIntervalException +(exchange, interval, ts, *args) +
    +
    +

    Common base class for all non-exit exceptions.

    +
    + +Expand source code + +
    class TimestampOutsideIntervalException(Exception):
    +    def __init__(self, exchange, interval, ts, *args):
    +        super().__init__(args)
    +        self.exchange = exchange
    +        self.interval = interval
    +        self.ts = ts
    +
    +    def __str__(self):
    +        return (f"Failed to map '{self.ts}' to '{self.interval}' interval on exchange '{self.exchange}'")
    +
    +

    Ancestors

    +
      +
    • builtins.Exception
    • +
    • builtins.BaseException
    • +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_financials_manager.html b/docs/yfinance_cache/yfc_financials_manager.html new file mode 100644 index 0000000..abe1065 --- /dev/null +++ b/docs/yfinance_cache/yfc_financials_manager.html @@ -0,0 +1,2111 @@ + + + + + + +yfinance_cache.yfc_financials_manager API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_financials_manager

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def sort_estimates(lst) +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class EarningsRelease +(interval, period_end, release_date, full_year_end) +
    +
    +
    +
    + +Expand source code + +
    class EarningsRelease():
    +    def __init__(self, interval, period_end, release_date, full_year_end):
    +        if not isinstance(period_end, (date, yfcd.DateEstimate)):
    +            raise Exception("'period_end' must be a 'yfcd.DateEstimate' or date object or None, not {0}".format(type(period_end)))
    +        if (release_date is not None):
    +            if not isinstance(release_date, (date, yfcd.DateEstimate)):
    +                raise Exception("'release_date' must be a 'yfcd.DateEstimate' or date object or None, not {0}".format(type(release_date)))
    +            if release_date < period_end:
    +                raise Exception("release_date={0} cannot occur before period_end={1}".format(release_date, period_end))
    +            if release_date > (period_end + timedelta(days=90)):
    +                raise Exception("release_date={0} shouldn't occur 90 days after period_end={1}".format(release_date, period_end))
    +        if not isinstance(full_year_end, date):
    +            raise Exception("'full_year_end' must be a date object or None, not {0}".format(type(full_year_end)))
    +        self.interval = interval
    +        self.period_end = period_end
    +        self.release_date = release_date
    +        self.full_year_end = full_year_end
    +
    +    def __str__(self):
    +        s = f'{self.interval} earnings'
    +        s += f" ending {self.period_end}"
    +        s += " released"
    +        s += " ?" if self.release_date is None else f" {self.release_date}"
    +        return s
    +
    +    def __repr__(self):
    +        return self.__str__()
    +
    +    def __lt__(self, other):
    +        return self.period_end < other.period_end or (self.period_end == other.period_end and self.release_date < other.release_date)
    +
    +    def __le__(self, other):
    +        return (self == other) or (self < other)
    +
    +    def __eq__(self, other):
    +        return self.period_end == other.period_end and self.release_date == other.release_date
    +
    +    def __gt__(self, other):
    +        return self.period_end > other.period_end or (self.period_end == other.period_end and self.release_date > other.release_date)
    +
    +    def __ge__(self, other):
    +        return (self == other) or (self > other)
    +
    +    def is_end_of_year(self):
    +        r_is_end_of_year = False
    +        rpe = self.period_end
    +        diff = (rpe - self.full_year_end)
    +        diff += timedelta(days=365)  # just in case is negative
    +        diff = diff % timedelta(days=365)
    +        try:
    +            if (diff > timedelta(days=-15) and diff < timedelta(days=15)) or\
    +                (diff > timedelta(days=350) and diff < timedelta(days=370)):
    +                # Aligns with annual release date
    +                r_is_end_of_year = True
    +        except yfcd.AmbiguousComparisonException:
    +            r_is_end_of_year = True
    +        return r_is_end_of_year
    +
    +

    Methods

    +
    +
    +def is_end_of_year(self) +
    +
    +
    +
    +
    +
    +
    +class FinancialsManager +(ticker, exchange, tzName, session) +
    +
    +
    +
    + +Expand source code + +
    class FinancialsManager:
    +    def __init__(self, ticker, exchange, tzName, session):
    +        yfcu.TypeCheckStr(ticker, "ticker")
    +        yfcu.TypeCheckStr(exchange, "exchange")
    +        yfcu.TypeCheckStr(tzName, "tzName")
    +
    +        self.ticker = ticker
    +        self.exchange = exchange
    +        self.tzName = tzName
    +        self.session = session
    +        self.dat = yf.Ticker(self.ticker, session=self.session)
    +
    +        # self._earnings = None
    +        # self._quarterly_earnings = None
    +        self._income_stmt = None
    +        self._quarterly_income_stmt = None
    +        self._balance_sheet = None
    +        self._quarterly_balance_sheet = None
    +        self._cashflow = None
    +        self._quarterly_cashflow = None
    +
    +        self._earnings_dates = None
    +        self._calendar = None
    +        self._calendar_clean = None
    +
    +        self._pruned_tbl_cache = {}
    +        self._fin_tbl_cache = {}
    +
    +    def get_income_stmt(self, refresh=True):
    +        if self._income_stmt is not None:
    +            return self._income_stmt
    +        self._income_stmt = self._get_fin_table(yfcd.Financials.IncomeStmt, yfcd.ReportingPeriod.Full, refresh)
    +        return self._income_stmt
    +
    +    def get_quarterly_income_stmt(self, refresh=True):
    +        if self._quarterly_income_stmt is not None:
    +            return self._quarterly_income_stmt
    +        self._quarterly_income_stmt = self._get_fin_table(yfcd.Financials.IncomeStmt, yfcd.ReportingPeriod.Interim, refresh)
    +        return self._quarterly_income_stmt
    +
    +    def get_balance_sheet(self, refresh=True):
    +        if self._balance_sheet is not None:
    +            return self._balance_sheet
    +        self._balance_sheet = self._get_fin_table(yfcd.Financials.BalanceSheet, yfcd.ReportingPeriod.Full, refresh)
    +        return self._balance_sheet
    +
    +    def get_quarterly_balance_sheet(self, refresh=True):
    +        if self._quarterly_balance_sheet is not None:
    +            return self._quarterly_balance_sheet
    +        self._quarterly_balance_sheet = self._get_fin_table(yfcd.Financials.BalanceSheet, yfcd.ReportingPeriod.Interim, refresh)
    +        return self._quarterly_balance_sheet
    +
    +    def get_cashflow(self, refresh=True):
    +        if self._cashflow is not None:
    +            return self._cashflow
    +        self._cashflow = self._get_fin_table(yfcd.Financials.CashFlow, yfcd.ReportingPeriod.Full, refresh)
    +        return self._cashflow
    +
    +    def get_quarterly_cashflow(self, refresh=True):
    +        if self._quarterly_cashflow is not None:
    +            return self._quarterly_cashflow
    +        self._quarterly_cashflow = self._get_fin_table(yfcd.Financials.CashFlow, yfcd.ReportingPeriod.Interim, refresh)
    +        return self._quarterly_cashflow
    +
    +    def _get_fin_table(self, finType, period, refresh=True):
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f"_get_fin_table({finType}, {period}, refresh={refresh})")
    +
    +        if not isinstance(finType, yfcd.Financials):
    +            raise Exception('Argument finType must be type Financials')
    +        if not isinstance(period, yfcd.ReportingPeriod):
    +            raise Exception('Argument period must be type ReportingPeriod')
    +
    +        cache_key = (finType, period, refresh)
    +        if cache_key in self._fin_tbl_cache:
    +            return self._fin_tbl_cache[cache_key]
    +        if not refresh:
    +            cache_key2 = (finType, period, True)
    +            if cache_key2 in self._fin_tbl_cache:
    +                return self._fin_tbl_cache[cache_key2]
    +
    +        if period == yfcd.ReportingPeriod.Interim:
    +            name = 'quarterly_'
    +        else:
    +            name = ''
    +        if finType == yfcd.Financials.IncomeStmt:
    +            name += 'income_stmt'
    +        elif finType == yfcd.Financials.BalanceSheet:
    +            name += 'balance_sheet'
    +        elif finType == yfcd.Financials.CashFlow:
    +            name += 'cashflow'
    +
    +        df, md = None, None
    +        if yfcm.IsDatumCached(self.ticker, name):
    +            df, md = yfcm.ReadCacheDatum(self.ticker, name, True)
    +            mod_dt = None
    +            if md is None or len(md) == 0:
    +                # Fix metadata
    +                fp = yfcm.GetFilepath(self.ticker, name)
    +                mod_dt = datetime.fromtimestamp(os.path.getmtime(fp)).astimezone()
    +                md = {'FetchDates':{}}
    +                for dt in df.columns:
    +                    md['FetchDates'][dt] = mod_dt
    +                yfcm.WriteCacheMetadata(self.ticker, name, 'FetchDates', md['FetchDates'])
    +                md['LastFetch'] = mod_dt
    +                yfcm.WriteCacheMetadata(self.ticker, name, 'LastFetch', md['LastFetch'])
    +            elif 'FetchDates' not in md:
    +                if mod_dt is None:
    +                    fp = yfcm.GetFilepath(self.ticker, name)
    +                    mod_dt = datetime.fromtimestamp(os.path.getmtime(fp)).astimezone()
    +                for dt in df.columns:
    +                    md['FetchDates'][dt] = mod_dt
    +                yfcm.WriteCacheMetadata(self.ticker, name, 'FetchDates', md['FetchDates'])
    +            elif 'LastFetch' not in md:
    +                if mod_dt is None:
    +                    fp = yfcm.GetFilepath(self.ticker, name)
    +                    mod_dt = datetime.fromtimestamp(os.path.getmtime(fp)).astimezone()
    +                md['LastFetch'] = mod_dt
    +                yfcm.WriteCacheMetadata(self.ticker, name, 'LastFetch', md['LastFetch'])
    +
    +            if md['LastFetch'].tzinfo is None:
    +                md['LastFetch'] = md['LastFetch'].astimezone()
    +                yfcm.WriteCacheMetadata(self.ticker, name, 'LastFetch', md['LastFetch'])
    +
    +        do_fetch = False
    +        if df is None:
    +            do_fetch = True
    +        elif refresh:
    +            dt_now = pd.Timestamp.utcnow().tz_convert(self.tzName)
    +            if df.empty:
    +                # Nothing to estimate releases on, so just periodically check
    +                try:
    +                    age = dt_now - md["LastFetch"]
    +                except Exception:
    +                    print(md)
    +                    raise
    +                if age > pd.Timedelta(days=30):
    +                    do_fetch = True
    +            else:
    +                td_1d = pd.Timedelta(1, unit='D')
    +                releases = self.get_release_dates(period, refresh=False)
    +                if debug:
    +                    print("- releases:") ; pprint(releases)
    +                if releases is None:
    +                    # Use crude logic to estimate when to re-fetch
    +                    if 'LastFetch' in md.keys():
    +                        do_fetch = md['LastFetch'] < (dt_now - td_1d*30)
    +                    else:
    +                        do_fetch = True
    +                else:
    +                    next_release = None
    +                    # last_d = df.columns.max().date()
    +                    # Update: analyse pruned dates:
    +                    last_d = self._prune_yf_financial_df(df).columns.max().date()
    +                    for r in releases:
    +                        # Release is newer than cache
    +                        try:
    +                            if r.period_end <= last_d:
    +                                continue
    +                        except yfcd.AmbiguousComparisonException:
    +                            # Treat as match
    +                            continue
    +
    +                        try:
    +                            fetched_long_after_release = md['LastFetch'].date() > (r.release_date + yf_max_grace_days_period)
    +                        except yfcd.AmbiguousComparisonException:
    +                            fetched_long_after_release = False
    +                        if not fetched_long_after_release:
    +                            next_release = r
    +                            break
    +                    if next_release is None:
    +                        pprint(releases)
    +                        print("- last_d =", last_d)
    +                        raise Exception('Failed to determine next release after cached financials')
    +                    if debug:
    +                        print("- last_d =", last_d, ", last_fetch =", md['LastFetch'])
    +                        print("- next_release:", next_release)
    +                    rd = next_release.release_date
    +                    try:
    +                        next_release_in_future = rd > d_today
    +                    except yfcd.AmbiguousComparisonException:
    +                        next_release_in_future = False
    +                    if debug:
    +                        print("- next_release_in_future =", next_release_in_future)
    +                    if not next_release_in_future:
    +                        try:
    +                            fair_to_expect_Yahoo_updated = (d_today-rd) >= yf_min_grace_days_period
    +                        except yfcd.AmbiguousComparisonException:
    +                            fair_to_expect_Yahoo_updated = True
    +                        if debug:
    +                            print("- fair_to_expect_Yahoo_updated =", fair_to_expect_Yahoo_updated)
    +                        if fair_to_expect_Yahoo_updated:
    +                            if debug:
    +                                print("- expect new release, but did we already fetch recently?")
    +                            if md['LastFetch'] < (dt_now - yf_spam_window):
    +                                do_fetch = True
    +
    +        if debug:
    +            print("- do_fetch =", do_fetch)
    +        if do_fetch:
    +            if print_fetches:
    +                msg = f"{self.ticker}: fetching {name}"
    +                if md is not None:
    +                    msg += f" (last fetch = {md['LastFetch']})"
    +                print(msg)
    +            df_new = getattr(self.dat, name)
    +            fetch_dt = pd.Timestamp.utcnow().tz_convert(self.tzName)
    +            if md is None:
    +                md = {'FetchDates':{}}
    +            for dt in df_new.columns:
    +                md['FetchDates'][dt] = fetch_dt
    +            md['LastFetch'] = fetch_dt
    +            if df is None or df.empty:
    +                df = df_new
    +            elif df_new is not None and not df_new.empty:
    +                df_pruned = df.drop([c for c in df.columns if c in df_new], axis=1)
    +                df_new_pruned = df_new.drop([c for c in df_new.columns if c in df], axis=1)
    +                if df_pruned.empty and df_new_pruned.empty:
    +                    if hasattr(next_release.release_date, 'confidence') and next_release.release_date.confidence == yfcd.Confidence.Low:
    +                        # Probably not released yet
    +                        pass
    +                    # else:
    +                    #     # Update: also check if a large amount of time has passed since release.
    +                    #     # Will Yahoo ever have it?
    +                    #     td_since_release = d_today - next_release.release_date
    +                    #     try:
    +                    #         Yahoo_very_late = td_since_release > yf_max_grace_days_period
    +                    #     except yfcd.AmbiguousComparisonException:
    +                    #         Yahoo_very_late = False
    +                    #     if Yahoo_very_late:
    +                    #         # print("- next_release:", next_release)
    +                    #         # print("- df:", df.columns, df.shape)
    +                    #         # print("- df_new:", df_new.columns, df_new.shape)
    +                    #         # print("- metadata old:") ; pprint(md_old)
    +                    #         # print("- td_since_release:", td_since_release)
    +                    #         ok = click.confirm(f"WARNING: Yahoo very late uploading newer {finType} for {self.ticker}, is this acceptable?", default=False)
    +                    #         if ok:
    +                    #             # print(f"WARNING: Yahoo missing newer financials for {self.ticker}")
    +                    #             pass
    +                    #         else:
    +                    #             # print("- next_release:", next_release)
    +                    #             # print("- df:", df.columns, df.shape)
    +                    #             # print("- df_new:", df_new.columns, df_new.shape)
    +                    #             # print("- metadata old:") ; pprint(md_old)
    +                    #             raise Exception(f'Why asking Yahoo for {finType} when nothing new ready?')
    +                elif not df_new.empty:
    +                    if df_pruned.empty:
    +                        df = df_new
    +                    else:
    +                        # Before merging, check for new/missing fields. Insert any with value NaN.
    +                        missing_keys = [k for k in df_pruned.index if k not in df_new.index]
    +                        new_keys = [k for k in df_new.index if k not in df_pruned.index]
    +                        actions = []
    +                        for k in missing_keys:
    +                            actions.append((k, "missing", df_pruned.index.get_loc(k)))
    +                        for k in new_keys:
    +                            actions.append((k, "new", df_new.index.get_loc(k)))
    +                        actions = sorted(actions, key=lambda x: x[2])
    +                        for a in actions:
    +                            k = a[0]
    +                            if a[1] == 'missing':
    +                                empty_row = pd.DataFrame(data={c:[np.nan] for c in df_new.columns}, index=[k])
    +                                idx = df_pruned.index.get_loc(k)
    +                                df_new = pd.concat([df_new.iloc[:idx], empty_row, df_new.iloc[idx:]])
    +                            else:
    +                                empty_row = pd.DataFrame(data={c:[np.nan] for c in df_pruned.columns}, index=[k])
    +                                idx = df_new.index.get_loc(k)
    +                                df_pruned = pd.concat([df_pruned.iloc[:idx], empty_row, df_pruned.iloc[idx:]])
    +                        df_new = df_new.reindex(df_pruned.index)
    +                        df = pd.concat([df_new, df_pruned], axis=1)
    +            yfcm.StoreCacheDatum(self.ticker, name, df, metadata=md)
    +
    +        self._fin_tbl_cache[cache_key] = df
    +        return df
    +
    +    def _get_interval_from_table(self, tbl):
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print("_get_interval_from_table()")
    +
    +        dates = tbl.columns
    +
    +        # Ensure only well-populated columns are retained, corresponding to report releases
    +        tbl = self._prune_yf_financial_df(tbl)
    +        tbl = tbl[tbl.columns.sort_values(ascending=False)]
    +        dates = tbl.columns
    +        if debug:
    +            print("- tbl:") ; print(tbl)
    +        if len(dates) <= 1:
    +            return yfcd.TimedeltaEstimate(yfcd.ComparableRelativedelta(months=6), yfcd.Confidence.Medium)
    +
    +        interval = None
    +        intervals = [(dates[i-1] - dates[i]).days for i in range(1,len(dates))]
    +        intervals = np.array(intervals)
    +
    +        # Cluster actual intervals
    +        def safe_add_to_cluster(clusters, num, std_pct_threshold):
    +            for c in clusters:
    +                c2 = np.append(c, num)
    +                if (np.std(c2) / np.mean(c2)) < std_pct_threshold:
    +                    c.append(num)
    +                    return True
    +            return False
    +        def cluster_numbers(numbers, std_pct):
    +            clusters = []
    +            for n in sorted(numbers):
    +                if not clusters or not safe_add_to_cluster(clusters, n, std_pct):
    +                    clusters.append([n])
    +            return clusters
    +        clusters = cluster_numbers(intervals, 0.05)
    +
    +        # Map clusters to legal intervals
    +        tol = 10
    +        intervals = []
    +        for i in range(len(clusters)-1, -1, -1):
    +            m = np.mean(clusters[i])
    +            if abs(m-365) < tol:
    +                intervals.append(yfcd.ComparableRelativedelta(years=1))
    +            elif abs(m-182) < tol:
    +                intervals.append(yfcd.ComparableRelativedelta(months=6))
    +            elif abs(m-91) < tol:
    +                intervals.append(yfcd.ComparableRelativedelta(months=3))
    +            elif abs(m-274) < tol:
    +                # 9 months, nonsense, but implies quarterly
    +                intervals.append(yfcd.TimedeltaEstimate(yfcd.ComparableRelativedelta(months=3), yfcd.Confidence.Medium))
    +            else:
    +                del clusters[i]
    +        if len(intervals) == 1:
    +            # good!
    +            return intervals[0]
    +        else:
    +            # Return the smallest. In case of ambiguous comparison, keep most confident.
    +            best = intervals[0]
    +            for i in range(1, len(intervals)):
    +                i2 = intervals[i]
    +                try:
    +                    best = min(best, i2)
    +                except yfcd.AmbiguousComparisonException:
    +                    best_confidence = best.confidence if hasattr(best, 'confidence') else yfcd.Confidence.High
    +                    i2_confidence = i2.confidence if hasattr(i2, 'confidence') else yfcd.Confidence.High
    +                    if i2_confidence > best_confidence:
    +                        best = i2
    +            return best
    +
    +    def _get_interval(self, finType, refresh=True):
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f"_get_interval({finType})")
    +
    +        if not isinstance(finType, yfcd.Financials):
    +            raise Exception('Argument finType must be type Financials')
    +
    +        tbl = self._get_fin_table(finType, yfcd.ReportingPeriod.Interim, refresh)
    +
    +        return self._get_interval_from_table(tbl)
    +
    +    def get_release_dates(self, period, as_df=False, refresh=True, check=False):
    +        # First, check cache:
    +        if period == yfcd.ReportingPeriod.Full:
    +            cache_key = "full"
    +        elif period == yfcd.ReportingPeriod.Interim:
    +            cache_key = "interim"
    +        else:
    +            raise Exception(f"Unknown period value '{period}'")
    +        cache_key += "-release-dates"
    +        releases, md = None, None
    +        if yfcm.IsDatumCached(self.ticker, cache_key):
    +            releases, md = yfcm.ReadCacheDatum(self.ticker, cache_key, True)
    +            if len(releases) == 0:
    +                releases = None
    +
    +        max_age = pd.Timedelta(yfcm._option_manager.max_ages.calendar)
    +        dt_now = pd.Timestamp.now()
    +        d_exchange = pd.Timestamp.utcnow().tz_convert(self.tzName).date()
    +        if releases is None:
    +            if md is None:
    +                do_calc = True
    +            else:
    +                do_calc = md['CalcDate'] < (dt_now - max_age)
    +        else:
    +            do_calc = False
    +
    +            # Check if cached release dates need a recalc
    +            if md['CalcDate'] < (dt_now - max_age):
    +                prev_r, next_r = None, None
    +                for i in range(len(releases)-1):
    +                    r0 = releases[i]
    +                    r1 = releases[i+1]
    +                    try:
    +                        r_is_history = r0.release_date < d_exchange
    +                    except yfcd.AmbiguousComparisonException:
    +                        r_is_history = r0.release_date.prob_lt(d_exchange) > 0.9
    +                    if r_is_history:
    +                        prev_r = r0
    +                        next_r = r1
    +                if hasattr(prev_r, 'confidence'):
    +                    do_calc = True
    +                elif hasattr(next_r, 'confidence'):
    +                    try:
    +                        d_exchange < next_r.release_date
    +                    except yfcd.AmbiguousComparisonException:
    +                        print("- next release date is estimated, time to recalc:", next_r)
    +                        do_calc = True
    +                # print("- releases:") ; pprint(releases)
    +                # print("- md:") ; pprint(md)
    +                # raise Exception('review cached release dates')
    +
    +        if do_calc:
    +            releases = self._calc_release_dates(period, refresh, check)
    +            md = {'CalcDate':pd.Timestamp.now()}
    +            if releases is None:
    +                yfcm.StoreCacheDatum(self.ticker, cache_key, [], metadata=md)
    +            else:
    +                yfcm.StoreCacheDatum(self.ticker, cache_key, releases, metadata=md)
    +        if releases is None:
    +            return None
    +
    +        if not as_df:
    +            return releases
    +
    +        period_ends = []
    +        period_ends_est = []
    +        release_dates = []
    +        release_dates_est = []
    +        delays = []
    +        for r in releases:
    +            rpe = r.period_end ; rrd = r.release_date
    +            if rpe is None or rrd is None:
    +                print(r)
    +                raise Exception('Release missing dates')
    +            period_ends.append(rpe if isinstance(rpe, date) else rpe.date)
    +            period_ends_est.append(rpe.confidence if isinstance(rpe, yfcd.DateEstimate) else yfcd.Confidence.High)
    +            dt1 = rpe if isinstance(rpe, date) else rpe.date
    +            if isinstance(rrd, yfcd.DateRange):
    +                rrd_range = rrd.end - rrd.start
    +                release_dates_est.append(yfcd.Confidence.High)
    +                release_dates.append((rrd.start, rrd.end))
    +                midpoint = rrd.start + timedelta(days=rrd_range.days//2)
    +                delays.append(midpoint - dt1)
    +            elif isinstance(rrd, yfcd.yfcd.DateRangeEstimate):
    +                release_dates_est.append(rrd.confidence)
    +                rrd_range = rrd.end - rrd.start
    +                midpoint = rrd.start + rrd_range*0.5
    +                if isinstance(midpoint, datetime):
    +                    midpoint = midpoint.date()
    +                release_dates.append((rrd.start, rrd.end))
    +                delays.append(midpoint - dt1)
    +            else:
    +                release_dates.append(rrd if isinstance(rrd, date) else rrd.date)
    +                release_dates_est.append(rrd.confidence if isinstance(rrd, yfcd.DateEstimate) else yfcd.Confidence.High)
    +                dt2 = rrd if isinstance(rrd, date) else rrd.date
    +                delays.append(dt2 - dt1)
    +        df = pd.DataFrame({'Period end':period_ends, 'PE confidence':period_ends_est, 'Release date':release_dates, 'RD confidence':release_dates_est, 'Delay':delays})
    +        df['Period end'] = pd.to_datetime(df['Period end'])
    +        df['Period end'] = df['Period end'].dt.tz_localize(self.tzName)
    +        df = df.set_index('Period end')
    +
    +        # Set timezone
    +        # release_dates_formatted = []
    +        # for i in range(df.shape[0]):
    +        #     idx = df.index[i]
    +        #     x = df['Release date'].iloc[i]
    +        #     if isinstance(x, tuple):
    +        #         x = (pd.to_datetime(x[0]).tz_localize(self.tzName), pd.to_datetime(x[1]).tz_localize(self.tzName))
    +        #     else:
    +        #         x = pd.to_datetime(x).tz_localize(self.tzName)
    +        #     release_dates_formatted.append(x)
    +        # df['Release date'] = release_dates_formatted
    +
    +        return df
    +
    +    def _calc_release_dates(self, period, refresh=True, check=False):
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f"_calc_release_dates({period}, refresh={refresh})")
    +
    +        if not isinstance(period, yfcd.ReportingPeriod):
    +            raise Exception('Argument period must be type ReportingPeriod')
    +        yfcu.TypeCheckBool(refresh, 'refresh')
    +        yfcu.TypeCheckBool(check, 'check')
    +
    +        # Get period ends
    +        tbl = None
    +        finType = None
    +        for f in yfcd.Financials:
    +            t = self._get_fin_table(f, period, refresh)
    +            t = self._prune_yf_financial_df(t)
    +            if tbl is None:
    +                tbl = t ; finType = f
    +            elif t is not None and t.shape[1] > tbl.shape[1]:
    +                tbl_wasnt_empty = not tbl.empty
    +                tbl = t ; finType = f
    +                if tbl_wasnt_empty:
    +                    break
    +
    +        if tbl is None or tbl.empty:
    +            return None
    +        tbl_cols = tbl.columns
    +        if isinstance(tbl_cols[0], (datetime, pd.Timestamp)):
    +            tbl_cols = [c.date() for c in tbl_cols]
    +        period_ends = [d.date() for d in tbl.columns if d.date() <= d_today]
    +        period_ends.sort(reverse=True)
    +        if debug:
    +            print("- period_ends:")
    +            for x in period_ends:
    +                print(x)
    +
    +        # Get calendar
    +        cal_release_dates = self._get_calendar_dates(refresh)
    +        if debug:
    +            if len(cal_release_dates) == 0:
    +                print("- calendar empty")
    +            else:
    +                print("- cal_release_dates:")
    +                for x in cal_release_dates:
    +                    print(x)
    +
    +        # Get earnings dates
    +        edf = self.get_earnings_dates(start=tbl.columns.min().date(), refresh=refresh, clean=False)
    +
    +        # Get full year end date
    +        tbl = None
    +        for f in yfcd.Financials:
    +            t = self._get_fin_table(f, yfcd.ReportingPeriod.Full, refresh=False)  # minimise fetches
    +            t = self._prune_yf_financial_df(t)
    +            if t is not None and not t.empty:
    +                tbl = t
    +                break
    +        if tbl is None and refresh:
    +            for f in yfcd.Financials:
    +                t = self._get_fin_table(f, yfcd.ReportingPeriod.Full, refresh)
    +                t = self._prune_yf_financial_df(t)
    +                if t is not None and not t.empty:
    +                    tbl = t
    +                    break
    +        if not tbl.empty:
    +            year_end = tbl.columns.max().date()
    +        else:
    +            year_end = None
    +        if pd.isna(year_end):
    +            print(tbl.iloc[0:4])
    +            raise Exception("'year_end' is NaN")
    +        if debug:
    +            print("- year_end =", year_end)
    +
    +        # Clean earnings dates
    +        if (edf is None) or (edf.shape[0]==0):
    +            if debug:
    +                print("- earnings_dates table is empty")
    +            release_dates = cal_release_dates
    +        else:
    +            # Prune old dates
    +            f_old = edf.index.date < period_ends[-1]
    +            if f_old.any():
    +                edf = edf[~f_old]
    +
    +            if edf.shape[0] > 1:
    +                # Drop dates that occurred just before another
    +                edf = edf.sort_index(ascending=True)
    +                d = edf.index.to_series().diff()
    +                d.iloc[0] = pd.Timedelta(999, unit='d')
    +                x_near = np.abs(d) < pd.Timedelta(5, "days")
    +                if x_near.any():
    +                    edf = edf[~x_near]
    +                edf = edf.sort_index(ascending=False)
    +
    +            release_dates = cal_release_dates
    +
    +            for i in range(edf.shape[0]):
    +                dt = edf.index[i].date()
    +                r = edf.iloc[i]
    +                td = None
    +                if td is None:
    +                    if pd.isnull(r["Reported EPS"]) and pd.isnull(r["Surprise(%)"]) and not r['Date confirmed?']:
    +                        td = yfcd.DateEstimate(dt, yfcd.Confidence.Medium)
    +                    else:
    +                        td = dt
    +
    +                # Protect against duplicating entries in calendar
    +                duplicate = False
    +                for c in release_dates:
    +                    diff = c - td
    +                    try:
    +                        duplicate = diff > timedelta(days=-20) and diff < timedelta(days=20)
    +                    except yfcd.AmbiguousComparisonException:
    +                        p1 = diff.prob_gt(timedelta(days=-20))
    +                        p2 = diff.prob_lt(timedelta(days=20))
    +                        duplicate = p1 > 0.9 and p2 > 0.9
    +                    if duplicate:
    +                        break
    +                if not duplicate:
    +                    release_dates.append(td)
    +        if debug:
    +            print("- edf:")
    +            print(edf)
    +            release_dates.sort(reverse=True)
    +            print("- release_dates:")
    +            pprint(release_dates)
    +
    +        # Deduce interval
    +        if period == yfcd.ReportingPeriod.Full:
    +            interval_td = interval_str_to_days['ANNUAL']
    +        else:
    +            interval_td = self._get_interval(finType, refresh)
    +        if debug:
    +            print(f"- interval_td = {interval_td}")
    +
    +        # Now combine known dates into 'Earnings Releases':
    +        if debug:
    +            print("# Now combine known dates into 'Earnings Releases':")
    +        releases = []
    +        for d in period_ends:
    +            r = EarningsRelease(interval_td, d, None, year_end)
    +            releases.append(r)
    +        if debug:
    +            releases.sort()
    +            print("> releases with known period-end-dates:")
    +            pprint(releases)
    +
    +        # Fill gap between last release and now+9mo with estimated releases
    +        if debug:
    +            print("# Fill gap between last release and now with estimated releases")
    +        releases.sort(reverse=True)
    +        last_release = releases[0]
    +        if debug:
    +            print("- last_release:", last_release)
    +        ct = 0
    +        while True:
    +            ct += 1
    +            if ct > 10:
    +                for r in releases:
    +                    print(r)
    +                print("interval_td = {0}".format(interval_td))
    +                raise Exception("Infinite loop detected while estimating next financial report")
    +
    +            next_period_end = yfcd.DateEstimate(interval_td + last_release.period_end, yfcd.Confidence.High)
    +
    +            r = EarningsRelease(interval_td, next_period_end, None, year_end)
    +
    +            releases.insert(0, r)
    +            last_release = r
    +            if debug:
    +                print("Inserting:", r)
    +
    +            try:
    +                if r.period_end > (d_today+timedelta(days=270)):
    +                    break
    +            except yfcd.AmbiguousComparisonException:
    +                p = r.period_end.prob_gt(d_today+timedelta(days=270))
    +                if p > 0.9:
    +                    break
    +        if debug:
    +            releases.sort()
    +            print("# Intermediate set of releases:")
    +            pprint(releases)
    +
    +        if release_dates is None or len(release_dates) == 0:
    +            if debug:
    +                print("No release dates in Yahoo so estimating all with Low confidence")
    +            for i in range(len(releases)):
    +                releases[i].release_date = yfcd.DateEstimate(releases[i].period_end+timedelta(days=5)+yfcd.confidence_to_buffer[yfcd.Confidence.Low], yfcd.Confidence.Low)
    +            return releases
    +        release_dates.sort()
    +
    +        # Add more releases to ensure their date range fully overlaps with release dates
    +        release_dates.sort()
    +        releases.sort()
    +        ct = 0
    +        while True:
    +            try:
    +                gt_than = releases[0].period_end > release_dates[0]
    +            except yfcd.AmbiguousComparisonException:
    +                if hasattr(releases[0].period_end, 'prob_gt'):
    +                    p = releases[0].period_end.prob_gt(release_dates[0])
    +                else:
    +                    p = release_dates[0].prob_lt(releases[0].period_end)
    +                gt_than = p > 0.9
    +            if not gt_than:
    +                break
    +
    +            ct += 1
    +            if ct > 100:
    +                raise Exception("Infinite loop detected while adding release objects")
    +            prev_period_end = releases[-1].period_end - interval_td
    +            conf = yfcd.Confidence.High
    +            if isinstance(prev_period_end, date):
    +                prev_period_end = yfcd.DateEstimate(prev_period_end, conf)
    +            else:
    +                prev_period_end = yfcd.DateEstimate(prev_period_end.date, min(prev_period_end.confidence, conf))
    +
    +            r = EarningsRelease(interval_td, prev_period_end, None, year_end)
    +
    +            releases.insert(0, r)
    +            if debug:
    +                print("Inserting:", r)
    +        ct = 0
    +        while True:
    +            try:
    +                less_than = releases[-1].period_end+interval_td < release_dates[-1]
    +            except yfcd.AmbiguousComparisonException:
    +                p = (releases[-1].period_end+interval_td).prob_lt(release_dates[-1])
    +                less_than = p > 0.5
    +            if not less_than:
    +                break
    +
    +            ct += 1
    +            if ct > 20:
    +                raise Exception("Infinite loop detected while adding release objects")
    +            next_period_end = releases[-1].period_end + interval_td
    +            if isinstance(next_period_end, date):
    +                next_period_end = yfcd.DateEstimate(next_period_end, yfcd.Confidence.Medium)
    +            else:
    +                next_period_end = yfcd.DateEstimate(next_period_end.date, min(next_period_end.confidence, yfcd.Confidence.Medium))
    +
    +            r = EarningsRelease(interval_td, next_period_end, None, year_end)
    +            releases.append(r)
    +            if debug:
    +                print("Appending:", r)
    +        # Fill in gaps in periods with estimates:
    +        for i in range(len(releases)-2, -1, -1):
    +            while True:
    +                r0 = releases[i]
    +                r1 = releases[i+1]
    +                try:
    +                    diff = r1.period_end - r0.period_end
    +                    gap_too_large = (diff/1.5) > interval_td
    +                except yfcd.AmbiguousComparisonException:
    +                    gap_too_large = False
    +                if gap_too_large:
    +                    new_r = EarningsRelease(interval_td, r1.period_end - interval_td, None, year_end)
    +                    if debug:
    +                        print(f"Inserting release estimate into gap: {new_r} (diff={diff}, interval_td={interval_td}, {type(interval_td)})")
    +                    releases.insert(i+1, new_r)
    +                else:
    +                    break
    +        if debug:
    +            releases.sort()
    +            print("# Final set of releases:")
    +            pprint(releases)
    +
    +        # Assign known dates to appropriate release(s) without dates
    +        if debug:
    +            print("# Assigning known dates to releases ...")
    +        releases = sort_estimates(releases)
    +        release_dates.sort()
    +        for i in range(len(release_dates)):
    +            dt = release_dates[i]
    +            if debug:
    +                print("- dt =", dt)
    +            # Find most recent period-end:
    +            rj = 0
    +            for j in range(1, len(releases)):
    +                try:
    +                    if releases[j].period_end > (dt-company_release_delay):
    +                        break
    +                except yfcd.AmbiguousComparisonException:
    +                    if hasattr(releases[j].period_end, "prob_gt"):
    +                        p = releases[j].period_end.prob_gt(dt-company_release_delay)
    +                    else:
    +                        p = (dt-company_release_delay).prob_lt(releases[j].period_end)
    +                    if debug:
    +                        print(f"  - prob. that {releases[j].period_end} > {dt-company_release_delay} = {100.0*p:.1f}%")
    +                    if p > 0.5:
    +                        break
    +                rj = j
    +            r = releases[rj]
    +            if debug:
    +                print("  - rj =", rj, ",  r =", r)
    +            if r.release_date is not None:
    +                # Already assigned an earlier release date.
    +
    +                dt_is_for_same_period = False
    +                if isinstance(dt, date) and dt in edf.index.date:
    +                    if isinstance(r.release_date, date) and r.release_date in edf.index.date:
    +                        # Not great because assumes two consecutive earnings don't report same EPS
    +                        dt_is_for_same_period1 = edf['Reported EPS'].loc[str(dt)].iloc[0] == edf['Reported EPS'].loc[str(r.release_date)].iloc[0]
    +                        dt_is_for_same_period2 = edf['EPS Estimate'].loc[str(dt)].iloc[0] == edf['EPS Estimate'].loc[str(r.release_date)].iloc[0]
    +                        dt_is_for_same_period = dt_is_for_same_period1 and dt_is_for_same_period2
    +                if dt_is_for_same_period:
    +                    # Assume the earlier release was just a preliminary cash-flow update, and that
    +                    # this later release is the full financials report
    +                    if debug:
    +                        print(f"  - assume earlier release {r.release_date} was just a preliminary cash-flow update, and that")
    +                        print(f"  - this later release {dt} is the full financials report")
    +                    r.release_date = dt
    +                    continue
    +
    +                dt_is_better = False
    +                if not hasattr(dt, 'confidence'):
    +                    if hasattr(r.release_date, 'confidence'):
    +                        # Maybe the previously-assigned date was estimate, from bad Yahoo data
    +                        dt_is_better = True
    +                else:
    +                    if hasattr(r.release_date, 'confidence') and dt.confidence > r.release_date.confidence:
    +                        dt_is_better = True
    +                if debug:
    +                    print("  - dt_is_better =", dt_is_better)
    +                if dt_is_better:
    +                    if debug:
    +                        print(f"  - dt={dt} is more accurate than date already assigned to {r}. so overwrite with dt")
    +                        print(f"  - discarding previously assigned dt={r.release_date}")
    +                    r.release_date = dt
    +                    continue
    +
    +                try:
    +                    quarterly = interval_td <= timedelta(days=100)
    +                except yfcd.AmbiguousComparisonException:
    +                    quarterly = True
    +                if not quarterly:
    +                    # Not quarterly releases so can't safely reassign dates.
    +                    # Probably this date 'dt' is for a cashflow update, not
    +                    # full earnings release.
    +                    # So treat this date 'dt' unassignable.
    +                    if debug:
    +                        print(f"  - because not quarterly, have to discard unassigned date {dt}")
    +                    continue
    +
    +                r_is_end_of_year = r.is_end_of_year()
    +                if debug:
    +                    print("  - r_is_end_of_year =", r_is_end_of_year)
    +
    +                # if r_is_end_of_year and rj > 0:
    +                #     # For annual reports, allow reassigned date to previous release,
    +                #     # because annual reports can take longer to release.
    +                #     if (releases[rj-1].release_date is not None) and (not dt_is_better):
    +                #         # print("- dt =", dt)
    +                #         # print("- dt_is_for_same_period =", dt_is_for_same_period)
    +                #         # print("- r_is_end_of_year =", r_is_end_of_year)
    +                #         # print("- this release:", releases[rj])
    +                #         # print("- previous release:", releases[rj-1])
    +                #         # print("- edf:")
    +                #         # print(edf.drop(['FetchDate'], axis=1))
    +                #         # print(edf.columns)
    +                #         # print("- release_dates:") ; pprint(release_dates)
    +                #         # raise Exception('Expected prior report to not be assigned date')
    +                #         # Update:
    +                #         # If this date not better, then just discard.
    +                #         pass
    +                #     else:
    +                #         if debug:
    +                #             print(f"  - reassigning dt={releases[rj].release_date} to previous report {releases[rj-1]}")
    +                #         releases[rj-1].release_date = r.release_date
    +                #         r.release_date = dt
    +                # else:
    +                #     if debug:
    +                #         print(f"  - discarding previously assigned dt={releases[rj].release_date}")
    +                # Update: refactor logic
    +                if r_is_end_of_year or dt_is_better:
    +                    # First, decide whether to reassign assigned date to previous release
    +                    if rj > 0 and releases[rj-1].release_date is None:
    +                        # if debug:
    +                        #     print(f"  - reassigning dt={releases[rj].release_date} to previous report {releases[rj-1]}")
    +                        # releases[rj-1].release_date = r.release_date
    +                        #
    +                        # But what if I don't? Might be causing trouble no benefit
    +                        pass
    +                    else:
    +                        if debug:
    +                            print(f"  - discarding previously assigned dt={releases[rj].release_date}")
    +                    r.release_date = dt
    +
    +            if debug:
    +                print(f"  - assigning dt={dt} to report={r}")
    +            r.release_date = dt
    +        if debug:
    +            releases.sort()
    +            print("> releases with known release dates:")
    +            for r in releases:
    +                print(r)
    +
    +        # Discard date assignments where delays are much higher than average
    +        delays = [(r.release_date - r.period_end) for r in releases if r.release_date is not None]
    +        if len(delays) >= 3:
    +            delays = sort_estimates(delays)
    +            median_delay = delays[len(delays)//2]
    +            delays = [(r.release_date - r.period_end) if r.release_date is not None else timedelta(0) for r in releases]
    +            outliers = np.ones(len(delays), dtype=bool)
    +            for i in range(len(delays)):
    +                r = releases[i]
    +                r_is_end_of_year = r.is_end_of_year()
    +                if debug:
    +                    print("  - r_is_end_of_year =", r_is_end_of_year)
    +
    +                threshold = median_delay*3
    +                if r_is_end_of_year:
    +                    threshold += timedelta(days=30)
    +                try:
    +                    outliers[i] = delays[i] > threshold
    +                except yfcd.AmbiguousComparisonException:
    +                    if hasattr(delays[i], 'prob_gt'):
    +                        p = delays[i].prob_gt(threshold)
    +                    else:
    +                        p = threshold.prob_lt(delays[i])
    +                    outliers[i] = p > 0.9
    +            for i in np.where(outliers)[0]:
    +                if debug:
    +                    print(f"discarding a release date because delay far above median {median_delay}:", releases[i])
    +                releases[i].release_date = None
    +
    +        # For any releases still without release dates, estimate with the following heuristics:
    +        # 1 - if release 12 months before/after has a date (or a multiple of 12), use that +/- 12 months
    +        # 2 - else used previous release + interval
    +        if debug:
    +            print("# Estimating release dates from other releases at similar time-of-year")
    +        report_delay = None
    +        releases.sort()
    +        if any([r.release_date is None for r in releases]):
    +            for try_interval in [365, 365//2, 365//4]:
    +                itd = timedelta(days=try_interval)
    +                for i in range(len(releases)):
    +                    if releases[i].release_date is None:
    +                        # Need to find a similar release to extrapolate date from
    +                        date_set = False
    +
    +                        for i2 in range(len(releases)):
    +                            if i2==i:
    +                                continue
    +                            if releases[i2].release_date is not None:
    +                                if period == yfcd.ReportingPeriod.Full:
    +                                    tolerance = timedelta(days=40)
    +                                else:
    +                                    tolerance = timedelta(days=10)
    +                                if releases[i2].period_end > releases[i].period_end:
    +                                    rem = (releases[i2].period_end - releases[i].period_end) % itd
    +                                else:
    +                                    rem = (releases[i].period_end - releases[i2].period_end) % itd
    +                                try:
    +                                    m1 = rem < tolerance
    +                                except yfcd.AmbiguousComparisonException:
    +                                    m1 = rem.prob_lt(tolerance) > 0.9
    +                                try:
    +                                    m2 = abs(rem-itd) < tolerance
    +                                except yfcd.AmbiguousComparisonException:
    +                                    m2 = abs(rem-itd).prob_lt(tolerance) > 0.9
    +                                match = m1 or m2
    +                                if match:
    +                                    if debug:
    +                                        print(f"- matching '{releases[i]}' with '{releases[i2]}' for interval '{try_interval}'")
    +                                    delay = releases[i2].release_date - releases[i2].period_end
    +                                    dt = delay + releases[i].period_end
    +
    +                                    r_is_end_of_year = releases[i].is_end_of_year()
    +                                    if debug:
    +                                        print("  - r_is_end_of_year =", r_is_end_of_year)
    +                                    if r_is_end_of_year and try_interval != 365:
    +                                        # Annual reports take longer than interims, so add on some more days
    +                                        if debug:
    +                                            print("  - adding 14d to dt")
    +                                        dt += timedelta(days=14)
    +
    +                                    if not hasattr(dt, 'confidence'):
    +                                        if r_is_end_of_year and try_interval != 365:
    +                                            confidence = yfcd.Confidence.Low
    +                                        else:
    +                                            confidence = yfcd.Confidence.Medium
    +                                        if isinstance(dt, date):
    +                                            dt = yfcd.DateEstimate(dt, confidence)
    +                                        elif isinstance(dt, yfcd.DateRange):
    +                                            dt = yfcd.DateRangeEstimate(dt.start, dt.end, confidence)
    +                                        else:
    +                                            raise Exception('Need to ensure this value has confidence:', dt)
    +                                    else:
    +                                        if r_is_end_of_year and try_interval != 365:
    +                                            confidences = [yfcd.Confidence.Low]
    +                                        else:
    +                                            confidences = [yfcd.Confidence.Medium]
    +                                        if isinstance(releases[i2].period_end, (yfcd.DateEstimate, yfcd.DateRangeEstimate)):
    +                                            confidences.append(releases[i2].period_end.confidence)
    +                                        if isinstance(releases[i2].release_date, (yfcd.DateEstimate, yfcd.DateRangeEstimate)):
    +                                            confidences.append(releases[i2].release_date.confidence)
    +                                        dt.confidence = min(confidences)
    +
    +                                    if i > 0 and (releases[i-1].release_date is not None):
    +                                        too_close_to_previous = False
    +                                        try:
    +                                            if isinstance(releases[i-1].release_date, yfcd.DateEstimate):
    +                                                too_close_to_previous = releases[i-1].release_date.isclose(dt)
    +                                            else:
    +                                                if releases[i-1].is_end_of_year():
    +                                                    threshold = timedelta(days=1)
    +                                                else:
    +                                                    threshold = timedelta(days=30)
    +                                                if debug:
    +                                                    diff = dt-releases[i-1].release_date
    +                                                    print(f"  - diff = {diff}")
    +                                                    print(f"  - threshold = {threshold}")
    +                                                too_close_to_previous = (dt-releases[i-1].release_date) < threshold
    +                                        except yfcd.AmbiguousComparisonException:
    +                                            p = (dt-releases[i-1].release_date).prob_lt(threshold)
    +                                            too_close_to_previous = p > 0.9
    +                                        if too_close_to_previous:
    +                                            if debug:
    +                                                print(f"  - dt '{dt}' would be too close to previous release date '{releases[i-1]}'")
    +                                            # Too close to last release date
    +                                            continue
    +                                    releases[i].release_date = dt
    +                                    date_set = True
    +                                    if debug:
    +                                        print("  - estimated release date {} of period-end {} from period-end {}".format(releases[i].release_date, releases[i].period_end, releases[i2].period_end))
    +                                    break
    +
    +                        if date_set and (report_delay is not None):
    +                            releases[i].release_date.date += report_delay
    +        if debug:
    +            print("> releases after estimating release dates:")
    +            for r in releases:
    +                print(r)
    +
    +        any_release_has_date = False
    +        for r in releases:
    +            if r.release_date is not None:
    +                any_release_has_date = True
    +                break
    +        if not any_release_has_date:
    +            if debug:
    +                print(f"- unable to map all {period} financials to release dates")
    +            return None
    +
    +        # Check for any releases still missing a release date that could be the Last earnings release:
    +        if any([r.release_date is None for r in releases]):
    +            for i in range(len(releases)):
    +                r = releases[i]
    +                if r.release_date is None:
    +                    problem = False
    +                    if i == len(releases)-1:
    +                        problem = True
    +                    else:
    +                        r2 = releases[i+1]
    +                        if (r2.release_date is not None) and (r2.release_date > d_today):
    +                            problem = True
    +                    if problem:
    +                        print(r)
    +                        raise Exception("A release that could be last is missing release date")
    +        if debug:
    +            print("> releases after estimating release dates:")
    +            for r in releases:
    +                print(r)
    +
    +        if check:
    +            self._check_release_dates(releases, finType, period, refresh)
    +
    +        return releases
    +
    +    def _check_release_dates(self, releases, finType, period, refresh):
    +        # if period == yfcd.ReportingPeriod.Full:
    +        #     interval_td = interval_str_to_days['ANNUAL']
    +        # else:
    +        #     interval_td = self._get_interval(finType, refresh)
    +
    +        # Ignore releases with no date:
    +        # - can happen with nonsense financials dates from Yahoo that
    +        #   even my prune function couldn't safely remove
    +        releases = [r for r in releases if r.release_date is not None]
    +
    +        for i0 in range(len(releases)-1):
    +            r0 = releases[i0]
    +            r0rd = r0.release_date
    +            if hasattr(r0rd, 'confidence') and r0rd.confidence == yfcd.Confidence.Low:
    +                continue
    +            for i1 in range(i0+1, len(releases)):
    +                r1 = releases[i1]
    +                r1rd = r1.release_date
    +                if hasattr(r1rd, 'confidence') and r1rd.confidence == yfcd.Confidence.Low:
    +                    continue
    +                #
    +                if isinstance(r0rd, date) and isinstance(r1rd, date):
    +                    isclose = r0rd == r1rd
    +                elif isinstance(r0rd, date):
    +                    isclose = r1rd.isclose(r0rd)
    +                else:
    +                    isclose = r0rd.isclose(r1rd)
    +                if isclose:
    +                    print(r0)
    +                    print(r1)
    +                    raise Exception(f'{self.ticker} Release dates have been assigned multiple times')
    +
    +                if not r0.is_end_of_year():
    +                    try:
    +                        # bad_order = r0.release_date > r1.period_end
    +                        bad_order = r0.release_date > (r1.period_end+timedelta(days=7))
    +                    except yfcd.AmbiguousComparisonException:
    +                        p = r0.release_date.prob_gt(r1.period_end+timedelta(days=7))
    +                        bad_order = p > 0.9
    +                    # try:
    +                    #     bad_order = bad_order and ((r1.period_end - r0.period_end)*2.0 > interval_td)
    +                    # except yfcd.AmbiguousComparisonException:
    +                    #     bad_order = False
    +                    if bad_order:
    +                        pprint(releases)
    +                        print(r0)
    +                        print(r1)
    +                        raise Exception(f'{self.ticker} Some releases dates are after next period ends')
    +        #
    +        for r in releases:
    +            try:
    +                is_negative = r.release_date < r.period_end
    +            except yfcd.AmbiguousComparisonException:
    +                p = r.release_date.prob_lt(r.period_end)
    +                is_negative = p > 0.9
    +            if is_negative:
    +                diff = r.release_date - r.period_end
    +                print("- rd =", r.release_date, type(r.release_date))
    +                print("- pe =", r.period_end, type(r.period_end))
    +                print("- diff =", diff, type(diff))
    +                print(r)
    +                raise Exception('Release dates contains negative delays')
    +
    +    def _prune_yf_financial_df(self, df):
    +        debug = False
    +        # debug = True
    +
    +        if df is None or df.empty:
    +            return df
    +
    +        ## Fiddly to put dates into a list and sort without reordering dataframe and without down-casting the date types!
    +        dates = [d for d in df.columns]
    +        dates.sort()
    +
    +        cache_key = tuple([df.index[0]] + dates)
    +        if cache_key in self._pruned_tbl_cache:
    +            return self._pruned_tbl_cache[cache_key]
    +
    +        # Drop duplicated columns
    +        if len(set(dates)) != len(dates):
    +            ## Search for duplicated columns
    +            df = df.T.drop_duplicates().T
    +            dates = [d for d in df.columns]
    +            dates.sort()
    +
    +        # Drop mostly-NaN duplicated dates:
    +        df_modified = False
    +        if len(set(dates)) != len(dates):
    +            for dt in set(dates):
    +                dff = df[dt]
    +                if len(dff.shape) == 2 and dff.shape[1] == 2:
    +                    # This date is duplicated, so count NaNs:
    +                    n_dups = dff.shape[1]
    +                    dt_indices = np.where(df.columns == dt)[0]
    +                    is_mostly_nans = np.array([False]*n_dups)
    +                    for i in range(n_dups):
    +                        dt_idx = dt_indices[i]
    +                        is_mostly_nans[i] = df.iloc[:,dt_idx].isnull().sum() > int(df.shape[0]*0.75)
    +                    if is_mostly_nans.sum() == n_dups-1:
    +                        ## All but one column are mostly nans, perfect!
    +                        drop_indices = dt_indices[is_mostly_nans]
    +                        indices = np.array(range(df.shape[1]))
    +                        keep_indices = indices[~np.isin(indices, drop_indices)]
    +                        df = df.iloc[:,keep_indices].copy()
    +                        df_modified = True
    +
    +                dff = df[dt]
    +                if len(dff.shape) == 2 and dff.shape[1] == 2:
    +                    # Date still duplicated. 
    +                    # Find instance with most non-nan values; if 
    +                    # all other instances are equal or nan then drop.
    +
    +                    n_dups = dff.shape[1]
    +                    dt_indices = np.where(df.columns == dt)[0]
    +                    nan_counts = np.zeros(n_dups)
    +                    for i in range(n_dups):
    +                        dt_idx = dt_indices[i]
    +                        nan_counts[i] = df.iloc[:,dt_idx].isnull().sum()
    +                    idx_min_na = 0
    +                    for i in range(1,n_dups):
    +                        if nan_counts[i] < nan_counts[idx_min_na]:
    +                            idx_min_na = i
    +                    drop_indices = []
    +                    for i in range(n_dups):
    +                        if i == idx_min_na:
    +                            continue
    +                        min_idx = dt_indices[idx_min_na]
    +                        dt_idx = dt_indices[i]
    +                        f_match = df.iloc[:,dt_idx].isnull() | (df.iloc[:,dt_idx]==df.iloc[:,min_idx])
    +                        if f_match.all():
    +                            drop_indices.append(dt_idx)
    +                    if len(drop_indices)>0:
    +                        indices = np.array(range(df.shape[1]))
    +                        keep_indices = indices[~np.isin(indices, drop_indices)]
    +                        df = df.iloc[:,keep_indices].copy()
    +                        df_modified = True
    +        if df_modified:
    +            dates = [d for d in df.columns]
    +            dates.sort()
    +
    +        # If duplicated date columns is very similar, then drop right-most:
    +        df_modified = False
    +        if len(set(dates)) != len(dates):
    +            for dt in set(dates):
    +                dff = df[dt]
    +                if len(dff.shape) == 2 and dff.shape[1] == 2:
    +                    dff.columns = [str(dff.columns[i])+str(i) for i in range(dff.shape[1])]
    +                    # r = dff.diff(axis=1)
    +                    r = (dff[dff.columns[0]] - dff[dff.columns[1]]).abs() / dff[dff.columns[0]]
    +                    r = r.sum()
    +                    if r < 0.15:
    +                        df = df.drop(dt, axis=1)
    +                        df[dt] = dff[dff.columns[0]]
    +                        df_modified = True
    +        if df_modified:
    +            dates = [d for d in df.columns]
    +            dates.sort()
    +
    +        if len(set(dates)) != len(dates):
    +            print(df)
    +            print("Dates: {}".format(dates))
    +            raise Exception("Duplicate dates found in financial df")
    +
    +        # Search for mostly-nan columns, where the non-nan values are exact match to an adjacent column.
    +        # Replace those nans with adjacent column values.
    +        # Optimise:
    +        df_isnull = df.isnull()
    +        df_isnull_sums = df_isnull.sum()
    +        nan_threshold = int(df.shape[0]*0.75)
    +        for i1 in range(1, len(dates)):
    +            d1 = dates[i1]
    +            d0 = dates[i1-1]
    +            d0_mostly_nans = df_isnull_sums[d0] > nan_threshold
    +            d1_mostly_nans = df_isnull_sums[d1] > nan_threshold
    +            if d0_mostly_nans and not d1_mostly_nans:
    +                f = (~df_isnull[d0]) & (~df_isnull[d1])
    +                if np.sum(f) >= 2:
    +                    # At least two actual values
    +                    if np.array_equal(df.loc[f,d0], df.loc[f,d1]):
    +                        # and those values match
    +                        df[d0] = df[d1].copy()
    +            elif d1_mostly_nans and not d0_mostly_nans:
    +                f = (~df_isnull[d1]) & (~df_isnull[d0])
    +                if np.sum(f) >= 2:
    +                    # At least two actual values
    +                    if np.array_equal(df.loc[f,d1], df.loc[f,d0]):
    +                        # and those values match
    +                        df[d1] = df[d0].copy()
    +
    +        # Drop mostly-nan columns:
    +        df_modified = False
    +        for i in range(len(dates)-1, -1, -1):
    +            d = dates[i]
    +            # if df[d].isnull().sum() == df.shape[0]:
    +            #   # Full of nans, drop column:
    +            if np.sum(df[d].isnull()) > nan_threshold:
    +                # Mostly nans, drop column
    +                if debug:
    +                    print(f"_prune_yf_financial_df(): column {d} is mostly NaNs")
    +                df = df.drop(d, axis=1)
    +                df_modified = True
    +        if df_modified:
    +            dates = [d for d in df.columns]
    +            dates.sort()
    +
    +        # # Then drop all columns devoid of data (NaN and 0.0):
    +        # for i in range(len(dates)-1, -1, -1):
    +        #     d = dates[i]
    +        #     fnan = df[d].isnull()
    +        #     fzero = df[d]==0.0
    +        #     if sum(np_or(fnan, fzero)) == df.shape[0]:
    +        #         # Completely devoid of data, drop column
    +        #         df = df.drop(d, axis=1)
    +
    +        # Search for populated columns, where values are very similar.
    +        similarity_pct_threshold = 0.8
    +        for i in range(len(dates)-2, -1, -1):
    +            d1 = dates[i+1]
    +            d0 = dates[i]
    +            delta = d1 - d0
    +            similarity_pct = np.sum(df[d0] == df[d1]) / df.shape[0]
    +            if df.shape[0] > 10 and delta < timedelta(days=45) and similarity_pct > similarity_pct_threshold:
    +                if debug:
    +                    print(f"{d0.date()} very similar & close to {d1.date()}, discarding later")
    +                # df = df.drop(d1, axis=1)
    +                # Instead of arbitrarily dropping one date, be smart.
    +                # Keep the one that makes most sense relative to distances to other dates
    +                diffs0 = [] ; diffs1 = []
    +                if i > 0:
    +                    diffs0.append((dates[i] - dates[i-1]).days)
    +                    diffs1.append((dates[i+1] - dates[i-1]).days)
    +                if i < (len(dates)-2):
    +                    diffs0.append((dates[i+2] - dates[i]).days)
    +                    diffs1.append((dates[i+2] - dates[i+1]).days)
    +                diffs0 = [min(abs(d-91), abs(d-182), abs(d-365)) for d in diffs0]
    +                diffs1 = [min(abs(d-91), abs(d-182), abs(d-365)) for d in diffs1]
    +                if mean(diffs0) < mean(diffs1):
    +                    df = df.drop(d1, axis=1)
    +                else:
    +                    df = df.drop(d0, axis=1)
    +                dates = [d for d in df.columns]
    +                dates.sort()
    +
    +        if len(set(dates)) != len(dates):
    +            print(f"Dates: {dates}")
    +            raise Exception("Duplicate dates found in financial df")
    +
    +        # Remove columns which YF created by backfilling
    +        df = df[df.columns.sort_values(ascending=False)]
    +        dates = [d for d in df.columns]
    +        for i1 in range(1, len(dates)):
    +            d0 = dates[i1-1]
    +            d1 = dates[i1]
    +            d0_values = df[d0].copy()
    +            d1_values = df[d1].copy()
    +            d0_values.loc[d0_values.isna()] = 0.0
    +            d1_values.loc[d1_values.isna()] = 0.0
    +            if np.array_equal(d0_values.values, d1_values.values):
    +                if debug:
    +                    print(f"_prune_yf_financial_df(): column {d0} appears backfilled by Yahoo")
    +                df = df.drop(d0, axis=1)
    +        df = df[df.columns.sort_values(ascending=True)]
    +
    +        if df.empty:
    +            raise Exception("_prune_yf_financial_df() has removed all columns")
    +
    +        self._pruned_tbl_cache[cache_key] = df
    +
    +        return df
    +
    +    def _earnings_interval(self, with_report, refresh=True):
    +        # Use cached data to deduce interval regardless of 'refresh'.
    +        # If refresh=True, only refresh if cached data not good enough.
    +
    +        yfcu.TypeCheckBool(with_report, 'with_report')
    +        yfcu.TypeCheckBool(refresh, 'refresh')
    +
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f'_earnings_interval(with_report={with_report}, refresh={refresh})')
    +
    +        interval = None
    +        inference_successful = False
    +
    +        if not with_report:
    +            edf = self.get_earnings_dates(start=d_today-timedelta(days=730), refresh=False)
    +            if (edf is None or edf.shape[0] <= 3) and refresh:
    +                edf = self.get_earnings_dates(start=d_today-timedelta(days=730), refresh=refresh)
    +            if edf is not None and edf.shape[0] > 3:
    +                # First, remove duplicates:
    +                deltas = np.flip((np.diff(np.flip(edf.index.date)) / pd.Timedelta(1, unit='D')))
    +                f = np.append(deltas > 0.5, True)
    +                edf = edf[f].copy()
    +
    +                edf_old = edf[edf.index.date < date.today()]
    +                if edf_old.shape[0] > 3:
    +                    edf = edf_old.copy()
    +                deltas = (np.diff(np.flip(edf.index.date)) / pd.Timedelta(1, unit='D'))
    +                if (deltas == deltas[0]).all():
    +                    # Identical, perfect
    +                    interval_days = deltas[0]
    +                    std_pct_mean = 0.0
    +                else:
    +                    # Discard large outliers
    +                    z_scores = np.abs(stats.zscore(deltas))
    +                    deltas_pruned = deltas[z_scores < 1.4]
    +                    # Discard small deltas
    +                    deltas_pruned = deltas_pruned[deltas_pruned > 10.0]
    +
    +                    std_pct_mean = np.std(deltas) / np.mean(deltas)
    +                    interval_days = np.mean(deltas_pruned)
    +                if debug:
    +                    print("- interval_days:", interval_days)
    +                if std_pct_mean < 0.68:
    +                    tol = 20
    +                    if abs(interval_days-365) < tol:
    +                        interval = 'ANNUAL'
    +                    elif abs(interval_days-182) < tol:
    +                        interval = 'HALF'
    +                    elif abs(interval_days-91) < tol:
    +                        interval = 'QUART'
    +                    if interval is not None:
    +                        return interval_str_to_days[interval]
    +
    +        if debug:
    +            print("- insufficient data in earnings_dates, analysing financials columns")
    +
    +        tbl_bs = self.get_quarterly_balance_sheet(refresh=False)
    +        tbl_fi = self.get_quarterly_income_stmt(refresh=False)
    +        tbl_cf = self.get_quarterly_cashflow(refresh=False)
    +        if refresh:
    +            if tbl_bs is None:
    +                tbl_bs = self.get_quarterly_balance_sheet(refresh)
    +            if tbl_fi is None:
    +                tbl_fi = self.get_quarterly_income_stmt(refresh)
    +            if tbl_cf is None:
    +                tbl_cf = self.get_quarterly_cashflow(refresh)
    +        tbl_bs = self._prune_yf_financial_df(tbl_bs)
    +        tbl_fi = self._prune_yf_financial_df(tbl_fi)
    +        tbl_cf = self._prune_yf_financial_df(tbl_cf)
    +        if with_report:
    +            # Expect all 3x financials present
    +            if tbl_bs is None or tbl_bs.empty or tbl_fi is None or tbl_fi.empty or tbl_cf is None or tbl_cf.empty:
    +                # Cannot be sure, but can estimate from any present table
    +                if tbl_bs is not None and not tbl_bs.empty:
    +                    tbl = tbl_bs
    +                elif tbl_fi is not None and not tbl_fi.empty:
    +                    tbl = tbl_fi
    +                else:
    +                    tbl = tbl_cf
    +            else:
    +                tbl = tbl_bs
    +        else:
    +            # Use whichever is available with most columns
    +            tbl = tbl_bs
    +            if tbl_fi is not None and len(tbl_fi.columns) > len(tbl.columns):
    +                tbl = tbl_fi
    +            if tbl_cf is not None and len(tbl_cf.columns) > len(tbl.columns):
    +                tbl = tbl_cf
    +
    +        if debug:
    +            print("- tbl:") ; print(tbl)
    +
    +        if tbl is not None and not tbl.empty and tbl.shape[0] > 1:
    +            return self._get_interval_from_table(tbl)
    +
    +        if not inference_successful:
    +            interval = yfcd.TimedeltaEstimate(interval_str_to_days['HALF'], yfcd.Confidence.Medium)
    +
    +        return interval
    +
    +    def get_earnings_dates(self, start, refresh=True, clean=True):
    +        start_dt, start = yfcu.ProcessUserDt(start, self.tzName)
    +        yfcu.TypeCheckDateStrict(start, 'start')
    +        yfcu.TypeCheckBool(refresh, 'refresh')
    +        yfcu.TypeCheckBool(clean, 'clean')
    +
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f"get_earnings_dates(start={start}, refresh={refresh})")
    +
    +        dt_now = pd.Timestamp.utcnow().tz_convert(self.tzName)
    +
    +        last_fetch = None
    +        if self._earnings_dates is None:
    +            if yfcm.IsDatumCached(self.ticker, "earnings_dates"):
    +                if debug:
    +                    print("- retrieving earnings dates from cache")
    +                self._earnings_dates, md = yfcm.ReadCacheDatum(self.ticker, "earnings_dates", True)
    +                if md is None:
    +                    md = {}
    +                if self._earnings_dates is None:
    +                    # Fine, just means last call failed to get earnings_dates
    +                    pass
    +                else:
    +                    if 'LastFetch' not in md:
    +                        raise Exception("f{self.ticker}: Why earnings_dates metadata missing 'LastFetch'?")
    +                        fp = yfcm.GetFilepath(self.ticker, "earnings_dates")
    +                        last_fetch = datetime.fromtimestamp(os.path.getmtime(fp)).astimezone()
    +                        md['LastFetch'] = last_fetch
    +                        yfcm.WriteCacheMetadata(self.ticker, "earnings_dates", 'LastFetch', md['LastFetch'])
    +                    if self._earnings_dates.empty:
    +                        self._earnings_dates = None
    +                    else:
    +                        edf_clean = self._clean_earnings_dates(self._earnings_dates, refresh)
    +                        if len(edf_clean) < len(self._earnings_dates):
    +                            # This is ok, because since the last fetch, the calendar can be updated which then allows resolving a 
    +                            # near-duplication in earnings_dates.
    +                            yfcm.StoreCacheDatum(self.ticker, "earnings_dates", edf_clean)
    +                        self._earnings_dates = edf_clean
    +
    +        last_fetch = yfcm.ReadCacheMetadata(self.ticker, "earnings_dates", "LastFetch")
    +        if debug:
    +            print("- last_fetch =", last_fetch)
    +
    +        # Ensure column 'Date confirmed?' is present, and update with calendar
    +        df_modified = False
    +        if self._earnings_dates is not None:
    +            if 'Date confirmed?' not in self._earnings_dates.columns:
    +                self._earnings_dates['Date confirmed?'] = False
    +                df_modified = True
    +            cal = self.get_calendar(refresh)
    +            if cal is not None and len(cal['Earnings Date']) == 1:
    +                x = cal['Earnings Date'][0]
    +                for dt in self._earnings_dates.index:
    +                    if abs(dt.date() - x) < timedelta(days=7):
    +                        # Assume same release
    +                        try:
    +                            if not self._earnings_dates['Date confirmed?'].loc[dt]:
    +                                self._earnings_dates.loc[dt, 'Date confirmed?'] = True
    +                                df_modified = True
    +                                break
    +                        except Exception:
    +                            print("- dt:", dt)
    +                            print("- edf:") ; print(self._earnings_dates)
    +                            raise
    +
    +        if not refresh:
    +            if df_modified:
    +                yfcm.StoreCacheDatum(self.ticker, "earnings_dates", self._earnings_dates)
    +
    +            if debug:
    +                print("get_earnings_dates() returning")
    +            if self._earnings_dates is not None:
    +                if start_dt > self._earnings_dates.index[-1]:
    +                    return self._earnings_dates.sort_index().loc[start_dt:].sort_index(ascending=False).copy()
    +                else:
    +                    return self._earnings_dates.copy()
    +            else:
    +                return None
    +
    +        # Limit spam:
    +        yf_start_date = yfcm.ReadCacheMetadata(self.ticker, 'earnings_dates', 'start_date')
    +        if debug:
    +            print("- yf_start_date =", yf_start_date)
    +        if last_fetch is not None:
    +            if (last_fetch + pd.Timedelta('14d')) > dt_now:
    +                # Avoid spamming Yahoo for data it doesn't have (empty earnings_dates).
    +                if self._earnings_dates is None:
    +                    # Already attempted a fetch recently, Yahoo has nothing.
    +                    if debug:
    +                        print("avoiding refetch")
    +                    refresh = False
    +
    +                # Avoid spamming Yahoo for new future dates
    +                if self._earnings_dates is not None:
    +                    if yf_start_date is not None:
    +                        # Cache has all previous earnings dates
    +                        refresh = False
    +                    elif start > self._earnings_dates.index.date[-1]:
    +                        refresh = False
    +            if debug:
    +                print("- refresh =", refresh)
    +
    +        if refresh:
    +            ei = self._earnings_interval(with_report=False, refresh=False)
    +            if isinstance(ei, yfcd.TimedeltaEstimate):
    +                # Don't care about confidence
    +                ei = ei.td
    +            elif isinstance(ei, yfcd.TimedeltaRangeEstimate):
    +                ei = mean([ei.td1, ei.td2])
    +            if isinstance(ei, (yfcd.ComparableRelativedelta, relativedelta)):
    +                # Convert to normal Timedelta, don't need 100% precision
    +                if ei.months == 3:
    +                    ei = pd.Timedelta('91d')
    +                elif ei.months == 6:
    +                    ei = pd.Timedelta('182d')
    +                elif ei.months == 12 or ei.years==1:
    +                    # ei = pd.Timedelta('365d')
    +                    # Don't believe it
    +                    ei = pd.Timedelta('182d')
    +                else:
    +                    raise Exception(ei, type(ei))
    +
    +            lookahead_dt = dt_now + pd.Timedelta('365d')
    +            if debug:
    +                print("- ei =", ei)
    +                print("- lookahead_dt =", lookahead_dt)
    +
    +            next_rd = None
    +            if self._earnings_dates is None or (start_dt < self._earnings_dates.index[-1] and yf_start_date is None):
    +                total_refetch = True
    +                n_intervals_to_fetch = int(math.floor(Decimal(1.25*(lookahead_dt - start_dt) / ei)))
    +            else:
    +                total_refetch = False
    +                df = self._earnings_dates.copy()
    +                f_na = df['Reported EPS'].isna().to_numpy()
    +                f_nna = ~f_na
    +                f_expired = f_na & (df.index < dt_now) & ((dt_now - df['FetchDate']) > pd.Timedelta('7d')).to_numpy()
    +                n = df.shape[0]
    +                if debug:
    +                    print("- n =", n)
    +
    +                n_intervals_missing_after = int(math.floor(Decimal((lookahead_dt - df.index[0]) / ei)))
    +                any_expired = f_expired.any()
    +                if debug:
    +                    print("- n_intervals_missing_after =", n_intervals_missing_after)
    +                    print("- any_expired =", any_expired)
    +                if not any_expired:
    +                    # ToDo: avoid refetching if next earnings after last fetch is (far) in future.
    +                    if f_nna.any():
    +                        if debug:
    +                            print("- checking against release dates ...")
    +                        rds = self.get_release_dates(yfcd.ReportingPeriod.Interim, as_df=False, refresh=False)
    +                        if rds is not None:
    +                            latest_certain_dt = df.index[np.where(f_nna)[0][0]].date()
    +                            for i in range(len(rds)):
    +                                try:
    +                                    in_future = rds[i].release_date > latest_certain_dt
    +                                except yfcd.AmbiguousComparisonException:
    +                                    p = rds[i].release_date.prob_gt(latest_certain_dt)
    +                                    in_future = p > 0.9
    +                                if in_future:
    +                                    next_rd = rds[i]
    +                                    break
    +                            try: 
    +                                next_rd_in_future = next_rd.release_date > (d_today + timedelta(days=7))
    +                            except yfcd.AmbiguousComparisonException:
    +                                p = next_rd.release_date.prob_gt(d_today + timedelta(days=7))
    +                                next_rd_in_future = p > 0.9
    +                            if next_rd_in_future:
    +                                # Avoid fetching while far from next earnings release
    +                                n_intervals_missing_after = 0
    +                    n_intervals_to_fetch = n_intervals_missing_after
    +                else:
    +                    earliest_expired_idx = np.where(f_expired)[0][-1]
    +                    n_intervals_expired = earliest_expired_idx + 1
    +                    n_intervals_to_fetch = n_intervals_expired + n_intervals_missing_after
    +
    +            if n_intervals_to_fetch > 0:
    +                # Ensure always fetching more than necessary
    +                n_intervals_to_fetch += 8
    +            if debug:
    +                print("- n_intervals_to_fetch =", n_intervals_to_fetch)
    +
    +            if n_intervals_to_fetch > 0:
    +                if debug:
    +                    print("- total_refetch =", total_refetch)
    +                try:
    +                    new_df = self._fetch_earnings_dates(n_intervals_to_fetch, refresh)
    +                except Exception:
    +                    print("- self._earnings_dates:") ; print(self._earnings_dates)
    +                    print("- start:", start)
    +                    print("- yf_start_date:", yf_start_date)
    +                    print("- last_fetch:", last_fetch)
    +                    print("- ei:", ei)
    +                    print("- next_rd:", next_rd)
    +                    print("- n_intervals_to_fetch:", n_intervals_to_fetch)
    +                    raise
    +                # Sanity test:
    +                if new_df is not None and not new_df.empty:
    +                    edf_clean = self._clean_earnings_dates(new_df, refresh)
    +                    if len(edf_clean) < len(new_df):
    +                        print("- edf:") ; print(new_df[['EPS Estimate', 'Reported EPS', 'FetchDate']])
    +                        print("- after clean:") ; print(edf_clean[['EPS Estimate', 'Reported EPS', 'FetchDate']])
    +                        raise Exception(f'{self.ticker}: We literally just fetched earnings dates, why not cleaned?')
    +                        yfcm.StoreCacheDatum(self.ticker, "earnings_dates", edf_clean)
    +
    +                yfcm.WriteCacheMetadata(self.ticker, "earnings_dates", 'LastFetch', dt_now)
    +                if debug:
    +                    print("- new_df:") ; print(new_df)
    +                if new_df is not None and not new_df.empty:
    +                    if self._earnings_dates is not None:
    +                        df_old = self._earnings_dates[self._earnings_dates.index < (new_df.index[-1]-timedelta(days=14))]
    +                        if not df_old.empty:
    +                            new_df = pd.concat([new_df, df_old])
    +                        if debug:
    +                            print("- new_df:") ; print(new_df)
    +                    self._earnings_dates = new_df
    +                    df_modified = True
    +
    +        if df_modified:
    +            if self._earnings_dates is None:
    +                yfcm.StoreCacheDatum(self.ticker, "earnings_dates", pd.DataFrame())
    +            else:
    +                yfcm.StoreCacheDatum(self.ticker, "earnings_dates", self._earnings_dates)
    +
    +        df = None
    +        if debug:
    +            print("get_earnings_dates() returning")
    +        if self._earnings_dates is not None:
    +            if start_dt > self._earnings_dates.index[-1]:
    +                df = self._earnings_dates.sort_index().loc[start_dt:].sort_index(ascending=False)
    +            else:
    +                df = self._earnings_dates
    +            if clean:
    +                df = df.drop(["FetchDate", "Date confirmed?"], axis=1, errors='ignore')
    +            return df.copy()
    +        else:
    +            return None
    +
    +    def _clean_earnings_dates(self, edf, refresh=True):
    +        edf = edf.sort_index(ascending=False)
    +
    +        # In rare cases, Yahoo has duplicated a date with different company name.
    +        # Retain the row with most data.
    +        for i in range(len(edf)-1, 0, -1):
    +            if edf.index[i-1] == edf.index[i]:
    +                mask = np.ones(len(edf), dtype=bool)
    +                if edf.iloc[i-1].isna().sum() > edf.iloc[i].isna().sum():
    +                    # Discard row i-1
    +                    mask[i-1] = False
    +                else:
    +                    # Discard row i
    +                    mask[i] = False
    +                edf = edf[mask].copy()
    +
    +        for i in range(len(edf)-2, -1, -1):
    +            if (edf.index[i]-edf.index[i+1]) < timedelta(days=7):
    +                # One must go
    +                if edf['FetchDate'].iloc[i] > edf['FetchDate'].iloc[i+1]:
    +                    edf = edf.drop(edf.index[i+1])
    +                elif edf['FetchDate'].iloc[i+1] > edf['FetchDate'].iloc[i]:
    +                    edf = edf.drop(edf.index[i])
    +                else:
    +                    cal = self.get_calendar(refresh)
    +                    if cal is None:
    +                        # print(edf.iloc[i:i+2])
    +                        # raise Exception('Review how to handle 2x almost-equal earnings dates.')
    +                        # pass  # Can't do anything with certainty
    +                        # Keep earlier
    +                        if edf.index[i] < edf.index[i+1]:
    +                            edf = edf.drop(edf.index[i+1])
    +                        else:
    +                            edf = edf.drop(edf.index[i])
    +                    else:
    +                        # Cross-check against calendar
    +                        dts = cal['Earnings Date']
    +                        if len(dts) == 1 and dts[0] in [edf.index[i].date(), edf.index[i+1].date()]:
    +                            if edf.index[i].date() == dts[0]:
    +                                edf = edf.drop(edf.index[i+1])
    +                            else:
    +                                edf = edf.drop(edf.index[i])
    +                        else:
    +                            # print(edf.iloc[i:i+2])
    +                            # raise Exception('Review how to handle 2x almost-equal earnings dates.')
    +                            # pass  # Can't do anything with certainty
    +                            # Keep earlier
    +                            if edf.index[i] < edf.index[i+1]:
    +                                edf = edf.drop(edf.index[i+1])
    +                            else:
    +                                edf = edf.drop(edf.index[i])
    +
    +        return edf
    +
    +    def _fetch_earnings_dates(self, limit, refresh=True):
    +        yfcu.TypeCheckInt(limit, "limit")
    +        yfcu.TypeCheckBool(refresh, "refresh")
    +        
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f"{self.ticker}: _fetch_earnings_dates(limit={limit}, refresh={refresh})")
    +        elif print_fetches:
    +            print(f"{self.ticker}: fetching {limit} earnings dates")
    +
    +        repeat_fetch = False
    +        try:
    +            df = self.dat.get_earnings_dates(limit)
    +        except KeyError as e:
    +            if "Earnings Date" in str(e):
    +                # Rarely, Yahoo returns a completely different table for earnings dates.
    +                # Try again.
    +                repeat_fetch = True
    +            else:
    +                raise
    +        if repeat_fetch:
    +            sleep(1)
    +            # Avoid cache this time, but add sleeps to maintain rate-limiting
    +            df = yf.Ticker(self.ticker).get_earnings_dates(limit)
    +            sleep(1)
    +        if df is None or df.empty:
    +            if debug:
    +                print("- Yahoo returned None")
    +            return None
    +        df['FetchDate'] = pd.Timestamp.utcnow().tz_convert(self.tzName)
    +
    +        if df.shape[0] < limit:
    +            if debug:
    +                print("- detected earnings_dates start at", df.index.min())
    +            yfcm.WriteCacheMetadata(self.ticker, 'earnings_dates', 'start_date', df.index.min())
    +
    +        cal = self.get_calendar(refresh)
    +        df['Date confirmed?'] = False
    +        if cal is not None and len(cal['Earnings Date']) == 1:
    +            x = cal['Earnings Date'][0]
    +            for dt in df.index:
    +                if abs(dt.date() - x) < timedelta(days=7):
    +                    # Assume same release
    +                    df.loc[dt, 'Date confirmed?'] = True
    +                    break
    +
    +        df = self._clean_earnings_dates(df, refresh)
    +
    +        return df
    +
    +    def get_calendar(self, refresh=True):
    +        yfcu.TypeCheckBool(refresh, 'refresh')
    +
    +        max_age = pd.Timedelta(yfcm._option_manager.max_ages.calendar)
    +
    +        if self._calendar is None:
    +            if yfcm.IsDatumCached(self.ticker, "calendar"):
    +                self._calendar = yfcm.ReadCacheDatum(self.ticker, "calendar")
    +
    +                self._calendar_clean = dict(self._calendar)
    +                del self._calendar_clean['FetchDate']
    +                if len(self._calendar_clean.keys()) == 0:
    +                    self._calendar_clean = None
    +
    +        if (self._calendar is not None) and (self._calendar["FetchDate"] + max_age) > pd.Timestamp.now():
    +            return self._calendar_clean
    +
    +        if not refresh:
    +            return self._calendar_clean
    +
    +        if print_fetches:
    +            print(f"{self.ticker}: Fetching calendar (last fetch = {self._calendar['FetchDate'].date()})")
    +
    +        c = self.dat.calendar
    +        c["FetchDate"] = pd.Timestamp.now()
    +
    +        if self._calendar is not None:
    +            # Check calendar is not downgrade
    +            diff = len(c) - len(self._calendar)
    +            if diff < -1:
    +                # More than 1 element disappeared
    +                msg = "When fetching new calendar, data has disappeared\n"
    +                msg += "- cached calendar:\n"
    +                msg += f"{self._calendar}" + "\n"
    +                msg += "- new calendar:\n"
    +                msg += f"{c}" + "\n"
    +                raise Exception(msg)
    +
    +        if c is not None:
    +            yfcm.StoreCacheDatum(self.ticker, "calendar", c)
    +        self._calendar = c
    +        self._calendar_clean = dict(self._calendar)
    +        del self._calendar_clean['FetchDate']
    +        if len(self._calendar_clean.keys()) == 0:
    +            self._calendar_clean = None
    +        return self._calendar_clean
    +
    +    def _get_calendar_dates(self, refresh=True):
    +        yfcu.TypeCheckBool(refresh, 'refresh')
    +
    +        debug = False
    +        # debug = True
    +
    +        if debug:
    +            print(f"_get_calendar_dates(refresh={refresh})")
    +
    +        cal = self.get_calendar(refresh)
    +        if cal is None or len(cal) == 0:
    +            return None
    +        if debug:
    +            print(f"- cal = {cal}")
    +
    +        cal_release_dates = []
    +        cal_release_dates.sort()
    +        last = None
    +        for d in cal["Earnings Date"]:
    +            if last is None:
    +                last = d
    +            else:
    +                diff = d - last
    +                if debug:
    +                    print(f"- diff = {diff}")
    +                if diff <= timedelta(days=15):
    +                    # Looks like a date range so tag last-added date as estimate. And change data to be middle of range
    +                    last = yfcd.DateRange(last, d)
    +                    cal_release_dates.append(last)
    +                    last = None
    +                else:
    +                    print("- cal_release_dates:") ; print(cal_release_dates)
    +                    print("- diff =", diff)
    +                    raise Exception(f"Implement/rejig this execution path (tkr={self.ticker})")
    +        if last is not None:
    +            cal_release_dates.append(last)
    +        if debug:
    +            print(f"- cal_release_dates = {cal_release_dates}")
    +        if debug:
    +            if len(cal_release_dates) == 0:
    +                print("- cal_release_dates: EMPTY")
    +            else:
    +                print("- cal_release_dates:")
    +                for e in cal_release_dates:
    +                    print(e)
    +
    +        return cal_release_dates
    +
    +

    Methods

    +
    +
    +def get_balance_sheet(self, refresh=True) +
    +
    +
    +
    +
    +def get_calendar(self, refresh=True) +
    +
    +
    +
    +
    +def get_cashflow(self, refresh=True) +
    +
    +
    +
    +
    +def get_earnings_dates(self, start, refresh=True, clean=True) +
    +
    +
    +
    +
    +def get_income_stmt(self, refresh=True) +
    +
    +
    +
    +
    +def get_quarterly_balance_sheet(self, refresh=True) +
    +
    +
    +
    +
    +def get_quarterly_cashflow(self, refresh=True) +
    +
    +
    +
    +
    +def get_quarterly_income_stmt(self, refresh=True) +
    +
    +
    +
    +
    +def get_release_dates(self, period, as_df=False, refresh=True, check=False) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_logging.html b/docs/yfinance_cache/yfc_logging.html new file mode 100644 index 0000000..ab885f9 --- /dev/null +++ b/docs/yfinance_cache/yfc_logging.html @@ -0,0 +1,202 @@ + + + + + + +yfinance_cache.yfc_logging API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_logging

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def DisableLogging() +
    +
    +
    +
    +
    +def DisableTracing() +
    +
    +
    +
    +
    +def EnableLogging(mode=20) +
    +
    +
    +
    +
    +def EnableTracing() +
    +
    +
    +
    +
    +def GetLogger(tkr) +
    +
    +
    +
    +
    +def IsLoggingEnabled() +
    +
    +
    +
    +
    +def IsTracingEnabled() +
    +
    +
    +
    +
    +def TraceEnter(log_msg) +
    +
    +
    +
    +
    +def TraceExit(log_msg) +
    +
    +
    +
    +
    +def TracePrint(log_msg) +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class Tracer +
    +
    +
    +
    + +Expand source code + +
    class Tracer:
    +    def __init__(self):
    +        self._trace_depth = 0
    +
    +    def Print(self, log_msg):
    +        if not IsTracingEnabled():
    +            return
    +        print(" "*self._trace_depth*2 + log_msg)
    +
    +    def Enter(self, log_msg):
    +        if not IsTracingEnabled():
    +            return
    +        self.Print(log_msg)
    +        self._trace_depth += 1
    +
    +        if self._trace_depth > 20:
    +            raise Exception("infinite recursion detected")
    +
    +    def Exit(self, log_msg):
    +        if not IsTracingEnabled():
    +            return
    +        self._trace_depth -= 1
    +        self.Print(log_msg)
    +
    +

    Methods

    +
    +
    +def Enter(self, log_msg) +
    +
    +
    +
    +
    +def Exit(self, log_msg) +
    +
    +
    +
    +
    +def Print(self, log_msg) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_multi.html b/docs/yfinance_cache/yfc_multi.html new file mode 100644 index 0000000..a04bd3e --- /dev/null +++ b/docs/yfinance_cache/yfc_multi.html @@ -0,0 +1,97 @@ + + + + + + +yfinance_cache.yfc_multi API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_multi

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def download(tickers, threads=True, ignore_tz=None, progress=True, interval='1d', group_by='column', max_age=None, period=None, start=None, end=None, prepost=False, actions=True, adjust_splits=True, adjust_divs=True, keepna=False, proxy=None, rounding=False, debug=True, quiet=False, trigger_at_market_close=False, session=None) +
    +
    +
    +
    +
    +def download_one(ticker, start=None, end=None, max_age=None, adjust_divs=True, adjust_splits=True, actions=False, period='max', interval='1d', prepost=False, proxy=None, rounding=False, keepna=False, session=None) +
    +
    +
    +
    +
    +def download_one_parallel(ticker, queue, start=None, end=None, max_age=None, adjust_divs=True, adjust_splits=True, actions=False, period='max', interval='1d', prepost=False, proxy=None, rounding=False, keepna=False, session=None) +
    +
    +
    +
    +
    +def reindex_dfs(dfs, ignore_tz) +
    +
    +
    +
    +
    +def reinitialize_locks(locks) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_options.html b/docs/yfinance_cache/yfc_options.html new file mode 100644 index 0000000..3ad1c85 --- /dev/null +++ b/docs/yfinance_cache/yfc_options.html @@ -0,0 +1,136 @@ + + + + + + +yfinance_cache.yfc_options API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_options

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class NestedOptions +(name, data) +
    +
    +
    +
    + +Expand source code + +
    class NestedOptions:
    +    def __init__(self, name, data):
    +        self.__dict__['name'] = name
    +        self.__dict__['data'] = data
    +
    +    def __getattr__(self, key):
    +        return self.data.get(key)
    +
    +    def __setattr__(self, key, value):
    +        if self.name == 'max_ages':
    +            # Type-check value
    +            Timedelta(value)
    +
    +        self.data[key] = value
    +        _option_manager._save_option()
    +
    +    def __len__(self):
    +        return len(self.__dict__['data'])
    +
    +    def __repr__(self):
    +        return json.dumps(self.data, indent=4)
    +
    +
    +
    +class OptionsManager +
    +
    +
    +
    + +Expand source code + +
    class OptionsManager:
    +    def __init__(self):
    +        d = yfcm.GetCacheDirpath()
    +        self.option_file = os.path.join(d, 'options.json')
    +        self._load_option()
    +
    +    def _load_option(self):
    +        try:
    +            with open(self.option_file, 'r') as file:
    +                self.options = json.load(file)
    +        except (FileNotFoundError, json.JSONDecodeError):
    +            self.options = {}
    +
    +    def _save_option(self):
    +        with open(self.option_file, 'w') as file:
    +            json.dump(self.options, file, indent=4)
    +
    +    def __getattr__(self, key):
    +        if key not in self.options:
    +            self.options[key] = {}
    +        return NestedOptions(key, self.options[key])
    +
    +    def __repr__(self):
    +        return json.dumps(self.options, indent=4)
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_prices_manager.html b/docs/yfinance_cache/yfc_prices_manager.html new file mode 100644 index 0000000..856bdee --- /dev/null +++ b/docs/yfinance_cache/yfc_prices_manager.html @@ -0,0 +1,4214 @@ + + + + + + +yfinance_cache.yfc_prices_manager API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_prices_manager

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class EventsHistory +(manager, ticker, exchange, tzName, proxy) +
    +
    +
    +
    + +Expand source code + +
    class EventsHistory:
    +    def __init__(self, manager, ticker, exchange, tzName, proxy):
    +        if not isinstance(manager, HistoriesManager):
    +            raise TypeError(f"'manager' must be HistoriesManager not {type(manager)}")
    +        yfcu.TypeCheckStr(ticker, "ticker")
    +        yfcu.TypeCheckStr(exchange, "exchange")
    +        yfcu.TypeCheckStr(tzName, "tzName")
    +
    +        self.manager = manager
    +        self.ticker = ticker
    +        self.exchange = exchange
    +        self.tzName = tzName
    +        self.proxy = proxy
    +
    +        self.tz = ZoneInfo(self.tzName)
    +
    +        if yfcm.IsDatumCached(self.ticker, "dividends"):
    +            self.divs = yfcm.ReadCacheDatum(self.ticker, "dividends").sort_index()
    +        else:
    +            self.divs = None
    +
    +        if yfcm.IsDatumCached(self.ticker, "splits"):
    +            self.splits = yfcm.ReadCacheDatum(self.ticker, "splits").sort_index()
    +        else:
    +            self.splits = None
    +
    +    def GetDivs(self, start=None, end=None):
    +        if start is not None:
    +            yfcu.TypeCheckDateStrict(start, "start")
    +        if end is not None:
    +            yfcu.TypeCheckDateStrict(end, "end")
    +
    +        if self.divs is None or self.divs.empty:
    +            return None
    +
    +        if start is None and end is None:
    +            return self.divs.copy()
    +
    +        tz = self.divs.index[0].tz
    +        if start is not None:
    +            start = pd.Timestamp(start).tz_localize(tz)
    +        if end is not None:
    +            end = pd.Timestamp(end).tz_localize(tz)
    +
    +        td_1d = timedelta(days=1)
    +        if end is None:
    +            slc = self.divs.loc[start:]
    +        elif start is None:
    +            slc = self.divs.loc[:end-td_1d]
    +        else:
    +            slc = self.divs.loc[start:end-td_1d]
    +        if slc.empty:
    +            return None
    +        else:
    +            return slc.copy()
    +
    +    def GetDivsFetchedSince(self, dt):
    +        yfcu.TypeCheckDatetime(dt, "dt")
    +
    +        if self.divs is None or self.divs.empty:
    +            result = None
    +        else:
    +            f = self.divs["FetchDate"] > dt
    +            if f.any():
    +                result = self.divs[f].copy()
    +            else:
    +                result = None
    +
    +        return result
    +
    +    def GetSplits(self, start, end=None):
    +        yfcu.TypeCheckDateStrict(start, "start")
    +        if end is not None:
    +            yfcu.TypeCheckDateStrict(end, "end")
    +
    +        result = None
    +        if self.splits is not None and not self.splits.empty:
    +            start = pd.Timestamp(start).tz_localize(self.splits.index[0].tz)
    +            if end is not None:
    +                end = pd.Timestamp(end).tz_localize(self.splits.index[0].tz)
    +            td_1d = timedelta(days=1)
    +            if end is None:
    +                slc = self.splits.loc[start:]
    +            else:
    +                slc = self.splits.loc[start:end-td_1d]
    +
    +            if slc.empty:
    +                result = None
    +            else:
    +                result = slc.copy()
    +
    +        return result
    +
    +    def GetSplitsFetchedSince(self, dt):
    +        yfcu.TypeCheckDatetime(dt, "dt")
    +
    +        if self.splits is None or self.splits.empty:
    +            result = None
    +        else:
    +            f = self.splits["FetchDate"] > dt
    +            if f.any():
    +                result = self.splits[f].copy()
    +            else:
    +                result = None
    +
    +        return result
    +
    +    def UpdateSplits(self, splits_df):
    +        n = splits_df.shape[0]
    +        yfcl.TraceEnter(f"PM: UpdateSplits({splits_df.index.date})") if n <= 2 else yfcl.TraceEnter(f"PM: UpdateSplits(n={n})")
    +
    +        self_splits_modified = False
    +
    +        debug = False
    +        # debug = True
    +
    +        yfcu.TypeCheckDataFrame(splits_df, "splits_df")
    +        splits_df = splits_df.copy()
    +        if not splits_df.empty:
    +            expected_cols = ["Stock Splits", "FetchDate"]
    +            for c in expected_cols:
    +                if c not in splits_df.columns:
    +                    raise ValueError("UpdateSplits() 'splits_df' columns must contain: '{expected_cols}'")
    +
    +            # Prepare 'splits_df' for append
    +            splits_df["Superseded split"] = 0.0
    +            splits_df["Superseded split FetchDate"] = pd.NaT
    +            if splits_df['Superseded split FetchDate'].dt.tz is None and self.splits is not None:
    +                splits_df['Superseded split FetchDate'] = splits_df['Superseded split FetchDate'].dt.tz_localize(self.splits['FetchDate'].dt.tz)
    +            for dt in splits_df.index:
    +                new_split = splits_df.loc[dt, "Stock Splits"]
    +                if self.splits is not None and dt in self.splits.index:
    +                    cached_split = self.splits.loc[dt, "Stock Splits"]
    +                    if debug:
    +                        yfcl.TracePrint(f"pre-existing stock-split @ {dt}: {cached_split} vs {new_split}")
    +                    diff_pct = 100*abs(cached_split-new_split)/cached_split
    +                    if diff_pct < 0.01:
    +                        # tiny difference, easier to just keep old value
    +                        splits_df = splits_df.drop(dt)
    +                        if debug:
    +                            yfcl.TracePrint("ignoring")
    +                    else:
    +                        splits_df.loc[dt, "Superseded split"] = self.splits.loc[dt, "Stock Splits"]
    +                        splits_df.loc[dt, "Superseded split FetchDate"] = self.splits.loc[dt, "FetchDate"]
    +                        self.splits = self.splits.drop(dt)
    +                        self_splits_modified = True
    +                        if debug:
    +                            yfcl.TracePrint("supersede")
    +
    +            cols = ["Stock Splits", "FetchDate", "Superseded split", "Superseded split FetchDate"]
    +            if not splits_df.empty:
    +                splits_pretty = splits_df["Stock Splits"]
    +                splits_pretty.index = splits_pretty.index.date.astype(str)
    +                self.manager.LogEvent("info", "SplitManager", f"{splits_pretty.shape[0]} new splits: {splits_pretty.to_dict()}")
    +
    +                if self.splits is None:
    +                    self.splits = splits_df[cols].copy()
    +                else:
    +                    f_na = self.splits['Superseded split FetchDate'].isna()
    +                    if f_na.all():
    +                        # Drop column. It breaks concat, and anyway 'divs_df' will restore it.
    +                        self.splits = self.splits.drop('Superseded split FetchDate', axis=1)
    +                    self.splits = pd.concat([self.splits, splits_df[cols]], sort=True).sort_index()
    +                yfcm.StoreCacheDatum(self.ticker, "splits", self.splits)
    +            elif self_splits_modified:
    +                yfcm.StoreCacheDatum(self.ticker, "splits", self.splits)
    +
    +        yfcl.TraceExit("UpdateSplits() returning")
    +
    +    def UpdateDividends(self, divs_df):
    +        debug = False
    +        # debug = True
    +
    +        n = divs_df.shape[0]
    +        yfcl.TraceEnter(f"PM: UpdateDividends({divs_df.index.date})") if n <= 2 else yfcl.TraceEnter(f"PM: UpdateDividends(n={n})")
    +
    +        self_divs_modified = False
    +
    +        yfcu.TypeCheckDataFrame(divs_df, "divs_df")
    +        divs_df = divs_df.copy()
    +        if not divs_df.empty:
    +            expected_cols = ["Dividends", "FetchDate", "Close day before"]
    +            # expected_cols = ["Dividends", "FetchDate", "Close today"]
    +            for c in expected_cols:
    +                if c not in divs_df.columns:
    +                    raise ValueError(f"AddDividends() 'divs_df' is missing column: '{c}'")
    +
    +            # Prepare 'divs_df' for append
    +            divs_df["Back Adj."] = np.nan
    +            divs_df["Superseded div"] = 0.0
    +            divs_df["Superseded back adj."] = 0.0
    +            divs_df["Superseded div FetchDate"] = pd.NaT
    +            if divs_df['Superseded div FetchDate'].dt.tz is None and self.divs is not None:
    +                divs_df['Superseded div FetchDate'] = divs_df['Superseded div FetchDate'].dt.tz_localize(self.divs['FetchDate'].dt.tz)
    +            divs_df_dts = divs_df.index.copy()
    +            for dt in divs_df_dts:
    +                new_div = divs_df.loc[dt, "Dividends"]
    +
    +                close_before = divs_df.loc[dt, "Close day before"]
    +                # adj = (close_before - new_div) / close_before
    +                adj = 1.0 - new_div / close_before
    +                # # F = P2/(P2+D)
    +                # # http://marubozu.blogspot.com/2006/09/how-yahoo-calculates-adjusted-closing.html#c8038064975185708856
    +                # close_today = divs_df.loc[dt, "Close today"]
    +                # adj = close_today / (close_today + new_div)
    +                try:
    +                    if np.isnan(adj):  # todo: remove once confirm YFC bug-free
    +                        print(divs_df.loc[dt])
    +                        raise Exception("Back Adj. is NaN")
    +                except:
    +                    print("dt=", dt)
    +                    print("T=", self.ticker)
    +                    print("divs_df:") ; print(divs_df)
    +                    raise
    +                if debug:
    +                    fetch_dt = divs_df.loc[dt, "FetchDate"]
    +                    msg = f"new dividend: {new_div} @ {dt.date()} adj={adj:.5f} close_before={close_before:.4f} fetch={fetch_dt.strftime('%Y-%m-%d %H:%M:%S%z')}"
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                divs_df.loc[dt, "Back Adj."] = adj
    +
    +                if self.divs is not None and dt in self.divs.index:
    +                    # Replaced cached dividend event if (i) dividend different or (ii) adj different.
    +                    cached_div = self.divs.loc[dt, "Dividends"]
    +                    cached_adj = self.divs.loc[dt, "Back Adj."]
    +                    if debug:
    +                        msg = f"pre-existing dividend @ {dt}: {cached_div} vs {new_div}"
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                    diff_pct = 100*abs(cached_div-new_div)/cached_div
    +                    diff_pct2 = 100*abs(cached_adj-adj)/cached_adj
    +                    diff_pct = max(diff_pct, diff_pct2)
    +                    if diff_pct < 0.01:
    +                        # tiny difference, easier to just keep old value
    +                        divs_df = divs_df.drop(dt)
    +                        if debug:
    +                            msg = "ignoring new div"
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                    else:
    +                        divs_df.loc[dt, "Superseded div"] = self.divs.loc[dt, "Dividends"]
    +                        divs_df.loc[dt, "Superseded back adj."] = self.divs.loc[dt, "Back Adj."]
    +                        divs_df.loc[dt, "Superseded div FetchDate"] = self.divs.loc[dt, "FetchDate"]
    +                        self.divs = self.divs.drop(dt)
    +                        self_divs_modified = True
    +                        if debug:
    +                            msg = "replacing old div"
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                elif new_div == 0.0:
    +                    # Discard, was only sent to deprecate previous dividend on this date
    +                    divs_df = divs_df.drop(dt)
    +
    +            cols = ["Dividends", "Back Adj.", "FetchDate", "Superseded div", "Superseded back adj.", "Superseded div FetchDate"]
    +            if not divs_df.empty:
    +                divs_pretty = divs_df[["Dividends", "Back Adj.", "Close day before"]].copy()
    +                divs_pretty = divs_pretty.rename(columns={'Dividends':'Div', 'Back Adj.':'Adj', 'Close day before':'Close'})
    +                divs_pretty['Adj'] = divs_pretty['Adj'].round(3)
    +                divs_pretty['Close'] = divs_pretty['Close'].round(3)
    +                divs_pretty.index = divs_pretty.index.date.astype(str)
    +                n = divs_pretty.shape[0]
    +                n_sup = np.sum((divs_df['Superseded div']>0.0).to_numpy())
    +                n_new = n - n_sup
    +                self.manager.LogEvent("info", "DividendManager", f"stored {n} dividends ({n_new} new, {n_sup} superseded): {divs_pretty.to_dict(orient='index')}")
    +
    +                if self.divs is None:
    +                    self.divs = divs_df[cols].copy()
    +                else:
    +                    f_na = self.divs['Superseded div FetchDate'].isna()
    +                    if f_na.all():
    +                        # Drop column. It breaks concat, and anyway 'divs_df' will restore it.
    +                        self.divs = self.divs.drop('Superseded div FetchDate', axis=1)
    +                    self.divs = pd.concat([self.divs, divs_df[cols]], sort=True).sort_index()
    +                yfcm.StoreCacheDatum(self.ticker, "dividends", self.divs)
    +            elif self_divs_modified:
    +                yfcm.StoreCacheDatum(self.ticker, "dividends", self.divs)
    +
    +        yfcl.TraceExit("UpdateDividends() returning")
    +
    +

    Methods

    +
    +
    +def GetDivs(self, start=None, end=None) +
    +
    +
    +
    +
    +def GetDivsFetchedSince(self, dt) +
    +
    +
    +
    +
    +def GetSplits(self, start, end=None) +
    +
    +
    +
    +
    +def GetSplitsFetchedSince(self, dt) +
    +
    +
    +
    +
    +def UpdateDividends(self, divs_df) +
    +
    +
    +
    +
    +def UpdateSplits(self, splits_df) +
    +
    +
    +
    +
    +
    +
    +class HistoriesManager +(ticker, exchange, tzName, session, proxy) +
    +
    +
    +
    + +Expand source code + +
    class HistoriesManager:
    +    # Intended as single to class to ensure:
    +    # - only one History() object exists for each timescale/data type
    +    # - different History() objects and communicate
    +
    +    def __init__(self, ticker, exchange, tzName, session, proxy):
    +        yfcu.TypeCheckStr(ticker, "ticker")
    +        yfcu.TypeCheckStr(exchange, "exchange")
    +        yfcu.TypeCheckStr(tzName, "tzName")
    +
    +        self.ticker = ticker
    +        self.exchange = exchange
    +        self.tzName = tzName
    +        self.histories = {}
    +        self.session = session
    +        self.proxy = proxy
    +
    +        self.logger = None
    +
    +    def __del__(self):
    +        if self.logger is not None:
    +            # Fix OS error "Too many open files"
    +            self.logger.handlers[0].close()
    +
    +    def GetHistory(self, key):
    +        permitted_keys = set(yfcd.intervalToString.keys()) | {"Events"}
    +        if key not in permitted_keys:
    +            raise ValueError(f"key='{key}' is invalid, must be one of: {permitted_keys}")
    +
    +        if key not in self.histories:
    +            if key in yfcd.intervalToString.keys():
    +                if key == yfcd.Interval.Days1:
    +                    self.histories[key] = PriceHistory(self, self.ticker, self.exchange, self.tzName, key, self.session, self.proxy, repair=True, contiguous=True)
    +                else:
    +                    self.histories[key] = PriceHistory(self, self.ticker, self.exchange, self.tzName, key, self.session, self.proxy, repair=True, contiguous=False)
    +            elif key == "Events":
    +                self.histories[key] = EventsHistory(self, self.ticker, self.exchange, self.tzName, self.proxy)
    +            else:
    +                raise Exception(f"Not implemented code path for key='{key}'")
    +
    +        return self.histories[key]
    +
    +    def LogEvent(self, level, group, msg):
    +        if not yfcl.IsLoggingEnabled():
    +            if yfcl.IsTracingEnabled():
    +                yfcl.TracePrint(msg)
    +            return
    +
    +        if not isinstance(level, str) or level not in ["debug", "info"]:
    +            raise Exception("'level' must be str 'debug' or 'info'")
    +
    +        if self.logger is None:
    +            self.logger = yfcl.GetLogger(self.ticker)
    +
    +        full_msg = f"{group}: {msg}"
    +        if level == "debug":
    +            self.logger.debug(full_msg)
    +        else:
    +            self.logger.info(full_msg)
    +
    +

    Methods

    +
    +
    +def GetHistory(self, key) +
    +
    +
    +
    +
    +def LogEvent(self, level, group, msg) +
    +
    +
    +
    +
    +
    +
    +class PriceHistory +(manager, ticker, exchange, tzName, interval, session, proxy, repair=True, contiguous=False) +
    +
    +
    +
    + +Expand source code + +
    class PriceHistory:
    +    def __init__(self, manager, ticker, exchange, tzName, interval, session, proxy, repair=True, contiguous=False):
    +        if isinstance(interval, str):
    +            if interval not in yfcd.intervalStrToEnum.keys():
    +                raise Exception("'interval' if str must be one of: {}".format(yfcd.intervalStrToEnum.keys()))
    +            interval = yfcd.intervalStrToEnum[interval]
    +        yfcu.TypeCheckStr(ticker, "ticker")
    +        yfcu.TypeCheckStr(exchange, "exchange")
    +        yfcu.TypeCheckStr(tzName, "tzName")
    +        yfcu.TypeCheckBool(repair, "repair")
    +        yfcu.TypeCheckBool(contiguous, "contiguous")
    +
    +        self.manager = manager
    +        self.ticker = ticker
    +        self.exchange = exchange
    +        self.tzName = tzName
    +        self.interval = interval
    +        self.session = session
    +        self.proxy = proxy
    +        self.repair = repair
    +        self.contiguous = contiguous
    +
    +        self.dat = yf.Ticker(self.ticker, session=self.session)
    +        self.tz = ZoneInfo(self.tzName)
    +
    +        self.itd = yfcd.intervalToTimedelta[self.interval]
    +        self.istr = yfcd.intervalToString[self.interval]
    +        self.interday = self.interval in [yfcd.Interval.Days1, yfcd.Interval.Week]#, yfcd.Interval.Months1, yfcd.Interval.Months3]
    +        self.intraday = not self.interday
    +        self.multiday = self.interday and self.interval != yfcd.Interval.Days1
    +
    +        # Load from cache
    +        self.cache_key = "history-"+self.istr
    +        self.h = self._getCachedPrices()
    +        self._reviewNewDivs()
    +
    +        # A place to temporarily store new dividends, until prices have
    +        # been repaired, then they can be sent to EventsHistory
    +
    +        self._debug = False
    +        # self._debug = True
    +
    +        # Manage potential for infinite recursion during price repair:
    +        self._record_stack_trace = True
    +        # self._record_stack_trace = False
    +        self._stack_trace = []
    +        self._infinite_recursion_detected = False
    +
    +    def _getCachedPrices(self):
    +        h = None
    +        if yfcm.IsDatumCached(self.ticker, self.cache_key):
    +            h = yfcm.ReadCacheDatum(self.ticker, self.cache_key)
    +
    +        if h is not None and h.empty:
    +            h = None
    +        elif h is not None:
    +            h_modified = False
    +
    +            # h = yfcu.CustomNanCheckingDataFrame(h)
    +
    +            if "Adj Close" in h.columns:
    +                raise Exception("Adj Close in cached h")
    +
    +            f_dups = h.index.duplicated()
    +            if f_dups.any():
    +                raise Exception("{}: These timepoints have been duplicated: {}".format(self.ticker, h.index[f_dups]))
    +
    +            f_na = np.isnan(h["CDF"].to_numpy())
    +            if f_na.any():
    +                h["CDF"] = h["CDF"].bfill().ffill()
    +                f_na = h["CDF"].isna()
    +                if f_na.any():
    +                    raise Exception("CDF NaN repair failed")
    +                h_modified = True
    +
    +            if h_modified:
    +                yfcm.StoreCacheDatum(self.ticker, self.cache_key, h)
    +
    +        return h
    +
    +    def _updatedCachedPrices(self, df):
    +        yfcu.TypeCheckDataFrame(df, "df")
    +
    +        expected_cols = ["Open", "High", "Low", "Close", "Volume", "Dividends", "Stock Splits"]
    +        expected_cols += ["Final?", "C-Check?", "FetchDate", "CSF", "CDF"]
    +        expected_cols += ["LastDivAdjustDt", "LastSplitAdjustDt"]
    +
    +        missing_cols = [c for c in expected_cols if c not in df.columns]
    +        if len(missing_cols) > 0:
    +            raise Exception(f"DF missing these columns: {missing_cols}")
    +
    +        if df.empty:
    +            df = None
    +        yfcm.StoreCacheDatum(self.ticker, self.cache_key, df)
    +
    +        self.h = df
    +
    +    def _reviewNewDivs(self):
    +        if self.interval != yfcd.Interval.Days1:
    +            return
    +
    +        cached_new_divs = yfcm.ReadCacheDatum(self.ticker, "new_divs")
    +        if cached_new_divs is not None:
    +            if yfcm.ReadCacheMetadata(self.ticker, "new_divs", "locked") is not None:
    +                # This is bad, means YFC was killed before 'new_divs' could be processed.
    +                # Means potentially future new dividends have not been processed
    +                h_divs = self.h.loc[self.h["Dividends"]!=0, ["Dividends", "FetchDate"]]
    +                h_divs_since = h_divs[h_divs.index > cached_new_divs.index.max()]
    +                if not h_divs_since.empty:
    +                    if 'Desplitted?' not in cached_new_divs.columns:
    +                        cached_new_divs['Desplitted?'] = False  # assume
    +                    h_divs_since['Desplitted?'] = True
    +                    cached_new_divs = pd.concat([cached_new_divs, h_divs_since])
    +                    yfcm.StoreCacheDatum(self.ticker, "new_divs", cached_new_divs)
    +                yfcm.WriteCacheMetadata(self.ticker, "new_divs", "locked", None)
    +
    +    def get(self, start=None, end=None, period=None, max_age=None, trigger_at_market_close=False, repair=True, prepost=False, adjust_splits=False, adjust_divs=False, quiet=False):
    +        if start is None and end is None and period is None:
    +            raise ValueError("Must provide value for one of: 'start', 'end', 'period'")
    +        if start is not None:
    +            yfcu.TypeCheckIntervalDt(start, self.interval, "start", strict=False)
    +        if end is not None:
    +            yfcu.TypeCheckIntervalDt(end, self.interval, "end", strict=False)
    +        if period is not None:
    +            yfcu.TypeCheckPeriod(period, "period")
    +        yfcu.TypeCheckBool(trigger_at_market_close, "trigger_at_market_close")
    +        yfcu.TypeCheckBool(repair, "repair")
    +        yfcu.TypeCheckBool(adjust_splits, "adjust_splits")
    +        yfcu.TypeCheckBool(adjust_divs, "adjust_divs")
    +
    +        # TODO: enforce 'max_age' value provided. Only 'None' while I dev
    +        if max_age is None:
    +            if self.interval == yfcd.Interval.Days1:
    +                max_age = timedelta(hours=4)
    +            elif self.interval == yfcd.Interval.Week:
    +                max_age = timedelta(hours=60)
    +            # elif self.interval == yfcd.Interval.Months1:
    +            #     max_age = timedelta(days=15)
    +            # elif self.interval == yfcd.Interval.Months3:
    +            #     max_age = timedelta(days=45)
    +            else:
    +                max_age = 0.5*yfcd.intervalToTimedelta[self.interval]
    +
    +        # YFC cannot handle pre- and post-market intraday
    +        prepost = self.interday
    +
    +        yfct.SetExchangeTzName(self.exchange, self.tzName)
    +        td_1d = timedelta(days=1)
    +        tz_exchange = ZoneInfo(self.tzName)
    +        dt_now = pd.Timestamp.utcnow().tz_convert(tz_exchange)
    +        d_now_exchange = dt_now.date()
    +        tomorrow_d = d_now_exchange + td_1d
    +        if self.interday:
    +            tomorrow = tomorrow_d
    +        else:
    +            tomorrow = datetime.combine(tomorrow_d, time(0), tz_exchange)
    +        if start is not None:
    +            if end is not None and start >= end:
    +                # raise ValueError(f"start={start} must < end={end}")
    +                return None
    +            # if (self.interday and start >= tomorrow) or (not self.interday and start > dt_now):
    +            if isinstance(start, datetime):
    +                if start > dt_now:
    +                    return None
    +            else:
    +                if start >= tomorrow_d:
    +                    return None
    +
    +        debug_yf = False
    +        debug_yfc = self._debug
    +        # debug_yfc = True
    +
    +        if period is not None:
    +            log_msg = f"PriceHistory-{self.istr}.get(tkr={self.ticker}, period={period}, max_age={max_age}, trigger_at_market_close={trigger_at_market_close}, prepost={prepost}, repair={repair})"
    +        else:
    +            log_msg = f"PriceHistory-{self.istr}.get(tkr={self.ticker}, start={start}, end={end}, max_age={max_age}, trigger_at_market_close={trigger_at_market_close}, prepost={prepost}, repair={repair})"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +
    +        self._applyNewEvents()
    +
    +        try:
    +            yf_lag = yfcd.exchangeToYfLag[self.exchange]
    +        except:
    +            print(f"- ticker = {self.ticker}")
    +            raise
    +
    +        pstr = None
    +        end_d = None ; end_dt = None
    +        if period is not None:
    +            start_d, end_d = yfct.MapPeriodToDates(self.exchange, period, self.interval)
    +            period = None
    +            if self.interday:
    +                start = start_d
    +                end = end_d
    +                start_dt = datetime.combine(start_d, time(0), tz_exchange)
    +                end_dt = datetime.combine(end_d, time(0), tz_exchange)
    +            else:
    +                start = datetime.combine(start_d, time(0), tz_exchange)
    +                end = None
    +                start_dt = start
    +                end_dt = None
    +        else:
    +            if self.interday:
    +                start_dt = datetime.combine(start, time(0), tz_exchange)
    +            else:
    +                if isinstance(start, datetime):
    +                    start_dt = start
    +                else:
    +                    start_dt = datetime.combine(start, time(0), tz_exchange)
    +                    start = start_dt
    +
    +        if end is None and pstr is None:
    +            if self.interday:
    +                end = d_now_exchange+td_1d
    +            else:
    +                sched = yfct.GetExchangeSchedule(self.exchange, d_now_exchange, d_now_exchange+td_1d)
    +                if sched is None or (dt_now + yf_lag) < sched["open"].iloc[0]:
    +                    # Before market open
    +                    end = datetime.combine(d_now_exchange, time(0), tz_exchange)
    +                else:
    +                    i = yfct.GetTimestampCurrentInterval(self.exchange, dt_now+yf_lag, self.interval, ignore_breaks=True)
    +                    if i is None:
    +                        # After interval
    +                        end = datetime.combine(d_now_exchange+td_1d, time(0), tz_exchange)
    +                    else:
    +                        # During interval
    +                        end = i["interval_close"]
    +        if end is not None and end_dt is None:
    +            if isinstance(end, datetime):
    +                end_dt = end
    +            else:
    +                # end_dt = datetime.combine(end+td_1d, time(0), tz_exchange)
    +                end_dt = datetime.combine(end, time(0), tz_exchange)
    +            if not self.interday:
    +                end = end_dt
    +
    +        if self.interday:
    +            if isinstance(start, datetime) or isinstance(end, datetime):
    +                raise TypeError(f"'start' and 'end' must be date type not {type(start)}, {type(end)}")
    +        else:
    +            if (not isinstance(start, datetime)) and (not isinstance(end, datetime)):
    +                raise TypeError(f"'start' and 'end' must be datetime type not {type(start)}, {type(end)}")
    +
    +        listing_date = yfcm.ReadCacheDatum(self.ticker, "listing_date")
    +        if listing_date is not None and start is not None:
    +            listing_date_dt = datetime.combine(listing_date, time(0), tz_exchange)
    +            if isinstance(start, datetime):
    +                start = max(start, listing_date_dt)
    +            else:
    +                start = max(start, listing_date)
    +
    +        if self.h is not None:
    +            if self.h.empty:
    +                self.h = None
    +
    +        # Remove expired intervals from cache
    +        if self.h is not None:
    +            n = self.h.shape[0]
    +            if self.interday:
    +                h_interval_dts = self.h.index.date if isinstance(self.h.index[0], pd.Timestamp) else self.h.index
    +            else:
    +                h_interval_dts = np.array([yfct.ConvertToDatetime(dt, tz=tz_exchange) for dt in self.h.index])
    +            h_interval_dts = np.array(h_interval_dts)
    +            # if self.interval == yfcd.Interval.Days1:
    +            if self.contiguous:
    +                # Daily data is always contiguous so only need to check last row
    +                h_interval_dt = h_interval_dts[-1]
    +                fetch_dt = yfct.ConvertToDatetime(self.h["FetchDate"].iloc[-1], tz=tz_exchange)
    +                f_final = self.h["Final?"].to_numpy()
    +                f_nfinal = ~f_final
    +                # - also treat repaired data as non-final, if fetched near to interval timepoint
    +                #   because Yahoo might now have correct data
    +                # - and treat NaN data as repaired
    +                f_repair = self.h["Repaired?"].to_numpy()
    +                f_na = self.h['Close'].isna().to_numpy()
    +                f_repair = f_repair | f_na
    +                cutoff_dts = self.h.index + self.itd + timedelta(days=7)
    +                # Ignore repaired data if fetched/repaired 7+ days after interval end
    +                f_repair[self.h['FetchDate'] > cutoff_dts] = False
    +                if f_repair.any():
    +                    f_nfinal = f_nfinal | f_repair
    +                if f_nfinal.any():
    +                    idx0 = np.where(f_nfinal)[0][0]
    +                    repaired = f_repair[idx0]
    +                    h_interval_dt = h_interval_dts[idx0]
    +                    fetch_dt = yfct.ConvertToDatetime(self.h["FetchDate"].iloc[idx0], tz=tz_exchange)
    +                    try:
    +                        expired = yfct.IsPriceDatapointExpired(h_interval_dt, fetch_dt, repaired, max_age, self.exchange, self.interval, yf_lag=yf_lag, triggerExpiryOnClose=trigger_at_market_close)
    +                    except yfcd.TimestampOutsideIntervalException as e:
    +                        if f_na[idx0]:
    +                            # YFC must have inserted a row of NaNs, wrongly thinking exchange should have been open here.
    +                            expired = True
    +                        else:
    +                            raise e
    +                    if expired:
    +                        self.h = self.h.iloc[:idx0]
    +                        h_interval_dts = h_interval_dts[:idx0]
    +
    +            else:
    +                expired = np.array([False]*n)
    +                f_final = self.h["Final?"].to_numpy()
    +                f_nfinal = ~f_final
    +                # - also treat repaired data as non-final, if fetched near to interval timepoint
    +                #   because Yahoo might now have correct data
    +                # - and treat NaN data as repaired
    +                cutoff_dt = dt_now - timedelta(days=7)
    +                idx = self.h.index.get_indexer([cutoff_dt], method='bfill')[0]
    +                f_repair = self.h["Repaired?"].to_numpy()
    +                f_na = self.h['Close'].isna().to_numpy()
    +                f_repair = f_repair | f_na
    +                cutoff_dts = self.h.index + self.itd + timedelta(days=7)
    +                # Ignore repaired data if fetched/repaired 7+ days after interval end
    +                f_repair[self.h['FetchDate'] > cutoff_dts] = False
    +                if f_repair.any():
    +                    f_nfinal = f_nfinal | f_repair
    +                for idx in np.where(f_nfinal)[0]:
    +                    # repaired = False
    +                    repaired = f_repair[idx]
    +                    h_interval_dt = h_interval_dts[idx]
    +                    fetch_dt = yfct.ConvertToDatetime(self.h["FetchDate"].iloc[idx], tz=tz_exchange)
    +                    try:
    +                        expired_idx = yfct.IsPriceDatapointExpired(h_interval_dt, fetch_dt, repaired, max_age, self.exchange, self.interval, yf_lag=yf_lag, triggerExpiryOnClose=trigger_at_market_close)
    +                    except yfcd.TimestampOutsideIntervalException as e:
    +                        if f_na[idx0]:
    +                            # YFC must have inserted a row of NaNs, wrongly thinking exchange should have been open here.
    +                            expired_idx = True
    +                        else:
    +                            raise e
    +                    expired[idx] = expired_idx
    +                if expired.any():
    +                    self.h = self.h.drop(self.h.index[expired])
    +                    h_interval_dts = h_interval_dts[~expired]
    +            if self.h.empty:
    +                self.h = None
    +
    +        ranges_to_fetch = []
    +        if self.h is None:
    +            # Simple, just fetch the requested data
    +
    +            if self.contiguous:
    +                # Ensure daily always up-to-now
    +                h = self._fetchYfHistory(start, tomorrow, prepost, debug_yf)
    +            else:
    +                h = self._fetchYfHistory(start, end, prepost, debug_yf)
    +            if h is None:
    +                raise Exception(f"{self.ticker}: Failed to fetch date range {start}->{end}")
    +
    +            # Adjust
    +            h = self._reverseYahooAdjust(h)
    +
    +            if self.interval == yfcd.Interval.Days1:
    +                h_splits = h[h["Stock Splits"] != 0]
    +                if len(h_splits) > 0:
    +                    self.manager.GetHistory("Events").UpdateSplits(h_splits)
    +
    +            self._updatedCachedPrices(h)
    +
    +        else:
    +            # Compare request against cached data, only fetch missing/expired data
    +
    +            # Performance TODO: tag rows as fully contiguous to avoid searching for gaps
    +
    +            # Calculate ranges_to_fetch
    +            if self.contiguous:
    +                if self.h is None or self.h.empty:
    +                    if self.interday:
    +                        if self.multiday:
    +                            ranges_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, start_d, end_d+self.itd, self.interval, [], minDistanceThreshold=5)
    +                        else:
    +                            ranges_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, start_d, end_d, self.interval, [], minDistanceThreshold=5)
    +                    else:
    +                        ranges_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, start, end, self.interval, [], minDistanceThreshold=5)
    +                else:
    +                    # Ensure that daily data always up-to-date to now
    +                    # Update: only necessary to be up-to-date to now if a fetch happens
    +                    dt_start = yfct.ConvertToDatetime(self.h.index[0], tz=tz_exchange)
    +                    dt_end = yfct.ConvertToDatetime(self.h.index[-1], tz=tz_exchange)
    +                    h_start = yfct.GetTimestampCurrentInterval(self.exchange, dt_start, self.interval, ignore_breaks=True)["interval_open"]
    +                    last_interval = yfct.GetTimestampCurrentInterval(self.exchange, dt_end, self.interval, ignore_breaks=True)
    +                    if last_interval is None:
    +                        # Possible if Yahoo returned price data when 'exchange_calendars' thinks exchange was closed
    +                        for i in range(1, 5):
    +                            last_interval = yfct.GetTimestampCurrentInterval(self.exchange, dt_end - td_1d*i, self.interval, ignore_breaks=True)
    +                            if last_interval is not None:
    +                                break
    +                    h_end = last_interval["interval_close"]
    +
    +                    rangePre_to_fetch = None
    +                    if start < h_start:
    +                        if debug_yfc:
    +                            msg = "checking for rangePre_to_fetch"
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                        try:
    +                            rangePre_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, start, h_start, self.interval, None, ignore_breaks=True, minDistanceThreshold=5)
    +                        except yfcd.NoIntervalsInRangeException:
    +                            rangePre_to_fetch = None
    +                    if rangePre_to_fetch is not None:
    +                        if len(rangePre_to_fetch) > 1:
    +                            raise Exception("Expected only one element in rangePre_to_fetch[], but = {}".format(rangePre_to_fetch))
    +                        rangePre_to_fetch = rangePre_to_fetch[0]
    +                    #
    +                    rangePost_to_fetch = None
    +                    if self.h["Final?"].iloc[-1] and (min(yfct.CalcIntervalLastDataDt(self.exchange, dt_end, self.interval), self.h["FetchDate"].iloc[-1])+max_age <= dt_now):
    +                        # Note: if self.h["Final?"].iloc[-1] == False, then that means above expiry check didn't remove it so don't fetch anything
    +                        if debug_yfc:
    +                            msg = "checking for rangePost_to_fetch"
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                        if self.interday:
    +                            if rangePre_to_fetch is not None or end > h_end:
    +                                target_end_d = tomorrow_d
    +                            else:
    +                                target_end_d = end
    +                            if self.multiday:
    +                                target_end_d += self.itd  # testing new code
    +                            if h_end < target_end_d:
    +                                try:
    +                                    rangePost_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, h_end, target_end_d, self.interval, None, minDistanceThreshold=5)
    +                                except yfcd.NoIntervalsInRangeException:
    +                                    rangePost_to_fetch = None
    +                        else:
    +                            if rangePre_to_fetch is not None or end > h_end:
    +                                target_end_dt = dt_now
    +                            else:
    +                                target_end_dt = end
    +                            d = target_end_dt.astimezone(tz_exchange).date()
    +                            sched = yfct.GetExchangeSchedule(self.exchange, d, d + td_1d)
    +                            if (sched is not None) and (not sched.empty) and (dt_now > sched["open"].iloc[0]):
    +                                target_end_dt = sched["close"].iloc[0]+timedelta(hours=2)
    +                            if h_end < target_end_dt:
    +                                try:
    +                                    rangePost_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, h_end, target_end_dt, self.interval, None, minDistanceThreshold=5)
    +                                except yfcd.NoIntervalsInRangeException:
    +                                    rangePost_to_fetch = None
    +                    ranges_to_fetch = []
    +                    if rangePost_to_fetch is not None:
    +                        if len(rangePost_to_fetch) > 1:
    +                            raise Exception("Expected only one element in rangePost_to_fetch[], but = {}".format(rangePost_to_fetch))
    +                        rangePost_to_fetch = rangePost_to_fetch[0]
    +                    if rangePre_to_fetch is not None:
    +                        ranges_to_fetch.append(rangePre_to_fetch)
    +                    if rangePost_to_fetch is not None:
    +                        ranges_to_fetch.append(rangePost_to_fetch)
    +            else:
    +                h_intervals = yfct.GetTimestampCurrentInterval_batch(self.exchange, h_interval_dts, self.interval, ignore_breaks=True)
    +                if h_intervals is None:
    +                    h_intervals = pd.DataFrame(data={"interval_open": [], "interval_close": []})
    +                else:
    +                    f_na = h_intervals["interval_open"].isna().to_numpy()
    +                    if f_na.any():
    +                        # Mapping Yahoo intervals -> xcal can fail now, because sometime xcal is wrong.
    +                        # Need to tolerate
    +                        h_intervals.loc[f_na, "interval_open"] = h_interval_dts[f_na]
    +                        h_intervals.loc[f_na, "interval_close"] = h_interval_dts[f_na]+self.itd
    +                if (not h_intervals.empty) and isinstance(h_intervals["interval_open"].iloc[0], datetime):
    +                    h_interval_opens = [x.to_pydatetime().astimezone(tz_exchange) for x in h_intervals["interval_open"]]
    +                else:
    +                    h_interval_opens = h_intervals["interval_open"].to_numpy()
    +
    +                try:
    +                    target_end = end
    +                    if self.multiday:
    +                        target_end += self.itd
    +                    ranges_to_fetch = yfct.IdentifyMissingIntervalRanges(self.exchange, start, target_end, self.interval, h_interval_opens, ignore_breaks=True, minDistanceThreshold=5)
    +                    if ranges_to_fetch is None:
    +                        ranges_to_fetch = []
    +                except yfcd.NoIntervalsInRangeException:
    +                    ranges_to_fetch = []
    +                except Exception:
    +                    print("Ticker =", self.ticker)
    +                    raise
    +            # Prune ranges in future:
    +            for i in range(len(ranges_to_fetch)-1, -1, -1):
    +                r = ranges_to_fetch[i]
    +                x = r[0]
    +                delete_range = False
    +                if isinstance(x, (datetime, pd.Timestamp)):
    +                    if x > dt_now:
    +                        delete_range = True
    +                    else:
    +                        sched = yfct.GetExchangeSchedule(self.exchange, x.date(), x.date() + td_1d)
    +                        delete_range = (sched is not None) and dt_now < (sched["open"].iloc[0] + yf_lag)
    +                else:
    +                    if datetime.combine(x, time(0), tzinfo=tz_exchange) > dt_now:
    +                        delete_range = True
    +                    else:
    +                        sched = yfct.GetExchangeSchedule(self.exchange, x, x + td_1d)
    +                        delete_range = (sched is not None) and dt_now < (sched["open"].iloc[0] + yf_lag)
    +                if delete_range:
    +                    if debug_yfc:
    +                        print("- deleting future range:", r[i])
    +                    del ranges_to_fetch[i]
    +                else:
    +                    # Check if range ends in future, if yes then adjust to tomorrow max
    +                    y = r[1]
    +                    if isinstance(y, (datetime, pd.Timestamp)):
    +                        if y > dt_now:
    +                            ranges_to_fetch[i] = (r[0], min(dt_now.ceil('1D'), y))
    +                    elif y > d_now_exchange:
    +                        sched = yfct.GetExchangeSchedule(self.exchange, d_now_exchange, y + td_1d)
    +                        if sched is not None:
    +                            if debug_yfc:
    +                                print("- capping last range_to_fetch end to d_now_exchange")
    +                            if dt_now < sched["open"].iloc[0]:
    +                                ranges_to_fetch[i] = (r[0], d_now_exchange)
    +                            else:
    +                                ranges_to_fetch[i] = (r[0], d_now_exchange + td_1d)
    +
    +            # Important that ranges_to_fetch in reverse order!
    +            ranges_to_fetch.sort(key=lambda x: x[0], reverse=True)
    +            if debug_yfc:
    +                print("- ranges_to_fetch:")
    +                pprint(ranges_to_fetch)
    +
    +            if len(ranges_to_fetch) > 0:
    +                if not self.h.empty:
    +                    # Ensure only one range max is after cached data:
    +                    h_last_dt = self.h.index[-1].to_pydatetime()
    +                    if not isinstance(ranges_to_fetch[0][0], datetime):
    +                        h_last_dt = h_last_dt.astimezone(tz_exchange).date()
    +                    n = 0
    +                    for r in ranges_to_fetch:
    +                        if r[0] > h_last_dt:
    +                            n += 1
    +                    if n > 1:
    +                        print("ranges_to_fetch:")
    +                        pprint(ranges_to_fetch)
    +                        raise Exception("ranges_to_fetch contains {} ranges that occur after h_last_dt={}, expected 1 max".format(n, h_last_dt))
    +
    +                # if not quiet:
    +                #     quiet = period is not None  # YFC generated date range so don't print message
    +                if debug_yfc:
    +                    quiet = False
    +                    # quiet = not debug_yfc
    +                # if self.interval == yfcd.Interval.Days1:
    +                if self.contiguous:
    +                    self._fetchAndAddRanges_contiguous(ranges_to_fetch, prepost, debug_yf, quiet=quiet)
    +                else:
    +                    self._fetchAndAddRanges_sparse(ranges_to_fetch, prepost, debug_yf, quiet=quiet)
    +
    +        # repair after all fetches complete
    +        if self.repair and repair:
    +            f_checked = self.h["C-Check?"].to_numpy()
    +            f_not_checked = ~f_checked
    +            if f_not_checked.any():
    +                # Check for 100x errors across ENTIRE table with split-adjustment applied temporarily.
    +                # Potential problem with very sparse data, but assume most users will
    +                # fetch "sparse" fairly contiguously.
    +                # - apply split-adjustment
    +                OHLC = ['Open', 'High', 'Low', 'Close']
    +                csf = self.h['CSF'].to_numpy()
    +                for c in OHLC:
    +                    self.h[c] *= csf
    +                self.h = self._repairUnitMixups(self.h)
    +                # Also repair split errors:
    +                self.h = self._fixBadStockSplits(self.h)
    +                # - reverse split-adjustment
    +                csf_rcp = 1.0/self.h['CSF'].to_numpy()
    +                for c in OHLC:
    +                    self.h[c] *= csf_rcp
    +                ha = self.h[f_not_checked].copy()
    +                hb = self.h[~f_not_checked]
    +                ha = self._repairZeroPrices(ha, silent=True)
    +                ha["C-Check?"] = True
    +                if not hb.empty:
    +                    self.h = pd.concat([ha, hb[ha.columns]])
    +                    self.h.index = pd.to_datetime(self.h.index, utc=True).tz_convert(tz_exchange)
    +                else:
    +                    self.h = ha
    +                self.h = self.h.sort_index()
    +                self._updatedCachedPrices(self.h)
    +
    +        # Now prices have been repaired, can send out dividends
    +        cached_new_divs = yfcm.ReadCacheDatum(self.ticker, "new_divs")
    +        if self.interval == yfcd.Interval.Days1 and cached_new_divs is not None and not cached_new_divs.empty:
    +            cached_new_divs_locked = yfcm.ReadCacheMetadata(self.ticker, "new_divs", "locked")
    +            yfcl.TracePrint(f"cached_new_divs_locked = {cached_new_divs_locked}")
    +            if cached_new_divs_locked is None:
    +                f_dups = cached_new_divs.index.duplicated()
    +                if f_dups.any():
    +                    print(cached_new_divs)
    +                    raise Exception('duplicates detected in cached_new_divs')
    +                yfcm.WriteCacheMetadata(self.ticker, "new_divs", "locked", 1)
    +                yfcl.TracePrint("sending out new dividends ...")
    +                # TODO: remove duplicates from _newDivs (possible when restoring file file)
    +                divs_df = cached_new_divs
    +                divs_df["Close day before"] = np.nan
    +                for dt in divs_df.index:
    +                    if dt == self.h.index[0]:
    +                        hist_before = self.manager.GetHistory(yfcd.Interval.Days1).get(start=dt.date()-timedelta(days=7), end=dt.date(), adjust_splits=False, adjust_divs=False)
    +                        close_day_before = hist_before["Close"].iloc[-1]
    +                        if np.isnan(close_day_before):
    +                            raise Exception("'close_day_before' is NaN")
    +                    else:
    +                        idx = self.h.index.get_loc(dt)
    +                        close_day_before = self.h["Close"].iloc[idx-1]
    +                        if np.isnan(close_day_before):
    +                            for idx in range(idx-1, idx-9, -1):
    +                                close_day_before = self.h["Close"].iloc[idx-1]
    +                                if not np.isnan(close_day_before):
    +                                    break
    +                            if np.isnan(close_day_before):
    +                                print(f"- idx={idx} dt={dt}")
    +                                print(self.h.iloc[idx-2:idx+3][["Close", "FetchDate"]])
    +                                raise Exception("'close_day_before' is NaN")
    +                    divs_df.loc[dt, "Close day before"] = close_day_before
    +                    # De-split div:
    +                    if not divs_df.loc[dt, 'Desplitted?']:
    +                        splits_post = self.manager.GetHistory('Events').GetSplits(dt.date())
    +                        if splits_post is not None:
    +                            post_csf = 1.0/splits_post["Stock Splits"].prod()
    +                            divs_df.loc[dt, 'Dividends'] /= post_csf
    +                        divs_df.loc[dt, 'Desplitted?'] = True
    +                self.manager.GetHistory("Events").UpdateDividends(divs_df)
    +                self._applyNewEvents()
    +                if cached_new_divs is not None and not cached_new_divs_locked:
    +                    yfcl.TracePrint("releasing lock on cached_new_divs")
    +                    yfcm.StoreCacheDatum(self.ticker, "new_divs", None)  # delete
    +
    +        if "Adj Close" in self.h.columns:
    +            raise Exception("Adj Close in self.h")
    +
    +        if (start is not None) and (end is not None):
    +            h_copy = self.h.loc[start_dt:end_dt-timedelta(milliseconds=1)].copy()
    +        else:
    +            h_copy = self.h.copy()
    +
    +        if adjust_splits:
    +            for c in ["Open", "High", "Low", "Close", "Dividends"]:
    +                h_copy[c] *= h_copy["CSF"]
    +            h_copy["Volume"] = (h_copy["Volume"]/h_copy["CSF"]).round(0).astype('int')
    +            h_copy = h_copy.drop("CSF", axis=1)
    +        if adjust_divs:
    +            for c in ["Open", "High", "Low", "Close"]:
    +                h_copy[c] *= h_copy["CDF"]
    +            h_copy = h_copy.drop("CDF", axis=1)
    +
    +        log_msg = f"PriceHistory-{self.istr}.get() returning"
    +        if h_copy.empty:
    +            log_msg += " empty df"
    +        else:
    +            log_msg += f" DF {h_copy.index[0]} -> {h_copy.index[-1]}"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +
    +        return h_copy
    +
    +    def _fetchAndAddRanges_contiguous(self, ranges_to_fetch, prepost, debug, quiet=False):
    +        yfcu.TypeCheckIterable(ranges_to_fetch, "ranges_to_fetch")
    +        yfcu.TypeCheckBool(prepost, "prepost")
    +        yfcu.TypeCheckBool(debug, "debug")
    +        yfcu.TypeCheckBool(quiet, "quiet")
    +
    +        # Fetch each range, appending/prepending to cached data
    +        if (ranges_to_fetch is None) or len(ranges_to_fetch) == 0:
    +            # return h
    +            return
    +
    +        debug_yfc = self._debug
    +        # debug_yfc = True
    +
    +        log_msg = f"_fetchAndAddRanges_contiguous-{self.istr}(n={len(ranges_to_fetch)} prepost={prepost}))"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +            print("- ranges_to_fetch:")
    +            pprint(ranges_to_fetch)
    +
    +        tz_exchange = ZoneInfo(self.tzName)
    +        yfct.SetExchangeTzName(self.exchange, self.tzName)
    +
    +        if self.h is None or self.h.empty:
    +            h_first_dt = None ; h_last_dt = None
    +        else:
    +            h_first_dt = self.h.index[0].to_pydatetime()
    +            h_last_dt = self.h.index[-1].to_pydatetime()
    +            if not isinstance(ranges_to_fetch[0][0], datetime):
    +                h_first_dt = h_first_dt.astimezone(tz_exchange).date()
    +                h_last_dt = h_last_dt.astimezone(tz_exchange).date()
    +        td_1d = timedelta(days=1)
    +
    +        # Because data should be contiguous, then ranges should meet some conditions:
    +        if len(ranges_to_fetch) > 2:
    +            pprint(ranges_to_fetch)
    +            raise Exception("For contiguous data generated {}>2 ranges".format(len(ranges_to_fetch)))
    +        if self.h.empty and len(ranges_to_fetch) > 1:
    +            raise Exception("For contiguous data generated {} ranges, but h is empty".format(len(ranges_to_fetch)))
    +        range_pre = None ; range_post = None
    +        if self.h.empty and len(ranges_to_fetch) == 1:
    +            range_pre = ranges_to_fetch[0]
    +        else:
    +            n_pre = 0 ; n_post = 0
    +            for r in ranges_to_fetch:
    +                if r[0] > h_last_dt:
    +                    n_post += 1
    +                    range_post = r
    +                elif r[0] < h_first_dt:
    +                    n_pre += 1
    +                    range_pre = r
    +            if n_pre > 1:
    +                pprint(ranges_to_fetch)
    +                raise Exception("For contiguous data generated {}>1 ranges before h_first_dt".format(n_pre))
    +            if n_post > 1:
    +                pprint(ranges_to_fetch)
    +                raise Exception("For contiguous data generated {}>1 ranges after h_last_dt".format(n_post))
    +
    +        # Fetch ranges
    +        h2_pre = None ; h2_post = None
    +        if range_pre is not None:
    +            r = range_pre
    +            try:
    +                h2_pre = self._fetchYfHistory(r[0], r[1], prepost, debug)
    +            except yfcd.NoPriceDataInRangeException:
    +                if self.interval == yfcd.Interval.Days1 and r[1] - r[0] == td_1d:
    +                    # If only trying to fetch 1 day of 1d data, then print warning instead of exception.
    +                    # Could add additional condition of dividend previous day (seems to mess up table).
    +                    if not quiet:
    +                        print(f"WARNING: {self.ticker}: No {yfcd.intervalToString[self.interval]}-price data fetched for {r[0]} -> {r[1]}")
    +                    h2_pre = None
    +                elif (range_post is None) and (r[1]-r[0] < td_1d*7) and (r[1]-r[0] > td_1d*3):
    +                    # Small date range, potentially trying to fetch before listing data
    +                    h2_pre = None
    +                else:
    +                    raise
    +
    +        if range_post is not None:
    +            r = range_post
    +            try:
    +                h2_post = self._fetchYfHistory(r[0], r[1], prepost, debug)
    +            except yfcd.NoPriceDataInRangeException:
    +                # If only trying to fetch 1 day of 1d data, then print warning instead of exception.
    +                # Could add additional condition of dividend previous day (seems to mess up table).
    +                if self.interval == yfcd.Interval.Days1 and r[1] - r[0] == td_1d:
    +                    if not quiet:
    +                        print(f"WARNING: {self.ticker}: No {yfcd.intervalToString[self.interval]}-price data fetched for {r[0]} -> {r[1]}")
    +                    h2_post = None
    +                else:
    +                    raise
    +
    +        listing_date = yfcm.ReadCacheDatum(self.ticker, "listing_date")
    +        if listing_date is None:
    +            listing_date = self.dat.history_metadata["firstTradeDate"]
    +            if isinstance(listing_date, int):
    +                listing_date = pd.to_datetime(listing_date, unit='s', utc=True).tz_convert(tz_exchange)
    +            yfcm.StoreCacheDatum(self.ticker, "listing_date", listing_date.date())
    +
    +        if h2_post is not None:
    +            # De-adjust the new data, and backport any new events in cached data
    +            # Note: Yahoo always returns split-adjusted price, so reverse it
    +
    +            # Simple append to bottom of table
    +            # 1) adjust h2_post
    +            h2_post = self._reverseYahooAdjust(h2_post)
    +            if debug_yfc:
    +                print("- h2_post:")
    +                print(h2_post)
    +
    +            # TODO: Problem: dividends need correct close
    +            if self.interval == yfcd.Interval.Days1:
    +                h2_post_splits = h2_post[h2_post["Stock Splits"] != 0][["Stock Splits", "FetchDate"]].copy()
    +                if not h2_post_splits.empty:
    +                    self.manager.GetHistory("Events").UpdateSplits(h2_post_splits)
    +                # Update: moving UpdateDividends() to after repair
    +
    +            # Backport new events across entire h table
    +            self._applyNewEvents()
    +
    +            if h2_post is not None and not isinstance(h2_post.index, pd.DatetimeIndex):
    +                raise Exception("h2_post.index not DatetimeIndex")
    +
    +            if "Adj Close" in h2_post.columns:
    +                raise Exception("Adj Close in h2_post")
    +            self.h = pd.concat([self.h, h2_post[self.h.columns]])
    +            self.h.index = pd.to_datetime(self.h.index, utc=True).tz_convert(tz_exchange)
    +
    +        if h2_pre is not None:
    +            if debug_yfc:
    +                print("- prepending new data")
    +            # Simple prepend to top of table
    +
    +            h2_pre = self._reverseYahooAdjust(h2_pre)
    +
    +            if self.interval == yfcd.Interval.Days1:
    +                h2_pre_splits = h2_pre[h2_pre["Stock Splits"] != 0][["Stock Splits", "FetchDate"]].copy()
    +                if not h2_pre_splits.empty:
    +                    self.manager.GetHistory("Events").UpdateSplits(h2_pre_splits)
    +
    +            # h2_pre = h2_pre.drop(["Dividends", "Stock Splits"], axis=1)
    +            if "Adj Close" in h2_pre.columns:
    +                raise Exception("Adj Close in h2_pre")
    +            self.h = pd.concat([self.h, h2_pre[self.h.columns]])
    +            self.h.index = pd.to_datetime(self.h.index, utc=True).tz_convert(tz_exchange)
    +
    +        self.h = self.h.sort_index()
    +        self._updatedCachedPrices(self.h)
    +
    +        log_msg = "_fetchAndAddRanges_contiguous() returning"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +        elif debug_yfc:
    +            print("- h:")
    +            print(self.h)
    +            print(log_msg)
    +
    +    def _fetchAndAddRanges_sparse(self, ranges_to_fetch, prepost, debug, quiet=False):
    +        yfcu.TypeCheckIterable(ranges_to_fetch, "ranges_to_fetch")
    +        yfcu.TypeCheckBool(prepost, "prepost")
    +        yfcu.TypeCheckBool(debug, "debug")
    +        yfcu.TypeCheckBool(quiet, "quiet")
    +
    +        # Fetch each range, but can be careless regarding de-adjust because
    +        # getting events from the carefully-managed daily data
    +        if (ranges_to_fetch is None) or len(ranges_to_fetch) == 0:
    +            # return h
    +            return
    +
    +        debug_yfc = self._debug
    +        # debug_yfc = True
    +
    +        log_msg = f"_fetchAndAddRanges_sparse(prepost={prepost})"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +
    +        tz_exchange = self.tz
    +        td_1d = timedelta(days=1)
    +        dtnow = pd.Timestamp.utcnow().tz_convert(tz_exchange)
    +
    +        # Backport events that occurred since last adjustment:
    +        self._applyNewEvents()
    +
    +        # Ensure have daily data covering all ranges_to_fetch, so they can be de-splitted
    +        r_start_earliest = ranges_to_fetch[0][0]
    +        for rstart, rend in ranges_to_fetch:
    +            r_start_earliest = min(rstart, r_start_earliest)
    +        r_start_earliest_d = r_start_earliest.date() if isinstance(r_start_earliest, datetime) else r_start_earliest
    +        if debug_yfc:
    +            print("- r_start_earliest = {}".format(r_start_earliest))
    +        # Trigger price sync:
    +        histDaily = self.manager.GetHistory(yfcd.Interval.Days1)
    +        # histDaily.get(start=r_start_earliest_d, max_age=td_1d)
    +        histDaily.get(start=r_start_earliest_d, max_age=td_1d, repair=False)
    +
    +        # Fetch each range, and adjust for splits that occurred after
    +        for rstart, rend in ranges_to_fetch:
    +            fetch_start = rstart
    +            fetch_end = rend
    +            # if not self.interday:  # and fetch_start.date() == fetch_end.date():
    +            #     # Intraday fetches behave better when time = midnight
    +            #     fetch_start = fetch_start.floor("1D")
    +            #     fetch_end = fetch_end.ceil("1D")
    +            # Update: data reliability now fixed by ChunkDatesIntoYfFetches()
    +            if debug_yfc:
    +                print("- fetching {} -> {}".format(fetch_start, fetch_end))
    +            try:
    +                h2 = self._fetchYfHistory(fetch_start, fetch_end, prepost, debug)
    +            except yfcd.NoPriceDataInRangeException:
    +                # If only trying to fetch 1 day of 1d data, then print warning instead of exception.
    +                # Could add additional condition of dividend previous day (seems to mess up table).
    +                ignore = False
    +                if self.interval == yfcd.Interval.Days1 and fetch_end - fetch_start == td_1d:
    +                    ignore = True
    +                elif self.intraday and fetch_start.date() == dtnow.date():
    +                    ignore = True
    +                elif self.interval == yfcd.Interval.Mins1 and fetch_end - fetch_start <= timedelta(minutes=10):
    +                    ignore = True
    +                if ignore:
    +                    if not quiet:
    +                        # print("WARNING: No {}-price data fetched for ticker {} between dates {} -> {}".format(yfcd.intervalToString[self.interval], self.ticker, rstart, rend))
    +                        print(f"WARNING: {self.ticker}: No {yfcd.intervalToString[self.interval]}-price data fetched for {rstart} -> {rend}")
    +                    h2 = None
    +                    continue
    +                else:
    +                    raise
    +
    +            if h2 is None:
    +                # raise Exception("YF returned None for: tkr={}, interval={}, start={}, end={}".format(self.ticker, self.interval, rstart, rend))
    +                # raise Exception(f"yfinance.history() returned None ({self.ticker} {self.istr} {rstart}->{rend})")
    +                raise yfcd.NoPriceDataInRangeException(self.ticker, self.istr, rstart, rend)
    +                # raise Exception(f"yfinance.history() returned None ({self.ticker} {self.istr} {fetch_start}->{fetch_end})")
    +
    +            # Ensure h2 is split-adjusted. Sometimes Yahoo returns unadjusted data
    +            h2 = self._reverseYahooAdjust(h2)
    +            if debug_yfc:
    +                print("- h2 adjusted:")
    +                print(h2[["Close", "Dividends", "Volume", "CSF", "CDF"]])
    +
    +            if fetch_start != rstart:
    +                h2 = h2[h2.index >= rstart]
    +            if fetch_end != rend:
    +                h2 = h2[h2.index < rend+self.itd]
    +
    +            if "Adj Close" in h2.columns:
    +                raise Exception("Adj Close in h2")
    +            try:
    +                self.h = self.h[yfcu.np_isin_optimised(self.h.index, h2.index, invert=True)]
    +            except Exception:
    +                print("self.h.shape:", self.h.shape)
    +                print("h2.shape:", h2.shape)
    +                raise
    +            self.h = pd.concat([self.h, h2[self.h.columns]])
    +            self.h.index = pd.to_datetime(self.h.index, utc=True).tz_convert(tz_exchange)
    +
    +            f_dups = self.h.index.duplicated()
    +            if f_dups.any():
    +                raise Exception(f"{self.ticker}: Adding range {rstart}->{rend} has added duplicate timepoints have been duplicated: {self.h.index[f_dups]}")
    +
    +        self.h = self.h.sort_index()
    +        self._updatedCachedPrices(self.h)
    +
    +        log_msg = "_fetchAndAddRanges_sparse() returning"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +
    +    def _verifyCachedPrices(self, rtol=0.0001, vol_rtol=0.004, correct=False, discard_old=False, quiet=True, debug=False):
    +        correct_values = [False, 'one', 'all']
    +        if correct not in correct_values:
    +            raise TypeError(f"'correct' must be one of: {correct_values}")
    +        yfcu.TypeCheckBool(discard_old, "discard_old")
    +        yfcu.TypeCheckBool(quiet, "quiet")
    +        yfcu.TypeCheckBool(debug, "debug")
    +
    +        if self.h is None or self.h.empty:
    +            return True
    +
    +        if debug:
    +            quiet = False
    +
    +        yfcl.TraceEnter(f"PM::_verifyCachedPrices-{self.istr}(correct={correct}, debug={debug})")
    +
    +        # New code: hopefully this will correct bad CDF in 1wk etc
    +        self._applyNewEvents()
    +
    +        h = self.h.copy()  # working copy for comparison with YF
    +        h_modified = False
    +        h_new = self.h.copy()  # copy for storing changes
    +        # Keep track of problems:
    +        f_diff_all = pd.Series(np.full(h.shape[0], False), h.index, name="None")
    +        n = h.shape[0]
    +
    +        # Ignore non-final data because will differ to Yahoo
    +        h_lastRow = h.iloc[-1]
    +
    +        # Apply stock-split adjustment to match YF
    +        for c in ["Open", "Close", "Low", "High", "Dividends"]:
    +            h[c] = h[c].to_numpy() * h["CSF"].to_numpy()
    +        h["Volume"] = (h["Volume"].to_numpy() / h["CSF"].to_numpy()).round().astype('int')
    +
    +        td_1d = pd.Timedelta("1D")
    +        dt_now = pd.Timestamp.utcnow().tz_convert(ZoneInfo("UTC"))
    +
    +        def _aggregate_yfdf_daily(df):
    +            df2 = df.copy()
    +            df2["_date"] = df2.index.date
    +            df2.loc[df2["Stock Splits"] == 0, "Stock Splits"] = 1
    +            if "CDF" in df.columns:
    +                df2 = df2.groupby("_date").agg(
    +                    Open=("Open", "first"),
    +                    Close=("Close", "last"),
    +                    Low=("Low", "min"),
    +                    High=("High", "max"),
    +                    Volume=("Volume", "sum"),
    +                    Dividends=("Dividends", "sum"),
    +                    StockSplits=("Stock Splits", "prod"),
    +                    CDF=("CDF", "prod"),
    +                    CSF=("CSF", "prod"),
    +                    FetchDate=("FetchDate", "first")).rename(columns={"StockSplits": "Stock Splits"})
    +            else:
    +                df2 = df2.groupby("_date").agg(
    +                    Open=("Open", "first"),
    +                    Close=("Close", "last"),
    +                    AdjClose=("Adj Close", "last"),
    +                    Low=("Low", "min"),
    +                    High=("High", "max"),
    +                    Volume=("Volume", "sum"),
    +                    Dividends=("Dividends", "sum"),
    +                    StockSplits=("Stock Splits", "prod")).rename(columns={"StockSplits": "Stock Splits", "AdjClose": "Adj Close"})
    +            df2.loc[df2["Stock Splits"] == 1, "Stock Splits"] = 0
    +            df2.index.name = df.index.name
    +            df2.index = pd.to_datetime(df2.index).tz_localize(df.index.tz)
    +            return df2
    +
    +        # For intraday data older than Yahoo limit, compare aggregated against 1d
    +        if self.intraday:
    +            dt_now_local = dt_now.tz_convert(self.tzName)
    +            if self.interval == yfcd.Interval.Hours1:
    +                max_lookback_days = 365*2
    +            elif self.interval == yfcd.Interval.Mins1:
    +                # max_lookback_days = 7
    +                max_lookback_days = 30
    +            else:
    +                max_lookback_days = 60
    +            max_lookback = timedelta(days=max_lookback_days)
    +            max_lookback -= timedelta(minutes=5)  # allow time for server processing
    +            fetch_start_min = dt_now_local - max_lookback
    +            if self.intraday:
    +                fetch_start_min = fetch_start_min.ceil("1D")
    +            if h.index[0] < fetch_start_min:
    +                # h_old = h.loc[:fetch_start_min-timedelta(seconds=1)]
    +                # h_old_1d = _aggregate_yfdf_daily(h_old.drop(["Final?", "C-Check?", "LastDivAdjustDt", "LastSplitAdjustDt"], axis=1))
    +
    +                # if self.interval == yfcd.Interval.Hours1:
    +                #     df_yf = self.dat.history(interval="1d", start=h_old.index[0].date(), end=h_old.index[-1].date()+td_1d, auto_adjust=False, repair="silent")
    +                # else:
    +                #     df_yf = self.dat.history(interval="1h", start=h_old.index[0].date(), end=h_old.index[-1].date()+td_1d, auto_adjust=False, repair="silent")
    +                #     df_yf = _aggregate_yfdf_daily(df_yf)
    +
    +                # # Aggregated data will almost always differ from actual daily, so only
    +                # # look for big errors
    +                # f_old_diff = yfcu.VerifyPricesDf(h_old_1d, df_yf, self.interval, rtol=0.2, debug=True)
    +                # raise Exception("- investigate")
    +                # f_old_diff = yfcu.VerifyPricesDf(h_old_1d, df_yf, self.interval, rtol=0.2)
    +                # if f_old_diff.any():
    +                #     msg = f"Significant differences detected between old {self.istr} data and 1d."
    +                #     print()
    +                #     # f_diff_all = f_diff_all | f_old_diff
    +                #     bad_dates = f_old_diff.index.date[f_old_diff]
    +                #     f_diff_all[np.isin(f_diff_all.index.date, bad_dates)] = True
    +
    +                if not isinstance(discard_old, bool):
    +                    msg = f"{self.ticker}: Some {self.istr} data is now older than {max_lookback_days} days" +\
    +                            " so cannot compare to Yahoo. Discard old data?"
    +                    discard_old = click.confirm(msg, default=False)
    +                if discard_old:
    +                    f_discard = pd.Series(h.index < fetch_start_min, h.index)
    +                    if f_discard.any():
    +                        if debug:
    +                            print(f"Discarding {np.sum(f_discard)}/{n} old rows because can't verify (fetch_start_min={fetch_start_min})")
    +                        f_diff_all = f_diff_all | f_discard
    +                        f_diff_all = f_diff_all.rename("Discard")
    +
    +                h = h.loc[fetch_start_min:]
    +
    +        if self.interval == yfcd.Interval.Days1:
    +            # Also verify dividends
    +            divs_df = self.manager.GetHistory("Events").GetDivs()
    +            if divs_df is not None:
    +                divs_df = divs_df[(divs_df['Dividends']!=0.0).to_numpy()]
    +                if divs_df.empty:
    +                    divs_df = None
    +
    +        if not h.empty:
    +            # Fetch YF data
    +            start_dt = h.index[0]
    +            last_dt = h.index[-1]
    +            end_dt = last_dt + self.itd
    +            fetch_start = start_dt.date()
    +            if self.itd > timedelta(days=1):
    +                fetch_end = last_dt.date()+yfcd.intervalToTimedelta[self.interval]
    +            else:
    +                fetch_end = last_dt.date()+td_1d
    +            # Sometimes Yahoo doesn't return full trading data for last day if end = day after.
    +            # Add some more days to avoid problem.
    +            fetch_end += 3*td_1d
    +            fetch_end = min(fetch_end, dt_now.tz_convert(self.tzName).ceil("D").date())
    +            repair = True
    +            if self.intraday:
    +                if self.interval == yfcd.Interval.Mins1:
    +                    # Fetch in 7-day batches
    +                    df_yf = None
    +                    td_7d = timedelta(days=7)
    +                    fetch_end_batch = fetch_end
    +                    fetch_start_batch = fetch_end - td_7d
    +                    while fetch_end_batch > fetch_start:
    +                        df_yf_batch = self.dat.history(interval=self.istr, start=fetch_start_batch, end=fetch_end_batch, auto_adjust=False, repair=repair, keepna=True)
    +                        if "Repaired?" not in df_yf_batch.columns:
    +                            df_yf_batch["Repaired?"] = False
    +                        if df_yf is None:
    +                            df_yf = df_yf_batch
    +                        else:
    +                            df_yf = pd.concat([df_yf, df_yf_batch], axis=0)
    +                        #
    +                        fetch_end_batch -= td_7d
    +                        fetch_start_batch -= td_7d
    +                        fetch_start_batch = max(fetch_start_batch, fetch_start)
    +                    #
    +                    df_yf = df_yf.sort_index()
    +                else:
    +                    df_yf = self.dat.history(interval=self.istr, start=fetch_start, end=fetch_end, auto_adjust=False, repair=repair, keepna=True)
    +                    if "Repaired?" not in df_yf.columns:
    +                        df_yf["Repaired?"] = False
    +                    df_yf = df_yf.loc[start_dt:]
    +                    df_yf = df_yf[df_yf.index < end_dt]
    +
    +                # Yahoo doesn't div-adjust intraday
    +                df_yf_1d = self.dat.history(interval="1d", start=df_yf.index[0].date(), end=df_yf.index[-1].date()+td_1d, auto_adjust=False)
    +                if "Repaired?" not in df_yf_1d.columns:
    +                    df_yf_1d["Repaired?"] = False
    +                df_yf["_indexBackup"] = df_yf.index
    +                df_yf["_date"] = df_yf.index.date
    +                df_yf_1d["_date"] = df_yf_1d.index.date
    +                #
    +                df_yf_1d["Adj"] = df_yf_1d["Adj Close"].to_numpy() / df_yf_1d["Close"].to_numpy()
    +                df_yf = df_yf.merge(df_yf_1d[["Adj", "_date"]], how="left", on="_date")
    +                df_yf["Adj Close"] = df_yf["Close"].to_numpy() * df_yf["Adj"].to_numpy()
    +                df_yf = df_yf.drop("Adj", axis=1)
    +                #
    +                df_yf.index = df_yf["_indexBackup"]
    +                df_yf = df_yf.drop(["_indexBackup", "_date"], axis=1)
    +            else:
    +                if self.interval == yfcd.Interval.Days1 and divs_df is not None and not divs_df.empty:
    +                    # Also use YF data to verify dividends.
    +                    fetch_start = min(fetch_start, divs_df.index[0].date())
    +                df_yf = self.dat.history(interval=self.istr, start=fetch_start, end=fetch_end, auto_adjust=False, repair=repair, keepna=True)
    +                if "Repaired?" not in df_yf.columns:
    +                    df_yf["Repaired?"] = False
    +                if df_yf.empty:
    +                    raise Exception(f"{self.ticker}: YF fetch failed for {self.istr} {fetch_start} -> {fetch_end}")
    +                # Make special adjustments for dividends / stock splits released TODAY
    +                if self.interval == yfcd.Interval.Days1:
    +                    if not np.isnan(df_yf["Dividends"].iloc[-1]) and df_yf["Dividends"].iloc[-1] > 0:
    +                        expect_new_div_missing = False
    +                        if np.isnan(df_yf["Close"].iloc[-1]):
    +                            expect_new_div_missing = True
    +                        elif h_lastRow.name == df_yf.index[-1] and h_lastRow["Dividends"] == 0.0:
    +                            expect_new_div_missing = True
    +                        if expect_new_div_missing:
    +                            # YFC won't have record of this dividend because occurs tonight/tomorrow, so remove it's adjustment from prices
    +                            rev_adj = df_yf["Close"].iloc[-2] / df_yf["Adj Close"].iloc[-2]
    +                            if debug:
    +                                msg = f"- removing dividend from df_yf-{self.istr}: {df_yf.index[-1]} adj={1.0/rev_adj:.4f}"
    +                                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                            df_yf["Adj Close"] *= rev_adj
    +                            df_yf = df_yf.drop(df_yf.index[-1])
    +                    if df_yf.index[-1] == h_lastRow.name and df_yf["Stock Splits"].iloc[-1] != 0 and h_lastRow["Stock Splits"] == 0:
    +                        # YFC doesn't have record of today's split yet so remove effect
    +                        rev_adj = df_yf["Stock Splits"].iloc[-1]
    +                        if debug:
    +                            msg = f"- removing split from df_yf-{self.istr}: {df_yf.index[-1]} adj={1.0/rev_adj}"
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                        for c in ["Open", "High", "Low", "Close", "Adj Close"]:
    +                            df_yf[c] *= rev_adj
    +                        df_yf["Volume"] /= rev_adj
    +                        df_yf.loc[df_yf.index[-1], "Stock Splits"] = 0.0
    +
    +            if df_yf is None or df_yf.empty:
    +                raise Exception(f"Fetching reference yfinance data failed (interval={self.istr}, start_dt={start_dt}, last_dt={last_dt})")
    +            if self.interval == yfcd.Interval.Week:
    +                # Ensure data aligned to Monday:
    +                if not df_yf.index[0].weekday() == 0:
    +                    n = 0
    +                    while n < 3:
    +                        fetch_start -= timedelta(days=2)
    +                        df_yf = self.dat.history(interval=self.istr, start=fetch_start, end=fetch_end, auto_adjust=False, repair=repair)
    +                        if "Repaired?" not in df_yf.columns:
    +                            df_yf["Repaired?"] = False
    +                        n += 1
    +                        if df_yf.index[0].weekday() == 0:
    +                            break
    +                    if not df_yf.index[0].weekday() == 0:
    +                        raise Exception("Failed to get Monday-aligned weekly data from YF")
    +                    df_yf = df_yf.loc[h.index[0]:]
    +
    +            if not self.interday:
    +                # Volume not split-adjusted
    +                ss = df_yf["Stock Splits"].copy()
    +                ss[(ss == 0.0) | ss.isna()] = 1.0
    +                ss_rcp = 1.0 / ss
    +                csf = ss_rcp.sort_index(ascending=False).cumprod().sort_index(ascending=True).shift(-1, fill_value=1.0)
    +                df_yf["Volume"] = df_yf["Volume"].to_numpy() / csf
    +
    +            if self.interval != yfcd.Interval.Days1 and correct in ['one', 'all']:
    +                # Copy over any missing dividends
    +                c = "Dividends"
    +                h_divs = h.loc[h[c] != 0.0, c].copy().dropna()
    +                yf_divs = df_yf.loc[df_yf[c] != 0.0, c]
    +                dts_missing_from_cache = yf_divs.index[~yf_divs.index.isin(h_divs.index)]
    +                dts_missing_from_cache = [dt for dt in dts_missing_from_cache if dt in h.index]
    +                if len(dts_missing_from_cache) > 0:
    +                    if debug:
    +                        msg = f"CORRECTING: Cache missing these dividends: {dts_missing_from_cache}"
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                    for dt in dts_missing_from_cache:
    +                        # Correct here
    +                        h.loc[dt, c] = yf_divs.loc[dt]
    +                        h_new.loc[dt, c] = yf_divs.loc[dt]
    +                        h_modified = True
    +
    +                # Copy over any missing stock splits
    +                c = "Stock Splits"
    +                h_ss = h.loc[h[c] != 0.0, c].copy().dropna()
    +                yf_ss = df_yf.loc[df_yf[c] != 0.0, c]
    +                dts_missing_from_cache = yf_ss.index[~yf_ss.index.isin(h_ss.index)]
    +                dts_missing_from_cache = [dt for dt in dts_missing_from_cache if dt in h.index]
    +                if len(dts_missing_from_cache) > 0:
    +                    if debug:
    +                        msg = f"CORRECTING: Cache missing these stock splits: {dts_missing_from_cache}"
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                    for dt in dts_missing_from_cache:
    +                        # Correct here
    +                        h.loc[dt, c] = yf_ss.loc[dt]
    +                        h_new.loc[dt, c] = yf_ss.loc[dt]
    +                        h_modified = True
    +
    +            if self.interval == yfcd.Interval.Days1 and divs_df is not None and not divs_df.empty:
    +                # Verify dividends
    +                yf_divs = df_yf['Dividends'][df_yf['Dividends']!=0.0]
    +                f_orphan = np.full(divs_df.shape[0], False)
    +                for i in range(divs_df.shape[0]):
    +                    div_dt = divs_df.index[i]
    +                    if div_dt not in yf_divs.index:
    +                        f_orphan[i] = True
    +                if f_orphan.any():
    +                    if correct in ['one', 'all']:
    +                        print(f'Dropping these orphan dividends: {divs_df.index.date[f_orphan]}')
    +                        orphan_divs = divs_df[f_orphan].copy()
    +                        orphan_divs['Dividends'] = 0.0
    +                        orphan_divs['Close day before'] = 1.0
    +                        # print("- divs_df:") ; print(divs_df[['Dividends', 'Superseded div']])
    +                        self.manager.GetHistory("Events").UpdateDividends(orphan_divs)
    +                    else:
    +                        if not quiet:
    +                            divs_orphan = divs_df[f_orphan][['Dividends']]
    +                            divs_orphan.index = divs_orphan.index.date
    +                            print('- detected orphan dividends:', divs_orphan.to_dict())
    +                        for dt in divs_df.index[f_orphan]:
    +                            f_diff_all[dt] = True
    +
    +            f_diff = yfcu.VerifyPricesDf(h, df_yf, self.interval, rtol=rtol, vol_rtol=vol_rtol, quiet=quiet, debug=debug)
    +            if f_diff.any():
    +                if not f_diff_all.any():
    +                    f_diff_all = (f_diff_all | f_diff).rename(f_diff.name)
    +                else:
    +                    f_diff_all = f_diff_all | f_diff
    +
    +        if not f_diff_all.any():
    +            if h_modified:
    +                # yfcm.StoreCacheDatum(self.ticker, self.cache_key, h)
    +                yfcm.StoreCacheDatum(self.ticker, self.cache_key, h_new)
    +                self.h = self._getCachedPrices()
    +
    +            yfcl.TraceExit(f"PM::_verifyCachedPrices-{self.istr}() returning True")
    +            return True
    +
    +        h = h_new
    +        if correct in ['one', 'all']:
    +            drop_dts = f_diff_all.index[f_diff_all]
    +            drop_dts_ages = dt_now - drop_dts
    +            if self.interval == yfcd.Interval.Week:
    +                f = drop_dts_ages > timedelta(days=8)
    +            else:
    +                f = drop_dts_ages > timedelta(days=4)  # allow for weekend
    +            drop_dts_not_recent = drop_dts[f]
    +            drop_dts_ages = drop_dts_ages[f]
    +            msg = f"{self.ticker}: {self.istr}-prices problems"
    +            if self.contiguous:
    +                # Daily must always be contiguous, so drop everything from first diff
    +                if len(drop_dts_not_recent) > 0:
    +                    if len(drop_dts_not_recent) == 1:
    +                        msg += f": dropping {drop_dts_not_recent[0].date()}"
    +                    else:
    +                        if self.interday:
    +                            msg += f": dropping all rows from {drop_dts_not_recent[0].date()}"
    +                        else:
    +                            msg += f": dropping all rows from {drop_dts_not_recent[0]}"
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                h = h[h.index < drop_dts[0]]
    +                h_modified = True
    +            else:
    +                n = self.h.shape[0]
    +                n_drop = np.sum(f_diff_all)
    +                if len(drop_dts_not_recent) > 0:
    +                    if len(drop_dts_not_recent) < 10:
    +                        if self.interday:
    +                            msg += f": dropping {drop_dts_not_recent.date.astype(str)}"
    +                        else:
    +                            msg += f": dropping {drop_dts_not_recent.tz_localize(None)}"
    +                    else:
    +                        msg += f": dropping {n_drop}/{n} rows"
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                h2 = h.drop(drop_dts)
    +                if h.shape[0]-h2.shape[0] != n_drop:
    +                    raise Exception("here")
    +                h = h2
    +                h_modified = True
    +            if h.empty:
    +                h = None
    +                h_modified = True
    +
    +        else:
    +            if debug:
    +                n = np.sum(f_diff_all)
    +                if n < 5:
    +                    msg = "differences found but not correcting: "
    +                    if self.interday:
    +                        msg += f"{f_diff_all.index[f_diff_all].date}"
    +                    else:
    +                        msg += f"{f_diff_all.index[f_diff_all]}"
    +                else:
    +                    msg = f"{np.sum(f_diff_all)} differences found but not correcting"
    +                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +
    +        if correct in ['one', 'all'] and f_diff_all.name == "Div-Adjust" and self.interval != yfcd.Interval.Days1:
    +            # All differences caused by bad dividend data.
    +            # To fix, need to force a re-fetch of 1d data.
    +            hist1d = self.manager.GetHistory(yfcd.Interval.Days1)
    +            h1d = hist1d._getCachedPrices()
    +            end_d = f_diff_all.index[-1].date()
    +            # But only if of 1d data fetched at least 24 hours ago, because maybe
    +            # we just fixed the 1d data.
    +            f_fetched_recently = h1d['FetchDate'] > (dt_now - timedelta(days=1))
    +            f_fetched_recently_not = ~f_fetched_recently
    +            if f_fetched_recently_not.any():
    +                f_fetched_recently_not_last_idx = np.where(f_fetched_recently_not)[0][-1]
    +                end_d = h1d.index[f_fetched_recently_not_last_idx]
    +                msg = f"hist-{self.istr} is discarding 1d data before {end_d} to force re-fetch of dividends"
    +                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(f"{self.ticker}: " + msg)
    +                h1d = h1d[str(end_d):]
    +
    +            hist1d._updatedCachedPrices(h1d)
    +
    +        if h_modified:
    +            yfcm.StoreCacheDatum(self.ticker, self.cache_key, h)
    +            self.h = self._getCachedPrices()
    +
    +        yfcl.TraceExit(f"PM::_verifyCachedPrices-{self.istr}() returning False")
    +        return False
    +
    +    def _fetchYfHistory(self, start, end, prepost, debug, verify_intervals=True, disable_yfc_metadata=False):
    +        if start is None and end is None:
    +            raise ValueError("Must provide value for one of: 'start', 'end'")
    +        if start is not None:
    +            yfcu.TypeCheckIntervalDt(start, self.interval, "start")
    +        if end is not None:
    +            yfcu.TypeCheckIntervalDt(end, self.interval, "end")
    +        yfcu.TypeCheckBool(prepost, "prepost")
    +        yfcu.TypeCheckBool(debug, "debug")
    +
    +        debug_yfc = False
    +        # debug_yfc = True
    +
    +        log_msg = f"PM::_fetchYfHistory-{self.istr}({start}->{end}, prepost={prepost})"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +        elif debug_yfc:
    +            print("")
    +            print(log_msg)
    +
    +        tz_exchange = self.tz
    +        td_1d = timedelta(days=1)
    +        dt_now = pd.Timestamp.utcnow().tz_convert(ZoneInfo("UTC"))
    +
    +        if self.intraday:
    +            maxLookback = yfcd.yfMaxFetchLookback[self.interval] - timedelta(seconds=10)
    +            if maxLookback is not None:
    +                start = max(start, dt_now - maxLookback)
    +                if start >= end:
    +                    return None
    +
    +        fetch_start = start
    +        fetch_end = end
    +
    +        if end is not None:
    +            # If 'fetch_end' in future then cap to exchange midnight
    +            dtnow_exchange = dt_now.tz_convert(tz_exchange)
    +            if isinstance(end, datetime):
    +                end_dt = end
    +                # end_d = end.astimezone(tz_exchange).date()
    +                end_d = None
    +            else:
    +                end_d = end
    +                end_dt = datetime.combine(end, time(0), tz_exchange)
    +            if end_dt > dt_now:
    +                exchange_midnight_dt = datetime.combine(dtnow_exchange.date()+td_1d, time(0), tz_exchange)
    +                if isinstance(end, datetime):
    +                    fetch_end = exchange_midnight_dt
    +                else:
    +                    fetch_end = exchange_midnight_dt.date()
    +        if start is not None:
    +            if isinstance(start, datetime):
    +                start_dt = start
    +                # start_d = start.astimezone(tz_exchange).date()
    +                start_d = None
    +            else:
    +                start_d = start
    +                start_dt = datetime.combine(start, time(0), tz_exchange)
    +
    +            if (fetch_start is not None) and (fetch_end <= fetch_start):
    +                return None
    +
    +        if fetch_start is not None:
    +            if not isinstance(fetch_start, (datetime, pd.Timestamp)):
    +                fetch_start_dt = datetime.combine(fetch_start, time(0), self.tz)
    +            else:
    +                fetch_start_dt = fetch_start
    +        if fetch_end is not None:
    +            if not isinstance(fetch_end, (datetime, pd.Timestamp)):
    +                fetch_end_dt = datetime.combine(fetch_end, time(0), tz_exchange)
    +            else:
    +                fetch_end_dt = fetch_end
    +
    +        if fetch_start is not None:
    +            if self.interval == yfcd.Interval.Week:
    +                # Ensure aligned to week start:
    +                fetch_start -= timedelta(days=fetch_start.weekday())
    +
    +        td_1d = timedelta(days=1)
    +        td_14d = timedelta(days=14)
    +        if self.interval == yfcd.Interval.Days1:
    +            # Add padding days to ensure Yahoo returns correct Volume
    +            s = yfct.GetExchangeSchedule(self.exchange, fetch_start - td_14d, fetch_end + td_14d)
    +            fetch_start_pad = s.iloc[s.index.get_indexer([str(fetch_start)], method="ffill")[0]-1].name.date()
    +
    +            first_fetch_failed = False
    +            try:
    +                df = self._fetchYfHistory_dateRange(fetch_start_pad, fetch_end, prepost, debug)
    +                df = df.loc[str(fetch_start):].copy()
    +            except yfcd.NoPriceDataInRangeException as e:
    +                first_fetch_failed = True
    +                ex = e
    +
    +            if first_fetch_failed and fetch_end is not None:
    +                # Try with wider date range, maybe entire range is just before listing date
    +                second_fetch_failed = False
    +                df_wider = None
    +                listing_date_check_tol = yfcd.listing_date_check_tols[self.interval]
    +                fetch_start -= 2*listing_date_check_tol
    +                fetch_end += 2*listing_date_check_tol
    +                if debug_yfc:
    +                    msg = "- first fetch failed, trying again with wider range: {} -> {}".format(fetch_start, fetch_end)
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                try:
    +                    df_wider = self._fetchYfHistory_dateRange(fetch_start, fetch_end, prepost, debug)
    +                    if debug_yfc:
    +                        msg = "- second fetch returned:"
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                        print(df_wider)
    +                except Exception as e:
    +                    if "Data doesn't exist for startDate" in str(e):
    +                        second_fetch_failed = True
    +                    elif "No data found for this date range" in str(e):
    +                        second_fetch_failed = True
    +                    else:
    +                        raise e
    +
    +                if df_wider is not None:
    +                    if debug_yfc:
    +                        print("- detected listing date =", df_wider.index[0].date())
    +                    yfcm.StoreCacheDatum(self.ticker, "listing_date", df_wider.index[0].date())
    +                    df = df_wider
    +                    if fetch_start is not None:
    +                        df = df.loc[fetch_start_dt:]
    +                    if fetch_end is not None:
    +                        df = df.loc[:fetch_end_dt-timedelta(milliseconds=1)]
    +
    +            if first_fetch_failed:
    +                if second_fetch_failed:
    +                    # Hopefully code never comes here
    +                    raise ex
    +                else:
    +                    # Requested date range was just before stock listing date,
    +                    # but wider range crosses over so can continue
    +                    pass
    +
    +        elif self.interday:
    +            # Add padding days to ensure Yahoo returns correct Volume
    +            s = yfct.GetExchangeSchedule(self.exchange, fetch_start - 2*self.itd, fetch_end + 2*self.itd)
    +            fetch_start_pad = s.iloc[s.index.get_indexer([str(fetch_start)], method="ffill")[0]-1].name.date()
    +            fetch_end_pad   = s.iloc[s.index.get_indexer([str(fetch_end)], method="bfill")[0]+1].name.date()
    +
    +            df = self._fetchYfHistory_dateRange(fetch_start_pad, fetch_end_pad, prepost, debug)
    +            df = df.loc[str(fetch_start) : str(fetch_end-td_1d)].copy()
    +
    +        else:
    +            # Intraday
    +            fetch_ranges = [(fetch_start, fetch_end)]
    +            if self.intraday:
    +                # Add padding days to ensure Yahoo returns correct Volume
    +                maxRange = yfcd.yfMaxFetchRange[self.interval]
    +                if maxRange is not None:
    +                    s = yfct.GetExchangeSchedule(self.exchange, start_dt.date() - td_14d, end_dt.date() + td_14d)
    +                    s = s.iloc[s.index.get_indexer([str(start_dt.date())], method="ffill")[0]-1:]
    +                    s = s.iloc[:s.index.get_indexer([str(end_dt.date())], method="bfill")[0]+1+1]
    +                    lag = yfcd.exchangeToYfLag[self.exchange]
    +                    if start_dt > s["close"].iloc[1]+lag:
    +                        s = s.drop(s.index[0])
    +                    if end_dt < s["open"].iloc[-2]+lag:
    +                        s = s.drop(s.index[-1])
    +                    # fetch_ranges = yfcu.ChunkDatesIntoYfFetches(start_d, end_d, s, maxRange.days, overlapDays=2)
    +                    fetch_ranges = yfcu.ChunkDatesIntoYfFetches(s, maxRange.days, overlapDays=2)
    +                    if debug_yfc:
    +                        print("- fetch_ranges:")
    +                        pprint(fetch_ranges)
    +                    # Don't need to fetch all of padding days, just the end/start of session
    +                    # fetch_ranges[0][0] = s["close"].iloc[0] - timedelta(hours=2)
    +                    # fetch_ranges[-1][1] = s["open"].iloc[-1] + timedelta(hours=2)
    +                    # fetch_ranges[0]["fetch start"] = s["close"].iloc[0] - timedelta(hours=2)
    +                    # Update: need start further back for low-volume tickers
    +                    fetch_ranges[0]["fetch start"] = s["open"].iloc[0]
    +                    fetch_ranges[-1]["fetch end"] = s["open"].iloc[-1] + timedelta(hours=2)
    +                    maxLookback = yfcd.yfMaxFetchLookback[self.interval] - timedelta(seconds=10)
    +                    if maxLookback is not None:
    +                        maxLookback_dt = (dt_now - maxLookback).tz_convert(tz_exchange)
    +                        for i in range(len(fetch_ranges)-1, -1, -1):
    +                            if fetch_ranges[i]["fetch start"] < maxLookback_dt:
    +                                if debug_yfc:
    +                                    print("- capping start to maxLookback_dt")
    +                                # fetch_ranges[i]["fetch start"] = maxLookback_dt
    +                                fetch_ranges[i]["fetch start"] = maxLookback_dt.ceil("D")
    +                                fetch_ranges[i]["core start"] = fetch_ranges[i]["fetch start"] + td_1d
    +                                if fetch_ranges[i]["fetch start"] >= fetch_ranges[i]["fetch end"]:
    +                                    del fetch_ranges[i]
    +
    +            df = None
    +            for r in fetch_ranges:
    +                if debug_yfc:
    +                    print("- fetching:")
    +                    print(r)
    +                fetch_start = r["fetch start"]
    +                fetch_end = r["fetch end"]
    +                dfr = self._fetchYfHistory_dateRange(fetch_start, fetch_end, prepost, debug)
    +                # Discard padding days:
    +                dfr = dfr.loc[r["core start"]: r["core end"] - timedelta(milliseconds=1)]
    +                if debug_yfc:
    +                    print("- dfr after discarding padding days:")
    +                    print(dfr[[c for c in ["Open", "Low", "High", "Close", "Dividends", "Volume"] if c in dfr.columns]])
    +                if df is None:
    +                    df = dfr
    +                else:
    +                    df = pd.concat([df, dfr[df.columns]])
    +                if df.index.duplicated().any():
    +                    raise Exception("df contains duplicated dates")
    +
    +        fetch_dt_utc = pd.Timestamp.utcnow().tz_convert(ZoneInfo("UTC"))
    +
    +        if (df is not None) and (df.index.tz is not None) and (not isinstance(df.index.tz, ZoneInfo)):
    +            # Convert to ZoneInfo
    +            df.index = df.index.tz_convert(tz_exchange)
    +
    +        if debug_yfc:
    +            if df is None:
    +                msg = "- YF returned None"
    +                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +            else:
    +                # pass
    +                msg = "- YF returned table:"
    +                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                print(df[[c for c in ["Open", "Low", "High", "Close", "Dividends", "Volume"] if c in df.columns]])
    +
    +        # Detect listing day
    +        listing_day = yfcm.ReadCacheDatum(self.ticker, "listing_date")
    +        if listing_day is None:
    +            if self.interval == yfcd.Interval.Days1:
    +                found_listing_day = False
    +                listing_day = None
    +                if df is not None and not df.empty:
    +                    tol = yfcd.listing_date_check_tols[self.interval]
    +                    fetch_start_d = fetch_start.date() if isinstance(fetch_start, datetime) else fetch_start
    +                    if (df.index[0].date() - fetch_start_d) > tol:
    +                        # Yahoo returned data starting significantly after requested start date, indicates
    +                        # request is before stock listed on exchange
    +                        found_listing_day = True
    +                    if debug_yfc:
    +                        msg = "- found_listing_day = {}".format(found_listing_day)
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                    if found_listing_day:
    +                        listing_day = df.index[0].date()
    +                        if debug_yfc:
    +                            msg = "YFC: inferred listing_date = {}".format(listing_day)
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                        yfcm.StoreCacheDatum(self.ticker, "listing_date", listing_day)
    +
    +                    if (listing_day is not None) and first_fetch_failed:
    +                        if end <= listing_day:
    +                            # Aha! Requested date range was entirely before listing
    +                            if debug_yfc:
    +                                msg = "- requested date range was before listing date"
    +                                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                            return None
    +                if found_listing_day and start is not None:
    +                    # Apply to fetch start
    +                    if isinstance(start, datetime):
    +                        listing_date = datetime.combine(listing_day, time(0), self.tz)
    +                        start = max(start, listing_date)
    +                    else:
    +                        start = max(start, listing_day)
    +                        start_d = start
    +
    +        if df is None:
    +            received_interval_starts = None
    +        else:
    +            if self.interday:
    +                received_interval_starts = df.index.date
    +            else:
    +                received_interval_starts = df.index.to_pydatetime()
    +        try:
    +            intervals_missing_df = yfct.IdentifyMissingIntervals(self.exchange, start, end, self.interval, received_interval_starts, ignore_breaks=True)
    +        except yfcd.NoIntervalsInRangeException:
    +            intervals_missing_df = None
    +        if (intervals_missing_df is not None) and (not intervals_missing_df.empty):
    +            # First, ignore any missing intervals today
    +            # For missing intervals during last 2 weeks, if few in number, then fill with NaNs
    +            # For missing intervals older than 2 weeks, fill all with NaNs
    +
    +            if debug_yfc:
    +                n = intervals_missing_df.shape[0]
    +                if n <= 3:
    +                    msg = f"YF data missing {n} intervals: {intervals_missing_df['open'].to_numpy()}"
    +                else:
    +                    msg = f"YF data missing {n} intervals"
    +                yfcl.TracePrint('- ' + msg) if yfcl.IsTracingEnabled() else print('- ' + msg)
    +
    +            cutoff_d = date.today() - timedelta(days=14)
    +            if self.interday:
    +                f_recent = intervals_missing_df["open"].to_numpy() > cutoff_d
    +            else:
    +                f_recent = intervals_missing_df["open"].dt.date > cutoff_d
    +            intervals_missing_df_recent = intervals_missing_df[f_recent]
    +            intervals_missing_df_old = intervals_missing_df[~f_recent]
    +            missing_intervals_to_add = None
    +            if not intervals_missing_df_old.empty:
    +                missing_intervals_to_add = intervals_missing_df_old["open"].to_numpy()
    +
    +            if not intervals_missing_df_recent.empty:
    +                # If very few intervals and not today (so Yahoo should have data),
    +                # then assume no trading occurred and insert NaN rows.
    +                # Normally Yahoo has already filled with NaNs but sometimes they forget/are late
    +                nm = intervals_missing_df_recent.shape[0]
    +                if self.interday:
    +                    threshold = 1
    +                else:
    +                    if self.itd <= timedelta(minutes=2):
    +                        threshold = 10
    +                    elif self.itd <= timedelta(minutes=5):
    +                        threshold = 3
    +                    else:
    +                        threshold = 2
    +                if nm <= threshold:
    +                    if debug_yfc:
    +                        msg = "- found missing intervals, inserting nans:"
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                        print(intervals_missing_df_recent)
    +                    if missing_intervals_to_add is None:
    +                        missing_intervals_to_add = intervals_missing_df_recent["open"].to_numpy()
    +                    else:
    +                        missing_intervals_to_add = np.append(missing_intervals_to_add, intervals_missing_df_recent["open"].to_numpy())
    +
    +            if missing_intervals_to_add is not None:
    +                n = missing_intervals_to_add.shape[0]
    +                if n <= 3:
    +                    msg = f"insertings NaNs for {n} missing intervals: {missing_intervals_to_add}"
    +                else:
    +                    msg = f"insertings NaNs for {n} missing intervals"
    +                if debug_yfc:
    +                    yfcl.TracePrint('- ' + msg) if yfcl.IsTracingEnabled() else print('- ' + msg)
    +                else:
    +                    self.manager.LogEvent("info", "PriceManager", msg)
    +
    +                nm = missing_intervals_to_add.shape[0]
    +                df_missing = pd.DataFrame(data={k: [np.nan]*nm for k in yfcd.yf_data_cols}, index=missing_intervals_to_add)
    +                df_missing['Volume'] = 0 # Needs to be int type
    +                if "Repaired?" in df.columns:
    +                    df_missing["Repaired?"] = False
    +                df_missing.index = pd.to_datetime(df_missing.index)
    +                if self.interday:
    +                    df_missing.index = df_missing.index.tz_localize(tz_exchange)
    +                for c in ["Volume", "Dividends", "Stock Splits", "Capital Gains"]:
    +                    df_missing[c] = 0
    +                if df is None:
    +                    df = df_missing
    +                else:
    +                    df = pd.concat([df, df_missing[df.columns]])
    +                    df.index = pd.to_datetime(df.index, utc=True).tz_convert(tz_exchange)
    +                    df = df.sort_index()
    +
    +        # Improve tolerance to calendar missing a recent new holiday:
    +        if df is None or df.empty:
    +            return None
    +
    +        n = df.shape[0]
    +
    +        fetch_dt = fetch_dt_utc.replace(tzinfo=ZoneInfo("UTC"))
    +
    +        if self.interval == yfcd.Interval.Days1:
    +            # Update: move checking for new dividends to here, before discarding out-of-range data
    +            df_divs = df[df["Dividends"] != 0][["Dividends"]].copy()
    +            if not df_divs.empty:
    +                df_divs['FetchDate'] = fetch_dt_utc
    +                df_divs['Desplitted?'] = False
    +                if debug_yfc:
    +                    print("- df_divs:")
    +                    print(df_divs)
    +                cached_new_divs = yfcm.ReadCacheDatum(self.ticker, "new_divs")
    +                if cached_new_divs is not None:
    +                    if 'Desplitted?' not in cached_new_divs.columns:
    +                        cached_new_divs['Desplitted?'] = False
    +                    df_divs = df_divs[~df_divs.index.isin(cached_new_divs.index)]
    +                    if not df_divs.empty:
    +                        divs_pretty = df_divs['Dividends'].copy()
    +                        divs_pretty.index = divs_pretty.index.date
    +                        self.manager.LogEvent("info", "DividendManager", f"detected {divs_pretty.shape[0]} new dividends: {divs_pretty} (before reversing adjust)")
    +                        if yfcm.ReadCacheMetadata(self.ticker, "new_divs", "locked") is not None:
    +                            # locked
    +                            pass
    +                        else:
    +                            cached_new_divs = pd.concat([cached_new_divs, df_divs])
    +                            yfcm.StoreCacheDatum(self.ticker, "new_divs", cached_new_divs)
    +                else:
    +                    yfcm.StoreCacheDatum(self.ticker, "new_divs", df_divs)
    +
    +        # Remove any out-of-range data:
    +        if (n > 0):
    +            # NOTE: YF has a bug-fix pending merge: https://github.com/ranaroussi/yfinance/pull/1012
    +            if end is not None:
    +                if self.interday:
    +                    df = df[df.index.date < end_d]
    +                else:
    +                    df = df[df.index < end_dt]
    +                n = df.shape[0]
    +            #
    +            # And again for pre-start data:
    +            if start is not None:
    +                if self.interday:
    +                    df = df[df.index.date >= start_d]
    +                else:
    +                    df = df[df.index >= start_dt]
    +                n = df.shape[0]
    +
    +        # Verify that all datetimes match up with actual intervals:
    +        if n == 0:
    +            raise yfcd.NoPriceDataInRangeException(self.ticker, self.istr, start, end)
    +        else:
    +            if self.interday:
    +                f = df.index.time != time(0)
    +                if f.any():
    +                    print(df[f])
    +                    raise Exception("Interday data contains times in index")
    +                yfIntervalStarts = df.index.date
    +            else:
    +                yfIntervalStarts = df.index.to_pydatetime()
    +            #
    +            if self.intraday and (self.exchange in yfcd.exchangesWithBreaks):
    +                # Discard any intervals fully within a break
    +                f_in_break = yfct.TimestampInBreak_batch(self.exchange, yfIntervalStarts, self.interval)
    +                if f_in_break.any():
    +                    # Discard these
    +                    if debug_yfc:
    +                        msg = "- dropping rows in break times"
    +                        yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                    yfIntervalStarts = yfIntervalStarts[~f_in_break]
    +                    df = df[~f_in_break]
    +                    n = df.shape[0]
    +            #
    +            intervals = yfct.GetTimestampCurrentInterval_batch(self.exchange, yfIntervalStarts, self.interval, discardTimes=self.interday, ignore_breaks=True)
    +
    +            f_na = intervals["interval_open"].isna().to_numpy()
    +            if verify_intervals and f_na.any():
    +                ts = intervals["interval_open"]
    +                if len(ts) != len(set(ts)):
    +                    dups = ts[ts.duplicated(keep=False)]
    +                    # Drop rows that map to duplicate intervals if no trading occurred.
    +                    f_no_trades = (df["Volume"] == 0) & ((df["Low"] == df["High"]) | df["Close"].isna())
    +                    drop_dts = None
    +                    for i in dups:
    +                        dts = intervals.index[intervals["interval_open"] == i]
    +                        dts_is_nan = np.array([f_no_trades[df.index.get_loc(dt)] for dt in dts])
    +                        if dts_is_nan.all():
    +                            # Keep first, drop others
    +                            drop_dts_sub = dts[1:]
    +                        else:
    +                            # Keep non-nan, drop nans
    +                            drop_dts_sub = dts[dts_is_nan]
    +                        drop_dts = drop_dts_sub if drop_dts is None else np.append(drop_dts, drop_dts_sub)
    +                    # print("dropping:", drop_dts)
    +                    yfIntervalStarts = np.delete(yfIntervalStarts, [df.index.get_loc(dt) for dt in drop_dts])
    +                    intervals = intervals.drop(drop_dts)
    +                    df = df.drop(drop_dts)
    +                    n = df.shape[0]
    +                    f_na = intervals["interval_open"].isna().to_numpy()
    +
    +                if not self.interday:
    +                    # For some exchanges (e.g. JSE) Yahoo returns intraday timestamps right on market close.
    +                    # - remove if volume 0
    +                    # - else, merge with previous interval
    +                    df2 = df.copy() ; df2["_date"] = df2.index.date ; df2["_intervalStart"] = df2.index
    +                    sched = yfct.GetExchangeSchedule(self.exchange, df2["_date"].min(), df2["_date"].max()+td_1d)
    +                    rename_cols = {"open": "market_open", "close": "market_close"}
    +                    sched.columns = [rename_cols[c] if c in rename_cols else c for c in sched.columns]
    +                    sched_df = sched.copy()
    +                    sched_df["_date"] = sched_df.index.date
    +                    df2 = df2.merge(sched_df, on="_date", how="left")
    +                    df2.index = df.index
    +                    f_close = (df2["_intervalStart"] == df2["market_close"]).to_numpy()
    +                    f_close = f_close & f_na
    +                    f_vol0 = df2["Volume"] == 0
    +                    f_drop = f_vol0 & f_close
    +                    if f_drop.any():
    +                        if debug_yfc:
    +                            msg = "- dropping 0-volume rows starting at market close"
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                        yfIntervalStarts = yfIntervalStarts[~f_drop]
    +                        intervals = intervals[~f_drop]
    +                        df = df[~f_drop]
    +                        df2 = df2[~f_drop]
    +                        n = df.shape[0]
    +                        f_na = intervals["interval_open"].isna().to_numpy()
    +                    #
    +                    f_close = (df2["_intervalStart"] == df2["market_close"]).to_numpy()
    +                    f_close = f_close & f_na
    +                    if f_close.any():
    +                        # Must merge with previous interval. Tricky!
    +                        df3 = df2[f_close]
    +                        df3_index_rev = sorted(list(df3.index), reverse=True)
    +                        for dt in df3_index_rev:
    +                            i = df.index.get_loc(dt)
    +                            if i == 0:
    +                                # Can't fix
    +                                continue
    +                            dt_before = df.index[i-1]
    +                            if (dt-dt_before) <= self.itd:
    +                                # Merge
    +                                df_rows = df.iloc[i-1:i+1]
    +                                df.loc[dt_before, "Low"] = df_rows["Low"].dropna().min()
    +                                df.loc[dt_before, "High"] = df_rows["High"].dropna().max()
    +                                df.loc[dt_before, "Open"] = df_rows["Open"].dropna()[0]
    +                                df.loc[dt_before, "Close"] = df_rows["Close"].dropna()[-1]
    +                                df.loc[dt_before, "Adj Close"] = df_rows["Adj Close"].dropna()[-1]
    +                                df.loc[dt_before, "Volume"] = df_rows["Volume"].dropna().sum()
    +
    +                                yfIntervalStarts = np.delete(yfIntervalStarts, i)
    +                                intervals = intervals.drop(dt)
    +                                df = df.drop(dt)
    +                                n = df.shape[0]
    +                                f_na = intervals["interval_open"].isna().to_numpy()
    +                            else:
    +                                # Previous interval too far, must insert a new interval
    +                                raise Exception("this code path not tested, review")
    +                                dt_correct = dt_before + self.itd
    +                                if dt_correct >= dt:
    +                                    raise Exception(f"dt_correct={dt_correct} >= dt={dt} , expected <")
    +                                if dt_correct.date() != dt.date():
    +                                    raise Exception(f"dt_correct={dt_correct} & dt={dt} , expected same day")
    +                                df.loc[dt_correct] = df.loc[dt] ; df = df.drop(dt).sort_index()
    +                                yfIntervalStarts[i] = dt_correct
    +                                intervals = intervals.drop(dt)
    +                                intervals.loc[dt_correct] = {"interval_open": dt_correct, "interval_close": df2.loc[dt, "market_close"]}
    +                                f_na = intervals["interval_open"].isna().to_numpy()
    +
    +                if f_na.any():
    +                    f_no_divs_splits = (df['Dividends']==0).to_numpy() & (df['Stock Splits']==0).to_numpy()
    +
    +                    # For some national holidays when exchange closed, Yahoo fills in row. Clue is 0 volume.
    +                    # Solution = drop:
    +                    f_na_zeroVol = f_na & (df["Volume"] == 0).to_numpy()
    +                    f_na_zeroVol = f_na_zeroVol & f_no_divs_splits
    +                    if f_na_zeroVol.any():
    +                        if debug_yfc:
    +                            msg = "- dropping {} 0-volume rows with no matching interval".format(sum(f_na_zeroVol))
    +                            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                        f_drop = f_na_zeroVol
    +                        yfIntervalStarts = yfIntervalStarts[~f_drop]
    +                        intervals = intervals[~f_drop]
    +                        df = df[~f_drop]
    +                        n = df.shape[0]
    +                        f_na = intervals["interval_open"].isna().to_numpy()
    +                        f_no_divs_splits = (df['Dividends']==0).to_numpy() & (df['Stock Splits']==0).to_numpy()
    +
    +                    # ... another clue is row is identical to previous trading day
    +                    if f_na.any():
    +                        f_drop = np.array([False]*n)
    +                        for i in np.where(f_na)[0]:
    +                            if i > 0:
    +                                dt = df.index[i]
    +                                last_dt = df.index[i-1]
    +                                if (df.loc[dt, yfcd.yf_data_cols] == df.loc[last_dt, yfcd.yf_data_cols]).all():
    +                                    f_drop[i] = True
    +                        if f_drop.any():
    +                            if debug_yfc:
    +                                msg = "- dropping rows with no interval that are identical to previous row"
    +                                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                            yfIntervalStarts = yfIntervalStarts[~f_drop]
    +                            intervals = intervals[~f_drop]
    +                            df = df[~f_drop]
    +                            n = df.shape[0]
    +                            f_na = intervals["interval_open"].isna().to_numpy()
    +                        f_no_divs_splits = (df['Dividends']==0).to_numpy() & (df['Stock Splits']==0).to_numpy()
    +
    +                    # ... and another clue is Open=High=Low=0.0
    +                    if f_na.any():
    +                        f_zero = (df['Open']==0).to_numpy() & (df['Low']==0).to_numpy() & (df['High']==0).to_numpy()
    +                        f_zero = f_zero & f_no_divs_splits
    +                        f_na_zero = f_na & f_zero
    +                        if f_na_zero.any():
    +                            if debug_yfc:
    +                                msg = "- dropping {} price=0 rows with no matching interval".format(sum(f_na_zero))
    +                                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                            f_drop = f_na_zero
    +                            yfIntervalStarts = yfIntervalStarts[~f_drop]
    +                            intervals = intervals[~f_drop]
    +                            df = df[~f_drop]
    +                            n = df.shape[0]
    +                            f_na = intervals["interval_open"].isna().to_numpy()
    +
    +                if f_na.any() and self.interval == yfcd.Interval.Mins1:
    +                    # If 1-minute interval at market close, then merge with previous minute
    +                    indices = sorted(np.where(f_na)[0], reverse=True)
    +                    for idx in indices:
    +                        dt = df.index[idx]
    +                        sched = yfct.GetExchangeSchedule(self.exchange, dt.date(), dt.date()+td_1d)
    +                        if dt.time() == sched["close"].iloc[0].time():
    +                            if idx == 0:
    +                                # Discard
    +                                print("discarding")
    +                                pass
    +                            else:
    +                                print("merging")
    +                                # Merge with previous
    +                                dt1 = df.index[idx-1]
    +                                df.loc[dt1, "Close"] = df["Close"].iloc[idx]
    +                                df.loc[dt1, "High"] = df["High"].iloc[idx-1:idx+1].max()
    +                                df.loc[dt1, "Low"] = df["Low"].iloc[idx-1:idx+1].min()
    +                                df.loc[dt1, "Volume"] = df["Volume"].iloc[idx-1:idx+1].sum()
    +                            df = df.drop(dt)
    +                            intervals = intervals.drop(dt)
    +                            yfIntervalStarts = np.delete(yfIntervalStarts, idx)
    +                    f_na = intervals["interval_open"].isna().to_numpy()
    +
    +                if f_na.any():
    +                    df_na = df[f_na][["Close", "Volume", "Dividends", "Stock Splits"]]
    +                    n = df_na.shape[0]
    +                    warning_msg = f"Failed to map these Yahoo intervals to xcal: (tkr={self.ticker}, exchange={self.exchange}, xcal={yfcd.exchangeToXcalExchange[self.exchange]})."
    +                    warning_msg += " Normally happens when 'exchange_calendars' is wrong so inform developers."
    +                    print("")
    +                    print(warning_msg)
    +                    print(df_na)
    +                    msg = "Accept into cache anyway?"
    +                    if False:
    +                        accept = True
    +                    else:
    +                        accept = click.confirm(msg, default=False)
    +                    if accept:
    +                        for idx in np.where(f_na)[0]:
    +                            dt = intervals.index[idx]
    +                            if self.interday:
    +                                intervals.loc[dt, "interval_open"] = df.index[idx].date()
    +                                intervals.loc[dt, "interval_close"] = df.index[idx].date() + self.itd
    +                            else:
    +                                intervals.loc[dt, "interval_open"] = df.index[idx]
    +                                intervals.loc[dt, "interval_close"] = df.index[idx] + self.itd
    +                    else:
    +                        raise Exception("Problem with dates returned by Yahoo, see above")
    +
    +        if df is None or df.empty:
    +            return None
    +
    +        df = df.copy()
    +
    +        if not disable_yfc_metadata:
    +            lastDataDts = yfct.CalcIntervalLastDataDt_batch(self.exchange, intervals["interval_open"].to_numpy(), self.interval)
    +            if f_na.any():
    +                # Hacky solution to handle xcal having incorrect schedule, for valid Yahoo data
    +                lastDataDts[f_na] = intervals.index[f_na] + self.itd
    +                if self.intraday:
    +                    lastDataDts[f_na] += yfct.GetExchangeDataDelay(self.exchange)
    +                    # For some exchanges, Yahoo has trades that occurred soon afer official market close, e.g. Johannesburg:
    +                    if self.exchange in ["JNB"]:
    +                        lastDataDts[f_na] += timedelta(minutes=15)
    +                else:
    +                    # Add ~10 hours to ensure hit next market open
    +                    lastDataDts[f_na] += timedelta(hours=10)
    +            data_final = fetch_dt >= lastDataDts
    +            df["Final?"] = data_final
    +
    +            # df["FetchDate"] = pd.Timestamp(fetch_dt_utc).tz_localize("UTC")
    +            df["FetchDate"] = fetch_dt_utc
    +
    +            df["C-Check?"] = False
    +
    +        log_msg = f"PM::_fetchYfHistory() returning DF {df.index[0]} -> {df.index[-1]}"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +
    +        return df
    +
    +    def _fetchYfHistory_dateRange(self, start, end, prepost, debug):
    +        yfcu.TypeCheckIntervalDt(start, self.interval, "start")
    +        yfcu.TypeCheckIntervalDt(end, self.interval, "end")
    +        yfcu.TypeCheckBool(prepost, "prepost")
    +        yfcu.TypeCheckBool(debug, "debug")
    +
    +        debug_yfc = False
    +        # debug_yfc = True
    +
    +        log_msg = f"PM::_fetchYfHistory_dateRange-{self.istr}(start={start} , end={end} , prepost={prepost})"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +        elif debug_yfc:
    +            print("")
    +            print(log_msg)
    +
    +        fetch_start = start
    +        fetch_end = end
    +        if not isinstance(fetch_start, (datetime, pd.Timestamp)):
    +            fetch_start_dt = datetime.combine(fetch_start, time(0), self.tz)
    +        else:
    +            fetch_start_dt = fetch_start
    +
    +        history_args = {"period": None,
    +                        "interval": self.istr,
    +                        "start": fetch_start, "end": fetch_end,
    +                        "prepost": prepost,
    +                        "actions": True,  # Always fetch
    +                        "keepna": True,
    +                        "repair": True,
    +                        "auto_adjust": False,  # store raw data, adjust myself
    +                        "back_adjust": False,  # store raw data, adjust myself
    +                        "proxy": self.proxy,
    +                        "rounding": False,  # store raw data, round myself
    +                        "raise_errors": True}
    +        if debug:
    +            yf_logger = logging.getLogger('yfinance')
    +            yf_logger.setLevel(logging.DEBUG)  # verbose: print errors & debug info
    +
    +        if debug_yfc:
    +            if (not isinstance(fetch_start, datetime)) or fetch_start.time() == time(0):
    +                start_str = fetch_start.strftime("%Y-%m-%d")
    +            else:
    +                start_str = fetch_start.strftime("%Y-%m-%d %H:%M:%S")
    +            if (not isinstance(fetch_end, datetime)) or fetch_end.time() == time(0):
    +                end_str = fetch_end.strftime("%Y-%m-%d")
    +            else:
    +                end_str = fetch_end.strftime("%Y-%m-%d %H:%M:%S")
    +            msg = f"- {self.ticker}: fetching {self.istr} {start_str} -> {end_str}"
    +            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +
    +        first_fetch_failed = False
    +        df = None
    +        try:
    +            if debug_yfc:
    +                msg = f"- fetch_start={fetch_start} ; fetch_end={fetch_end}"
    +                yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +            df = self.dat.history(**history_args)
    +            df = df.sort_index()
    +            if "Repaired?" not in df.columns:
    +                df["Repaired?"] = False
    +            if debug_yfc:
    +                if df is None:
    +                    msg = "- YF returned None"
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                else:
    +                    msg = "- YF returned table:"
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                    print(df[[c for c in ["Open", "Low", "High", "Close", "Dividends", "Volume"] if c in df.columns]])
    +            if df is None or df.empty:
    +                raise Exception("No data found for this date range")
    +        except Exception as e:
    +            first_fetch_failed = True
    +            if "Data doesn't exist for startDate" in str(e):
    +                raise yfcd.NoPriceDataInRangeException(self.ticker, self.istr, start, end)
    +            elif "No data found for this date range" in str(e):
    +                raise yfcd.NoPriceDataInRangeException(self.ticker, self.istr, start, end)
    +            elif "No price data found, symbol may be delisted" in str(e):
    +                raise yfcd.NoPriceDataInRangeException(self.ticker, self.istr, start, end)
    +            else:
    +                print("df:")
    +                print(df)
    +                raise e
    +
    +        if not first_fetch_failed and fetch_start is not None:
    +            log_msg = f"requested from YF: {self.istr} {history_args['start']} -> {history_args['end']}"
    +            if self.interday:
    +                log_msg += f", received: {df.index[0].date()} -> {df.index[-1].date()}"
    +            else:
    +                log_msg += f", received: {df.index[0]} -> {df.index[-1]}"
    +            self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +            df = df.loc[fetch_start_dt:]
    +            if df.empty:
    +                first_fetch_failed = True
    +                raise yfcd.NoPriceDataInRangeException(self.ticker, self.istr, start, end)
    +
    +        # Check that weekly aligned to Monday. If not, shift start date back and re-fetch
    +        if self.interval == yfcd.Interval.Week and (not df.empty) and (df.index[0].weekday() != 0):
    +            # Despite fetch_start aligned to Monday, sometimes Yahoo returns weekly
    +            # data starting a different day. Shifting back a little fixes
    +            shift_backs = [2, 4]
    +            for d in shift_backs:
    +                fetch_start2 = fetch_start - timedelta(days=d)
    +                history_args["start"] = fetch_start2
    +                if debug_yfc:
    +                    msg = "- weekly data not aligned to Monday, re-fetching from {}".format(fetch_start2)
    +                    yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +                df = self.dat.history(**history_args)
    +                if "Repaired?" not in df.columns:
    +                    df["Repaired?"] = False
    +                if self.interval == yfcd.Interval.Week and (df.index[0].weekday() == 0):
    +                    log_msg = f"requested from YF: {self.istr} {history_args['start']} -> {history_args['end']}"
    +                    log_msg += f", received: {df.index[0]} -> {df.index[-1]}"
    +                    self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +                    if isinstance(start, datetime):
    +                        start_dt = start
    +                    else:
    +                        start_dt = datetime.combine(start, time(0), self.tz)
    +                    df = df.loc[start_dt:]
    +                    break
    +
    +            if self.interval == yfcd.Interval.Week and (df.index[0].weekday() != 0):
    +                # print("Date range requested: {} -> {}".format(fetch_start, fetch_end))
    +                print(df)
    +                raise Exception("Weekly data returned by YF doesn't begin Monday but {}".format(df.index[0].weekday()))
    +
    +        if df is not None and df.empty:
    +            df = None
    +
    +        if df is None:
    +            log_msg = "PM::_fetchYfHistory_dateRange() returning None"
    +        else:
    +            log_msg = f"PM::_fetchYfHistory_dateRange() returning DF {df.index[0]} -> {df.index[-1]}"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +        elif debug_yfc:
    +            print(log_msg)
    +
    +        # df = yfcu.CustomNanCheckingDataFrame(df)
    +
    +        return df
    +
    +    def _reconstruct_intervals_batch(self, df, tag=-1):
    +        if not isinstance(df, pd.DataFrame):
    +            raise Exception("'df' must be a Pandas DataFrame not", type(df))
    +        if self.interval == yfcd.Interval.Mins1:
    +            return df
    +
    +        # Reconstruct values in df using finer-grained price data. Delimiter marks what to reconstruct
    +
    +        debug = False
    +        # debug = True
    +
    +        price_cols = [c for c in ["Open", "High", "Low", "Close", "Adj Close"] if c in df]
    +        data_cols = price_cols + ["Volume"]
    +
    +        log_msg = f"PM::_reconstruct_intervals_batch-{self.istr}(dt0={df.index[0]})"
    +        yfcl.TraceEnter(log_msg)
    +
    +        # If interval is weekly then can construct with daily. But if smaller intervals then
    +        # restricted to recent times:
    +        min_lookback = None
    +        # - daily = hourly restricted to last 730 days
    +        if self.interval == yfcd.Interval.Week:
    +            # Correct by fetching week of daily data
    +            sub_interval = yfcd.Interval.Days1
    +            td_range = timedelta(days=7)
    +        elif self.interval == yfcd.Interval.Days1:
    +            # Correct by fetching day of hourly data
    +            sub_interval = yfcd.Interval.Hours1
    +            td_range = timedelta(days=1)
    +            min_lookback = timedelta(days=730)
    +        elif self.interval == yfcd.Interval.Hours1:
    +            # Correct by fetching hour of 30m data
    +            sub_interval = yfcd.Interval.Mins30
    +            td_range = timedelta(hours=1)
    +            min_lookback = timedelta(days=60)
    +        elif self.interval == yfcd.Interval.Mins30:
    +            # Correct by fetching hour of 15m data
    +            sub_interval = yfcd.Interval.Mins15
    +            td_range = timedelta(minutes=30)
    +            min_lookback = timedelta(days=60)
    +        elif self.interval == yfcd.Interval.Mins15:
    +            # Correct by fetching hour of 5m data
    +            sub_interval = yfcd.Interval.Mins5
    +            td_range = timedelta(minutes=15)
    +            min_lookback = timedelta(days=60)
    +        elif self.interval == yfcd.Interval.Mins5:
    +            # Correct by fetching hour of 2m data
    +            sub_interval = yfcd.Interval.Mins2
    +            td_range = timedelta(minutes=5)
    +            min_lookback = timedelta(days=60)
    +        elif self.interval == yfcd.Interval.Mins2:
    +            # Correct by fetching hour of 1m data
    +            sub_interval = yfcd.Interval.Mins1
    +            td_range = timedelta(minutes=2)
    +            min_lookback = timedelta(days=30)
    +        else:
    +            msg = f"WARNING: Have not implemented repair for '{self.interval}' interval. Contact developers"
    +            yfcl.TracePrint(msg) if yfcl.IsTracingEnabled() else print(msg)
    +            log_msg = "PM::_reconstruct_intervals_batch() returning"
    +            yfcl.TraceExit(log_msg)
    +            return df
    +        sub_interday = sub_interval in [yfcd.Interval.Days1, yfcd.Interval.Week]#, yfcd.Interval.Months1, yfcd.Interval.Months3]
    +        sub_intraday = not sub_interday
    +
    +        df = df.sort_index()
    +
    +        f_repair = df[data_cols].to_numpy() == tag
    +        f_repair_rows = f_repair.any(axis=1)
    +
    +        # Ignore old intervals for which Yahoo won't return finer data:
    +        # if sub_interval == yfcd.Interval.Hours1:
    +        #     f_recent = date.today() - df.index.date < timedelta(days=730)
    +        #     f_repair_rows = f_repair_rows & f_recent
    +        # elif sub_interval in [yfcd.Interval.Mins30, yfcd.Interval.Mins15]:
    +        #     f_recent = date.today() - df.index.date < timedelta(days=60)
    +        #     f_repair_rows = f_repair_rows & f_recent
    +        if min_lookback is None:
    +            min_dt = None
    +        else:
    +            min_dt = pd.Timestamp.utcnow().tz_convert(ZoneInfo("UTC")) - min_lookback
    +        if debug:
    +            print(f"- min_dt={min_dt} interval={self.interval} sub_interval={sub_interval}")
    +        if min_dt is not None:
    +            f_recent = df.index > min_dt
    +            f_repair_rows = f_repair_rows & f_recent
    +            if not f_repair_rows.any():
    +                if debug:
    +                    print("- data too old to repair")
    +                return df
    +
    +        dts_to_repair = df.index[f_repair_rows]
    +        # indices_to_repair = np.where(f_repair_rows)[0]
    +
    +        if len(dts_to_repair) == 0:
    +            return df
    +
    +        df_v2 = df.copy()
    +        if "Repaired?" not in df_v2.columns:
    +            df_v2["Repaired?"] = False
    +        df_good = df[~df[price_cols].isna().any(axis=1)]
    +        f_tag = df_v2[price_cols].to_numpy() == tag
    +
    +        # Group nearby NaN-intervals together to reduce number of Yahoo fetches
    +        dts_groups = [[dts_to_repair[0]]]
    +        # last_dt = dts_to_repair[0]
    +        # last_ind = indices_to_repair[0]
    +        # td = yfcd.intervalToTimedelta[self.interval]
    +        # if self.interval == yfcd.Interval.Months1:
    +        #     grp_td_threshold = timedelta(days=28)
    +        # elif self.interval == yfcd.Interval.Week:
    +        #     grp_td_threshold = timedelta(days=28)
    +        # elif self.interval == yfcd.Interval.Days1:
    +        #     grp_td_threshold = timedelta(days=14)
    +        # elif self.interval == yfcd.Interval.Hours1:
    +        #     grp_td_threshold = timedelta(days=7)
    +        # else:
    +        #     grp_td_threshold = timedelta(days=2)
    +        #     # grp_td_threshold = timedelta(days=7)
    +        # for i in range(1, len(dts_to_repair)):
    +        #     ind = indices_to_repair[i]
    +        #     dt = dts_to_repair[i]
    +        #     if (dt-dts_groups[-1][-1]) < grp_td_threshold:
    +        #         dts_groups[-1].append(dt)
    +        #     elif ind - last_ind <= 3:
    +        #         dts_groups[-1].append(dt)
    +        #     else:
    +        #         dts_groups.append([dt])
    +        #     last_dt = dt
    +        #     last_ind = ind
    +        # for i in range(1, len(dts_to_repair)):
    +        #     ind = indices_to_repair[i]
    +        #     dt = dts_to_repair[i]
    +        #     if (dt-dts_groups[-1][-1]) < grp_td_threshold:
    +        #         dts_groups[-1].append(dt)
    +        #     elif ind - last_ind <= 3:
    +        #         dts_groups[-1].append(dt)
    +        #     else:
    +        #         dts_groups.append([dt])
    +        #     last_dt = dt
    +        #     last_ind = ind
    +        # if self.interval == yfcd.Interval.Months1:
    +        #     grp_max_size = dateutil.relativedelta.relativedelta(years=2)
    +        # elif self.interval == yfcd.Interval.Week:
    +        if self.interval == yfcd.Interval.Week:
    +            grp_max_size = dateutil.relativedelta.relativedelta(years=2)
    +        elif self.interval == yfcd.Interval.Days1:
    +            grp_max_size = dateutil.relativedelta.relativedelta(years=2)
    +        elif self.interval == yfcd.Interval.Hours1:
    +            grp_max_size = dateutil.relativedelta.relativedelta(years=1)
    +        else:
    +            grp_max_size = timedelta(days=30)
    +        if debug:
    +            print("- grp_max_size =", grp_max_size)
    +        for i in range(1, len(dts_to_repair)):
    +            # ind = indices_to_repair[i]
    +            dt = dts_to_repair[i]
    +            if dt.date() < dts_groups[-1][0].date()+grp_max_size:
    +                dts_groups[-1].append(dt)
    +            else:
    +                dts_groups.append([dt])
    +            # last_dt = dt
    +            # last_ind = ind
    +
    +        if debug:
    +            print("Repair groups:")
    +            for g in dts_groups:
    +                print(f"- {g[0]} -> {g[-1]}")
    +
    +        # Add some good data to each group, so can calibrate later:
    +        # for i in range(len(dts_groups)):
    +        #     g = dts_groups[i]
    +        #     g0 = g[0]
    +        #     i0 = df_good.index.get_loc(g0)
    +        #     if i0 > 0:
    +        #         dts_groups[i].insert(0, df_good.index[i0-1])
    +        #     gl = g[-1]
    +        #     il = df_good.index.get_loc(gl)
    +        #     if il < len(df_good)-1:
    +        #         dts_groups[i].append(df_good.index[il+1])
    +        for i in range(len(dts_groups)):
    +            g = dts_groups[i]
    +            g0 = g[0]
    +            i0 = df_good.index.get_indexer([g0], method="nearest")[0]
    +            if i0 > 0:
    +                if (min_dt is None or df_good.index[i0-1] >= min_dt) \
    +                    and ((not self.intraday) or df_good.index[i0-1].date() == g0.date()):
    +                    i0 -= 1
    +            gl = g[-1]
    +            il = df_good.index.get_indexer([gl], method="nearest")[0]
    +            if il < len(df_good)-1:
    +                if (not self.intraday) or df_good.index[il+1].date() == gl.date():
    +                    il += 1
    +            good_dts = df_good.index[i0:il+1]
    +            dts_groups[i] += good_dts.to_list()
    +            dts_groups[i].sort()
    +
    +        n_fixed = 0
    +        for g in dts_groups:
    +            df_block = df[df.index.isin(g)]
    +
    +            if debug:
    +                print("df_block:") ; print(df_block)
    +
    +            start_dt = g[0]
    +            start_d = start_dt.date()
    +
    +            if sub_interval == yfcd.Interval.Hours1 and (date.today()-start_d) > timedelta(days=729):
    +                # Don't bother requesting more price data, Yahoo will reject
    +                continue
    +            elif sub_interval in [yfcd.Interval.Mins30, yfcd.Interval.Mins15] and (date.today()-start_d) > timedelta(days=59):
    +                # Don't bother requesting more price data, Yahoo will reject
    +                continue
    +
    +            if self._record_stack_trace:
    +                # Log function calls to detect and manage infinite recursion
    +                fn_tuple = ("_reconstruct_intervals_batch()", f"dt0={df.index[0]}", f"interval={self.interval}")
    +                if fn_tuple in self._stack_trace:
    +                    # Detected a potential recursion loop
    +                    reconstruct_detected = False
    +                    for i in range(len(self._stack_trace)-1, -1, -1):
    +                        if "_reconstruct_intervals_batch" in str(self._stack_trace[i]):
    +                            reconstruct_detected = True
    +                            break
    +                    if reconstruct_detected:
    +                        self._infinite_recursion_detected = True
    +                self._stack_trace.append(fn_tuple)
    +
    +            if self.interday:
    +                log_msg = f"repairing {self.istr} block {g[0].date()} -> {g[-1].date()+timedelta(days=1)}"
    +            else:
    +                log_msg = f"repairing {self.istr} block {g[0]} -> {g[-1]}"
    +            self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +            # Infinite loop potential here via repair:
    +            # - request fine-grained data e.g. 1H
    +            # - 1H requires accurate dividend data
    +            # - triggers fetch of 1D data which must be kept contiguous
    +            # - triggers fetch of older 1D data which requires repair using 1H data -> recursion loop
    +            # Solution:
    +            # 1) add tuple to fn stack buy with YF=True
    +            # 2) if that tuple already in stack then raise Exception
    +            if self.interval == yfcd.Interval.Week:
    +                fetch_start = start_d - td_range  # need previous week too
    +                fetch_end = g[-1].date() + td_range
    +            elif self.interval == yfcd.Interval.Days1:
    +                fetch_start = start_d
    +                fetch_end = g[-1].date() + td_range
    +            else:
    +                fetch_start = g[0]
    +                fetch_end = g[-1] + td_range
    +            # print(f"fetch_start={fetch_start} fetch_end={fetch_end}")
    +
    +            # prepost = self.interval == yfcd.Interval.Days1
    +            prepost = self.interday
    +            if debug:
    +                print(f"- fetch_start={fetch_start}, fetch_end={fetch_end} prepost={prepost}")
    +            if self._infinite_recursion_detected:
    +                for i in range(len(self._stack_trace)):
    +                    print("  "*i + str(self._stack_trace[i]))
    +                raise Exception("WARNING: Infinite recursion detected (see stack trace above). Switch to fetching prices direct from YF")
    +                print("WARNING: Infinite recursion detected (see stack trace above). Switch to fetching prices direct from YF")
    +                # df_fine = self.dat.history(start=fetch_start, end=fetch_end, interval=yfcd.intervalToString[sub_interval], auto_adjust=False, prepost=prepost, keepna=True)
    +            # elif self.interval in [yfcd.Interval.Days1]:  # or self._infinite_recursion_detected:
    +            #     # Assume infinite recursion will happen
    +            #     df_fine = self.dat.history(start=fetch_start, end=fetch_end, interval=yfcd.intervalToString[sub_interval], auto_adjust=False, repair=True)
    +            else:
    +                if prepost and sub_intraday:
    +                    # YFC cannot handle intraday pre- and post-market, so fetch via yfinance
    +                    if debug:
    +                        # print("- fetching df_fine direct from YF")
    +                        print(f"- - fetch_start={fetch_start} fetch_end={fetch_end}")
    +                    df_fine_old = self.dat.history(start=fetch_start, end=fetch_end, interval=yfcd.intervalToString[sub_interval], auto_adjust=True, prepost=prepost)
    +                    hist_sub = self.manager.GetHistory(sub_interval)
    +                    if not isinstance(fetch_start, datetime):
    +                        fetch_start = datetime.combine(fetch_start, time(0), ZoneInfo(self.tzName))
    +                    if not isinstance(fetch_end, datetime):
    +                        fetch_end = datetime.combine(fetch_end, time(0), ZoneInfo(self.tzName))
    +                    if debug:
    +                        print("- fetching df_fine via _fetchYfHistory() wrapper")
    +                        print(f"- - fetch_start={fetch_start} fetch_end={fetch_end}")
    +                    try:
    +                        df_fine = hist_sub._fetchYfHistory(start=fetch_start, end=fetch_end, prepost=prepost, debug=False, verify_intervals=False, disable_yfc_metadata=True)
    +                    except yfcd.NoPriceDataInRangeException as e:
    +                        if debug:
    +                            print("- fetch of fine price data failed:" + str(e))
    +                        continue
    +                    if df_fine is not None:
    +                        adj = (df_fine["Adj Close"]/df_fine["Close"]).to_numpy()
    +                        for c in ["Open", "Low", "High", "Close"]:
    +                            df_fine[c] *= adj
    +                        df_fine = df_fine[["Open", "Low", "High", "Close", "Volume", "Dividends", "Stock Splits"]]
    +                    if debug:
    +                        print("df_fine_old:")
    +                        print(df_fine_old)
    +                        print("df_fine:")
    +                        print(df_fine)
    +                    # raise Exception("here")
    +                else:
    +                    if debug:
    +                        print("- fetching df_fine via YFC")
    +                    hist_sub = self.manager.GetHistory(sub_interval)
    +                    df_fine = hist_sub.get(fetch_start, fetch_end, adjust_splits=False, adjust_divs=False, repair=False, prepost=prepost)
    +                # df_fine["Adj Close"] = df_fine["Close"] * df_fine["CDF"]
    +            if debug:
    +                print("- df_fine:")
    +                print(df_fine)
    +            if df_fine is None or len(df_fine) == 0:
    +                print("YF: WARNING: Cannot reconstruct because Yahoo not returning data in interval")
    +                if self._record_stack_trace:
    +                    # Pop stack trace
    +                    if len(self._stack_trace) == 0:
    +                        raise Exception("Failing to correctly push/pop stack trace (is empty too early)")
    +                    if not self._stack_trace[-1] == fn_tuple:
    +                        for i in range(len(self._stack_trace)):
    +                            print("  "*i + str(self._stack_trace[i]))
    +                        raise Exception("Failing to correctly push/pop stack trace (see above)")
    +                    self._stack_trace.pop(len(self._stack_trace) - 1)
    +                continue
    +
    +            df_fine["ctr"] = 0
    +            if self.interval == yfcd.Interval.Week:
    +                # df_fine["Week Start"] = df_fine.index.tz_localize(None).to_period("W-SUN").start_time
    +                weekdays = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
    +                week_end_day = weekdays[(df_block.index[0].weekday() + 7 - 1) % 7]
    +                df_fine["Week Start"] = df_fine.index.tz_localize(None).to_period("W-"+week_end_day).start_time
    +                grp_col = "Week Start"
    +            elif self.interval == yfcd.Interval.Days1:
    +                df_fine["Day Start"] = pd.to_datetime(df_fine.index.date)
    +                grp_col = "Day Start"
    +            else:
    +                df_fine.loc[df_fine.index.isin(df_block.index), "ctr"] = 1
    +                df_fine["intervalID"] = df_fine["ctr"].cumsum()
    +                df_fine = df_fine.drop("ctr", axis=1)
    +                grp_col = "intervalID"
    +            # df_fine = df_fine[~df_fine[price_cols].isna().all(axis=1)]
    +
    +            df_new = df_fine.groupby(grp_col).agg(
    +                Open=("Open", "first"),
    +                Close=("Close", "last"),
    +                # AdjClose=("Adj Close", "last"),
    +                Low=("Low", "min"),
    +                High=("High", "max"),
    +                Volume=("Volume", "sum"))
    +                # CSF=("CSF", "first"),
    +                # CDF=("CDF", "first"))#.rename(columns={"AdjClose": "Adj Close"})
    +            if grp_col in ["Week Start", "Day Start"]:
    +                df_new.index = df_new.index.tz_localize(df_fine.index.tz)
    +            else:
    +                df_fine["diff"] = df_fine["intervalID"].diff()
    +                new_index = np.append([df_fine.index[0]], df_fine.index[df_fine["intervalID"].diff() > 0])
    +                df_new.index = new_index
    +
    +            # Calibrate! Check whether 'df_fine' has different split-adjustment.
    +            # If different, then adjust to match 'df'
    +            df_block_calib = df_block[price_cols]
    +            # df_new_calib = df_new[df_new.index.isin(df_block_calib.index)][price_cols]
    +            # df_block_calib = df_block_calib[df_block_calib.index.isin(df_new_calib)]
    +            common_index = np.intersect1d(df_block_calib.index, df_new.index)
    +            df_new_calib = df_new[price_cols].loc[common_index]
    +            df_block_calib = df_block_calib.loc[common_index]
    +            calib_filter = (df_block_calib != tag).to_numpy()
    +            if not calib_filter.any():
    +                # Can't calibrate so don't attempt repair
    +                if self._record_stack_trace:
    +                    # Pop stack trace
    +                    if len(self._stack_trace) == 0:
    +                        raise Exception("Failing to correctly push/pop stack trace (is empty too early)")
    +                    if not self._stack_trace[-1] == fn_tuple:
    +                        for i in range(len(self._stack_trace)):
    +                            print("  "*i + str(self._stack_trace[i]))
    +                        raise Exception("Failing to correctly push/pop stack trace (see above)")
    +                    self._stack_trace.pop(len(self._stack_trace) - 1)
    +                continue
    +            if debug:
    +                print("calib_filter:") ; print(calib_filter)
    +                print("df_new_calib:") ; print(df_new_calib)
    +                print("df_block_calib:") ; print(df_block_calib)
    +            # Avoid divide-by-zero warnings printing:
    +            df_new_calib = df_new_calib.to_numpy()
    +            df_block_calib = df_block_calib.to_numpy()
    +            for j in range(len(price_cols)):
    +                c = price_cols[j]
    +                f = ~calib_filter[:, j]
    +                if f.any():
    +                    df_block_calib[f, j] = 1
    +                    df_new_calib[f, j] = 1
    +            ratios = (df_block_calib / df_new_calib)[calib_filter]
    +            ratio = np.mean(ratios)
    +            #
    +            ratio_rcp = round(1.0 / ratio, 1)
    +            ratio = round(ratio, 1)
    +            if ratio == 1 and ratio_rcp == 1:
    +                # Good!
    +                pass
    +            else:
    +                if ratio > 1:
    +                    # data has different split-adjustment than fine-grained data
    +                    # Adjust fine-grained to match
    +                    df_new[price_cols] *= ratio
    +                    df_new["Volume"] /= ratio
    +                    df_new["Volume"] = df_new["Volume"].round(0).astype('int')
    +                elif ratio_rcp > 1:
    +                    # data has different split-adjustment than fine-grained data
    +                    # Adjust fine-grained to match
    +                    df_new[price_cols] *= 1.0 / ratio_rcp
    +                    df_new["Volume"] *= ratio_rcp
    +                    df_new["Volume"] = df_new["Volume"].round(0).astype('int')
    +
    +            # Repair!
    +            bad_dts = df_block.index[(df_block[price_cols] == tag).any(axis=1)]
    +
    +            for idx in bad_dts:
    +                if idx not in df_new.index:
    +                    # Yahoo didn't return finer-grain data for this interval,
    +                    # so probably no trading happened.
    +                    # print("no fine data")
    +                    continue
    +                df_new_row = df_new.loc[idx]
    +
    +                if self.interval == yfcd.Interval.Week:
    +                    df_last_week = df_new.iloc[df_new.index.get_loc(idx)-1]
    +                    df_fine = df_fine.loc[idx:]
    +
    +                df_bad_row = df.loc[idx]
    +                bad_fields = df_bad_row.index[df_bad_row == tag].to_numpy()
    +                if "High" in bad_fields:
    +                    df_v2.loc[idx, "High"] = df_new_row["High"]
    +                if "Low" in bad_fields:
    +                    df_v2.loc[idx, "Low"] = df_new_row["Low"]
    +                if "Open" in bad_fields:
    +                    if self.interval == yfcd.Interval.Week and idx != df_fine.index[0]:
    +                        # Exchange closed Monday. In this case, Yahoo sets Open to last week close
    +                        df_v2.loc[idx, "Open"] = df_last_week["Close"]
    +                        df_v2.loc[idx, "Low"] = min(df_v2.loc[idx, "Open"], df_v2.loc[idx, "Low"])
    +                    else:
    +                        df_v2.loc[idx, "Open"] = df_new_row["Open"]
    +                if "Close" in bad_fields:
    +                    df_v2.loc[idx, "Close"] = df_new_row["Close"]
    +                    # Should not need to copy over CDF & CSF, because
    +                    # correct values are already merged in from daily
    +                    # df_v2.loc[idx, "CDF"] = df_new_row["CDF"]
    +                    # df_v2.loc[idx, "CSF"] = df_new_row["CSF"]
    +                if "Volume" in bad_fields:
    +                    df_v2.loc[idx, "Volume"] = df_new_row["Volume"]
    +                df_v2.loc[idx, "Repaired?"] = True
    +                n_fixed += 1
    +
    +            # Restore un-repaired bad values
    +            f_nan = df_v2[price_cols].isna().to_numpy()
    +            f_failed = f_tag & f_nan
    +            for j in range(len(price_cols)):
    +                f = f_failed[:, j]
    +                if f.any():
    +                    c = price_cols[j]
    +                    df_v2.loc[f, c] = df.loc[f, c]
    +
    +            if self._record_stack_trace:
    +                # Pop stack trace
    +                if len(self._stack_trace) == 0:
    +                    raise Exception("Failing to correctly push/pop stack trace (is empty too early)")
    +                if not self._stack_trace[-1] == fn_tuple:
    +                    for i in range(len(self._stack_trace)):
    +                        print("  "*i + str(self._stack_trace[i]))
    +                    raise Exception("Failing to correctly push/pop stack trace (see above)")
    +                self._stack_trace.pop(len(self._stack_trace) - 1)
    +
    +        log_msg = "PM::_reconstruct_intervals_batch() returning"
    +        yfcl.TraceExit(log_msg)
    +
    +        return df_v2
    +
    +    def _repairUnitMixups(self, df, silent=False):
    +        df2 = self._fixUnitSwitch(df)
    +        df3 = self._repairSporadicUnitMixups(df2, silent)
    +        return df3
    +
    +    def _repairSporadicUnitMixups(self, df, silent=False):
    +        yfcu.TypeCheckDataFrame(df, "df")
    +
    +        # Sometimes Yahoo returns few prices in cents/pence instead of $/£
    +        # I.e. 100x bigger
    +        # Easy to detect and fix, just look for outliers = ~100x local median
    +
    +        if df.empty:
    +            return df
    +        if df.shape[0] == 1:
    +            # Need multiple rows to confidently identify outliers
    +            return df
    +
    +        log_msg_enter = f"PM::_repairSporadicUnitMixups-{self.istr}()"
    +        log_msg_exit = f"PM::_repairSporadicUnitMixups-{self.istr}() returning"
    +        yfcl.TraceEnter(log_msg_enter)
    +
    +        df2 = df.copy()
    +
    +        data_cols = ["High", "Open", "Low", "Close"]  # Order important, separate High from Low
    +        data_cols = [c for c in data_cols if c in df2.columns]
    +        f_zeroes = (df2[data_cols] == 0).any(axis=1)
    +        if f_zeroes.any():
    +            df2_zeroes = df2[f_zeroes]
    +            df2 = df2[~f_zeroes]
    +        else:
    +            df2_zeroes = None
    +        if df2.shape[0] <= 1:
    +            yfcl.TraceExit(log_msg_exit)
    +            return df
    +        df2_data = df2[data_cols].to_numpy()
    +        median = _ndimage.median_filter(df2_data, size=(3, 3), mode="wrap")
    +        ratio = df2_data / median
    +        ratio_rounded = (ratio / 20).round() * 20  # round ratio to nearest 20
    +        f = ratio_rounded == 100
    +        ratio_rcp = 1.0/ratio
    +        ratio_rcp_rounded = (ratio_rcp / 20).round() * 20  # round ratio to nearest 20
    +        f_rcp = (ratio_rounded == 100) | (ratio_rcp_rounded == 100)
    +        f_either = f | f_rcp
    +        if not f_either.any():
    +            yfcl.TraceExit(log_msg_exit)
    +            return df
    +
    +        # Mark values to send for repair
    +        tag = -1.0
    +        for i in range(len(data_cols)):
    +            fi = f_either[:, i]
    +            c = data_cols[i]
    +            df2.loc[fi, c] = tag
    +
    +        n_before = (df2_data == tag).sum()
    +        try:
    +            df2 = self._reconstruct_intervals_batch(df2, tag=tag)
    +        except Exception:
    +            if len(self._stack_trace) > 0:
    +                self._stack_trace.pop(len(self._stack_trace) - 1)
    +            raise
    +        n_after = (df2[data_cols].to_numpy() == tag).sum()
    +
    +        if n_after > 0:
    +            # This second pass will *crudely* "fix" any remaining errors in High/Low
    +            # simply by ensuring they don't contradict e.g. Low = 100x High.
    +            f = (df2[data_cols].to_numpy() == tag) & f
    +            for i in range(f.shape[0]):
    +                fi = f[i, :]
    +                if not fi.any():
    +                    continue
    +                idx = df2.index[i]
    +
    +                for c in ['Open', 'Close']:
    +                    j = data_cols.index(c)
    +                    if fi[j]:
    +                        df2.loc[idx, c] = df.loc[idx, c] * 0.01
    +
    +                c = "High" ; j = data_cols.index(c)
    +                if fi[j]:
    +                    df2.loc[idx, c] = df2.loc[idx, ["Open", "Close"]].max()
    +
    +                c = "Low" ; j = data_cols.index(c)
    +                if fi[j]:
    +                    df2.loc[idx, c] = df2.loc[idx, ["Open", "Close"]].min()
    +
    +            f_rcp = (df2[data_cols].to_numpy() == tag) & f_rcp
    +            for i in range(f_rcp.shape[0]):
    +                fi = f_rcp[i, :]
    +                if not fi.any():
    +                    continue
    +                idx = df2.index[i]
    +
    +                for c in ['Open', 'Close']:
    +                    j = data_cols.index(c)
    +                    if fi[j]:
    +                        df2.loc[idx, c] = df.loc[idx, c] * 100.0
    +
    +                c = "High" ; j = data_cols.index(c)
    +                if fi[j]:
    +                    df2.loc[idx, c] = df2.loc[idx, ["Open", "Close"]].max()
    +
    +                c = "Low" ; j = data_cols.index(c)
    +                if fi[j]:
    +                    df2.loc[idx, c] = df2.loc[idx, ["Open", "Close"]].min()
    +
    +        n_after_crude = (df2[data_cols].to_numpy() == tag).sum()
    +
    +        n_fixed = n_before - n_after_crude
    +        n_fixed_crudely = n_after - n_after_crude
    +        if not silent and n_fixed > 0:
    +            report_msg = f"{self.ticker}: fixed {n_fixed}/{n_before} currency unit mixups "
    +            if n_fixed_crudely > 0:
    +                report_msg += f"({n_fixed_crudely} crudely) "
    +            report_msg += f"in {self.interval} price data"
    +            print(report_msg)
    +
    +        # Restore original values where repair failed
    +        f_either = df2[data_cols].to_numpy() == tag
    +        for j in range(len(data_cols)):
    +            fj = f_either[:, j]
    +            if fj.any():
    +                c = data_cols[j]
    +                df2.loc[fj, c] = df.loc[fj, c]
    +        if df2_zeroes is not None:
    +            df2 = pd.concat([df2, df2_zeroes]).sort_index()
    +            df2.index = pd.to_datetime()
    +
    +        yfcl.TraceExit(log_msg_exit)
    +
    +        return df2
    +
    +    def _fixUnitSwitch(self, df):
    +        # Sometimes Yahoo returns few prices in cents/pence instead of $/£
    +        # I.e. 100x bigger
    +        # 2 ways this manifests:
    +        # - random 100x errors spread throughout table
    +        # - a sudden switch between $<->cents at some date
    +        # This function fixes the second.
    +        # Eventually Yahoo fixes but could take them 2 weeks.
    +
    +        if self.exchange == 'KUW':
    +            # Kuwaiti Dinar divided into 1000 not 100
    +            n = 1000.0
    +        else:
    +            n = 100.0
    +
    +        return self._fixPricesSuddenChange(df, n)
    +
    +    def _fixBadStockSplits(self, df):
    +        # Original logic only considered latest split adjustment could be missing, but 
    +        # actually **any** split adjustment can be missing. So check all splits in df.
    +        #
    +        # Improved logic looks for BIG daily price changes that closely match the
    +        # **nearest future** stock split ratio. This indicates Yahoo failed to apply a new
    +        # stock split to old price data.
    +        #
    +        # There is a slight complication, because Yahoo does another stupid thing.
    +        # Sometimes the old data is adjusted twice. So cannot simply assume
    +        # which direction to reverse adjustment - have to analyse prices and detect.
    +        # Not difficult.
    +
    +        if df.empty:
    +            return df
    +
    +        if not self.interday:
    +            return df
    +
    +        df = df.sort_index()   # scan splits oldest -> newest
    +        split_f = df['Stock Splits'].to_numpy() != 0
    +        if not split_f.any():
    +            return df
    +
    +        for split_idx in np.where(split_f)[0]:
    +            split_dt = df.index[split_idx]
    +            split = df.loc[split_dt, 'Stock Splits']
    +            if split_dt == df.index[0]:
    +                continue
    +
    +            # logger.debug(f'price-repair-split: Checking split {split:.4f} @ {split_dt.date()} for possible repair')
    +
    +            cutoff_idx = min(df.shape[0], split_idx+1)  # add one row after to detect big change
    +            df_pre_split = df.iloc[0:cutoff_idx+1]
    +
    +            df_pre_split_repaired = self._fixPricesSuddenChange(df_pre_split, split, correct_volume=True)
    +            # Merge back in:
    +            if cutoff_idx == df.shape[0]-1:
    +                df = df_pre_split_repaired
    +            else:
    +                df = pd.concat([df_pre_split_repaired.sort_index(), df.iloc[cutoff_idx+1:]])
    +        return df
    +
    +    def _fixPricesSuddenChange(self, df, change, correct_volume=False):
    +        log_func = f"PM::_fixPricesSuddenChange-{self.istr}(change={change:.2f})"
    +        yfcl.TraceEnter(log_func)
    +
    +        df2 = df.sort_index(ascending=False)
    +        split = change
    +        split_rcp = 1.0 / split
    +
    +        if change in [100.0, 0.01]:
    +            fix_type = '100x error'
    +            start_min = None
    +        else:
    +            fix_type = 'bad split'
    +            f = df2['Stock Splits'].to_numpy() != 0.0
    +            start_min = (df2.index[f].min() - dateutil.relativedelta.relativedelta(years=1)).date()
    +
    +        OHLC = ['Open', 'High', 'Low', 'Close']
    +
    +        # Do not attempt repair of the split is small, 
    +        # could be mistaken for normal price variance
    +        if 0.8 < split < 1.25:
    +            yfcl.TraceExit(log_func + ": aborting, split too near 1.0")
    +            return df
    +
    +        n = df2.shape[0]
    +
    +        df_debug = df2.copy()
    +        df_debug = df_debug.drop(['Volume', 'Dividends', 'Repaired?'], axis=1, errors='ignore')
    +        df_debug = df_debug.drop(['CDF', 'CSF', 'C-Check?', 'LastDivAdjustDt', 'LastSplitAdjustDt'], axis=1, errors='ignore')
    +        debug_cols = ['Open', 'Close']
    +        df_debug = df_debug.drop([c for c in OHLC if c not in debug_cols], axis=1, errors='ignore')
    +
    +        # Calculate daily price % change. To reduce effect of price volatility, 
    +        # calculate change for each OHLC column.
    +        if self.interday and self.interval != yfcd.Interval.Days1 and split not in [100.0, 100, 0.001]:
    +            # Avoid using 'Low' and 'High'. For multiday intervals, these can be 
    +            # very volatile so reduce ability to detect genuine stock split errors
    +            _1d_change_x = np.full((n, 2), 1.0)
    +            price_data = df2[['Open','Close']].to_numpy()
    +            f_zero = price_data == 0.0
    +        else:
    +            _1d_change_x = np.full((n, 4), 1.0)
    +            price_data = df2[OHLC].to_numpy()
    +            f_zero = price_data == 0.0
    +        if f_zero.any():
    +            price_data[f_zero] = 1.0
    +
    +        # Update: if a VERY large dividend is paid out, then can be mistaken for a 1:2 stock split.
    +        # Fix = use adjusted prices
    +        for j in range(price_data.shape[1]):
    +            price_data[:,j] *= df2['CDF']
    +
    +        _1d_change_x[1:] = price_data[1:, ] / price_data[:-1, ]
    +        f_zero_num_denom = f_zero | np.roll(f_zero, 1, axis=0)
    +        if f_zero_num_denom.any():
    +            _1d_change_x[f_zero_num_denom] = 1.0
    +        if self.interday and self.interval != yfcd.Interval.Days1:
    +            # average change
    +            _1d_change_minx = np.average(_1d_change_x, axis=1)
    +        else:
    +            # # change nearest to 1.0
    +            # diff = np.abs(_1d_change_x - 1.0)
    +            # j_indices = np.argmin(diff, axis=1)
    +            # _1d_change_minx = _1d_change_x[np.arange(n), j_indices]
    +            # Still distorted by extreme-low high/low. So try median:
    +            _1d_change_minx = np.median(_1d_change_x, axis=1)
    +        f_na = np.isnan(_1d_change_minx)
    +        if f_na.any():
    +            # Possible if data was too old for reconstruction.
    +            _1d_change_minx[f_na] = 1.0
    +        df_debug['1D change X'] = _1d_change_minx
    +        df_debug['1D change X'] = df_debug['1D change X'].round(2).astype('str')
    +
    +        # If all 1D changes are closer to 1.0 than split, exit
    +        split_max = max(split, split_rcp)
    +        if np.max(_1d_change_minx) < (split_max - 1) * 0.5 + 1 and np.min(_1d_change_minx) > 1.0 / ((split_max - 1) * 0.5 + 1):
    +            reason = "changes too near 1.0"
    +            reason += f" (_1d_change_minx = {np.min(_1d_change_minx):.2f} -> {np.max(_1d_change_minx):.2f})"
    +            yfcl.TracePrint(reason)
    +            yfcl.TraceExit(log_func + " aborting")
    +            return df
    +
    +        # Calculate the true price variance, i.e. remove effect of bad split-adjustments.
    +        # Key = ignore 1D changes outside of interquartile range
    +        q1, q3 = np.percentile(_1d_change_minx, [25, 75])
    +        iqr = q3 - q1
    +        lower_bound = q1 - 1.5 * iqr
    +        upper_bound = q3 + 1.5 * iqr
    +        f = (_1d_change_minx >= lower_bound) & (_1d_change_minx <= upper_bound)
    +        avg = np.mean(_1d_change_minx[f])
    +        sd = np.std(_1d_change_minx[f])
    +        # Now can calculate SD as % of mean
    +        sd_pct = sd / avg
    +        msg = f"Estimation of true 1D change stats: mean = {avg:.2f}, StdDev = {sd:.4f} ({sd_pct*100.0:.1f}% of mean)"
    +        self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +
    +        # Only proceed if split adjustment far exceeds normal 1D changes
    +        largest_change_pct = 5 * sd_pct
    +        if self.interday and self.interval != yfcd.Interval.Days1:
    +            largest_change_pct *= 3
    +            # if self.interval in [yfcd.Interval.Months1, yfcd.Interval.Months3]:
    +            #     largest_change_pct *= 2
    +        if max(split, split_rcp) < 1.0 + largest_change_pct:
    +            msg = "Split ratio too close to normal price volatility. Won't repair"
    +            self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +            # msg = f"price-repair-split: my workings:" + '\n' + str(df_debug)
    +            # self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +            yfcl.TraceExit(log_func + ": aborting, changes to near normal price volatility")
    +            return df
    +
    +        # Now can detect bad split adjustments
    +        # Set threshold to halfway between split ratio and largest expected normal price change
    +        r = _1d_change_minx / split_rcp
    +        split_max = max(split, split_rcp)
    +        threshold = (split_max + 1.0 + largest_change_pct) * 0.5
    +        msg = f"split_max={split_max:.3f} largest_change_pct={largest_change_pct:.4f} threshold={threshold:.3f}"
    +        self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +
    +        if 'Repaired?' not in df2.columns:
    +            df2['Repaired?'] = False
    +
    +        if self.interday and self.interval != yfcd.Interval.Days1:
    +            # Yahoo creates multi-day intervals using potentiall corrupt data, e.g.
    +            # the Close could be 100x Open. This means have to correct each OHLC column
    +            # individually
    +            correct_columns_individually = True
    +        else:
    +            correct_columns_individually = False
    +
    +        if correct_columns_individually:
    +            _1d_change_x = np.full((n, 4), 1.0)
    +            price_data = df2[OHLC].replace(0.0, 1.0).to_numpy()
    +            _1d_change_x[1:] = price_data[1:, ] / price_data[:-1, ]
    +        else:
    +            _1d_change_x = _1d_change_minx
    +
    +        r = _1d_change_x / split_rcp
    +        f_down = _1d_change_x < 1.0 / threshold
    +        f_up = _1d_change_x > threshold
    +        f = f_down | f_up
    +        if not correct_columns_individually:
    +            df_debug['r'] = r
    +            df_debug['f_down'] = f_down
    +            df_debug['f_up'] = f_up
    +            df_debug['r'] = df_debug['r'].round(2).astype('str')
    +        else:
    +            for j in range(len(OHLC)):
    +                c = OHLC[j]
    +                if c in debug_cols:
    +                    df_debug[c + '_r'] = r[:, j]
    +                    df_debug[c + '_f_down'] = f_down[:, j]
    +                    df_debug[c + '_f_up'] = f_up[:, j]
    +                    df_debug[c + '_r'] = df_debug[c + '_r'].round(2).astype('str')
    +
    +        if not f.any():
    +            yfcl.TraceExit(log_func + ": aborting, did not detect split errors")
    +            return df
    +
    +        # If stock is currently suspended and not in USA, then usually Yahoo introduces
    +        # 100x errors into suspended intervals. Clue is no price change and 0 volume.
    +        # Better to use last active trading interval as baseline.
    +        f_no_activity = (df2['Low'] == df2['High']) & (df2['Volume']==0)
    +        f_no_activity = f_no_activity | df2[OHLC].isna().all(axis=1)
    +        appears_suspended = f_no_activity.any() and np.where(f_no_activity)[0][0]==0
    +        f_active = ~f_no_activity
    +        # First, ideally, look for 2 consecutive intervals of activity that are not
    +        # affected by change errors
    +        if f.ndim == 1:
    +            f_active = f_active & (~f)
    +        else:
    +            f_active = f_active & (~f.any(axis=1))
    +        f_active = f_active & np.roll(f_active, 1)
    +        if not f_active.any():
    +            # First plan failed, will have to settle for most recent active interval
    +            f_active = ~f_no_activity
    +            f_active = f_active & np.roll(f_active, 1)
    +        idx_latest_active = np.where(f_active)[0]
    +        if len(idx_latest_active) == 0:
    +            idx_latest_active = None
    +        else:
    +            idx_latest_active = int(idx_latest_active[0])
    +        msg = f'appears_suspended={appears_suspended} idx_latest_active={idx_latest_active}'
    +        if idx_latest_active is not None:
    +            msg += f' ({df2.index[idx_latest_active].date()})'
    +        self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +
    +        # Update: if any 100x changes are soon after a stock split, so could be confused with split error, then abort
    +        threshold_days = 30
    +        f_splits = df2['Stock Splits'].to_numpy() != 0.0
    +        if change in [100.0, 0.01] and f_splits.any():
    +            indices_A = np.where(f_splits)[0]
    +            indices_B = np.where(f)[0]
    +            if not len(indices_A) or not len(indices_B):
    +                yfcl.TraceExit(log_func)
    +                return None
    +            gaps = indices_B[:, None] - indices_A
    +            # Because data is sorted in DEscending order, need to flip gaps
    +            gaps *= -1
    +            f_pos = gaps > 0
    +            if f_pos.any():
    +                gap_min = gaps[f_pos].min()
    +                gap_td = self.itd * gap_min
    +                if isinstance(gap_td, dateutil.relativedelta.relativedelta):
    +                    threshold = dateutil.relativedelta.relativedelta(days=threshold_days)
    +                else:
    +                    threshold = timedelta(days=threshold_days)
    +                if gap_td < threshold:
    +                    msg = 'price-repair-split: 100x changes are too soon after stock split events, aborting'
    +                    self.manager.LogEvent('info', 'price-repair-split-'+self.istr, msg)
    +                    yfcl.TraceExit(log_func)
    +                    return df
    +
    +        # if self.interday:
    +        #     df_debug.index = df_debug.index.date
    +        # for c in ['FetchDate']:
    +        #     df_debug[c] = df_debug[c].dt.strftime('%Y-%m-%d %H:%M:%S%z')
    +        # # f_change_happened = df_debug['f_down'] | df_debug['f_up']
    +        # f_change_happened = df_debug['High_f_down'] | df_debug['High_f_up'] | df_debug['Low_f_down'] | df_debug['Low_f_up']
    +        # f_change_happened = f_change_happened | np.roll(f_change_happened, 1) | np.roll(f_change_happened, -1) | np.roll(f_change_happened, 2) | np.roll(f_change_happened, -2)
    +        # f_change_happened[0] = True ; f_change_happened[-1] = True
    +        # df_debug = df_debug[f_change_happened]
    +        # # # df_debug = df_debug.loc[df.index.date <= date(2023, 2, 13)]['Close'].to_numpy()
    +        # # # df_debug = df_debug.iloc[42*5 : 46*5]
    +        # # # df_debug = df.sort_index().loc['2023-06-29':'2023-07-04'][OHLC].sort_index(ascending=False)
    +        # msg = f"price-repair-split: my workings:" + '\n' + str(df_debug)
    +        # self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +        # quit()
    +
    +        def map_signals_to_ranges(f, f_up, f_down):
    +            # Ensure 0th element is False, because True is nonsense
    +            if f[0]:
    +                f = np.copy(f) ; f[0] = False
    +                f_up = np.copy(f_up) ; f_up[0] = False
    +                f_down = np.copy(f_down) ; f_down[0] = False
    +
    +            if not f.any():
    +                return []
    +
    +            true_indices = np.where(f)[0]
    +            ranges = []
    +
    +            for i in range(len(true_indices) - 1):
    +                if i % 2 == 0:
    +                    if split > 1.0:
    +                        adj = 'split' if f_down[true_indices[i]] else '1.0/split'
    +                    else:
    +                        adj = '1.0/split' if f_down[true_indices[i]] else 'split'
    +                    ranges.append((true_indices[i], true_indices[i + 1], adj))
    +
    +            if len(true_indices) % 2 != 0:
    +                if split > 1.0:
    +                    adj = 'split' if f_down[true_indices[-1]] else '1.0/split'
    +                else:
    +                    adj = '1.0/split' if f_down[true_indices[-1]] else 'split'
    +                ranges.append((true_indices[-1], len(f), adj))
    +
    +            return ranges
    +
    +        if idx_latest_active is not None:
    +            idx_rev_latest_active = df.shape[0] - 1 - idx_latest_active
    +            msg = f'idx_latest_active={idx_latest_active}, idx_rev_latest_active={idx_rev_latest_active}'
    +            self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +        if correct_columns_individually:
    +            f_corrected = np.full(n, False)
    +            if correct_volume:
    +                # If Open or Close is repaired but not both, 
    +                # then this means the interval has a mix of correct
    +                # and errors. A problem for correcting Volume, 
    +                # so use a heuristic:
    +                # - if both Open & Close were Nx bad => Volume is Nx bad
    +                # - if only one of Open & Close are Nx bad => Volume is 0.5*Nx bad
    +                f_open_fixed = np.full(n, False)
    +                f_close_fixed = np.full(n, False)
    +
    +            OHLC_correct_ranges = [None, None, None, None]
    +            for j in range(len(OHLC)):
    +                c = OHLC[j]
    +                idx_first_f = np.where(f)[0][0]
    +                if appears_suspended and (idx_latest_active is not None and idx_latest_active >= idx_first_f):
    +                    # Suspended midway during data date range.
    +                    # 1: process data before suspension in index-ascending (date-descending) order.
    +                    # 2: process data after suspension in index-descending order. Requires signals to be reversed, 
    +                    #    then returned ranges to also be reversed, because this logic was originally written for
    +                    #    index-ascending (date-descending) order.
    +                    fj = f[:, j]
    +                    f_upj = f_up[:, j]
    +                    f_downj = f_down[:, j]
    +                    ranges_before = map_signals_to_ranges(fj[idx_latest_active:], f_upj[idx_latest_active:], f_downj[idx_latest_active:])
    +                    if len(ranges_before) > 0:
    +                        # Shift each range back to global indexing
    +                        for i in range(len(ranges_before)):
    +                            r = ranges_before[i]
    +                            ranges_before[i] = (r[0] + idx_latest_active, r[1] + idx_latest_active, r[2])
    +                    f_rev_downj = np.flip(np.roll(f_upj, -1))  # correct
    +                    f_rev_upj = np.flip(np.roll(f_downj, -1))  # correct
    +                    f_revj = f_rev_upj | f_rev_downj
    +                    ranges_after = map_signals_to_ranges(f_revj[idx_rev_latest_active:], f_rev_upj[idx_rev_latest_active:], f_rev_downj[idx_rev_latest_active:])
    +                    if len(ranges_after) > 0:
    +                        # Shift each range back to global indexing:
    +                        for i in range(len(ranges_after)):
    +                            r = ranges_after[i]
    +                            ranges_after[i] = (r[0] + idx_rev_latest_active, r[1] + idx_rev_latest_active, r[2])
    +                        # Flip range to normal ordering
    +                        for i in range(len(ranges_after)):
    +                            r = ranges_after[i]
    +                            ranges_after[i] = (n-r[1], n-r[0], r[2])
    +                    ranges = ranges_before ; ranges.extend(ranges_after)
    +                else:
    +                    ranges = map_signals_to_ranges(f[:, j], f_up[:, j], f_down[:, j])
    +
    +                if start_min is not None:
    +                    # Prune ranges that are older than start_min
    +                    for i in range(len(ranges)-1, -1, -1):
    +                        r = ranges[i]
    +                        if df2.index[r[0]].date() < start_min:
    +                            msg = f'price-repair-split: Pruning {c} range {df2.index[r[0]]}->{df2.index[r[1]-1]} because too old.'
    +                            self.manager.LogEvent('info', 'price-repair-split-'+self.istr, msg)
    +                            del ranges[i]
    +
    +                if len(ranges) > 0:
    +                    OHLC_correct_ranges[j] = ranges
    +
    +            count = sum([1 if x is not None else 0 for x in OHLC_correct_ranges])
    +            if count == 0:
    +                pass
    +            elif count == 1:
    +                # If only 1 column then assume false positive
    +                idxs = [i if OHLC_correct_ranges[i] else -1 for i in range(len(OHLC))]
    +                idx = np.where(np.array(idxs) != -1)[0][0]
    +                col = OHLC[idx]
    +                msg = f'price-repair-split: Potential {fix_type} detected only in column {col}, so treating as false positive (ignore)'
    +                self.manager.LogEvent('info', 'price-repair-split-'+self.istr, msg)
    +            else:
    +                # Only correct if at least 2 columns require correction.
    +                for j in range(len(OHLC)):
    +                    c = OHLC[j]
    +                    ranges = OHLC_correct_ranges[j]
    +                    if ranges is None:
    +                        ranges = []
    +                    for r in ranges:
    +                        if r[2] == 'split':
    +                            m = split ; m_rcp = split_rcp
    +                        else:
    +                            m = split_rcp ; m_rcp = split
    +                        if self.interday:
    +                            msg = f"Corrected bad split adjustment on col={c} range=[{df2.index[r[0]].date()}:{df2.index[r[1]-1].date()}] m={m:.4f}"
    +                        else:
    +                            msg = f"Corrected bad split adjustment on col={c} range=[{df2.index[r[0]]}:{df2.index[r[1]-1]}] m={m:.4f}"
    +                        self.manager.LogEvent('info', 'price-repair-split-'+self.istr, msg)
    +                        df2.iloc[r[0]:r[1], df2.columns.get_loc(c)] *= m
    +                        if correct_volume:
    +                            if c == 'Open':
    +                                f_open_fixed[r[0]:r[1]] = True
    +                            elif c == 'Close':
    +                                f_close_fixed[r[0]:r[1]] = True
    +                        f_corrected[r[0]:r[1]] = True
    +
    +            if correct_volume:
    +                f_open_and_closed_fixed = f_open_fixed & f_close_fixed
    +                f_open_xor_closed_fixed = np.logical_xor(f_open_fixed, f_close_fixed)
    +                if f_open_and_closed_fixed.any():
    +                    df2.loc[f_open_and_closed_fixed, "Volume"] *= m_rcp
    +                if f_open_xor_closed_fixed.any():
    +                    df2.loc[f_open_xor_closed_fixed, "Volume"] *= 0.5 * m_rcp
    +
    +            df2.loc[f_corrected, 'Repaired?'] = True
    +
    +        else:
    +            idx_first_f = np.where(f)[0][0]
    +            if appears_suspended and (idx_latest_active is not None and idx_latest_active >= idx_first_f):
    +                # Suspended midway during data date range.
    +                # 1: process data before suspension in index-ascending (date-descending) order.
    +                # 2: process data after suspension in index-descending order. Requires signals to be reversed, 
    +                #    then returned ranges to also be reversed, because this logic was originally written for
    +                #    index-ascending (date-descending) order.
    +                ranges_before = map_signals_to_ranges(f[idx_latest_active:], f_up[idx_latest_active:], f_down[idx_latest_active:])
    +                if len(ranges_before) > 0:
    +                    # Shift each range back to global indexing
    +                    for i in range(len(ranges_before)):
    +                        r = ranges_before[i]
    +                        ranges_before[i] = (r[0] + idx_latest_active, r[1] + idx_latest_active, r[2])
    +                f_rev_down = np.flip(np.roll(f_up, -1))
    +                f_rev_up = np.flip(np.roll(f_down, -1))
    +                f_rev = f_rev_up | f_rev_down
    +                ranges_after = map_signals_to_ranges(f_rev[idx_rev_latest_active:], f_rev_up[idx_rev_latest_active:], f_rev_down[idx_rev_latest_active:])
    +                if len(ranges_after) > 0:
    +                    # Shift each range back to global indexing:
    +                    for i in range(len(ranges_after)):
    +                        r = ranges_after[i]
    +                        ranges_after[i] = (r[0] + idx_rev_latest_active, r[1] + idx_rev_latest_active, r[2])
    +                    # Flip range to normal ordering
    +                    for i in range(len(ranges_after)):
    +                        r = ranges_after[i]
    +                        ranges_after[i] = (n-r[1], n-r[0], r[2])
    +                ranges = ranges_before ; ranges.extend(ranges_after)
    +            else:
    +                ranges = map_signals_to_ranges(f, f_up, f_down)
    +            if start_min is not None:
    +                # Prune ranges that are older than start_min
    +                for i in range(len(ranges)-1, -1, -1):
    +                    r = ranges[i]
    +                    if df2.index[r[0]].date() < start_min:
    +                        msg = f'price-repair-split: Pruning range {df2.index[r[0]]}->{df2.index[r[1]-1]} because too old.'
    +                        self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +                        del ranges[i]
    +            for r in ranges:
    +                if r[2] == 'split':
    +                    m = split ; m_rcp = split_rcp
    +                else:
    +                    m = split_rcp ; m_rcp = split
    +                msg = f"price-repair-split: range={r} m={m}"
    +                self.manager.LogEvent('debug', 'price-repair-split-'+self.istr, msg)
    +                for c in ['Open', 'High', 'Low', 'Close']:
    +                    df2.iloc[r[0]:r[1], df2.columns.get_loc(c)] *= m
    +                if correct_volume:
    +                    df2.iloc[r[0]:r[1], df2.columns.get_loc("Volume")] *= m_rcp
    +                df2.iloc[r[0]:r[1], df2.columns.get_loc('Repaired?')] = True
    +                if r[0] == r[1] - 1:
    +                    if self.interday:
    +                        msg = f"Corrected bad split adjustment on interval {df2.index[r[0]].date()} m={m:.4f}"
    +                    else:
    +                        msg = f"Corrected bad split adjustment on interval {df2.index[r[0]]} m={m:.4f}"
    +                else:
    +                    # Note: df2 sorted with index descending
    +                    start = df2.index[r[1] - 1]
    +                    end = df2.index[r[0]]
    +                    if self.interday:
    +                        msg = f"Corrected bad split adjustment across intervals {start.date()} -> {end.date()} (inclusive) m={m:.4f}"
    +                    else:
    +                        msg = f"Corrected bad split adjustment across intervals {start} -> {end} (inclusive) m={m:.4f}"
    +                self.manager.LogEvent('info', 'price-repair-split-'+self.istr, msg)
    +
    +        if correct_volume:
    +            df2['Volume'] = df2['Volume'].round(0).astype('int')
    +
    +        yfcl.TraceExit(log_func + " returning")
    +        return df2.sort_index()
    +
    +    def _repairZeroPrices(self, df, silent=False):
    +        yfcu.TypeCheckDataFrame(df, "df")
    +
    +        # Sometimes Yahoo returns prices=0 when obviously wrong e.g. Volume > 0 and Close > 0.
    +        # Easy to detect and fix
    +
    +        if df.empty:
    +            return df
    +        if df.shape[0] == 1:
    +            # Need multiple rows to confidently identify outliers
    +            return df
    +
    +        log_msg_enter = f"PM::_repairZeroPrices-{self.istr}(date_range={df.index[0]}->{df.index[-1]+self.itd})"
    +        log_msg_exit = f"PM::_repairZeroPrices-{self.istr}() returning"
    +        yfcl.TraceEnter(log_msg_enter)
    +
    +        df2 = df.copy()
    +
    +        price_cols = [c for c in ["Open", "High", "Low", "Close", "Adj Close"] if c in df2.columns]
    +        f_zero_or_nan = (df2[price_cols] == 0.0).to_numpy() | df2[price_cols].isna().to_numpy()
    +        # Check whether worth attempting repair
    +        if f_zero_or_nan.any(axis=1).sum() == 0:
    +            yfcl.TraceExit(log_msg_exit + " (no bad data)")
    +            return df
    +        if f_zero_or_nan.sum() == len(price_cols)*len(df2):
    +            # Need some good data to calibrate
    +            yfcl.TraceExit(log_msg_exit + " (insufficient calibration data)")
    +            return df
    +        # - avoid repair if many zeroes/NaNs
    +        pct_zero_or_nan = f_zero_or_nan.sum() / (len(price_cols)*len(df2))
    +        if f_zero_or_nan.any(axis=1).sum() > 2 and pct_zero_or_nan > 0.05:
    +            yfcl.TraceExit(log_msg_exit + " (too much bad data)")
    +            return df
    +
    +        data_cols = price_cols + ["Volume"]
    +
    +        # Mark values to send for repair
    +        tag = -1.0
    +        for i in range(len(price_cols)):
    +            c = price_cols[i]
    +            df2.loc[f_zero_or_nan[:, i], c] = tag
    +        # If volume=0 or NaN for bad prices, then tag volume for repair
    +        if self.ticker.endswith("=X"):
    +            # FX, volume always 0
    +            pass
    +        else:
    +            df2.loc[f_zero_or_nan.any(axis=1) & (df2["Volume"] == 0), "Volume"] = tag
    +            df2.loc[f_zero_or_nan.any(axis=1) & (df2["Volume"].isna()), "Volume"] = tag
    +
    +        # print(df2[f_zero_or_nan.any(axis=1)])
    +        n_before = (df2[data_cols].to_numpy() == tag).sum()
    +        # print("n_before =", n_before)
    +        try:
    +            df2 = self._reconstruct_intervals_batch(df2, tag=tag)
    +        except Exception:
    +            if len(self._stack_trace) > 0:
    +                self._stack_trace.pop(len(self._stack_trace) - 1)
    +            raise
    +        n_after = (df2[data_cols].to_numpy() == tag).sum()
    +        n_fixed = n_before - n_after
    +        msg = f"Fixed {n_fixed}/{n_before} price=0.0 errors in {self.istr} price data"
    +        if not silent and n_fixed > 0:
    +            print(f"{self.ticker}: " + msg)
    +        else:
    +            self.manager.LogEvent("info", "PriceManager", msg)
    +
    +        # Restore original values where repair failed (i.e. remove tag values)
    +        f = df2[data_cols].to_numpy() == tag
    +        for j in range(len(data_cols)):
    +            fj = f[:, j]
    +            if fj.any():
    +                c = data_cols[j]
    +                df2.loc[fj, c] = df.loc[fj, c]
    +
    +        yfcl.TraceExit(log_msg_exit)
    +
    +        return df2
    +
    +    def _reverseYahooAdjust(self, df):
    +        yfcu.TypeCheckDataFrame(df, "df")
    +
    +        # Yahoo returns data split-adjusted so reverse that.
    +        #
    +        # Except for hourly/minute data, Yahoo isn't consistent with adjustment:
    +        # - prices only split-adjusted if date range contains a split
    +        # - dividend appears split-adjusted
    +        # Easy to fix using daily data:
    +        # - divide prices by daily to determine if split-adjusted
    +        # - copy dividends from daily
    +
    +        # Finally, add 'CSF' & 'CDF' columns to allow cheap on-demand adjustment
    +
    +        if not isinstance(df, pd.DataFrame):
    +            raise Exception("'df' must be pd.DataFrame not {}".format(type(df)))
    +
    +        debug = False
    +        # debug = True
    +
    +        log_msg = f"PM::_reverseYahooAdjust-{self.istr}, {df.index[0]}->{df.index[-1]}"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +        elif debug:
    +            print("")
    +            print(log_msg)
    +
    +        if debug:
    +            print(df[["Open", "Low", "High", "Close", "Volume"]])
    +
    +        cdf = None
    +        csf = None
    +
    +        if self.interval != yfcd.Interval.Days1:
    +            # Trigger update of daily price data, to get all events
    +            histDaily = self.manager.GetHistory(yfcd.Interval.Days1)
    +            df_daily_raw = histDaily.get(start=df.index[0].date(), repair=False)
    +
    +        # Step 1: ensure intraday price data is always split-adjusted
    +        td_7d = timedelta(days=7)
    +        if not self.interday:
    +            # Get daily price data during and after 'df'
    +
    +            df_daily = df_daily_raw.copy()
    +            for c in ["Open", "Close", "Low", "High"]:
    +                df_daily[c] *= df_daily["CSF"]
    +            df_daily["Volume"] /= df_daily["CSF"]
    +            df_daily = df_daily.drop("CSF", axis=1)
    +
    +            if df_daily is None or df_daily.empty:
    +                # df = df.drop("Adj Close", axis=1)
    +                df["CSF"] = 1.0
    +                df["CDF"] = 1.0
    +                return df
    +
    +            f_post = df_daily.index.date > df.index[-1].date()
    +            df_daily_during = df_daily[~f_post].copy()
    +            df_daily_post = df_daily[f_post].copy()
    +            df_daily_during.index = df_daily_during.index.date ; df_daily_during.index.name = "_date"
    +
    +            # Also get raw daily data from cache
    +            df_daily_raw = df_daily_raw[df_daily_raw.index.date >= df.index[0].date()]
    +            #
    +            f_post = df_daily_raw.index.date > df.index[-1].date()
    +            df_daily_raw_during = df_daily_raw[~f_post].copy()
    +            df_daily_raw_during_d = df_daily_raw_during.copy()
    +            df_daily_raw_during_d.index = df_daily_raw_during_d.index.date ; df_daily_raw_during_d.index.name = "_date"
    +
    +            if df_daily_post.empty:
    +                csf_post = 1.0
    +            else:
    +                csf_post = yfcu.GetCSF0(df_daily_post)
    +            expectedRatio = 1.0 / csf_post
    +
    +            ss_ratio = expectedRatio
    +            ss_ratioRcp = 1.0 / ss_ratio
    +            #
    +            price_data_cols = ["Open", "Close", "Adj Close", "Low", "High"]
    +            if ss_ratio > 1.01:
    +                for c in price_data_cols:
    +                    df[c] *= ss_ratioRcp
    +                if debug:
    +                    print("Applying 1:{} stock-split".format(round(ss_ratio, 2)))
    +            elif ss_ratioRcp > 1.01:
    +                for c in price_data_cols:
    +                    df[c] *= ss_ratio
    +                if debug:
    +                    print("Applying {.2f}:1 reverse-split-split".format(ss_ratioRcp))
    +            # Note: volume always returned unadjusted
    +
    +            # Yahoo messes up dividend adjustment too so copy correct dividend from daily,
    +            # but only to first time periods of each day:
    +            df["_date"] = df.index.date
    +            df["_indexBackup"] = df.index
    +            # Copy over CSF and CDF too from daily
    +            df = pd.merge(df, df_daily_raw_during_d[["CDF", "CSF"]], how="left", on="_date", validate="many_to_one")
    +            df.index = df["_indexBackup"] ; df.index.name = None ; df = df.drop(["_indexBackup", "_date"], axis=1)
    +            cdf = df["CDF"]
    +            df["Adj Close"] = df["Close"] * cdf
    +            csf = df["CSF"]
    +
    +            if not df_daily_post.empty:
    +                post_csf = yfcu.GetCSF0(df_daily_post)
    +
    +        elif self.interval == yfcd.Interval.Week:
    +            df_daily = histDaily.get(start=df.index[-1].date()+td_7d, repair=False)
    +            if (df_daily is not None) and (not df_daily.empty):
    +                post_csf = yfcu.GetCSF0(df_daily)
    +                if debug:
    +                    print("- post_csf of daily date range {}->{} = {}".format(df_daily.index[0], df_daily.index[-1], post_csf))
    +
    +        # elif self.interval in [yfcd.Interval.Months1, yfcd.Interval.Months3]:
    +        #     raise Exception("not implemented")
    +
    +        # If 'df' does not contain all stock splits until present, then
    +        # set 'post_csf' to cumulative stock split factor just after last 'df' date
    +        splits_post = self.manager.GetHistory("Events").GetSplits(start=df.index[-1].date()+timedelta(days=1))
    +        if splits_post is not None:
    +            post_csf = 1.0/splits_post["Stock Splits"].prod()
    +        else:
    +            post_csf = None
    +
    +        # Cumulative dividend factor
    +        if cdf is None:
    +            f_nna = ~df["Close"].isna()
    +            if not f_nna.any():
    +                cdf = 1.0
    +            else:
    +                cdf = np.full(df.shape[0], np.nan)
    +                cdf[f_nna] = df.loc[f_nna, "Adj Close"] / df.loc[f_nna, "Close"]
    +                cdf = pd.Series(cdf).bfill().ffill().to_numpy()
    +
    +        # In rare cases, Yahoo is not calculating 'Adj Close' correctly
    +        if self.interday:
    +            divs_since = self.manager.GetHistory("Events").GetDivs(start=df.index[-1].date()+self.itd)
    +            if divs_since is not None and not divs_since.empty:
    +                # Check that 'Adj Close' reflects all future dividends
    +                expected_adj = divs_since['Back Adj.'].prod()
    +                if self.interday and self.interval != yfcd.Interval.Days1:
    +                    if df['Dividends'].iloc[-1] != 0.0:
    +                        dt = df.index[-1]
    +                        # Note: df hasn't been de-splitted yet
    +                        hist_before = self.manager.GetHistory(yfcd.Interval.Days1).get(start=dt.date()-timedelta(days=7), end=dt.date(), adjust_splits=True, adjust_divs=False)
    +                        close = hist_before['Close'].iloc[-1]
    +                        adj_adj = 1 - df['Dividends'].iloc[-1] / close
    +                        if adj_adj < 1.0:
    +                            msg = f"Adjusting expected_adj={expected_adj:.3f} by last-row div={df['Dividends'].iloc[-1]} adj={adj_adj:.3f} @ {dt.date()}"
    +                            self.manager.LogEvent("info", '_reverseYahooAdjust', msg)
    +                            expected_adj *= adj_adj
    +                actual_adj = cdf[-1]
    +                if not np.isnan(actual_adj):
    +                    diff_pct = abs(actual_adj / expected_adj - 1.0)
    +                    if diff_pct > 0.005:
    +                        msg = f'expected_adj={expected_adj:.4f} != actual_adj={actual_adj:.4f}, correcting (last dt = {df.index[-1].date()})'
    +                        self.manager.LogEvent("info", '_reverseYahooAdjust', msg)
    +                        divs_since.index = divs_since.index.date
    +                        # Bad. Dividends have occurred after this price data, but 'Adj Close' is missing adjustment(s).
    +                        # Fix
    +                        cdf *= expected_adj / actual_adj
    +
    +        # Cumulative stock-split factor
    +        if csf is None:
    +            ss = df["Stock Splits"].copy()
    +            ss[(ss == 0.0) | ss.isna()] = 1.0
    +            ss_rcp = 1.0 / ss
    +            csf = ss_rcp.sort_index(ascending=False).cumprod().sort_index(ascending=True).shift(-1, fill_value=1.0)
    +            if post_csf is not None:
    +                csf *= post_csf
    +        csf_rcp = 1.0 / csf
    +
    +        # Reverse Yahoo's split adjustment:
    +        data_cols = ["Open", "High", "Low", "Close", "Dividends"]
    +        for dc in data_cols:
    +            df[dc] = df[dc] * csf_rcp
    +        if not self.interday:
    +            # Don't need to de-split volume data because Yahoo always returns interday volume unadjusted
    +            pass
    +        else:
    +            df["Volume"] *= csf
    +
    +        if df["Volume"].dtype != 'int64':
    +            df["Volume"] = df["Volume"].round(0).astype('int')
    +
    +        # Drop 'Adj Close', replace with scaling factors:
    +        df = df.drop("Adj Close", axis=1)
    +        df["CSF"] = csf
    +        df["CDF"] = cdf
    +
    +        h_lastDivAdjustDt = pd.Timestamp.utcnow().tz_convert(ZoneInfo("UTC"))
    +        h_lastSplitAdjustDt = h_lastDivAdjustDt
    +        df["LastDivAdjustDt"] = h_lastDivAdjustDt
    +        df["LastSplitAdjustDt"] = h_lastSplitAdjustDt
    +
    +        if debug:
    +            print("- unadjusted:")
    +            print(df[["Close", "Dividends", "Volume", "CSF", "CDF"]])
    +            f = df["Dividends"] != 0.0
    +            if f.any():
    +                print("- dividends:")
    +                print(df.loc[f, ["Open", "Low", "High", "Close", "Dividends", "Volume", "CSF", "CDF"]])
    +            print("")
    +
    +        log_msg = f"PM::_reverseYahooAdjust-{self.istr}() returning"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +        elif debug:
    +            print(log_msg)
    +
    +        if debug:
    +            print(df[["Open", "Low", "High", "Close", "Dividends", "Volume", "CSF"]])
    +
    +        return df
    +
    +    def _applyNewEvents(self):
    +        if self.h is None or self.h.empty:
    +            return
    +
    +        # debug = False
    +        # debug = True
    +
    +        h_modified = False
    +
    +        log_msg = f"PM::_applyNewEvents()-{self.istr}"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceEnter(log_msg)
    +
    +        # Backport new splits across entire h table
    +        lastSplitAdjustDt_min = self.h["LastSplitAdjustDt"].min()
    +        splits_since = self.manager.GetHistory("Events").GetSplitsFetchedSince(lastSplitAdjustDt_min)
    +        if splits_since is not None and not splits_since.empty:
    +            LastSplitAdjustDt_new = self.h["LastSplitAdjustDt"].copy()
    +
    +            f_sup = splits_since["Superseded split"] != 0.0
    +            if f_sup.any():
    +                for dt in splits_since.index[f_sup]:
    +                    split = splits_since.loc[dt]
    +                    f1 = self.h.index < dt
    +                    diff1 = (self.h["LastSplitAdjustDt"] - split["Superseded split FetchDate"]).abs()
    +                    f2 = (diff1 < pd.Timedelta("15s")).to_numpy()
    +                    diff2 = (self.h["FetchDate"] - split["Superseded split FetchDate"]).abs()
    +                    f3 = (diff2 < pd.Timedelta("15s")).to_numpy()
    +                    f = f1 & (f2 | f3)
    +                    if not f.any():
    +                        if self.interval != yfcd.Interval.Days1:
    +                            # Probably ok, assuming superseded split was never applied to this price data
    +                            continue
    +                        print(split)
    +                        raise Exception("For superseded split above, failed to identify rows to undo. Problem?")
    +                    else:
    +                        # Next check: expect cached CSF != 1.0
    +
    +                        log_msg = f"{self.istr}: Reversing split [dt={dt.date()} {split['Superseded split']} fetch={split['Superseded split FetchDate'].strftime('%Y-%m-%d %H:%M:%S%z')}]"
    +                        indices = np.where(f)[0]
    +                        log_msg += " from intervals "
    +                        if self.interday:
    +                            log_msg += f"{self.h.index[indices[0]].date()} -> {self.h.index[indices[-1]].date()} (inc)"
    +                        else:
    +                            log_msg += f"{self.h.index[indices[0]]} -> {self.h.index[indices[-1]]}"
    +                        h_lastRow = self.h.loc[self.h.index[indices[-1]]]
    +                        log_msg += f". Last CSF = {h_lastRow['CSF']:.5f} @ {h_lastRow['LastSplitAdjustDt'].strftime('%Y-%m-%d %H:%M:%S%z')}"
    +                        self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +                        self.h.loc[f, "CSF"] *= split["Stock Splits"]
    +                        LastSplitAdjustDt_new[f] = np.maximum(split['FetchDate'], LastSplitAdjustDt_new[f])
    +
    +            for dt in splits_since.index:
    +                split = splits_since.loc[dt]
    +                f1 = self.h.index < dt
    +                f2 = self.h["LastSplitAdjustDt"] < split["FetchDate"]
    +                f = f1 & f2
    +                if f.any():
    +                    log_msg = f"{self.istr}: Applying split [dt={dt.date()} {split['Stock Splits']} fetch={split['FetchDate'].strftime('%Y-%m-%d %H:%M:%S%z')}]"
    +                    indices = np.where(f)[0]
    +                    log_msg += " across intervals "
    +                    if self.interday:
    +                        log_msg += f"{self.h.index[indices[0]].date()} -> {self.h.index[indices[-1]].date()} (inc)"
    +                    else:
    +                        log_msg += f"{self.h.index[indices[0]]} -> {self.h.index[indices[-1]]}"
    +                    h_lastRow = self.h.loc[self.h.index[indices[-1]]]
    +                    log_msg += f". Last CSF = {h_lastRow['CSF']:.5f} @ {h_lastRow['LastSplitAdjustDt'].strftime('%Y-%m-%d %H:%M:%S%z')}"
    +                    self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +                    if isinstance(self.h["CSF"].iloc[0], (int, np.int64)):
    +                        self.h["CSF"] = self.h["CSF"].astype(float)
    +                    self.h.loc[f, "CSF"] /= split["Stock Splits"]
    +                    LastSplitAdjustDt_new.loc[f] = np.maximum(LastSplitAdjustDt_new[f], split["FetchDate"])
    +            self.h["LastSplitAdjustDt"] = LastSplitAdjustDt_new
    +
    +            h_modified = True
    +
    +        # Backport new divs across entire h table
    +        lastDivAdjustDt_min = self.h["LastDivAdjustDt"].min()
    +        if isinstance(lastDivAdjustDt_min.tzinfo, pytz.BaseTzInfo):
    +            self.h["LastDivAdjustDt"] = self.h["LastDivAdjustDt"].dt.tz_convert(self.h.index.tz)
    +            h_modified = True
    +            lastDivAdjustDt_min = self.h["LastDivAdjustDt"].min()
    +        divs_since = self.manager.GetHistory("Events").GetDivsFetchedSince(lastDivAdjustDt_min)
    +        if divs_since is not None and not divs_since.empty:
    +            LastDivAdjustDt_new = self.h["LastDivAdjustDt"].copy()
    +
    +            f_sup = divs_since["Superseded back adj."] != 0.0
    +            if f_sup.any():
    +                for dt in divs_since.index[f_sup]:
    +                    div = divs_since.loc[dt]
    +                    f1 = self.h.index < dt
    +                    # Update: new strategy
    +                    # Instead of last adjust being the superseded dividend,
    +                    # set condition as last adjust being before this new dividend
    +                    f2 = ((div["FetchDate"] - self.h["LastDivAdjustDt"]) > pd.Timedelta('1m')).to_numpy()
    +                    f3 = ((div["FetchDate"] - self.h["FetchDate"]) > pd.Timedelta('1m')).to_numpy()
    +                    f = f1 & (f2 & f3)
    +                    if not f.any():
    +                        if self.interval != yfcd.Interval.Days1:
    +                            # Probably ok, assuming superseded div was never applied to this price data
    +                            continue
    +                        else:
    +                            diff = (self.h["FetchDate"] - div["FetchDate"]).abs()
    +                            f_recent = (diff < pd.Timedelta("15s")).to_numpy()
    +                            if f_recent[f1].all():
    +                                # All price data that could be affected by new dividend, was just
    +                                # fetched with that dividend. So can safely ignore.
    +                                continue
    +                        # print(div)
    +                        # raise Exception(f"{self.ticker}: {self.istr}: For superseded div above, failed to identify rows to undo. Problem?")
    +                    else:
    +                        # Next check: expect cached CDF < 1.0:
    +                        f1 = self.h.loc[f, "CDF"] >= 1.0
    +                        if f1.any():
    +                            # This can happen with recent multiday intervals and that's ok, and will correct manually.
    +                            f1_oldest_idx = np.where(f1)[0][-1]
    +                            f1_oldest_dt = self.h.index[f1_oldest_idx]
    +                            is_f1_oldest_dt_recent = (pd.Timestamp.utcnow() - f1_oldest_dt) < (1.5*self.itd)
    +                            if self.interday and self.interval != yfcd.Interval.Days1 and is_f1_oldest_dt_recent:
    +                                # Yup, that's what happened
    +                                f[f1_oldest_idx:] = False
    +                                f1 = self.h.loc[f, "CDF"] >= 1.0
    +                        # if f1.any():
    +                        #     print(self.h[f1|np.roll(f1,-1)][['Close', 'CDF', 'CSF', 'FetchDate']])
    +                        #     print(div)
    +                        #     raise Exception(f"{self.ticker}: {self.istr}: For superseded div above, attempting to undo div-adjust from rows where CDF=1. Investigate.")
    +
    +                        log_msg = f"{self.istr}: Reversing div [dt={dt.date()} {div['Superseded div']} adj={div['Superseded back adj.']:.5f} fetch={div['Superseded div FetchDate'].strftime('%Y-%m-%d %H:%M:%S%z')}]"
    +                        indices = np.where(f)[0]
    +                        log_msg += " from intervals "
    +                        if self.interday:
    +                            log_msg += f"{self.h.index[indices[0]].date()} -> {self.h.index[indices[-1]].date()} (inc)"
    +                        else:
    +                            log_msg += f"{self.h.index[indices[0]]} -> {self.h.index[indices[-1]]}"
    +                        h_lastRow = self.h.loc[self.h.index[indices[-1]]]
    +                        log_msg += f". Last CDF = {h_lastRow['CDF']:.5f} @ {h_lastRow['LastDivAdjustDt'].strftime('%Y-%m-%d %H:%M:%S%z')}"
    +                        self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +                        self.h.loc[f, "CDF"] /= div["Superseded back adj."]
    +                        LastDivAdjustDt_new[f] = np.maximum(div['FetchDate'], LastDivAdjustDt_new[f])
    +
    +            for dt in divs_since.index:
    +                div = divs_since.loc[dt]
    +                f1 = self.h.index < dt
    +                f2 = self.h["LastDivAdjustDt"] < div["FetchDate"]
    +                f = f1 & f2
    +                if f.any():
    +                    log_msg = f"{self.istr}: Applying div [dt={dt.date()} {div['Dividends']} adj={div['Back Adj.']:.5f} fetch={div['FetchDate'].strftime('%Y-%m-%d %H:%M:%S%z')}]"
    +                    indices = np.where(f)[0]
    +                    log_msg += " across intervals "
    +                    if self.interday:
    +                        log_msg += f"{self.h.index[indices[0]].date()} -> {self.h.index[indices[-1]].date()} (inc)"
    +                    else:
    +                        log_msg += f"{self.h.index[indices[0]]} -> {self.h.index[indices[-1]]}"
    +                    h_lastRow = self.h.loc[self.h.index[indices[-1]]]
    +                    log_msg += f". Last CDF = {h_lastRow['CDF']:.5f} @ {h_lastRow['LastDivAdjustDt'].strftime('%Y-%m-%d %H:%M:%S%z')}"
    +                    self.manager.LogEvent("info", "PriceManager", log_msg)
    +
    +                    self.h.loc[f, "CDF"] *= div["Back Adj."]
    +                    LastDivAdjustDt_new[f] = np.maximum(LastDivAdjustDt_new[f], div["FetchDate"])
    +            self.h["LastDivAdjustDt"] = LastDivAdjustDt_new
    +
    +            h_modified = True
    +
    +        if h_modified:
    +            f1 = self.h.loc[f, "CDF"] > 1.0
    +            if f1.any():
    +                self.loc[f,'CDF'] = 1.0
    +
    +            self._updatedCachedPrices(self.h)
    +
    +        log_msg = "PM::_applyNewEvents() returning"
    +        if yfcl.IsTracingEnabled():
    +            yfcl.TraceExit(log_msg)
    +
    +

    Methods

    +
    +
    +def get(self, start=None, end=None, period=None, max_age=None, trigger_at_market_close=False, repair=True, prepost=False, adjust_splits=False, adjust_divs=False, quiet=False) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_time.html b/docs/yfinance_cache/yfc_time.html new file mode 100644 index 0000000..8d8f02e --- /dev/null +++ b/docs/yfinance_cache/yfc_time.html @@ -0,0 +1,251 @@ + + + + + + +yfinance_cache.yfc_time API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_time

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def CalcIntervalLastDataDt(exchange, intervalStart, interval, ignore_breaks=False, yf_lag=None) +
    +
    +
    +
    +
    +def CalcIntervalLastDataDt_batch(exchange, intervalStart, interval, ignore_breaks=False, yf_lag=None) +
    +
    +
    +
    +
    +def ConvertToDatetime(dt, tz=None) +
    +
    +
    +
    +
    +def DtSubtractPeriod(dt, period) +
    +
    +
    +
    +
    +def ExchangeOpenOnDay(exchange, d) +
    +
    +
    +
    +
    +def GetCalendarViaCache(exchange, start, end=None) +
    +
    +
    +
    +
    +def GetExchangeDataDelay(exchange) +
    +
    +
    +
    +
    +def GetExchangeSchedule(exchange, start_d, end_d) +
    +
    +
    +
    +
    +def GetExchangeScheduleIntervals(exchange, interval, start, end, discardTimes=None, week7days=True, weekForceStartMonday=True, ignore_breaks=False, exclude_future=True) +
    +
    +
    +
    +
    +def GetExchangeTzName(exchange) +
    +
    +
    +
    +
    +def GetExchangeWeekSchedule(exchange, start, end, ignoreHolidays, ignoreWeekends, forceStartMonday=True) +
    +
    +
    +
    +
    +def GetTimestampCurrentInterval(exchange, ts, interval, discardTimes=None, week7days=True, ignore_breaks=False) +
    +
    +
    +
    +
    +def GetTimestampCurrentInterval_batch(exchange, ts, interval, discardTimes=None, week7days=True, ignore_breaks=False) +
    +
    +
    +
    +
    +def GetTimestampCurrentSession(exchange, ts) +
    +
    +
    +
    +
    +def GetTimestampMostRecentSession(exchange, ts) +
    +
    +
    +
    +
    +def GetTimestampNextInterval(exchange, ts, interval, discardTimes=None, week7days=True, ignore_breaks=False) +
    +
    +
    +
    +
    +def GetTimestampNextInterval_batch(exchange, ts, interval, discardTimes=None, week7days=True, ignore_breaks=False) +
    +
    +
    +
    +
    +def GetTimestampNextSession(exchange, ts) +
    +
    +
    +
    +
    +def IdentifyMissingIntervalRanges(exchange, start, end, interval, knownIntervalStarts, ignore_breaks=False, minDistanceThreshold=5) +
    +
    +
    +
    +
    +def IdentifyMissingIntervals(exchange, start, end, interval, knownIntervalStarts, week7days=True, ignore_breaks=False) +
    +
    +
    +
    +
    +def IsPriceDatapointExpired(intervalStart, fetch_dt, repaired, max_age, exchange, interval, ignore_breaks=False, triggerExpiryOnClose=True, yf_lag=None, dt_now=None) +
    +
    +
    +
    +
    +def IsTimestampInActiveSession(exchange, ts) +
    +
    +
    +
    +
    +def JoinTwoXcals(cal1, cal2) +
    +
    +
    +
    +
    +def MapPeriodToDates(exchange, period, interval) +
    +
    +
    +
    +
    +def SetExchangeTzName(exchange, tz) +
    +
    +
    +
    +
    +def Simple247xcal(opens, closes) +
    +
    +
    +
    +
    +def TimestampInBreak_batch(exchange, ts, interval) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_upgrade.html b/docs/yfinance_cache/yfc_upgrade.html new file mode 100644 index 0000000..8ea9deb --- /dev/null +++ b/docs/yfinance_cache/yfc_upgrade.html @@ -0,0 +1,55 @@ + + + + + + +yfinance_cache.yfc_upgrade API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_upgrade

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/yfinance_cache/yfc_utils.html b/docs/yfinance_cache/yfc_utils.html new file mode 100644 index 0000000..e3df455 --- /dev/null +++ b/docs/yfinance_cache/yfc_utils.html @@ -0,0 +1,685 @@ + + + + + + +yfinance_cache.yfc_utils API documentation + + + + + + + + + + + +
    +
    +
    +

    Module yfinance_cache.yfc_utils

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def CalculateRounding(n, sigfigs) +
    +
    +
    +
    +
    +def ChunkDatesIntoYfFetches(schedule, maxDays, overlapDays) +
    +
    +
    +
    +
    +def GetCDF0(df, close_day_before=None) +
    +
    +
    +
    +
    +def GetCSF0(df) +
    +
    +
    +
    +
    +def GetMagnitude(n) +
    +
    +
    +
    +
    +def GetSigFigs(n) +
    +
    +
    +
    +
    +def JsonDecodeDict(value) +
    +
    +
    +
    +
    +def JsonEncodeValue(value) +
    +
    +
    +
    +
    +def ProcessUserDt(dt, tz_name) +
    +
    +
    +
    +
    +def RDtoDO(rd) +
    +
    +
    +
    +
    +def TypeCheckBool(var, varName) +
    +
    +
    +
    +
    +def TypeCheckDataFrame(var, varName) +
    +
    +
    +
    +
    +def TypeCheckDateEasy(var, varName) +
    +
    +
    +
    +
    +def TypeCheckDateStrict(var, varName) +
    +
    +
    +
    +
    +def TypeCheckDatetime(var, varName) +
    +
    +
    +
    +
    +def TypeCheckDatetimeIndex(var, varName) +
    +
    +
    +
    +
    +def TypeCheckFloat(var, varName) +
    +
    +
    +
    +
    +def TypeCheckInt(var, varName) +
    +
    +
    +
    +
    +def TypeCheckInterval(var, varName) +
    +
    +
    +
    +
    +def TypeCheckIntervalDt(var, interval, varName, strict=True) +
    +
    +
    +
    +
    +def TypeCheckIterable(var, varName) +
    +
    +
    +
    +
    +def TypeCheckNpArray(var, varName) +
    +
    +
    +
    +
    +def TypeCheckPeriod(var, varName) +
    +
    +
    +
    +
    +def TypeCheckStr(var, varName) +
    +
    +
    +
    +
    +def TypeCheckTimedelta(var, varName) +
    +
    +
    +
    +
    +def TypeCheckYear(var, varName) +
    +
    +
    +
    +
    +def VerifyPricesDf(h, df_yf, interval, rtol=0.0001, vol_rtol=0.005, exit_first_error=False, quiet=False, debug=False) +
    +
    +
    +
    +
    +def display_progress_bar(completed, total) +
    +
    +

    Function to display progress bar with percentage completion.

    +
    +
    +def np_isin_optimised(a, b, invert=False) +
    +
    +
    +
    +
    +def np_weighted_mean_and_std(values, weights) +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class CustomNanCheckingDataFrame +(*args, **kwargs) +
    +
    +

    Two-dimensional, size-mutable, potentially heterogeneous tabular data.

    +

    Data structure also contains labeled axes (rows and columns). +Arithmetic operations align on both row and column labels. Can be +thought of as a dict-like container for Series objects. The primary +pandas data structure.

    +

    Parameters

    +
    +
    data : ndarray (structured or homogeneous), Iterable, dict, or DataFrame
    +
    +

    Dict can contain Series, arrays, constants, dataclass or list-like objects. If +data is a dict, column order follows insertion-order. If a dict contains Series +which have an index defined, it is aligned by its index. This alignment also +occurs if data is a Series or a DataFrame itself. Alignment is done on +Series/DataFrame inputs.

    +

    If data is a list of dicts, column order follows insertion-order.

    +
    +
    index : Index or array-like
    +
    Index to use for resulting frame. Will default to RangeIndex if +no indexing information part of input data and no index provided.
    +
    columns : Index or array-like
    +
    Column labels to use for resulting frame when data does not have them, +defaulting to RangeIndex(0, 1, 2, …, n). If data contains column labels, +will perform column selection instead.
    +
    dtype : dtype, default None
    +
    Data type to force. Only a single dtype is allowed. If None, infer.
    +
    copy : bool or None, default None
    +
    +

    Copy data from inputs. +For dict data, the default of None behaves like copy=True. +For DataFrame +or 2d ndarray input, the default of None behaves like copy=False. +If data is a dict containing one or more Series (possibly of different dtypes), +copy=False will ensure that these inputs are not copied.

    +
    +

    Changed in version: 1.3.0

    +
    +
    +
    +

    See Also

    +
    +
    DataFrame.from_records
    +
    Constructor from tuples, also record arrays.
    +
    DataFrame.from_dict
    +
    From dicts of Series, arrays, or dicts.
    +
    read_csv
    +
    Read a comma-separated values (csv) file into DataFrame.
    +
    read_table
    +
    Read general delimited file into DataFrame.
    +
    read_clipboard
    +
    Read text from clipboard into DataFrame.
    +
    +

    Notes

    +

    Please reference the :ref:User Guide <basics.dataframe> for more information.

    +

    Examples

    +

    Constructing DataFrame from a dictionary.

    +
    >>> d = {'col1': [1, 2], 'col2': [3, 4]}
    +>>> df = pd.DataFrame(data=d)
    +>>> df
    +   col1  col2
    +0     1     3
    +1     2     4
    +
    +

    Notice that the inferred dtype is int64.

    +
    >>> df.dtypes
    +col1    int64
    +col2    int64
    +dtype: object
    +
    +

    To enforce a single dtype:

    +
    >>> df = pd.DataFrame(data=d, dtype=np.int8)
    +>>> df.dtypes
    +col1    int8
    +col2    int8
    +dtype: object
    +
    +

    Constructing DataFrame from a dictionary including Series:

    +
    >>> d = {'col1': [0, 1, 2, 3], 'col2': pd.Series([2, 3], index=[2, 3])}
    +>>> pd.DataFrame(data=d, index=[0, 1, 2, 3])
    +   col1  col2
    +0     0   NaN
    +1     1   NaN
    +2     2   2.0
    +3     3   3.0
    +
    +

    Constructing DataFrame from numpy ndarray:

    +
    >>> df2 = pd.DataFrame(np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]),
    +...                    columns=['a', 'b', 'c'])
    +>>> df2
    +   a  b  c
    +0  1  2  3
    +1  4  5  6
    +2  7  8  9
    +
    +

    Constructing DataFrame from a numpy ndarray that has labeled columns:

    +
    >>> data = np.array([(1, 2, 3), (4, 5, 6), (7, 8, 9)],
    +...                 dtype=[("a", "i4"), ("b", "i4"), ("c", "i4")])
    +>>> df3 = pd.DataFrame(data, columns=['c', 'a'])
    +...
    +>>> df3
    +   c  a
    +0  3  1
    +1  6  4
    +2  9  7
    +
    +

    Constructing DataFrame from dataclass:

    +
    >>> from dataclasses import make_dataclass
    +>>> Point = make_dataclass("Point", [("x", int), ("y", int)])
    +>>> pd.DataFrame([Point(0, 0), Point(0, 3), Point(2, 3)])
    +   x  y
    +0  0  0
    +1  0  3
    +2  2  3
    +
    +

    Constructing DataFrame from Series/DataFrame:

    +
    >>> ser = pd.Series([1, 2, 3], index=["a", "b", "c"])
    +>>> df = pd.DataFrame(data=ser, index=["a", "c"])
    +>>> df
    +   0
    +a  1
    +c  3
    +
    +
    >>> df1 = pd.DataFrame([1, 2, 3], index=["a", "b", "c"], columns=["x"])
    +>>> df2 = pd.DataFrame(data=df1, index=["a", "c"])
    +>>> df2
    +   x
    +a  1
    +c  3
    +
    +
    + +Expand source code + +
    class CustomNanCheckingDataFrame(pd.DataFrame):
    +    def __init__(self, *args, **kwargs):
    +        super(CustomNanCheckingDataFrame, self).__init__(*args, **kwargs)
    +        self.check_nans()
    +
    +    def __setitem__(self, key, value):
    +        super(CustomNanCheckingDataFrame, self).__setitem__(key, value)
    +        self.check_nans()
    +
    +    @classmethod
    +    def concat(cls, objs, *args, **kwargs):
    +        result = super(CustomNanCheckingDataFrame, cls).concat(objs, *args, **kwargs)
    +        result.check_nans()
    +        return result
    +    
    +    @classmethod
    +    def merge(cls, *args, **kwargs):
    +        result = super(CustomNanCheckingDataFrame, cls).merge(*args, **kwargs)
    +        result.check_nans()
    +        return result
    +    
    +    def check_nans(self):
    +        if 'Repaired?' not in self.columns:
    +            return
    +        if self['Repaired?'].isna().any():
    +            raise Exception("NaNs detected in column 'Repaired?'!")
    +
    +

    Ancestors

    +
      +
    • pandas.core.frame.DataFrame
    • +
    • pandas.core.generic.NDFrame
    • +
    • pandas.core.base.PandasObject
    • +
    • pandas.core.accessor.DirNamesMixin
    • +
    • pandas.core.indexing.IndexingMixin
    • +
    • pandas.core.arraylike.OpsMixin
    • +
    +

    Static methods

    +
    +
    +def concat(objs, *args, **kwargs) +
    +
    +
    +
    +
    +def merge(*args, **kwargs) +
    +
    +

    Merge DataFrame or named Series objects with a database-style join.

    +

    A named Series object is treated as a DataFrame with a single named column.

    +

    The join is done on columns or indexes. If joining columns on +columns, the DataFrame indexes will be ignored. Otherwise if joining indexes +on indexes or indexes on a column or columns, the index will be passed on. +When performing a cross merge, no column specifications to merge on are +allowed.

    +
    +

    Warning

    +

    If both key columns contain rows where the key is a null value, those +rows will be matched against each other. This is different from usual SQL +join behaviour and can lead to unexpected results.

    +
    +

    Parameters

    +
    +
    right : DataFrame or named Series
    +
    Object to merge with.
    +
    how : {'left', 'right', 'outer', 'inner', 'cross'}, default 'inner'
    +
    +

    Type of merge to be performed.

    +
      +
    • left: use only keys from left frame, similar to a SQL left outer join; +preserve key order.
    • +
    • right: use only keys from right frame, similar to a SQL right outer join; +preserve key order.
    • +
    • outer: use union of keys from both frames, similar to a SQL full outer +join; sort keys lexicographically.
    • +
    • inner: use intersection of keys from both frames, similar to a SQL inner +join; preserve the order of the left keys.
    • +
    • cross: creates the cartesian product from both frames, preserves the order +of the left keys.
    • +
    +
    +
    on : label or list
    +
    Column or index level names to join on. These must be found in both +DataFrames. If on is None and not merging on indexes then this defaults +to the intersection of the columns in both DataFrames.
    +
    left_on : label or list, or array-like
    +
    Column or index level names to join on in the left DataFrame. Can also +be an array or list of arrays of the length of the left DataFrame. +These arrays are treated as if they are columns.
    +
    right_on : label or list, or array-like
    +
    Column or index level names to join on in the right DataFrame. Can also +be an array or list of arrays of the length of the right DataFrame. +These arrays are treated as if they are columns.
    +
    left_index : bool, default False
    +
    Use the index from the left DataFrame as the join key(s). If it is a +MultiIndex, the number of keys in the other DataFrame (either the index +or a number of columns) must match the number of levels.
    +
    right_index : bool, default False
    +
    Use the index from the right DataFrame as the join key. Same caveats as +left_index.
    +
    sort : bool, default False
    +
    Sort the join keys lexicographically in the result DataFrame. If False, +the order of the join keys depends on the join type (how keyword).
    +
    suffixes : list-like, default is ("_x", "_y")
    +
    A length-2 sequence where each element is optionally a string +indicating the suffix to add to overlapping column names in +left and right respectively. Pass a value of None instead +of a string to indicate that the column name from left or +right should be left as-is, with no suffix. At least one of the +values must not be None.
    +
    copy : bool, default True
    +
    +

    If False, avoid copy if possible.

    +
    +

    Note

    +

    The copy keyword will change behavior in pandas 3.0. +Copy-on-Write +<https://pandas.pydata.org/docs/dev/user_guide/copy_on_write.html>__ +will be enabled by default, which means that all methods with a +copy keyword will use a lazy copy mechanism to defer the copy and +ignore the copy keyword. The copy keyword will be removed in a +future version of pandas.

    +

    You can already get the future behavior and improvements through +enabling copy on write pd.options.mode.copy_on_write = True

    +
    +
    +
    indicator : bool or str, default False
    +
    If True, adds a column to the output DataFrame called "_merge" with +information on the source of each row. The column can be given a different +name by providing a string argument. The column will have a Categorical +type with the value of "left_only" for observations whose merge key only +appears in the left DataFrame, "right_only" for observations +whose merge key only appears in the right DataFrame, and "both" +if the observation's merge key is found in both DataFrames.
    +
    validate : str, optional
    +
    +

    If specified, checks if merge is of specified type.

    +
      +
    • "one_to_one" or "1:1": check if merge keys are unique in both +left and right datasets.
    • +
    • "one_to_many" or "1:m": check if merge keys are unique in left +dataset.
    • +
    • "many_to_one" or "m:1": check if merge keys are unique in right +dataset.
    • +
    • "many_to_many" or "m:m": allowed, but does not result in checks.
    • +
    +
    +
    +

    Returns

    +
    +
    DataFrame
    +
    A DataFrame of the two merged objects.
    +
    +

    See Also

    +
    +
    merge_ordered
    +
    Merge with optional filling/interpolation.
    +
    merge_asof
    +
    Merge on nearest keys.
    +
    DataFrame.join
    +
    Similar method using indices.
    +
    +

    Examples

    +
    >>> df1 = pd.DataFrame({'lkey': ['foo', 'bar', 'baz', 'foo'],
    +...                     'value': [1, 2, 3, 5]})
    +>>> df2 = pd.DataFrame({'rkey': ['foo', 'bar', 'baz', 'foo'],
    +...                     'value': [5, 6, 7, 8]})
    +>>> df1
    +    lkey value
    +0   foo      1
    +1   bar      2
    +2   baz      3
    +3   foo      5
    +>>> df2
    +    rkey value
    +0   foo      5
    +1   bar      6
    +2   baz      7
    +3   foo      8
    +
    +

    Merge df1 and df2 on the lkey and rkey columns. The value columns have +the default suffixes, _x and _y, appended.

    +
    >>> df1.merge(df2, left_on='lkey', right_on='rkey')
    +  lkey  value_x rkey  value_y
    +0  foo        1  foo        5
    +1  foo        1  foo        8
    +2  bar        2  bar        6
    +3  baz        3  baz        7
    +4  foo        5  foo        5
    +5  foo        5  foo        8
    +
    +

    Merge DataFrames df1 and df2 with specified left and right suffixes +appended to any overlapping columns.

    +
    >>> df1.merge(df2, left_on='lkey', right_on='rkey',
    +...           suffixes=('_left', '_right'))
    +  lkey  value_left rkey  value_right
    +0  foo           1  foo            5
    +1  foo           1  foo            8
    +2  bar           2  bar            6
    +3  baz           3  baz            7
    +4  foo           5  foo            5
    +5  foo           5  foo            8
    +
    +

    Merge DataFrames df1 and df2, but raise an exception if the DataFrames have +any overlapping columns.

    +
    >>> df1.merge(df2, left_on='lkey', right_on='rkey', suffixes=(False, False))
    +Traceback (most recent call last):
    +...
    +ValueError: columns overlap but no suffix specified:
    +    Index(['value'], dtype='object')
    +
    +
    >>> df1 = pd.DataFrame({'a': ['foo', 'bar'], 'b': [1, 2]})
    +>>> df2 = pd.DataFrame({'a': ['foo', 'baz'], 'c': [3, 4]})
    +>>> df1
    +      a  b
    +0   foo  1
    +1   bar  2
    +>>> df2
    +      a  c
    +0   foo  3
    +1   baz  4
    +
    +
    >>> df1.merge(df2, how='inner', on='a')
    +      a  b  c
    +0   foo  1  3
    +
    +
    >>> df1.merge(df2, how='left', on='a')
    +      a  b  c
    +0   foo  1  3.0
    +1   bar  2  NaN
    +
    +
    >>> df1 = pd.DataFrame({'left': ['foo', 'bar']})
    +>>> df2 = pd.DataFrame({'right': [7, 8]})
    +>>> df1
    +    left
    +0   foo
    +1   bar
    +>>> df2
    +    right
    +0   7
    +1   8
    +
    +
    >>> df1.merge(df2, how='cross')
    +   left  right
    +0   foo      7
    +1   foo      8
    +2   bar      7
    +3   bar      8
    +
    +
    +
    +

    Methods

    +
    +
    +def check_nans(self) +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + +