Merge pull request #1285 from TauricResearch/v0.4.1

Post-v0.4.0 fixes: FRED vintage, Reddit 429, debate neutrality, feed bound
This commit is contained in:
Yijia Xiao
2026-09-01 00:38:45 -05:00
committed by GitHub
7 changed files with 149 additions and 31 deletions

View File

@@ -151,22 +151,46 @@ class FredFormattingTests(unittest.TestCase):
self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back
def test_requests_pin_the_data_vintage(self): def test_requests_pin_the_data_vintage(self):
# #1275: both the metadata and observations requests must set # #1275: both the metadata and observations requests must pin the vintage
# realtime_start=realtime_end=curr_date, or FRED serves the latest # to curr_date (clamped to FRED's today), or FRED serves the latest
# revision and revision-prone series leak future information. # revision and revision-prone series leak future information. A past
# curr_date sits below FRED's today, so it pins through unchanged.
captured = {} captured = {}
def _capture(path, params): def _capture(path, params):
captured[path] = params captured[path] = params
return _META if path == "series" else _OBS return _META if path == "series" else _OBS
with mock.patch.object(fred, "_request", side_effect=_capture): with mock.patch.object(fred, "_fred_today", return_value="2026-01-01"), \
mock.patch.object(fred, "_request", side_effect=_capture):
fred.get_macro_data("cpi", "2025-09-30", 90) fred.get_macro_data("cpi", "2025-09-30", 90)
for path in ("series", "series/observations"): for path in ("series", "series/observations"):
self.assertEqual(captured[path]["realtime_start"], "2025-09-30", path) self.assertEqual(captured[path]["realtime_start"], "2025-09-30", path)
self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path) self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path)
def test_future_curr_date_clamps_vintage_to_fred_today(self):
# #1275 regression: on a live run curr_date is the caller's LOCAL date,
# which can be a day ahead of FRED's US-Central clock. Pinning the vintage
# to that future date 400s, and the routing layer then drops macro data
# silently. The pin must clamp to FRED's today; the observation window
# (future bars can't exist yet) stays at curr_date.
captured = {}
def _capture(path, params):
captured[path] = params
return _META if path == "series" else _OBS
with mock.patch.object(fred, "_fred_today", return_value="2026-08-31"), \
mock.patch.object(fred, "_request", side_effect=_capture):
fred.get_macro_data("cpi", "2026-09-01", 90) # local a day ahead of Chicago
for path in ("series", "series/observations"):
self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path)
self.assertEqual(captured[path]["realtime_end"], "2026-08-31", path)
# the observation window still tracks curr_date, not the clamped vintage
self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01")
@pytest.mark.unit @pytest.mark.unit
class FredRoutingTests(unittest.TestCase): class FredRoutingTests(unittest.TestCase):

View File

@@ -36,8 +36,9 @@ def _resp(read_fn):
def __exit__(self_inner, *a): def __exit__(self_inner, *a):
return False return False
def read(self_inner): def read(self_inner, size=-1):
return read_fn() data = read_fn()
return data if size is None or size < 0 else data[:size]
return _Resp() return _Resp()
@@ -147,6 +148,26 @@ class TestRss429Backoff:
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
slept.assert_called_once_with(12.0) slept.assert_called_once_with(12.0)
def test_retry_after_zero_is_honoured_not_treated_as_absent(self):
# A valid "Retry-After: 0" means retry at once; it must not fall through
# to the fallback wait (the earlier `or 5.0` bug turned 0 into 5s).
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "0"}, None)
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
patch.object(reddit.time, "sleep") as slept:
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
slept.assert_called_once_with(0.0)
def test_headerless_429_fallback_is_jittered(self):
# No Retry-After -> our own ~5s fallback, jittered so concurrent runs
# don't retry in lockstep (kept within a tight band).
err = HTTPError("url", 429, "Too Many Requests", {}, None)
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
patch.object(reddit.time, "sleep") as slept:
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
slept.assert_called_once()
(wait,), _ = slept.call_args
assert 4.0 <= wait <= 6.0 # 5s +/-20% jitter
@pytest.mark.unit @pytest.mark.unit
class TestChunkedTransferErrorsHandled: class TestChunkedTransferErrorsHandled:
@@ -163,6 +184,14 @@ class TestChunkedTransferErrorsHandled:
reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0) reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
rss.assert_called_once() rss.assert_called_once()
def test_oversized_rss_feed_is_refused_not_parsed(self):
# A hostile/misbehaving endpoint streaming an unbounded body must not be
# read into memory before parsing; overflow degrades to an empty feed.
big = _resp(lambda: b"x" * 100)
with patch.object(reddit, "_MAX_FEED_BYTES", 10), \
patch.object(reddit, "urlopen", return_value=big):
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
@pytest.mark.unit @pytest.mark.unit
class TestFormatterHandlesRssPosts: class TestFormatterHandlesRssPosts:

