Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/source/reference/examples/tickers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
tickers.tickers['AAPL'].history(period="1mo")
tickers.tickers['GOOG'].actions

# fetch info for all symbols at once
all_info = tickers.info
print(all_info['MSFT'].get('symbol'))

# websocket
tickers.live()
3 changes: 3 additions & 0 deletions doc/source/reference/yfinance.ticker_tickers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ To initialize multiple `Ticker` objects, use
.. literalinclude:: examples/tickers.py
:language: python

To fetch company info for all symbols in one call, use `Tickers.info`
or `Tickers.get_info()` which returns a dictionary keyed by ticker symbol.

For tickers that are ETFs/Mutual Funds, `Ticker.funds_data` provides access to fund related data.

Funds' Top Holdings and other data with category average is returned as `pd.DataFrame`.
Expand Down
70 changes: 70 additions & 0 deletions tests/test_multi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import threading
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch

import pandas as pd
Expand Down Expand Up @@ -59,5 +60,74 @@ def do_download(tickers, key):
self.assertEqual(msft_tickers, ['MSFT'])


class TestInfoThreadSafety(unittest.TestCase):

def test_multi_info_best_effort_partial_failure(self):
class FakeTicker:
def __init__(self, symbol, session=None):
self.symbol = symbol.upper()

@property
def info(self):
if self.symbol == "BAD":
raise RuntimeError("ticker failed")
return {"symbol": self.symbol}

with patch('yfinance.multi.Ticker', new=FakeTicker), \
patch('yfinance.multi.YfData'):
results = yf.multi.info(["AAPL", "BAD", "MSFT"], threads=False, progress=False)

self.assertEqual(set(results.keys()), {"AAPL", "BAD", "MSFT"})
self.assertEqual(results["AAPL"]["symbol"], "AAPL")
self.assertEqual(results["MSFT"]["symbol"], "MSFT")
self.assertEqual(results["BAD"], {})

def test_concurrent_multi_info_calls_keep_results_separate(self):
class FakeTicker:
def __init__(self, symbol, session=None):
self.symbol = symbol.upper()

@property
def info(self):
time.sleep(0.02)
return {"symbol": self.symbol}

def fetch(tickers):
return yf.multi.info(tickers, threads=False, progress=False)

with patch('yfinance.multi.Ticker', new=FakeTicker), \
patch('yfinance.multi.YfData'):
with ThreadPoolExecutor(max_workers=2) as ex:
f_a = ex.submit(fetch, ["AAPL", "MSFT"])
f_b = ex.submit(fetch, ["NVDA", "META"])
res_a = f_a.result()
res_b = f_b.result()

self.assertEqual(set(res_a.keys()), {"AAPL", "MSFT"})
self.assertEqual(set(res_b.keys()), {"NVDA", "META"})

def test_multi_info_threads_do_not_serialize_fetches(self):
class SlowTicker:
def __init__(self, symbol, session=None):
self.symbol = symbol.upper()

@property
def info(self):
time.sleep(0.15)
return {"symbol": self.symbol}

symbols = ["AAPL", "MSFT", "NVDA", "META"]

with patch('yfinance.multi.Ticker', new=SlowTicker), \
patch('yfinance.multi.YfData'):
t0 = time.perf_counter()
results = yf.multi.info(symbols, threads=True, progress=False)
dt = time.perf_counter() - t0

self.assertEqual(set(results.keys()), set(symbols))
# Serial execution would be ~0.60s; allow generous margin for CI jitter.
self.assertLess(dt, 0.50, f"Expected threaded info fetches, took {dt:.3f}s")


if __name__ == '__main__':
unittest.main()
31 changes: 31 additions & 0 deletions tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,37 @@ def test_empty_info(self):
# fast_info_keys = set()
# for ticker in self.tickers:
# fast_info_keys.update(set(ticker.fast_info.keys()))


class TestTickersInfo(unittest.TestCase):

def test_get_info_delegates_to_multi(self):
tickers = yf.Tickers("aapl msft")
expected = {
"AAPL": {"symbol": "AAPL"},
"MSFT": {"symbol": "MSFT"},
}

with patch("yfinance.tickers.multi.info", return_value=expected) as m_info:
result = tickers.get_info(threads=False, progress=True)

self.assertIs(result, expected)
m_info.assert_called_once()
args, kwargs = m_info.call_args
self.assertEqual(args[0], ["AAPL", "MSFT"])
self.assertEqual(kwargs["threads"], False)
self.assertEqual(kwargs["progress"], True)
self.assertIn("session", kwargs)

def test_info_property_uses_get_info(self):
tickers = yf.Tickers("aapl")
expected = {"AAPL": {"symbol": "AAPL"}}

with patch.object(yf.Tickers, "get_info", return_value=expected) as m_get_info:
result = tickers.info

self.assertIs(result, expected)
m_get_info.assert_called_once_with()
# fast_info_keys = sorted(list(fast_info_keys))

# key_rename_map = {}
Expand Down
123 changes: 123 additions & 0 deletions yfinance/multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ def __init__(self):
self.progress_bar = None
self.lock = threading.Lock()


