mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821848bb82 | ||
|
|
d6ca23aee5 | ||
|
|
ef383df8f4 | ||
|
|
d58b838081 | ||
|
|
ffd5d9a180 | ||
|
|
16f7fd613c | ||
|
|
260c899c72 | ||
|
|
94113c8d11 | ||
|
|
7cc478ad07 | ||
|
|
1c44dd1ffc | ||
|
|
96111aa368 | ||
|
|
9dee508c44 | ||
|
|
5a26ae17a1 | ||
|
|
a4acd8a174 | ||
|
|
2322dd9baa | ||
|
|
70b58c21dc | ||
|
|
2448d0a125 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -57,5 +57,5 @@ jobs:
|
|||||||
run: pip install "ruff>=0.15"
|
run: pip install "ruff>=0.15"
|
||||||
- name: Lint the repository
|
- name: Lint the repository
|
||||||
# The repo is fully clean under the strict select, so we lint everything
|
# The repo is fully clean under the strict select, so we lint everything
|
||||||
# (results/ and worklog/ are excluded via pyproject extend-exclude).
|
# (generated results/ is excluded via pyproject extend-exclude).
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ filterwarnings = [
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
extend-exclude = ["results", "worklog"]
|
extend-exclude = ["results"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +
|
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
|
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
|
||||||
responses mislabeled as rate limits and silently treated as transient), and
|
responses mislabeled as rate limits and silently treated as transient), and
|
||||||
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
|
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
|
||||||
string, not a dict).
|
string, not a dict), and the date trim that keeps post-end_date bars out of a
|
||||||
|
historical run.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import pytest
|
|||||||
|
|
||||||
import tradingagents.dataflows.alpha_vantage_common as av
|
import tradingagents.dataflows.alpha_vantage_common as av
|
||||||
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
|
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
|
||||||
|
import tradingagents.dataflows.alpha_vantage_stock as avs
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
@@ -94,3 +96,40 @@ def test_fundamentals_no_curr_date_passes_through(monkeypatch):
|
|||||||
def test_fundamentals_non_json_body_unchanged(monkeypatch):
|
def test_fundamentals_non_json_body_unchanged(monkeypatch):
|
||||||
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
|
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
|
||||||
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json"
|
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date trim (see the rationale on the unguarded trim in alpha_vantage_common)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DAILY_CSV = (
|
||||||
|
"timestamp,open,high,low,close,volume\n"
|
||||||
|
"2024-05-13,1,1,1,1,10\n" # after end_date -> must never be served
|
||||||
|
"2024-05-10,1,1,1,1,10\n"
|
||||||
|
"2024-05-09,1,1,1,1,10\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stock_data_is_trimmed_to_the_requested_window(monkeypatch):
|
||||||
|
monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: _DAILY_CSV)
|
||||||
|
out = avs.get_stock("IBM", "2024-05-09", "2024-05-10")
|
||||||
|
assert "2024-05-10" in out and "2024-05-09" in out
|
||||||
|
assert "2024-05-13" not in out, "bar after end_date leaked into the window"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_unparseable_body_is_never_served_untrimmed(monkeypatch):
|
||||||
|
"""The trim used to swallow the failure and return the whole body, putting
|
||||||
|
bars after end_date into a backtest. It must raise instead."""
|
||||||
|
monkeypatch.setattr(avs, "_make_api_request",
|
||||||
|
lambda *a, **k: "timestamp,close\nnot-a-date,1\n")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
avs.get_stock("IBM", "2024-05-09", "2024-05-10")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_empty_body_still_passes_through(monkeypatch):
|
||||||
|
monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: "")
|
||||||
|
assert avs.get_stock("IBM", "2024-05-09", "2024-05-10") == ""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
130
tests/test_fundamentals_lookahead.py
Normal file
130
tests/test_fundamentals_lookahead.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""Historical fundamentals must not leak a live company profile (#1300).
|
||||||
|
|
||||||
|
Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage
|
||||||
|
``OVERVIEW``) serve only present-day values: market cap, valuation multiples,
|
||||||
|
the 52-week range and TTM income all move with today's quote, and even name,
|
||||||
|
sector and industry shift when a company renames or is reclassified. None of it
|
||||||
|
carries a historical vintage, so emitting it into a run dated in the past puts
|
||||||
|
post-decision information into the analyst's context, in the same family as the
|
||||||
|
FRED (#1275), social (#1220) and memory (#1251) leaks.
|
||||||
|
|
||||||
|
Both vendors withhold on one shared rule (``date_window.withhold_live_profile``)
|
||||||
|
so switching ``fundamental_data`` between them cannot reintroduce the leak. The
|
||||||
|
statement tools stay point-in-time by filtering on ``curr_date``, and a live run
|
||||||
|
is unchanged. All API access is mocked.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.dataflows import alpha_vantage_fundamentals as av, date_window, y_finance
|
||||||
|
|
||||||
|
_TODAY = "2026-09-07"
|
||||||
|
_PAST = "2024-05-10"
|
||||||
|
|
||||||
|
# A profile payload mixing stable-looking identity fields with market-dependent ones.
|
||||||
|
_INFO = {
|
||||||
|
"longName": "Apple Inc.",
|
||||||
|
"sector": "Technology",
|
||||||
|
"industry": "Consumer Electronics",
|
||||||
|
"marketCap": 3_500_000_000_000,
|
||||||
|
"trailingPE": 34.2,
|
||||||
|
"fiftyTwoWeekHigh": 260.1,
|
||||||
|
"totalRevenue": 391_000_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Values that must never reach a historical run.
|
||||||
|
_LEAKY = ("3500000000000", "34.2", "260.1", "391000000000",
|
||||||
|
"Apple Inc.", "Technology", "Consumer Electronics")
|
||||||
|
|
||||||
|
|
||||||
|
def _yf(curr_date, info=_INFO, today=_TODAY):
|
||||||
|
with mock.patch.object(date_window, "get_current_date", return_value=today), \
|
||||||
|
mock.patch.object(y_finance, "yf_retry", lambda fn: info), \
|
||||||
|
mock.patch.object(y_finance.yf, "Ticker"):
|
||||||
|
return y_finance.get_fundamentals("AAPL", curr_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _av(curr_date, today=_TODAY):
|
||||||
|
"""Alpha Vantage path; the API call is mocked so a leak would be visible."""
|
||||||
|
with mock.patch.object(date_window, "get_current_date", return_value=today), \
|
||||||
|
mock.patch.object(av, "_make_api_request",
|
||||||
|
return_value="MarketCapitalization: 3500000000000") as req:
|
||||||
|
return av.get_fundamentals("AAPL", curr_date), req
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestYFinanceHistoricalRun:
|
||||||
|
def test_no_profile_value_survives(self):
|
||||||
|
out = _yf(_PAST)
|
||||||
|
for leaked in _LEAKY:
|
||||||
|
assert leaked not in out, f"leaked live-profile value {leaked!r}"
|
||||||
|
|
||||||
|
def test_states_the_as_of_date_and_explains_itself(self):
|
||||||
|
# The analyst must be told why the figures are absent, so it does not
|
||||||
|
# read the gap as a real signal or fabricate around it.
|
||||||
|
out = _yf(_PAST)
|
||||||
|
assert f"Point-in-time as of: {_PAST}" in out
|
||||||
|
assert "withheld" in out
|
||||||
|
assert _PAST in out and _TODAY in out
|
||||||
|
|
||||||
|
def test_no_wall_clock_retrieval_stamp(self):
|
||||||
|
# The old header stamped datetime.now(), which is what surfaced the leak.
|
||||||
|
assert "Data retrieved on:" not in _yf(_PAST)
|
||||||
|
|
||||||
|
def test_the_request_is_not_even_made(self):
|
||||||
|
# The response would only be discarded; skipping it also avoids burning
|
||||||
|
# vendor quota on a call whose result cannot be used.
|
||||||
|
with mock.patch.object(date_window, "get_current_date", return_value=_TODAY), \
|
||||||
|
mock.patch.object(y_finance.yf, "Ticker") as tk:
|
||||||
|
y_finance.get_fundamentals("AAPL", _PAST)
|
||||||
|
tk.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAlphaVantageHistoricalRun:
|
||||||
|
"""The same rule must hold for the other fundamentals vendor, or switching
|
||||||
|
data_vendors["fundamental_data"] would silently reintroduce the leak."""
|
||||||
|
|
||||||
|
def test_overview_is_withheld(self):
|
||||||
|
out, _ = _av(_PAST)
|
||||||
|
assert "3500000000000" not in out
|
||||||
|
assert "withheld" in out
|
||||||
|
assert f"Point-in-time as of: {_PAST}" in out
|
||||||
|
|
||||||
|
def test_the_api_call_is_not_made(self):
|
||||||
|
_, req = _av(_PAST)
|
||||||
|
req.assert_not_called()
|
||||||
|
|
||||||
|
def test_live_run_still_calls_the_api(self):
|
||||||
|
out, req = _av(_TODAY)
|
||||||
|
req.assert_called_once()
|
||||||
|
assert "3500000000000" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestLiveRunUnchanged:
|
||||||
|
def test_yfinance_current_date_returns_the_full_profile(self):
|
||||||
|
out = _yf(_TODAY)
|
||||||
|
for value in _LEAKY:
|
||||||
|
assert value in out
|
||||||
|
assert "Data retrieved on:" in out
|
||||||
|
assert "withheld" not in out
|
||||||
|
|
||||||
|
def test_yfinance_absent_curr_date_returns_the_full_profile(self):
|
||||||
|
out = _yf(None)
|
||||||
|
assert "Market Cap: 3500000000000" in out
|
||||||
|
assert "withheld" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNoUsableFieldsStillRaises:
|
||||||
|
def test_stub_payload_raises_no_market_data(self):
|
||||||
|
# yfinance returns {"trailingPegRatio": None} for unknown symbols; on a
|
||||||
|
# live run that must stay a hard "no data", not a bare header.
|
||||||
|
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||||
|
|
||||||
|
with pytest.raises(NoMarketDataError):
|
||||||
|
_yf(_TODAY, info={"trailingPegRatio": None})
|
||||||
@@ -4,8 +4,12 @@ yfinance can return the newest in-range bar with a NaN close (an unsettled or
|
|||||||
glitched session). The old path parsed dates without normalizing timezone and
|
glitched session). The old path parsed dates without normalizing timezone and
|
||||||
dropped every NaN-close row before applying the curr_date cutoff, so the latest
|
dropped every NaN-close row before applying the curr_date cutoff, so the latest
|
||||||
bar disappeared and the previous trading day looked like the latest. Now dates
|
bar disappeared and the previous trading day looked like the latest. Now dates
|
||||||
are normalized, and a latest in-range bar with no close raises rather than
|
are normalized before the cutoff, so the frame ends at the last settled bar
|
||||||
silently falling back.
|
instead of carrying a fabricated close.
|
||||||
|
|
||||||
|
Refusing the whole frame instead (the first attempt at #1201) reported a
|
||||||
|
tradable symbol as invalid or delisted (#1289), so only a range with no close
|
||||||
|
anywhere counts as no data and the staleness check judges the rest.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -92,19 +96,46 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date):
|
|||||||
def _fail_download(*a, **k):
|
def _fail_download(*a, **k):
|
||||||
raise AssertionError("should use the seeded cache, not download")
|
raise AssertionError("should use the seeded cache, not download")
|
||||||
monkeypatch.setattr(su.yf, "download", _fail_download)
|
monkeypatch.setattr(su.yf, "download", _fail_download)
|
||||||
monkeypatch.setattr(su, "_assert_ohlcv_not_stale", lambda *a, **k: None)
|
|
||||||
return su.load_ohlcv("AAPL", curr_date)
|
return su.load_ohlcv("AAPL", curr_date)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path):
|
def test_unsettled_latest_bar_is_served_as_the_last_settled_bar(monkeypatch, tmp_path):
|
||||||
# Newest bar (the curr_date) has no close -> raise, don't return Thursday.
|
# Newest bar (the curr_date) has no close: serve the last settled bar rather
|
||||||
|
# than reporting the whole symbol as unavailable (#1289).
|
||||||
frame = pd.DataFrame({
|
frame = pd.DataFrame({
|
||||||
"Date": ["2026-05-07", "2026-05-08"],
|
"Date": ["2026-05-07", "2026-05-08"],
|
||||||
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
})
|
})
|
||||||
with pytest.raises(NoMarketDataError, match="no closing price"):
|
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-07")
|
||||||
|
assert out["Close"].iloc[-1] == 100.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_no_settled_bar_at_all_is_still_no_data(monkeypatch, tmp_path):
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-05-07", "2026-05-08"],
|
||||||
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
|
"Close": [float("nan"), float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
with pytest.raises(NoMarketDataError, match="no bar in range has a closing price"):
|
||||||
|
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_serving_the_last_settled_bar_does_not_bypass_the_staleness_check(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
# Falling back must not resurrect a long-dead series: once the closeless
|
||||||
|
# tail is gone, the remaining bar is judged on its age like any other.
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-01-05", "2026-05-08"],
|
||||||
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
|
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
with pytest.raises(NoMarketDataError, match="stale"):
|
||||||
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Rich colorizes console output and highlights numbers and URLs, which splits
|
||||||
|
# asserted substrings with escape codes ("port \x1b[1;33m11434"). Whether it
|
||||||
|
# does so depends on the ambient terminal, so strip the codes to keep these
|
||||||
|
# assertions independent of where the suite runs.
|
||||||
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
def _console_out(capsys) -> str:
|
||||||
|
return _ANSI.sub("", capsys.readouterr().out)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
def _resync_reloaded_modules():
|
def _resync_reloaded_modules():
|
||||||
@@ -122,7 +133,7 @@ def test_confirm_endpoint_shows_default(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
|
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "http://localhost:11434/v1" in out
|
assert "http://localhost:11434/v1" in out
|
||||||
assert "OLLAMA_BASE_URL" not in out # not from env
|
assert "OLLAMA_BASE_URL" not in out # not from env
|
||||||
assert "Note" not in out # no warnings for the canonical default
|
assert "Note" not in out # no warnings for the canonical default
|
||||||
@@ -133,7 +144,7 @@ def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
|
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "http://remote-host:11434/v1" in out
|
assert "http://remote-host:11434/v1" in out
|
||||||
assert "OLLAMA_BASE_URL" in out
|
assert "OLLAMA_BASE_URL" in out
|
||||||
|
|
||||||
@@ -144,7 +155,7 @@ def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("0.0.0.128")
|
cli_utils.confirm_ollama_endpoint("0.0.0.128")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "missing a scheme" in out
|
assert "missing a scheme" in out
|
||||||
assert "http://<host>:11434/v1" in out
|
assert "http://<host>:11434/v1" in out
|
||||||
|
|
||||||
@@ -155,7 +166,7 @@ def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
|
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "port 11434" in out
|
assert "port 11434" in out
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +176,7 @@ def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
|
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "Note" not in out # localhost is fine without explicit port
|
assert "Note" not in out # localhost is fine without explicit port
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -85,9 +86,9 @@ class TestRssParsing:
|
|||||||
assert posts[0]["created_utc"] > 0
|
assert posts[0]["created_utc"] > 0
|
||||||
assert "datacenter unit" in posts[0]["selftext"]
|
assert "datacenter unit" in posts[0]["selftext"]
|
||||||
|
|
||||||
def test_malformed_xml_fails_open(self):
|
def test_malformed_xml_reports_unavailable(self):
|
||||||
with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<<not xml>>")):
|
with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<<not xml>>")):
|
||||||
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -138,7 +139,7 @@ class TestRss429Backoff:
|
|||||||
patch.object(reddit.time, "sleep"):
|
patch.object(reddit.time, "sleep"):
|
||||||
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
assert op.call_count == 2 # one retry, then gives up cleanly
|
assert op.call_count == 2 # one retry, then gives up cleanly
|
||||||
assert posts == []
|
assert posts is None
|
||||||
|
|
||||||
def test_retry_after_header_is_honoured(self):
|
def test_retry_after_header_is_honoured(self):
|
||||||
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, None)
|
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, None)
|
||||||
@@ -147,15 +148,35 @@ 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 48.0 <= wait <= 72.0 # 60s +/-20% jitter
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestChunkedTransferErrorsHandled:
|
class TestChunkedTransferErrorsHandled:
|
||||||
"""IncompleteRead/RemoteDisconnected come from http.client and are NOT
|
"""IncompleteRead/RemoteDisconnected come from http.client and are NOT
|
||||||
OSErrors, so they were previously uncaught and crashed the pipeline (#1024)."""
|
OSErrors, so they were previously uncaught and crashed the pipeline (#1024)."""
|
||||||
|
|
||||||
def test_rss_incomplete_read_degrades_to_empty(self):
|
def test_rss_incomplete_read_reports_unavailable(self):
|
||||||
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
|
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
|
||||||
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
|
||||||
|
|
||||||
def test_json_incomplete_read_falls_back_to_rss(self):
|
def test_json_incomplete_read_falls_back_to_rss(self):
|
||||||
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))), \
|
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))), \
|
||||||
@@ -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) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestFormatterHandlesRssPosts:
|
class TestFormatterHandlesRssPosts:
|
||||||
@@ -199,7 +228,7 @@ class TestCryptoSearchTerm:
|
|||||||
def _captured_ticker(self, ticker):
|
def _captured_ticker(self, ticker):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def fake_fetch(t, sub, limit, timeout):
|
def fake_fetch(t, sub, limit, timeout, **kwargs):
|
||||||
seen["ticker"] = t
|
seen["ticker"] = t
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -212,3 +241,64 @@ class TestCryptoSearchTerm:
|
|||||||
|
|
||||||
def test_equity_passes_through(self):
|
def test_equity_passes_through(self):
|
||||||
assert self._captured_ticker("NVDA") == "NVDA"
|
assert self._captured_ticker("NVDA") == "NVDA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestFailedFetchIsNotSilence:
|
||||||
|
"""A throttled fetch must not be rendered as "no posts found" (#1295).
|
||||||
|
|
||||||
|
Returning [] for both a failed request and a genuinely empty search made the
|
||||||
|
sentiment analyst read rate limiting as real silence ("r/stocks and
|
||||||
|
r/investing are silent"), which is a signal that was never observed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_POST = {
|
||||||
|
"title": "NVDA pops", "score": None, "num_comments": None,
|
||||||
|
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
||||||
|
"selftext": "", "source": "rss",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run(self, results):
|
||||||
|
"""Drive fetch_reddit_posts with a per-subreddit result sequence."""
|
||||||
|
subs = tuple(f"s{i}" for i in range(len(results)))
|
||||||
|
with patch.object(reddit, "_fetch_subreddit", side_effect=list(results)):
|
||||||
|
return reddit.fetch_reddit_posts(
|
||||||
|
"NVDA", subreddits=subs, inter_request_delay=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_subreddit_is_marked_unavailable_not_empty(self):
|
||||||
|
out = self._run([None, [self._POST]])
|
||||||
|
assert "unavailable" in out
|
||||||
|
assert "no posts found" not in out.split("unavailable")[0]
|
||||||
|
|
||||||
|
def test_all_sources_failing_does_not_claim_no_posts(self):
|
||||||
|
out = self._run([None, None])
|
||||||
|
assert "Reddit unavailable" in out
|
||||||
|
assert "no Reddit posts found" not in out
|
||||||
|
|
||||||
|
def test_mixed_failure_and_empty_only_claims_silence_for_searched_subs(self):
|
||||||
|
# s0 failed, s1 genuinely returned nothing: the "no posts" claim must
|
||||||
|
# cover only s1, with s0 reported separately as unavailable.
|
||||||
|
out = self._run([None, []])
|
||||||
|
assert "r/s1" in out.split("unavailable (fetch failed)")[0]
|
||||||
|
assert "unavailable (fetch failed): r/s0" in out
|
||||||
|
|
||||||
|
def test_genuine_empty_still_reports_no_posts(self):
|
||||||
|
out = self._run([[], []])
|
||||||
|
assert "no Reddit posts found" in out
|
||||||
|
assert "unavailable" not in out
|
||||||
|
|
||||||
|
def test_retry_is_not_spent_again_after_a_failure(self):
|
||||||
|
# The 60s back-off must be paid at most once per run, so subsequent
|
||||||
|
# subreddits are fetched with retry disabled rather than stalling.
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def record(t, sub, limit, timeout, _retry=True):
|
||||||
|
seen.append(_retry)
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(reddit, "_fetch_subreddit", side_effect=record):
|
||||||
|
reddit.fetch_reddit_posts(
|
||||||
|
"NVDA", subreddits=("a", "b", "c"), inter_request_delay=0
|
||||||
|
)
|
||||||
|
assert seen == [True, False, False]
|
||||||
|
|||||||
@@ -97,6 +97,43 @@ class TestNullishFloatCoercion:
|
|||||||
)
|
)
|
||||||
assert d.price_target is None
|
assert d.price_target is None
|
||||||
|
|
||||||
|
def test_percentage_answer_to_a_price_field_becomes_none(self):
|
||||||
|
# The Trader is asked for concrete levels and may answer a price field
|
||||||
|
# with a distance ("15%"), which failed the whole proposal (#1288).
|
||||||
|
# A percentage cannot be salvaged: 15% must not become a $15 stop.
|
||||||
|
for pct in ("15%", " 7.5% ", "-10%"):
|
||||||
|
p = TraderProposal(
|
||||||
|
action=TraderAction.BUY,
|
||||||
|
reasoning="x",
|
||||||
|
entry_price=pct,
|
||||||
|
stop_loss=pct,
|
||||||
|
)
|
||||||
|
assert p.entry_price is None
|
||||||
|
assert p.stop_loss is None
|
||||||
|
|
||||||
|
def test_human_formatted_price_is_reduced_to_its_number(self):
|
||||||
|
p = TraderProposal(
|
||||||
|
action=TraderAction.BUY,
|
||||||
|
reasoning="x",
|
||||||
|
entry_price="$1,234.50",
|
||||||
|
stop_loss="1,180",
|
||||||
|
)
|
||||||
|
assert p.entry_price == 1234.50
|
||||||
|
assert p.stop_loss == 1180.0
|
||||||
|
|
||||||
|
def test_one_bad_field_no_longer_fails_the_whole_proposal(self):
|
||||||
|
# Previously a single '15%' raised, forcing a free-text retry that lost
|
||||||
|
# the action and reasoning; now the rest of the proposal survives.
|
||||||
|
p = TraderProposal(
|
||||||
|
action=TraderAction.SELL,
|
||||||
|
reasoning="downgrade on margin compression",
|
||||||
|
entry_price="612.40",
|
||||||
|
stop_loss="15%",
|
||||||
|
)
|
||||||
|
assert p.action is TraderAction.SELL
|
||||||
|
assert p.entry_price == 612.40
|
||||||
|
assert p.stop_loss is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestRenderResearchPlan:
|
class TestRenderResearchPlan:
|
||||||
@@ -393,6 +430,21 @@ def _structured_sentiment_llm(captured: dict, report: SentimentReport | None = N
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestSentimentAnalystAgent:
|
class TestSentimentAnalystAgent:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stub_prefetched_sources(self, monkeypatch):
|
||||||
|
"""Stub the sources the analyst pre-fetches before prompting.
|
||||||
|
|
||||||
|
create_sentiment_analyst fetches news, StockTwits and Reddit itself, so
|
||||||
|
without this these tests hit the live network. A real Reddit 429 then
|
||||||
|
backs the fetcher off for a minute per subreddit, which is what turned
|
||||||
|
this file into a multi-minute hang.
|
||||||
|
"""
|
||||||
|
from tradingagents.agents.analysts import sentiment_analyst as sentiment
|
||||||
|
|
||||||
|
monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st")
|
||||||
|
monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd")
|
||||||
|
monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False)
|
||||||
|
|
||||||
def test_structured_path_produces_rendered_markdown(self):
|
def test_structured_path_produces_rendered_markdown(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
report = SentimentReport(
|
report = SentimentReport(
|
||||||
|
|||||||
@@ -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()}"""
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,23 @@ _NULLISH_FLOAT = {"", "none", "n/a", "na", "null", "nil", "-", "tbd", "unknown"}
|
|||||||
|
|
||||||
|
|
||||||
def _coerce_optional_float(value):
|
def _coerce_optional_float(value):
|
||||||
if isinstance(value, str) and value.strip().lower() in _NULLISH_FLOAT:
|
"""Normalise an LLM-written optional numeric field before validation.
|
||||||
|
|
||||||
|
Three shapes show up in practice: a placeholder string ("None", "N/A") in
|
||||||
|
place of an omitted value (#1058); a percentage where a price was asked for
|
||||||
|
("15%", #1288); and a human-formatted price ("$1,234.50"). A percentage
|
||||||
|
cannot be salvaged into an absolute level -- reading "15%" as 15 would put a
|
||||||
|
stop at $15 on a $600 stock -- so it is dropped like a placeholder, leaving
|
||||||
|
one bad field to null out instead of failing the whole proposal. A formatted
|
||||||
|
price is reduced to its number. Anything else passes through to pydantic.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
text = value.strip()
|
||||||
|
if text.lower() in _NULLISH_FLOAT or text.endswith("%"):
|
||||||
return None
|
return None
|
||||||
return value
|
cleaned = text.replace(",", "").lstrip("$€£¥").strip()
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -82,9 +96,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(
|
||||||
@@ -138,11 +154,19 @@ class TraderProposal(BaseModel):
|
|||||||
)
|
)
|
||||||
entry_price: float | None = Field(
|
entry_price: float | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional entry price target in the instrument's quote currency.",
|
description=(
|
||||||
|
"Optional entry price target as an absolute number in the instrument's "
|
||||||
|
"quote currency (e.g. 189.5), never a percentage or a range. Omit it "
|
||||||
|
"if you cannot state a specific level."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
stop_loss: float | None = Field(
|
stop_loss: float | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional stop-loss price in the instrument's quote currency.",
|
description=(
|
||||||
|
"Optional stop-loss as an absolute price in the instrument's quote "
|
||||||
|
"currency (e.g. 172.0), never a percentage. Convert a percentage "
|
||||||
|
"distance to the price level it implies, or omit it."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
position_sizing: str | None = Field(
|
position_sizing: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -197,7 +221,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(
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ def create_trader(llm):
|
|||||||
"You are a trading agent analyzing market data to make investment decisions. "
|
"You are a trading agent analyzing market data to make investment decisions. "
|
||||||
"Based on your analysis, provide a specific recommendation to buy, sell, or hold. "
|
"Based on your analysis, provide a specific recommendation to buy, sell, or hold. "
|
||||||
+ grounding
|
+ grounding
|
||||||
|
# Entry/stop are numeric price fields. Asking for concrete
|
||||||
|
# levels invites a percentage ("15%"), which is not a price
|
||||||
|
# and fails the structured parse (#1288).
|
||||||
|
+ "State entry price and stop-loss as absolute price levels in the "
|
||||||
|
"instrument's quote currency (for example 189.5), never a percentage "
|
||||||
|
"or a range; convert a percentage distance to the price level it "
|
||||||
|
"implies, or omit the field if you cannot state a number. "
|
||||||
+ NO_EXTERNAL_TOOLS
|
+ NO_EXTERNAL_TOOLS
|
||||||
+ get_language_instruction()
|
+ get_language_instruction()
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -128,24 +128,18 @@ def _filter_csv_by_date_range(csv_data: str, start_date: str, end_date: str) ->
|
|||||||
if not csv_data or csv_data.strip() == "":
|
if not csv_data or csv_data.strip() == "":
|
||||||
return csv_data
|
return csv_data
|
||||||
|
|
||||||
try:
|
# Deliberately unguarded: TIME_SERIES_DAILY_ADJUSTED returns the full series
|
||||||
# Parse CSV data
|
# up to today, so this trim is the only thing keeping bars after end_date out
|
||||||
df = pd.read_csv(StringIO(csv_data))
|
# of a historical run. Swallowing a parse failure would serve the untrimmed
|
||||||
|
# body, and with it future prices.
|
||||||
|
df = pd.read_csv(StringIO(csv_data))
|
||||||
|
|
||||||
# Assume the first column is the date column (timestamp)
|
# Assume the first column is the date column (timestamp)
|
||||||
date_col = df.columns[0]
|
date_col = df.columns[0]
|
||||||
df[date_col] = pd.to_datetime(df[date_col])
|
df[date_col] = pd.to_datetime(df[date_col])
|
||||||
|
|
||||||
# Filter by date range
|
start_dt = pd.to_datetime(start_date)
|
||||||
start_dt = pd.to_datetime(start_date)
|
end_dt = pd.to_datetime(end_date)
|
||||||
end_dt = pd.to_datetime(end_date)
|
filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)]
|
||||||
|
|
||||||
filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)]
|
return filtered_df.to_csv(index=False)
|
||||||
|
|
||||||
# Convert back to CSV string
|
|
||||||
return filtered_df.to_csv(index=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# If filtering fails, return original data with a warning
|
|
||||||
print(f"Warning: Failed to filter CSV data by date range: {e}")
|
|
||||||
return csv_data
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from .alpha_vantage_common import _make_api_request
|
from .alpha_vantage_common import _make_api_request
|
||||||
|
from .date_window import withhold_live_profile
|
||||||
|
|
||||||
|
|
||||||
def _filter_reports_by_date(result, curr_date: str):
|
def _filter_reports_by_date(result, curr_date: str):
|
||||||
@@ -31,13 +32,22 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str:
|
|||||||
"""
|
"""
|
||||||
Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage.
|
Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage.
|
||||||
|
|
||||||
|
OVERVIEW serves only present-day values and carries no historical vintage, so
|
||||||
|
a past ``curr_date`` withholds it rather than leaking post-decision figures
|
||||||
|
into a backtest (#1300); the statement endpoints below stay point-in-time via
|
||||||
|
``_filter_reports_by_date``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticker (str): Ticker symbol of the company
|
ticker (str): Ticker symbol of the company
|
||||||
curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage)
|
curr_date (str): Analysis date, yyyy-mm-dd
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Company overview data including financial ratios and key metrics
|
str: Company overview data including financial ratios and key metrics
|
||||||
"""
|
"""
|
||||||
|
withheld = withhold_live_profile(curr_date, ticker)
|
||||||
|
if withheld:
|
||||||
|
return withheld
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"symbol": ticker,
|
"symbol": ticker,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from .alpha_vantage_common import AlphaVantageNotConfiguredError, _make_api_request
|
from .alpha_vantage_common import AlphaVantageNotConfiguredError, _make_api_request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_indicator(
|
def get_indicator(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -211,5 +215,5 @@ def get_indicator(
|
|||||||
# successful-looking error string.
|
# successful-looking error string.
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting Alpha Vantage indicator data for {indicator}: {e}")
|
logger.warning("Alpha Vantage indicator %s failed: %s", indicator, e)
|
||||||
return f"Error retrieving {indicator} data: {str(e)}"
|
return f"Error retrieving {indicator} data: {str(e)}"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from .utils import get_current_date
|
||||||
|
|
||||||
|
|
||||||
def to_utc(dt: datetime) -> datetime:
|
def to_utc(dt: datetime) -> datetime:
|
||||||
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
|
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
|
||||||
@@ -28,3 +30,32 @@ def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) ->
|
|||||||
if pub_dt is not None:
|
if pub_dt is not None:
|
||||||
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
|
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(timezone.utc) - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def withhold_live_profile(curr_date: str | None, label: str) -> str | None:
|
||||||
|
"""Notice to serve instead of a live-only company profile, or None to serve it.
|
||||||
|
|
||||||
|
Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage
|
||||||
|
``OVERVIEW``) carry no historical vintage — not even name, sector and
|
||||||
|
industry, which move when a company renames or is reclassified — so serving
|
||||||
|
one into a run dated in the past leaks post-decision information (#1300).
|
||||||
|
Every fundamentals vendor withholds on this rule, so switching between them
|
||||||
|
cannot reintroduce the leak.
|
||||||
|
"""
|
||||||
|
if not curr_date:
|
||||||
|
return None
|
||||||
|
today = get_current_date()
|
||||||
|
if curr_date >= today:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"# Company Fundamentals for {label}\n"
|
||||||
|
f"# Point-in-time as of: {curr_date}\n\n"
|
||||||
|
f"Profile fundamentals are withheld for this date. This vendor serves "
|
||||||
|
f"only present-day values ({today}) with no historical vintage: market "
|
||||||
|
f"cap, valuation multiples, the 52-week range and TTM income move with "
|
||||||
|
f"today's quote, and even the name, sector and industry reflect today "
|
||||||
|
f"rather than {curr_date} (companies rename and get reclassified). "
|
||||||
|
f"Serving them would put post-decision information into a {curr_date} "
|
||||||
|
f"analysis. Point-in-time fundamentals for {curr_date} are available "
|
||||||
|
f"from the balance sheet, income statement, and cash flow tools."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ off once (honouring ``Retry-After``). RSS lacks score / comment counts, so those
|
|||||||
posts are marked and the formatter omits the metrics rather than printing fake
|
posts are marked and the formatter omits the metrics rather than printing fake
|
||||||
zeros.
|
zeros.
|
||||||
|
|
||||||
|
A fetch that fails is reported as ``<unavailable>``, never as "no posts found":
|
||||||
|
the two are different claims, and passing a rate-limited fetch off as silence
|
||||||
|
hands the sentiment analyst a signal that was never observed (#1295).
|
||||||
|
|
||||||
No API key required. Returns formatted plaintext blocks ready for prompt
|
No API key required. Returns formatted plaintext blocks ready for prompt
|
||||||
injection and degrades gracefully — returns a placeholder string rather than
|
injection and degrades gracefully — returns a placeholder string rather than
|
||||||
raising, so callers never special-case missing data.
|
raising, so callers never special-case missing data.
|
||||||
@@ -21,6 +25,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,37 +106,84 @@ 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. Measured against
|
||||||
|
# /r/{sub}/search.rss, a retry still 429s at 8s, 10s and 30s of spacing and
|
||||||
|
# succeeds at 60s, so a shorter wait spends the one retry on a request that
|
||||||
|
# cannot succeed (#1295). Jittered so several analyses sharing an IP don't
|
||||||
|
# retry in lockstep and re-collide on the limit.
|
||||||
|
_RETRY_FALLBACK_SECONDS = 60.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 60s.
|
||||||
|
|
||||||
|
The cap matches ``_RETRY_FALLBACK_SECONDS``: honouring less than we would
|
||||||
|
wait on our own would spend the one retry on a request we already know is
|
||||||
|
too early.
|
||||||
|
|
||||||
|
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), 60.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,
|
||||||
limit: int,
|
limit: int,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
_retry: bool = True,
|
_retry: bool = True,
|
||||||
) -> list[dict]:
|
) -> list[dict] | None:
|
||||||
"""Default path: parse the public Atom search feed for a subreddit.
|
"""Default path: parse the public Atom search feed for a subreddit.
|
||||||
|
|
||||||
Carries no score / comment counts, so those fields are left None and the
|
Carries no score / comment counts, so those fields are left None and the
|
||||||
post is tagged ``source="rss"`` for honest display. On a 429 (Reddit's
|
post is tagged ``source="rss"`` for honest display. On a 429 (Reddit's
|
||||||
per-IP rate limit) we back off once — honouring ``Retry-After`` when
|
per-IP rate limit) we back off once — honouring ``Retry-After`` when
|
||||||
present — before giving up, so a transient burst doesn't blank the feed.
|
present — before giving up, so a transient burst doesn't blank the feed.
|
||||||
|
|
||||||
|
Returns ``[]`` when the search ran and matched nothing, and ``None`` when
|
||||||
|
the fetch itself failed. The caller must keep these apart: rendering a
|
||||||
|
failed fetch as "no posts found" hands the sentiment analyst an absence of
|
||||||
|
discussion that was never observed (#1295).
|
||||||
"""
|
"""
|
||||||
url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit))
|
url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit))
|
||||||
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,
|
||||||
@@ -139,12 +191,12 @@ def _fetch_subreddit_rss(
|
|||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=False)
|
return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=False)
|
||||||
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
||||||
return []
|
return None
|
||||||
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
|
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
|
||||||
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
||||||
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
||||||
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
||||||
return []
|
return None
|
||||||
|
|
||||||
posts = []
|
posts = []
|
||||||
for entry in root.findall("atom:entry", _ATOM_NS)[:limit]:
|
for entry in root.findall("atom:entry", _ATOM_NS)[:limit]:
|
||||||
@@ -182,7 +234,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:
|
||||||
@@ -198,14 +250,15 @@ def _fetch_subreddit(
|
|||||||
sub: str,
|
sub: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> list[dict]:
|
_retry: bool = True,
|
||||||
"""Fetch one subreddit, RSS-first.
|
) -> list[dict] | None:
|
||||||
|
"""Fetch one subreddit, RSS-first. ``None`` means the fetch failed.
|
||||||
|
|
||||||
The JSON search endpoint is reliably WAF-blocked (403) for public clients,
|
The JSON search endpoint is reliably WAF-blocked (403) for public clients,
|
||||||
so we go straight to the RSS feed — which serves our identified User-Agent
|
so we go straight to the RSS feed — which serves our identified User-Agent
|
||||||
reliably — halving our request volume against Reddit's per-IP rate limit.
|
reliably — halving our request volume against Reddit's per-IP rate limit.
|
||||||
"""
|
"""
|
||||||
return _fetch_subreddit_rss(ticker, sub, limit, timeout)
|
return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=_retry)
|
||||||
|
|
||||||
|
|
||||||
def fetch_reddit_posts(
|
def fetch_reddit_posts(
|
||||||
@@ -231,13 +284,26 @@ def fetch_reddit_posts(
|
|||||||
# 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.
|
||||||
ticker = crypto_base(ticker) or ticker
|
ticker = crypto_base(ticker) or ticker
|
||||||
|
subreddits = list(subreddits)
|
||||||
blocks = []
|
blocks = []
|
||||||
total_posts = 0
|
total_posts = 0
|
||||||
|
unavailable = []
|
||||||
|
allow_retry = True
|
||||||
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),
|
fetched = _fetch_subreddit(ticker, sub, limit_per_sub, timeout, _retry=allow_retry)
|
||||||
start_date, end_date)
|
if fetched is None:
|
||||||
|
# A failed fetch is not an absence of discussion, so it must not be
|
||||||
|
# rendered as "no posts found" (#1295). One failure also means the
|
||||||
|
# per-IP budget is likely gone, so skip the (now 60s) back-off on
|
||||||
|
# the remaining subreddits rather than stalling the run on retries
|
||||||
|
# that cannot succeed; #1286 tracks coordinating this properly.
|
||||||
|
allow_retry = False
|
||||||
|
unavailable.append(sub)
|
||||||
|
blocks.append(f"r/{sub}: <unavailable: fetch failed, not an absence of posts>")
|
||||||
|
continue
|
||||||
|
posts = _within_window(fetched, 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>")
|
||||||
@@ -270,8 +336,23 @@ def fetch_reddit_posts(
|
|||||||
blocks.append("\n".join(lines))
|
blocks.append("\n".join(lines))
|
||||||
|
|
||||||
if total_posts == 0:
|
if total_posts == 0:
|
||||||
return (
|
searched = [s for s in subreddits if s not in unavailable]
|
||||||
|
if not searched:
|
||||||
|
# Every source failed: claiming "no posts" here would assert a
|
||||||
|
# silence we never observed.
|
||||||
|
return (
|
||||||
|
f"<Reddit unavailable: every source failed to fetch "
|
||||||
|
f"({', '.join(f'r/{s}' for s in unavailable)}); this is not an "
|
||||||
|
f"absence of discussion>"
|
||||||
|
)
|
||||||
|
summary = (
|
||||||
f"<no Reddit posts found mentioning {ticker.upper()} across "
|
f"<no Reddit posts found mentioning {ticker.upper()} across "
|
||||||
f"{', '.join(f'r/{s}' for s in subreddits)} in the past 7 days>"
|
f"{', '.join(f'r/{s}' for s in searched)} in the past 7 days>"
|
||||||
)
|
)
|
||||||
|
if unavailable:
|
||||||
|
summary += (
|
||||||
|
f"\n<unavailable (fetch failed): "
|
||||||
|
f"{', '.join(f'r/{s}' for s in unavailable)}>"
|
||||||
|
)
|
||||||
|
return summary
|
||||||
return "\n\n".join(blocks)
|
return "\n\n".join(blocks)
|
||||||
|
|||||||
@@ -250,13 +250,20 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
|||||||
# Filter to curr_date to prevent look-ahead bias in backtesting.
|
# Filter to curr_date to prevent look-ahead bias in backtesting.
|
||||||
data = data[data["Date"] <= curr_date_dt]
|
data = data[data["Date"] <= curr_date_dt]
|
||||||
|
|
||||||
# Guard the latest in-range bar before dropping incomplete rows: a newest bar
|
# A closeless newest bar is an unsettled session, not a symbol without data.
|
||||||
# with no close is "not settled yet", not "does not exist". Silently dropping
|
# _fill_price_gaps below drops it, here and mid-series alike, so the frame
|
||||||
# it would make the previous trading day look like the latest (#1201); raise
|
# ends at the last settled bar; only a range with no close anywhere is no
|
||||||
# instead so the router surfaces it rather than fabricating a fallback.
|
# data (#1201, #1289).
|
||||||
if not data.empty and pd.isna(data["Close"].iloc[-1]):
|
if not data.empty and pd.isna(data["Close"].iloc[-1]):
|
||||||
raise NoMarketDataError(
|
settled = data["Close"].notna().to_numpy().nonzero()[0]
|
||||||
symbol, canonical, "latest in-range OHLCV bar has no closing price"
|
if settled.size == 0:
|
||||||
|
raise NoMarketDataError(
|
||||||
|
symbol, canonical, "no bar in range has a closing price"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"%s: %d trailing bar(s) through %s have no closing price; using %s "
|
||||||
|
"as the latest close.", canonical, len(data) - settled[-1] - 1,
|
||||||
|
data["Date"].iloc[-1].date(), data["Date"].iloc[settled[-1]].date(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = _fill_price_gaps(data)
|
data = _fill_price_gaps(data)
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
SavePathType = Annotated[str, "File path to save data. If None, data is not saved."]
|
|
||||||
|
|
||||||
# Tickers can contain letters, digits, dot, dash, underscore, caret
|
# Tickers can contain letters, digits, dot, dash, underscore, caret
|
||||||
# (index symbols like ^GSPC), equals (futures like GC=F), and plus
|
# (index symbols like ^GSPC), equals (futures like GC=F), and plus
|
||||||
@@ -42,34 +37,5 @@ def safe_ticker_component(value: str, *, max_len: int = 32) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def save_output(data: pd.DataFrame, tag: str, save_path: SavePathType = None) -> None:
|
|
||||||
if save_path:
|
|
||||||
data.to_csv(save_path, encoding="utf-8")
|
|
||||||
print(f"{tag} saved to {save_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_date():
|
def get_current_date():
|
||||||
return date.today().strftime("%Y-%m-%d")
|
return date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
def decorate_all_methods(decorator):
|
|
||||||
def class_decorator(cls):
|
|
||||||
for attr_name, attr_value in cls.__dict__.items():
|
|
||||||
if callable(attr_value):
|
|
||||||
setattr(cls, attr_name, decorator(attr_value))
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return class_decorator
|
|
||||||
|
|
||||||
|
|
||||||
def get_next_weekday(date):
|
|
||||||
|
|
||||||
if not isinstance(date, datetime):
|
|
||||||
date = datetime.strptime(date, "%Y-%m-%d")
|
|
||||||
|
|
||||||
if date.weekday() >= 5:
|
|
||||||
days_to_add = 7 - date.weekday()
|
|
||||||
next_weekday = date + timedelta(days=days_to_add)
|
|
||||||
return next_weekday
|
|
||||||
else:
|
|
||||||
return date
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -5,6 +6,7 @@ import pandas as pd
|
|||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from .date_window import withhold_live_profile
|
||||||
from .stockstats_utils import (
|
from .stockstats_utils import (
|
||||||
StockstatsUtils,
|
StockstatsUtils,
|
||||||
_assert_ohlcv_not_stale,
|
_assert_ohlcv_not_stale,
|
||||||
@@ -14,6 +16,8 @@ from .stockstats_utils import (
|
|||||||
)
|
)
|
||||||
from .symbol_utils import NoMarketDataError, normalize_symbol
|
from .symbol_utils import NoMarketDataError, normalize_symbol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_YFin_data_online(
|
def get_YFin_data_online(
|
||||||
symbol: Annotated[str, "ticker symbol of the company"],
|
symbol: Annotated[str, "ticker symbol of the company"],
|
||||||
@@ -188,7 +192,7 @@ def get_stock_stats_indicators_window(
|
|||||||
except NoMarketDataError:
|
except NoMarketDataError:
|
||||||
raise # Unknown/delisted symbol — let the router emit the sentinel
|
raise # Unknown/delisted symbol — let the router emit the sentinel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting bulk stockstats data: {e}")
|
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
|
||||||
# Fallback to original implementation if bulk method fails
|
# Fallback to original implementation if bulk method fails
|
||||||
ind_string = ""
|
ind_string = ""
|
||||||
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
||||||
@@ -263,9 +267,7 @@ def get_stockstats_indicator(
|
|||||||
except NoMarketDataError:
|
except NoMarketDataError:
|
||||||
raise # Unknown/delisted symbol — let the router emit the sentinel
|
raise # Unknown/delisted symbol — let the router emit the sentinel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
logger.warning("Stockstats indicator %s failed on %s: %s", indicator, curr_date, e)
|
||||||
f"Error getting stockstats indicator data for indicator {indicator} on {curr_date}: {e}"
|
|
||||||
)
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
return str(indicator_value)
|
return str(indicator_value)
|
||||||
@@ -273,10 +275,22 @@ def get_stockstats_indicator(
|
|||||||
|
|
||||||
def get_fundamentals(
|
def get_fundamentals(
|
||||||
ticker: Annotated[str, "ticker symbol of the company"],
|
ticker: Annotated[str, "ticker symbol of the company"],
|
||||||
curr_date: Annotated[str, "current date (not used for yfinance)"] = None
|
curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None
|
||||||
):
|
):
|
||||||
"""Get company fundamentals overview from yfinance."""
|
"""Get company fundamentals overview from yfinance.
|
||||||
|
|
||||||
|
``Ticker.info`` is a present-day snapshot with no historical vintage, so a
|
||||||
|
past ``curr_date`` withholds it through the shared point-in-time guard
|
||||||
|
(``date_window.withhold_live_profile``, #1300).
|
||||||
|
"""
|
||||||
canonical = normalize_symbol(ticker)
|
canonical = normalize_symbol(ticker)
|
||||||
|
|
||||||
|
# Guard before the request: the response would only be discarded, and the
|
||||||
|
# answer does not depend on it.
|
||||||
|
withheld = withhold_live_profile(curr_date, canonical)
|
||||||
|
if withheld:
|
||||||
|
return withheld
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ticker_obj = yf.Ticker(canonical)
|
ticker_obj = yf.Ticker(canonical)
|
||||||
info = yf_retry(lambda: ticker_obj.info)
|
info = yf_retry(lambda: ticker_obj.info)
|
||||||
@@ -315,10 +329,7 @@ def get_fundamentals(
|
|||||||
("Free Cash Flow", info.get("freeCashflow")),
|
("Free Cash Flow", info.get("freeCashflow")),
|
||||||
]
|
]
|
||||||
|
|
||||||
lines = []
|
lines = [f"{label}: {v}" for label, v in fields if v is not None]
|
||||||
for label, value in fields:
|
|
||||||
if value is not None:
|
|
||||||
lines.append(f"{label}: {value}")
|
|
||||||
|
|
||||||
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
|
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
|
||||||
# unknown symbols, so `info` is truthy but every field is empty. Treat
|
# unknown symbols, so `info` is truthy but every field is empty. Treat
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ _QWEN_MODELS: dict[str, list[ModelOption]] = {
|
|||||||
# Shared model list for MiniMax's global and CN endpoints (same IDs).
|
# Shared model list for MiniMax's global and CN endpoints (same IDs).
|
||||||
# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api.
|
# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api.
|
||||||
# M3 carries a 1M-token context window; the M2.x line is 204,800 tokens.
|
# M3 carries a 1M-token context window; the M2.x line is 204,800 tokens.
|
||||||
|
# Kimi (Moonshot). Source: platform.kimi.ai/docs/models. "Custom model ID" stays
|
||||||
|
# available for models newer than this list. The k2.7-code variants are omitted:
|
||||||
|
# they are coding specialists, not analysis models.
|
||||||
|
_KIMI_MODELS: dict[str, list[ModelOption]] = {
|
||||||
|
"quick": [
|
||||||
|
("Kimi K2.6 - 256K ctx, thinking modes, agent tasks", "kimi-k2.6"),
|
||||||
|
("Kimi K3 - Flagship, 1M ctx", "kimi-k3"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
|
],
|
||||||
|
"deep": [
|
||||||
|
("Kimi K3 - Flagship, 1M ctx, native visual understanding", "kimi-k3"),
|
||||||
|
("Kimi K2.6 - 256K ctx, thinking modes, agent tasks", "kimi-k2.6"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_MINIMAX_MODELS: dict[str, list[ModelOption]] = {
|
_MINIMAX_MODELS: dict[str, list[ModelOption]] = {
|
||||||
"quick": [
|
"quick": [
|
||||||
("MiniMax-M3 - Latest, 1M ctx, native multimodal", "MiniMax-M3"),
|
("MiniMax-M3 - Latest, 1M ctx, native multimodal", "MiniMax-M3"),
|
||||||
@@ -151,6 +168,7 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
"glm-cn": _GLM_MODELS,
|
"glm-cn": _GLM_MODELS,
|
||||||
# MiniMax: same model IDs across global (.io) and China (.com) regions,
|
# MiniMax: same model IDs across global (.io) and China (.com) regions,
|
||||||
# so the two provider keys share one model list.
|
# so the two provider keys share one model list.
|
||||||
|
"kimi": _KIMI_MODELS,
|
||||||
"minimax": _MINIMAX_MODELS,
|
"minimax": _MINIMAX_MODELS,
|
||||||
"minimax-cn": _MINIMAX_MODELS,
|
"minimax-cn": _MINIMAX_MODELS,
|
||||||
# OpenRouter: fetched dynamically. Azure: any deployed model name.
|
# OpenRouter: fetched dynamically. Azure: any deployed model name.
|
||||||
@@ -183,7 +201,6 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
# stale. The endpoint + key are wired by the provider; the user picks the
|
# stale. The endpoint + key are wired by the provider; the user picks the
|
||||||
# model their account has access to.
|
# model their account has access to.
|
||||||
"mistral": _CUSTOM_ONLY,
|
"mistral": _CUSTOM_ONLY,
|
||||||
"kimi": _CUSTOM_ONLY,
|
|
||||||
"groq": _CUSTOM_ONLY,
|
"groq": _CUSTOM_ONLY,
|
||||||
"nvidia": _CUSTOM_ONLY,
|
"nvidia": _CUSTOM_ONLY,
|
||||||
# Bedrock model IDs / cross-region inference profile IDs are user-specified.
|
# Bedrock model IDs / cross-region inference profile IDs are user-specified.
|
||||||
|
|||||||
Reference in New Issue
Block a user