diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e869ed0ed..48cd4b255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] # One run away from UTC, so a test that only passes in UTC fails here. include: - python-version: "3.12" diff --git a/README.md b/README.md index 686ad0c03..98c411d44 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ git clone https://github.com/TauricResearch/TradingAgents.git cd TradingAgents ``` -Create a virtual environment in any of your favorite environment managers: +TradingAgents needs Python 3.11 or later. Create a virtual environment in any of your favorite environment managers: ```bash conda create -n tradingagents python=3.13 conda activate tradingagents diff --git a/cli/models.py b/cli/models.py index 1eef2b7e3..244f5d40d 100644 --- a/cli/models.py +++ b/cli/models.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class AnalystType(str, Enum): +class AnalystType(StrEnum): MARKET = "market" # Wire value stays "social" for saved-config and string-keyed-caller # back-compat; the user-facing label is "Sentiment Analyst". @@ -10,6 +10,6 @@ class AnalystType(str, Enum): FUNDAMENTALS = "fundamentals" -class AssetType(str, Enum): +class AssetType(StrEnum): STOCK = "stock" CRYPTO = "crypto" diff --git a/pyproject.toml b/pyproject.toml index 4cb3f1216..47c4269b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "tradingagents" dynamic = ["version"] description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "langchain-core>=1.6.5", "langchain-anthropic>=1.7.4", @@ -15,7 +15,7 @@ dependencies = [ "langchain-openai>=1.6.6", "langgraph>=1.2.12", "langgraph-checkpoint-sqlite>=3.1.1", - "pandas>=2.3.0", + "pandas>=3.0.6", "python-dotenv>=1.0.0", "pytz>=2025.2", "questionary>=2.1.0", @@ -65,7 +65,7 @@ filterwarnings = [ [tool.ruff] line-length = 100 -target-version = "py310" +target-version = "py311" extend-exclude = ["results"] [tool.ruff.lint] diff --git a/tests/test_news_lookahead.py b/tests/test_news_lookahead.py index 5c5c1c420..9d96dfbab 100644 --- a/tests/test_news_lookahead.py +++ b/tests/test_news_lookahead.py @@ -6,7 +6,7 @@ news injected future articles), #993 (empty-after-filter returned a blank body), and #1126 (inclusive upper bound leaked the midnight-after article; host-local timestamp parsing made filtering machine-dependent). """ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -16,7 +16,7 @@ from tradingagents.dataflows.date_window import in_window def _epoch(date_str): """Epoch seconds for UTC midnight of ``date_str`` (host-timezone independent).""" - return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).timestamp()) @pytest.mark.unit @@ -28,7 +28,7 @@ def test_flat_article_publish_time_is_parsed(): ) assert data["pub_date"] is not None assert data["pub_date"].tzinfo is not None - assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc) + assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=UTC) @pytest.mark.unit @@ -45,7 +45,7 @@ def test_window_excludes_future_and_undated_in_backtest(): @pytest.mark.unit def test_window_keeps_undated_in_live_window(): # Live window (reaches today): undated articles can't be "future", so keep them. - now = datetime.now(timezone.utc) + now = datetime.now(UTC) assert in_window(None, now, now) is True @@ -55,8 +55,8 @@ def test_upper_bound_is_exclusive(): # the old inclusive bound; the whole of end_date itself must still be kept. start = datetime(2025, 5, 1) end = datetime(2025, 5, 9) - midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=timezone.utc) - last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=timezone.utc) + midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=UTC) + last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=UTC) assert in_window(midnight_after, start, end) is False assert in_window(last_moment, start, end) is True @@ -146,11 +146,11 @@ def test_ticker_news_covered_but_empty_window_is_a_real_absence(monkeypatch): @pytest.mark.parametrize("dates, expect_gap", [ ([], True), # empty feed: covers at most now ([None], True), # undated only: same - ([datetime(2026, 5, 20, tzinfo=timezone.utc)], True), # all after the window - ([datetime(2026, 5, 4, tzinfo=timezone.utc)], True), # starts mid-window: partial - ([datetime(2026, 5, 1, 18, tzinfo=timezone.utc)], False), # reaches the first day - ([datetime(2026, 5, 20, tzinfo=timezone.utc), - datetime(2026, 4, 1, tzinfo=timezone.utc)], False), # coverage reaches back + ([datetime(2026, 5, 20, tzinfo=UTC)], True), # all after the window + ([datetime(2026, 5, 4, tzinfo=UTC)], True), # starts mid-window: partial + ([datetime(2026, 5, 1, 18, tzinfo=UTC)], False), # reaches the first day + ([datetime(2026, 5, 20, tzinfo=UTC), + datetime(2026, 4, 1, tzinfo=UTC)], False), # coverage reaches back ]) def test_coverage_gap_boundaries(dates, expect_gap): from tradingagents.dataflows.date_window import coverage_gap @@ -208,7 +208,7 @@ def test_coverage_gap_future_window_is_unavailable(): from datetime import timedelta from tradingagents.dataflows.date_window import coverage_gap - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() out = coverage_gap([], str(today), str(today + timedelta(days=3)), "Feed", "items") assert out is not None and "past today" in out diff --git a/tests/test_social_lookahead.py b/tests/test_social_lookahead.py index ca58e1a37..11e62c42d 100644 --- a/tests/test_social_lookahead.py +++ b/tests/test_social_lookahead.py @@ -8,7 +8,7 @@ as-of date. All three sources share dataflows.date_window.in_window. from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -38,10 +38,10 @@ class _JsonResp: 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 + assert in_window(datetime(2026, 5, 5, tzinfo=UTC), start, end) is True + assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=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 + assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=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 @@ -50,7 +50,7 @@ def test_in_window_bounds_and_exclusive_upper(): 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) + now = datetime.now(UTC) assert in_window(None, now, now) is True # live @@ -96,7 +96,7 @@ def test_stocktwits_no_window_is_unfiltered(monkeypatch): # --- Reddit ----------------------------------------------------------------- def _epoch(date_str): - return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).timestamp()) @pytest.mark.unit @@ -184,7 +184,7 @@ def test_reddit_window_straddling_the_lookback_is_unavailable(monkeypatch): # Ten days ago through five days ago: the week-long search never reaches the # first three days, so an empty result cannot stand for the whole window. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: []) out = reddit.fetch_reddit_posts( "AAPL", subreddits=("stocks",), @@ -198,7 +198,7 @@ def test_reddit_standard_week_window_empty_is_a_real_absence(monkeypatch): # The graph's window is [trade_date - 7, trade_date]; the week-long search # covers it, so an empty result is genuine silence. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: []) out = reddit.fetch_reddit_posts( "AAPL", subreddits=("stocks",), @@ -212,7 +212,7 @@ def test_reddit_full_page_does_not_vouch_for_older_days(monkeypatch): # 100 posts from today say nothing about five days ago: the page may have # cut older matches off, so the window stays unavailable. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() ts = _epoch(str(today)) page = [{"title": f"T{i}", "created_utc": ts, "subreddit": "stocks"} for i in range(reddit._FEED_PAGE)] monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: page) diff --git a/tradingagents/agents/schemas.py b/tradingagents/agents/schemas.py index fb89aa42b..be561f2c4 100644 --- a/tradingagents/agents/schemas.py +++ b/tradingagents/agents/schemas.py @@ -18,7 +18,7 @@ so that: from __future__ import annotations -from enum import Enum +from enum import StrEnum from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -63,7 +63,7 @@ def _coerce_optional_float(value): # --------------------------------------------------------------------------- -class PortfolioRating(str, Enum): +class PortfolioRating(StrEnum): """5-tier rating used by the Research Manager and Portfolio Manager.""" BUY = "Buy" @@ -73,7 +73,7 @@ class PortfolioRating(str, Enum): SELL = "Sell" -class TraderAction(str, Enum): +class TraderAction(StrEnum): """3-tier transaction direction used by the Trader. The Trader's job is to translate the Research Manager's investment plan @@ -293,7 +293,7 @@ def render_pm_decision(decision: PortfolioDecision) -> str: # --------------------------------------------------------------------------- -class SentimentBand(str, Enum): +class SentimentBand(StrEnum): """Discrete sentiment direction produced by the Sentiment Analyst. Six tiers keep the signal granular enough to be actionable while remaining diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index 7852da1ac..6c30051f7 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -12,12 +12,12 @@ from __future__ import annotations -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta 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) + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool: @@ -28,7 +28,7 @@ def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> 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) + return end >= datetime.now(UTC) - timedelta(days=1) def get_current_date() -> str: @@ -57,7 +57,7 @@ def coverage_gap( returned newest-first and unbroken in time; a merged or relevance-ranked result passes no dates, leaving only the present as the bound. """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) oldest = min((to_utc(d) for d in dates if d is not None), default=now) if datetime.strptime(end_date, "%Y-%m-%d").date() > now.date(): reason = "the window extends past today" diff --git a/tradingagents/dataflows/vendors/polymarket.py b/tradingagents/dataflows/vendors/polymarket.py index 2dae8f367..69e27e6ff 100644 --- a/tradingagents/dataflows/vendors/polymarket.py +++ b/tradingagents/dataflows/vendors/polymarket.py @@ -11,7 +11,7 @@ outcomes (a "Yes" at 0.76 means the market prices a 76% chance). """ import json import logging -from datetime import datetime, timezone +from datetime import UTC, datetime import requests @@ -101,7 +101,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str f"Proceed without prediction-market signal for '{topic}'." ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) candidates = [ m for event in data.get("events", []) diff --git a/tradingagents/dataflows/vendors/reddit.py b/tradingagents/dataflows/vendors/reddit.py index c490ebabe..d39a052fd 100644 --- a/tradingagents/dataflows/vendors/reddit.py +++ b/tradingagents/dataflows/vendors/reddit.py @@ -24,7 +24,7 @@ import re import time import xml.etree.ElementTree as ET from collections.abc import Iterable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -51,7 +51,7 @@ def _within_window(posts, start_date, end_date): def _posted_at(post) -> datetime | None: """A post's ``created_utc`` epoch as a UTC datetime, or None when missing.""" ts = post.get("created_utc") - return datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None + return datetime.fromtimestamp(ts, tz=UTC) if ts else None def _coverage_dates(posts) -> list: @@ -61,7 +61,7 @@ def _coverage_dates(posts) -> list: themselves do.""" dates = [_posted_at(p) for p in posts] if len(posts) < _FEED_PAGE: - dates.append(datetime.now(timezone.utc) - _SEARCH_LOOKBACK) + dates.append(datetime.now(UTC) - _SEARCH_LOOKBACK) return dates diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 49e5c08cf..1671daa8b 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -1,7 +1,7 @@ """yfinance-based news data fetching functions.""" import contextlib -from datetime import datetime, timezone +from datetime import UTC, datetime import yfinance as yf from dateutil.relativedelta import relativedelta @@ -47,7 +47,7 @@ def _extract_article_data(article: dict) -> dict: # Epoch seconds are UTC; parse them as UTC-aware so filtering does # not shift with the host timezone (#1126). with contextlib.suppress(ValueError, OSError, TypeError): - pub_date = datetime.fromtimestamp(ts, tz=timezone.utc) + pub_date = datetime.fromtimestamp(ts, tz=UTC) return { "title": article.get("title", "No title"), "summary": article.get("summary", ""),