class _InfoCtx:
"""Per-call scratch state for info(). Concurrent calls each get
their own instance, so no shared mutation between threads."""
__slots__ = ('infos', 'errors', 'tracebacks', 'progress_bar', 'lock')

def __init__(self):
self.infos = {}
self.errors = {}
self.tracebacks = {}
self.progress_bar = None
self.lock = threading.Lock()

@utils.log_indent_decorator
def download(tickers, start=None, end=None, actions=False, threads=True,
ignore_tz=None, group_by='column', auto_adjust=True, back_adjust=False,
Expand Down Expand Up @@ -117,6 +130,26 @@ def download(tickers, start=None, end=None, actions=False, threads=True,
)


@utils.log_indent_decorator
def info(tickers, threads=True, progress=False, session=None):
"""Download info for multiple tickers.

:Parameters:
tickers : str, list
List of tickers to download info for
threads: bool / int
How many threads to use for mass downloading. Default is True
progress: bool
Show progress bar. Default is False
session: None or Session
Optional. Pass your own session object to be used for all requests
"""
return _info_impl(
_InfoCtx(),
tickers, threads=threads, progress=progress, session=session,
)


def _download_impl(ctx, tickers, start=None, end=None, actions=False, threads=True,
ignore_tz=None, group_by='column', auto_adjust=True, back_adjust=False,
repair=False, keepna=False, progress=True, period=period_default, interval="1d",
Expand Down Expand Up @@ -223,6 +256,68 @@ def _download_impl(ctx, tickers, start=None, end=None, actions=False, threads=Tr

return data


def _info_impl(ctx, tickers, threads=True, progress=False, session=None):
logger = utils.get_yf_logger()
session = session or new_session()

YfData(session=session)

if logger.isEnabledFor(logging.DEBUG):
if threads:
# multi-threaded log messages would interleave; serialize.
logger.debug('Disabling multithreading because DEBUG logging enabled')
threads = False
if progress:
progress = False

tickers = tickers if isinstance(
tickers, (list, set, tuple)) else tickers.replace(',', ' ').split()
tickers = list(dict.fromkeys([t.upper() for t in tickers]))

if progress:
ctx.progress_bar = utils.ProgressBar(len(tickers), 'completed')

if threads:
if threads is True:
threads = min([len(tickers), _multitasking.cpu_count() * 2])
_multitasking.set_max_threads(threads)
for i, ticker in enumerate(tickers):
_info_one_threaded(ctx, ticker, session=session, progress=(progress and i > 0))
while True:
with ctx.lock:
if len(ctx.infos) >= len(tickers):
break
_time.sleep(0.01)
else:
for ticker in tickers:
_info_one(ctx, ticker, session=session)
if progress:
ctx.progress_bar.animate()

if progress:
ctx.progress_bar.completed()

if ctx.errors:
logger.error('\n%.f Failed info fetch%s:' % (
len(ctx.errors), 'es' if len(ctx.errors) > 1 else ''))

errors = {}
for ticker, err in ctx.errors.items():
err = err.replace(f'${ticker}: ', '')
errors.setdefault(err, []).append(ticker)
for err, syms in errors.items():
logger.error(f'{syms}: ' + err)

tbs = {}
for ticker, tb in ctx.tracebacks.items():
tb = tb.replace(f'${ticker}: ', '')
tbs.setdefault(tb, []).append(ticker)
for tb, syms in tbs.items():
logger.debug(f'{syms}: ' + tb)

return {ticker: ctx.infos.get(ticker, {}) for ticker in tickers}

def reindex_dfs(dfs, ignore_tz):
if ignore_tz:
for tkr in dfs.keys():
Expand Down Expand Up @@ -251,6 +346,34 @@ def reindex_dfs(dfs, ignore_tz):

return dfs


@_multitasking.task
def _info_one_threaded(ctx, ticker, session=None, progress=True):
_info_one(ctx, ticker, session=session)
if progress:
ctx.progress_bar.animate()


def _info_one(ctx, ticker, session=None):
sym = ticker.upper()

backup = YfConfig.network.hide_exceptions
YfConfig.network.hide_exceptions = False
try:
tkr = Ticker(ticker, session=session)
info = tkr.info
with ctx.lock:
ctx.infos[sym] = info
except Exception as e:
with ctx.lock:
ctx.infos[sym] = {}
ctx.errors[sym] = repr(e)
ctx.tracebacks[sym] = traceback.format_exc()

YfConfig.network.hide_exceptions = backup

return ctx.infos[sym]

@_multitasking.task
def _download_one_threaded(ctx, ticker, start=None, end=None,
auto_adjust=False, back_adjust=False, repair=False,
Expand Down
13 changes: 13 additions & 0 deletions yfinance/tickers.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ def download(self, period='1mo if start & end None', interval="1d",

return data

@property
def info(self):
return self.get_info()

def get_info(self, threads=True, progress=False):
"""Return info payloads keyed by ticker symbol.

Best-effort semantics: when one symbol fails, its value is an empty
dictionary and other symbols are still returned.
"""
session = getattr(self._data, '_session', None)
return multi.info(self.symbols, threads=threads, progress=progress, session=session)

def news(self):
return {ticker: [item for item in Ticker(ticker).news] for ticker in self.symbols}

Expand Down