View File

@@ -62,7 +62,7 @@ def create_portfolio_manager(llm):
--- ---
Be decisive and ground every conclusion in specific evidence from the analysts. Ground every conclusion in specific evidence from the analysts. Commit to a directional call only when the evidence clearly supports one; choose Hold when the case is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure, rather than forcing a direction to appear decisive. Weigh the analysts on their merits, independent of speaking order.
{NO_EXTERNAL_TOOLS}{get_language_instruction()}""" {NO_EXTERNAL_TOOLS}{get_language_instruction()}"""

View File

@@ -36,7 +36,7 @@ def create_research_manager(llm):
- **Underweight**: Cautious view; recommend trimming exposure - **Underweight**: Cautious view; recommend trimming exposure
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position - **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
Commit to a clear stance whenever the debate's strongest arguments warrant one; reserve Hold for situations where the evidence on both sides is genuinely balanced. Commit to a directional stance only when the debate's strongest arguments clearly warrant one. Choose Hold when the evidence is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure; do not manufacture a direction merely to appear decisive. Weigh the bull and bear cases on their merits, independent of which side spoke first or last.
--- ---

View File

@@ -82,9 +82,11 @@ class ResearchPlan(BaseModel):
recommendation: PortfolioRating = Field( recommendation: PortfolioRating = Field(
description=( description=(
"The investment recommendation. Exactly one of Buy / Overweight / " "The investment recommendation. Exactly one of Buy / Overweight / "
"Hold / Underweight / Sell. Reserve Hold for situations where the " "Hold / Underweight / Sell. Choose Hold when the evidence is "
"evidence on both sides is genuinely balanced; otherwise commit to " "balanced, materially conflicting, ambiguous, or insufficient to "
"the side with the stronger arguments." "justify changing exposure; otherwise commit to the side with the "
"clearly stronger arguments. Do not pick a direction merely to be "
"decisive."
), ),
) )
rationale: str = Field( rationale: str = Field(
@@ -197,7 +199,10 @@ class PortfolioDecision(BaseModel):
rating: PortfolioRating = Field( rating: PortfolioRating = Field(
description=( description=(
"The final position rating. Exactly one of Buy / Overweight / Hold / " "The final position rating. Exactly one of Buy / Overweight / Hold / "
"Underweight / Sell, picked based on the analysts' debate." "Underweight / Sell, picked based on the analysts' debate. Choose "
"Hold when the case is balanced, materially conflicting, ambiguous, "
"or insufficient to justify changing exposure, rather than forcing a "
"direction to appear decisive."
), ),
) )
executive_summary: str = Field( executive_summary: str = Field(

View File

@@ -12,6 +12,7 @@ import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz
import requests import requests
from .errors import VendorNotConfiguredError from .errors import VendorNotConfiguredError
@@ -20,6 +21,12 @@ logger = logging.getLogger(__name__)
FRED_API_BASE = "https://api.stlouisfed.org/fred" FRED_API_BASE = "https://api.stlouisfed.org/fred"
# FRED's realtime clock runs on US Central (St. Louis Fed). It rejects a
# realtime date in its own future with a 400, so the vintage pin is clamped to
# this rather than the caller's local date (#1275). pytz (already a dependency)
# bundles its own tz database, so this works where system tzdata is absent.
FRED_TZ = pytz.timezone("America/Chicago")
# Network timeout (seconds) so a stalled request can't hang the agents, # Network timeout (seconds) so a stalled request can't hang the agents,
# mirroring the Alpha Vantage client. # mirroring the Alpha Vantage client.
REQUEST_TIMEOUT = 30 REQUEST_TIMEOUT = 30
@@ -115,6 +122,16 @@ def _resolve_series_id(indicator: str) -> str:
return candidate return candidate
def _fred_today() -> str:
"""FRED's current calendar date (US Central) as ``yyyy-mm-dd``.
The vintage pin is clamped to this: FRED rejects a ``realtime_start`` after
its own today with a 400, and ``curr_date`` on a live run comes from the
caller's local clock, which can already be tomorrow in Chicago.
"""
return datetime.now(FRED_TZ).strftime("%Y-%m-%d")
def _request(path: str, params: dict) -> dict: def _request(path: str, params: dict) -> dict:
"""GET a FRED endpoint, surfacing FRED's JSON error body on a bad request.""" """GET a FRED endpoint, surfacing FRED's JSON error body on a bad request."""
api_params = {**params, "api_key": get_api_key(), "file_type": "json"} api_params = {**params, "api_key": get_api_key(), "file_type": "json"}
@@ -144,11 +161,11 @@ def get_macro_data(
indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury")
or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10").
curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window
AND pins the data vintage: FRED is queried with AND pins the data vintage: FRED is queried with the realtime bounds
``realtime_start = realtime_end = curr_date`` so a historical run sees set to ``curr_date`` (clamped to FRED's own today) so a historical
the values that were actually published by that date, not later run sees the values that were actually published by that date, not
revisions. Without this, revision-prone series (CPI, GDP, ...) would later revisions. Without this, revision-prone series (CPI, GDP, ...)
leak future information into a backtest (#1275). would leak future information into a backtest (#1275).
look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS. look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS.
Returns: Returns:
@@ -161,11 +178,16 @@ def get_macro_data(
end_dt = datetime.strptime(curr_date, "%Y-%m-%d") end_dt = datetime.strptime(curr_date, "%Y-%m-%d")
start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d") start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d")
# Pin the data vintage to curr_date. FRED defaults both realtime bounds to # Pin the data vintage. FRED defaults both realtime bounds to today, serving
# today, which serves the LATEST revision of every observation; a single-day # the LATEST revision of every observation; a single-day realtime interval
# realtime interval asks for the values known as of curr_date instead. This # asks for the values known as of the pin instead, on both the metadata and
# is applied to both the metadata and observations requests (#1275). # observations requests (#1275). Clamp to FRED's today: on a live run
realtime = {"realtime_start": curr_date, "realtime_end": curr_date} # curr_date is the caller's local date, which can be a day ahead of Chicago,
# and a realtime date in FRED's future 400s -> the routing layer would then
# drop macro data silently. A past curr_date is unaffected, so historical
# point-in-time behaviour is preserved.
pit = min(curr_date, _fred_today())
realtime = {"realtime_start": pit, "realtime_end": pit}
# Invalid LLM-supplied indicator: return guidance rather than raising, so a # Invalid LLM-supplied indicator: return guidance rather than raising, so a
# bad argument doesn't abort the run (the routing layer also degrades macro # bad argument doesn't abort the run (the routing layer also degrades macro
@@ -215,8 +237,10 @@ def get_macro_data(
if not points: if not points:
return header + ( return header + (
f"\nNo observations for {series_id} in this window. The series may " f"\nNo observations for {series_id} in this window at the {pit} "
f"report less frequently than the window length; widen look_back_days." f"vintage. The series may report less frequently than the window "
f"(try a longer look_back_days), or have no vintage published by "
f"then (unpublished as of {pit}, or before ALFRED coverage begins)."
) )
first_date, first_val = points[0] first_date, first_val = points[0]

View File

@@ -21,6 +21,7 @@ import html
import http.client import http.client
import json import json
import logging import logging
import random
import re import re
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -101,15 +102,47 @@ def _strip_html(content: str) -> str:
return " ".join(html.unescape(text).split()) return " ".join(html.unescape(text).split())
# Headerless-429 backoff when Reddit gives no Retry-After. Jittered so several
# analyses sharing an IP don't retry in lockstep and re-collide on the limit.
_RETRY_FALLBACK_SECONDS = 5.0
def _jitter(seconds: float, frac: float = 0.2) -> float:
"""Return ``seconds`` with +/-``frac`` random jitter, to desynchronize
concurrent runs pacing against the same per-IP limit."""
return seconds * (1.0 + random.uniform(-frac, frac))
def _retry_after_seconds(exc: HTTPError) -> float | None: def _retry_after_seconds(exc: HTTPError) -> float | None:
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 30s.""" """Seconds to wait from a 429's ``Retry-After`` header, capped at 30s.
Returns ``None`` only when the header is absent or unparseable; a valid
``Retry-After: 0`` returns ``0.0`` (retry at once), not ``None``.
"""
try: try:
val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
return min(float(val), 30.0) if val else None return min(float(val), 30.0) if val is not None else None
except (ValueError, TypeError, AttributeError): except (ValueError, TypeError, AttributeError):
return None return None
# Reddit search feeds are small (a page of results); cap the read so a
# compromised or misbehaving endpoint can't stream an unbounded body into
# memory before we parse it. Overflow raises http.client.HTTPException, which
# both fetch paths already treat as a failed fetch (degrade to empty / RSS).
_MAX_FEED_BYTES = 5 * 1024 * 1024
def _read_capped(resp) -> bytes:
"""Read a response body bounded to ``_MAX_FEED_BYTES``, raising on overflow."""
data = resp.read(_MAX_FEED_BYTES + 1)
if len(data) > _MAX_FEED_BYTES:
raise http.client.HTTPException(
f"Reddit feed exceeded {_MAX_FEED_BYTES} bytes; refusing to parse"
)
return data
def _fetch_subreddit_rss( def _fetch_subreddit_rss(
ticker: str, ticker: str,
sub: str, sub: str,
@@ -128,10 +161,13 @@ def _fetch_subreddit_rss(
req = Request(url, headers={"User-Agent": _UA}) req = Request(url, headers={"User-Agent": _UA})
try: try:
with urlopen(req, timeout=timeout) as resp: with urlopen(req, timeout=timeout) as resp:
root = ET.fromstring(resp.read()) root = ET.fromstring(_read_capped(resp))
except HTTPError as exc: except HTTPError as exc:
if exc.code == 429 and _retry: if exc.code == 429 and _retry:
wait = _retry_after_seconds(exc) or 5.0 # Honour a server-supplied Retry-After exactly (including 0); jitter
# only our own fallback so concurrent runs don't retry in lockstep.
retry_after = _retry_after_seconds(exc)
wait = retry_after if retry_after is not None else _jitter(_RETRY_FALLBACK_SECONDS)
logger.warning( logger.warning(
"Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once", "Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once",
sub, ticker, wait, sub, ticker, wait,
@@ -182,7 +218,7 @@ def _fetch_subreddit_json(
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"}) req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
try: try:
with urlopen(req, timeout=timeout) as resp: with urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read()) payload = json.loads(_read_capped(resp))
children = (payload.get("data") or {}).get("children") or [] children = (payload.get("data") or {}).get("children") or []
return [c.get("data", {}) for c in children if isinstance(c, dict)] return [c.get("data", {}) for c in children if isinstance(c, dict)]
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc: except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
@@ -234,8 +270,8 @@ def fetch_reddit_posts(
blocks = [] blocks = []
total_posts = 0 total_posts = 0
for i, sub in enumerate(subreddits): for i, sub in enumerate(subreddits):
if i > 0: if i > 0 and inter_request_delay:
time.sleep(inter_request_delay) time.sleep(_jitter(inter_request_delay))
posts = _within_window(_fetch_subreddit(ticker, sub, limit_per_sub, timeout), posts = _within_window(_fetch_subreddit(ticker, sub, limit_per_sub, timeout),
start_date, end_date) start_date, end_date)
total_posts += len(posts) total_posts += len(posts)