build: require Python 3.11 and pandas 3

- requires-python 3.11 and pandas 3.0.6; the CI matrix runs Python 3.11 to 3.14 and ruff targets 3.11
- datetime.UTC in place of timezone.utc, and the rating, action, band, analyst and asset enums are StrEnum, which formats a member as its value
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent da6b35f22f
commit 7d38885e81
11 changed files with 44 additions and 44 deletions
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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]
+12 -12
View File
@@ -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
+9 -9
View File
@@ -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)
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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"
+2 -2
View File
@@ -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", [])
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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", ""),