fix(y_finance): make suppress_yfinance_warnings thread-safe

The previous implementation redirected sys.stderr to /dev/null using a
context manager. This is not thread-safe: 8 concurrent scanner threads each
mutate sys.stderr, and when one thread's context manager closes the devnull
file, another thread that captured devnull as its saved stderr attempts to
write to the closed fd and raises "I/O operation on closed file".

This corrupted sys.stderr state caused _fetch_batch_prices to fail and
all per-ticker get_stock_price fallback calls to return None, resulting in
every candidate being dropped with "no data available".

Fix by suppressing at the Python logging level instead of redirecting
sys.stderr. Logger.setLevel() is protected by internal locks and is safe
to call from concurrent threads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Youssef Aitousarrah 2026-04-07 16:36:57 -07:00
parent 3a69427dc8
commit c09cc7ec25
1 changed files with 28 additions and 7 deletions

View File

@ -1,5 +1,4 @@
import os
import sys
import warnings
from contextlib import contextmanager
from datetime import datetime, timedelta
@ -20,17 +19,39 @@ logger = get_logger(__name__)
@contextmanager
def suppress_yfinance_warnings():
"""Suppress yfinance stderr warnings about delisted tickers."""
"""Suppress yfinance log and warning output in a thread-safe way.
Previous implementation redirected sys.stderr to /dev/null, but that is
NOT thread-safe: concurrent scanner threads each mutate the process-global
sys.stderr, causing race conditions where one thread closes a file descriptor
that another thread is still writing to ("I/O operation on closed file").
This implementation suppresses at the Python logging level, which is
protected by internal locks and therefore safe to call from many threads.
"""
import logging
yf_logger_names = [
"yfinance",
"yfinance.base",
"yfinance.utils",
"peewee",
"urllib3.connectionpool",
"urllib3",
]
saved_levels = {}
for name in yf_logger_names:
lgr = logging.getLogger(name)
saved_levels[name] = lgr.level
lgr.setLevel(logging.CRITICAL)
with warnings.catch_warnings():
warnings.filterwarnings("ignore")
# Redirect stderr to devnull temporarily
old_stderr = sys.stderr
sys.stderr = open(os.devnull, "w")
try:
yield
finally:
sys.stderr.close()
sys.stderr = old_stderr
for name, level in saved_levels.items():
logging.getLogger(name).setLevel(level)
def get_ticker_info(symbol: str) -> dict: