mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
fix(dataflows): trim social sentiment sources to the analysis window
- StockTwits and Reddit were fetched with no date, so a historical run showed today's chatter as if it were from the as-of date - pass the analysis window to both fetchers, filter to it, and emit a clear placeholder when nothing qualifies - centralize the UTC half-open window rule in dataflows/date_window so news, StockTwits, and Reddit share one look-ahead-safe filter #1220
This commit is contained in:
121
tests/test_social_lookahead.py
Normal file
121
tests/test_social_lookahead.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Historical social sentiment must not leak current data into a backtest (#1220).
|
||||||
|
|
||||||
|
StockTwits and Reddit fetchers pull only recent items, so for a historical run
|
||||||
|
they must be trimmed to the analysis window (and yield a clear placeholder when
|
||||||
|
nothing qualifies) rather than showing today's chatter as if it were from the
|
||||||
|
as-of date. All three sources share dataflows.date_window.in_window.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.dataflows import reddit, stocktwits
|
||||||
|
from tradingagents.dataflows.date_window import in_window
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonResp:
|
||||||
|
"""Minimal urlopen() context-manager stub returning a JSON body."""
|
||||||
|
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._body = json.dumps(payload).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
# --- shared window helper ---------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_in_window_bounds_and_exclusive_upper():
|
||||||
|
start = datetime(2026, 5, 1)
|
||||||
|
end = datetime(2026, 5, 9)
|
||||||
|
assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True
|
||||||
|
assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True
|
||||||
|
# exactly midnight after end -> excluded (no leak)
|
||||||
|
assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False
|
||||||
|
# offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z
|
||||||
|
assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_in_window_undated_excluded_in_backtest_kept_live():
|
||||||
|
old = datetime(2026, 5, 9)
|
||||||
|
assert in_window(None, datetime(2026, 5, 1), old) is False # historical
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
assert in_window(None, now, now) is True # live
|
||||||
|
|
||||||
|
|
||||||
|
# --- StockTwits -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _msg(created_iso, sentiment=None):
|
||||||
|
return {
|
||||||
|
"created_at": created_iso,
|
||||||
|
"user": {"username": "u"},
|
||||||
|
"entities": {"sentiment": {"basic": sentiment}},
|
||||||
|
"body": "text",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_historical_window_excludes_recent(monkeypatch):
|
||||||
|
# All messages are "today"; a run as-of a past week must show none of them.
|
||||||
|
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
|
assert "no StockTwits messages" in out
|
||||||
|
assert "2026-05-01..2026-05-08" in out
|
||||||
|
assert "Bullish: 1" not in out # the recent bullish message did not leak
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_live_window_keeps_in_range(monkeypatch):
|
||||||
|
msgs = [_msg("2026-05-05T12:00:00Z", "Bullish"), _msg("2026-05-07T09:00:00Z", "Bearish")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
|
assert "Total: 2" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_no_window_is_unfiltered(monkeypatch):
|
||||||
|
msgs = [_msg("2026-08-30T12:00:00Z", "Bullish")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL") # live caller, no dates
|
||||||
|
assert "Total: 1" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reddit -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _epoch(date_str):
|
||||||
|
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_historical_window_excludes_recent(monkeypatch):
|
||||||
|
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
||||||
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
|
)
|
||||||
|
assert "NOW" not in out
|
||||||
|
assert "no posts" in out.lower() or "no reddit posts" in out.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_live_window_keeps_in_range(monkeypatch):
|
||||||
|
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
||||||
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
|
)
|
||||||
|
assert "INRANGE" in out
|
||||||
@@ -68,8 +68,12 @@ def create_sentiment_analyst(llm):
|
|||||||
# returns a string (no exceptions surface from here), so the LLM
|
# returns a string (no exceptions surface from here), so the LLM
|
||||||
# always sees something — either real data or a clear placeholder.
|
# always sees something — either real data or a clear placeholder.
|
||||||
news_block = get_news.func(ticker, start_date, end_date)
|
news_block = get_news.func(ticker, start_date, end_date)
|
||||||
stocktwits_block = fetch_stocktwits_messages(ticker, limit=30)
|
# Pass the analysis window so a historical run trims social posts to it
|
||||||
reddit_block = fetch_reddit_posts(ticker)
|
# instead of leaking today's chatter into a backtest (#1220).
|
||||||
|
stocktwits_block = fetch_stocktwits_messages(
|
||||||
|
ticker, limit=30, start_date=start_date, end_date=end_date
|
||||||
|
)
|
||||||
|
reddit_block = fetch_reddit_posts(ticker, start_date=start_date, end_date=end_date)
|
||||||
|
|
||||||
system_message = _build_system_message(
|
system_message = _build_system_message(
|
||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
|
|||||||
30
tradingagents/dataflows/date_window.py
Normal file
30
tradingagents/dataflows/date_window.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Shared look-ahead-safe date-window filtering for dated content.
|
||||||
|
|
||||||
|
News, StockTwits, and Reddit all pull recent items that must be trimmed to the
|
||||||
|
analysis window so a historical/backtest run never sees content published after
|
||||||
|
its as-of date. Centralizing the rule keeps every source consistent (#1126,
|
||||||
|
#1220): every timestamp is normalized to UTC, the upper bound is exclusive at
|
||||||
|
midnight after ``end`` (so an item stamped exactly then can't leak), and an
|
||||||
|
undated item is kept only when the window reaches the present (a live run), since
|
||||||
|
in a backtest we can't prove it isn't future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def to_utc(dt: datetime) -> datetime:
|
||||||
|
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
|
||||||
|
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool:
|
||||||
|
"""Whether an item belongs in the half-open window ``[start, end + 1 day)``.
|
||||||
|
|
||||||
|
``pub_dt`` None means undated: kept only when the window reaches the present.
|
||||||
|
"""
|
||||||
|
end = to_utc(end_dt)
|
||||||
|
if pub_dt is not None:
|
||||||
|
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
|
||||||
|
return end >= datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
@@ -25,15 +25,35 @@ import re
|
|||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from .date_window import in_window
|
||||||
from .symbol_utils import crypto_base
|
from .symbol_utils import crypto_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _within_window(posts, start_date, end_date):
|
||||||
|
"""Keep only posts published in [start_date, end_date] (look-ahead safe).
|
||||||
|
|
||||||
|
No window (both None) leaves the list untouched for live callers. A post with
|
||||||
|
no ``created_utc`` epoch is dropped in a historical window (#1220).
|
||||||
|
"""
|
||||||
|
if not (start_date and end_date):
|
||||||
|
return posts
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
kept = []
|
||||||
|
for p in posts:
|
||||||
|
ts = p.get("created_utc")
|
||||||
|
created = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
|
||||||
|
if in_window(created, start_dt, end_dt):
|
||||||
|
kept.append(p)
|
||||||
|
return kept
|
||||||
|
|
||||||
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
|
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
|
||||||
_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}"
|
_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}"
|
||||||
# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit
|
# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit
|
||||||
@@ -194,6 +214,8 @@ def fetch_reddit_posts(
|
|||||||
limit_per_sub: int = 5,
|
limit_per_sub: int = 5,
|
||||||
timeout: float = 10.0,
|
timeout: float = 10.0,
|
||||||
inter_request_delay: float = 1.0,
|
inter_request_delay: float = 1.0,
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetch recent Reddit posts mentioning ``ticker`` across finance
|
"""Fetch recent Reddit posts mentioning ``ticker`` across finance
|
||||||
subreddits and return them as a formatted plaintext block.
|
subreddits and return them as a formatted plaintext block.
|
||||||
@@ -201,6 +223,10 @@ def fetch_reddit_posts(
|
|||||||
``inter_request_delay`` paces the (now RSS-only) per-subreddit requests to
|
``inter_request_delay`` paces the (now RSS-only) per-subreddit requests to
|
||||||
stay under Reddit's public per-IP rate limit; combined with the RSS-first
|
stay under Reddit's public per-IP rate limit; combined with the RSS-first
|
||||||
path it makes 429s rare even when several analyses run back-to-back.
|
path it makes 429s rare even when several analyses run back-to-back.
|
||||||
|
|
||||||
|
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, posts are trimmed to
|
||||||
|
that window so a historical run does not leak current discussion into a
|
||||||
|
backtest (#1220).
|
||||||
"""
|
"""
|
||||||
# Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base
|
# Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base
|
||||||
# ("BTC") so the query actually matches discussion instead of near-nothing.
|
# ("BTC") so the query actually matches discussion instead of near-nothing.
|
||||||
@@ -210,7 +236,8 @@ def fetch_reddit_posts(
|
|||||||
for i, sub in enumerate(subreddits):
|
for i, sub in enumerate(subreddits):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
time.sleep(inter_request_delay)
|
time.sleep(inter_request_delay)
|
||||||
posts = _fetch_subreddit(ticker, sub, limit_per_sub, timeout)
|
posts = _within_window(_fetch_subreddit(ticker, sub, limit_per_sub, timeout),
|
||||||
|
start_date, end_date)
|
||||||
total_posts += len(posts)
|
total_posts += len(posts)
|
||||||
if not posts:
|
if not posts:
|
||||||
blocks.append(f"r/{sub}: <no posts found mentioning {ticker.upper()} in the past 7 days>")
|
blocks.append(f"r/{sub}: <no posts found mentioning {ticker.upper()} in the past 7 days>")
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ network call succeeded.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from .date_window import in_window
|
||||||
from .symbol_utils import crypto_base
|
from .symbol_utils import crypto_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -27,6 +30,29 @@ _API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
|
|||||||
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
|
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
|
||||||
|
|
||||||
|
|
||||||
|
def _within_window(messages, start_date, end_date):
|
||||||
|
"""Keep only messages published in [start_date, end_date] (look-ahead safe).
|
||||||
|
|
||||||
|
No window (both None) leaves the list untouched for live callers. A message
|
||||||
|
whose ``created_at`` (ISO 8601) is unparseable is dropped in a historical
|
||||||
|
window, since we can't prove it isn't from after the as-of date (#1220).
|
||||||
|
"""
|
||||||
|
if not (start_date and end_date):
|
||||||
|
return messages
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
kept = []
|
||||||
|
for m in messages:
|
||||||
|
created = None
|
||||||
|
raw = m.get("created_at")
|
||||||
|
if raw:
|
||||||
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
|
created = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
|
if in_window(created, start_dt, end_dt):
|
||||||
|
kept.append(m)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def _stocktwits_symbol(ticker: str) -> str:
|
def _stocktwits_symbol(ticker: str) -> str:
|
||||||
"""Map a crypto pair to StockTwits' ``<BASE>.X`` convention.
|
"""Map a crypto pair to StockTwits' ``<BASE>.X`` convention.
|
||||||
|
|
||||||
@@ -38,10 +64,21 @@ def _stocktwits_symbol(ticker: str) -> str:
|
|||||||
return f"{base}.X" if base else ticker.strip().upper()
|
return f"{base}.X" if base else ticker.strip().upper()
|
||||||
|
|
||||||
|
|
||||||
def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str:
|
def fetch_stocktwits_messages(
|
||||||
|
ticker: str,
|
||||||
|
limit: int = 30,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
|
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
|
||||||
formatted plaintext block ready for prompt injection.
|
formatted plaintext block ready for prompt injection.
|
||||||
|
|
||||||
|
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, messages are trimmed
|
||||||
|
to that window. The StockTwits public stream only serves recent messages, so
|
||||||
|
for a historical run they all fall after the window and a clear placeholder
|
||||||
|
is returned rather than leaking today's chatter into a backtest (#1220).
|
||||||
|
|
||||||
Returns a placeholder string when the endpoint is unreachable, the
|
Returns a placeholder string when the endpoint is unreachable, the
|
||||||
symbol has no messages, or the response shape is unexpected — the
|
symbol has no messages, or the response shape is unexpected — the
|
||||||
caller never has to special-case None or exceptions.
|
caller never has to special-case None or exceptions.
|
||||||
@@ -58,7 +95,13 @@ def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.
|
|||||||
return f"<stocktwits unavailable: {type(exc).__name__}>"
|
return f"<stocktwits unavailable: {type(exc).__name__}>"
|
||||||
|
|
||||||
messages = data.get("messages", []) if isinstance(data, dict) else []
|
messages = data.get("messages", []) if isinstance(data, dict) else []
|
||||||
|
messages = _within_window(messages, start_date, end_date)
|
||||||
if not messages:
|
if not messages:
|
||||||
|
if start_date and end_date:
|
||||||
|
return (
|
||||||
|
f"<no StockTwits messages for ${ticker.upper()} within "
|
||||||
|
f"{start_date}..{end_date} (public stream serves only recent messages)>"
|
||||||
|
)
|
||||||
return f"<no StockTwits messages found for ${ticker.upper()}>"
|
return f"<no StockTwits messages found for ${ticker.upper()}>"
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
"""yfinance-based news data fetching functions."""
|
"""yfinance-based news data fetching functions."""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
|
from .date_window import in_window
|
||||||
from .stockstats_utils import yf_retry
|
from .stockstats_utils import yf_retry
|
||||||
from .symbol_utils import normalize_symbol
|
from .symbol_utils import normalize_symbol
|
||||||
|
|
||||||
|
|
||||||
def _as_utc(dt: datetime) -> datetime:
|
|
||||||
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC.
|
|
||||||
|
|
||||||
Window bounds arrive naive (parsed from ``yyyy-mm-dd``) while article
|
|
||||||
timestamps may be offset-aware, so every operand is normalized before
|
|
||||||
comparison. Without this the filter depends on the host timezone (#1126).
|
|
||||||
"""
|
|
||||||
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_article_data(article: dict) -> dict:
|
def _extract_article_data(article: dict) -> dict:
|
||||||
"""Extract article data from yfinance news format (handles nested 'content' structure)."""
|
"""Extract article data from yfinance news format (handles nested 'content' structure)."""
|
||||||
# Handle nested content structure
|
# Handle nested content structure
|
||||||
@@ -70,18 +61,8 @@ def _extract_article_data(article: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _in_news_window(pub_date, start_dt, end_dt) -> bool:
|
def _in_news_window(pub_date, start_dt, end_dt) -> bool:
|
||||||
"""Whether an article belongs in the half-open window ``[start, end + 1 day)``.
|
"""Look-ahead-safe article-window check; see dataflows.date_window.in_window."""
|
||||||
|
return in_window(pub_date, start_dt, end_dt)
|
||||||
Every operand is normalized to UTC, and the upper bound is exclusive so an
|
|
||||||
article stamped exactly at midnight after ``end_dt`` cannot leak into a
|
|
||||||
historical run (#1126). An undated article is kept only when the window
|
|
||||||
reaches the present (live run) — in a historical/backtest window it's
|
|
||||||
excluded, since we can't prove it isn't future news (#992/#1007).
|
|
||||||
"""
|
|
||||||
end = _as_utc(end_dt)
|
|
||||||
if pub_date is not None:
|
|
||||||
return _as_utc(start_dt) <= _as_utc(pub_date) < end + timedelta(days=1)
|
|
||||||
return end >= datetime.now(timezone.utc) - timedelta(days=1)
|
|
||||||
|
|
||||||
|
|
||||||
def get_news_yfinance(
|
def get_news_yfinance(
|
||||||
|
|||||||
Reference in New Issue
Block a user