fix(memory): don't settle a decision before its holding window trades

- _fetch_returns settled on min(holding_days, available), so a rerun a day or
  two after a decision reflected on a 1-2 day partial return as if final
- require the full holding window in both the stock and benchmark series before
  resolving; otherwise leave the entry pending to retry next run
- this also makes the #1251 resolution date the full-window date, not a partial
  bar's #1169
This commit is contained in:
Yijia-Xiao
2026-08-31 01:55:31 +00:00
parent a2f51da917
commit 30d42abd5d
2 changed files with 51 additions and 17 deletions

View File

@@ -58,7 +58,7 @@ def _price_df(prices, start="2026-01-05"):
"""Minimal DataFrame matching yfinance .history() output shape. """Minimal DataFrame matching yfinance .history() output shape.
Uses a DatetimeIndex like real yfinance output, so resolution-date Uses a DatetimeIndex like real yfinance output, so resolution-date
extraction (stock.index[actual_days]) works (#1251). extraction (stock.index[holding_days]) works (#1251).
""" """
idx = pd.date_range(start=start, periods=len(prices), freq="D") idx = pd.date_range(start=start, periods=len(prices), freq="D")
return pd.DataFrame({"Close": prices}, index=idx) return pd.DataFrame({"Close": prices}, index=idx)
@@ -529,9 +529,10 @@ class TestDeferredReflection:
assert (raw, alpha, days, resolved) == (None, None, None, None) assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_spy_shorter_than_stock(self): def test_fetch_returns_spy_shorter_than_stock(self):
"""SPY having fewer rows than the stock must not raise IndexError.""" """SPY having fewer rows than the stock (but still a full window) must
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0] not raise IndexError."""
spy_prices = [400.0, 402.0, 403.0] stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows
mock_graph = MagicMock(spec=TradingAgentsGraph) mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym): def _make_ticker(sym):
@@ -540,9 +541,25 @@ class TestDeferredReflection:
return m return m
mock_ticker_cls.side_effect = _make_ticker mock_ticker_cls.side_effect = _make_ticker
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
assert raw is not None and alpha is not None and days is not None assert raw is not None and alpha is not None
assert days == 2 assert days == 5 # full holding window used for both series
assert resolved == "2026-01-07" # 2 sessions after the trade date assert resolved == "2026-01-10"
def test_fetch_returns_incomplete_window_stays_pending(self):
"""#1169: a rerun before the full holding window has traded returns
unavailable (all-None) so the entry stays pending, rather than settling
on a premature partial return."""
stock_prices = [100.0, 102.0, 104.0] # only 3 rows; holding window is 5
spy_prices = [400.0, 402.0, 404.0]
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym):
m = MagicMock()
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m
mock_ticker_cls.side_effect = _make_ticker
result = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
assert result == (None, None, None, None)
# TradingAgentsGraph._resolve_benchmark — picks index for alpha calc # TradingAgentsGraph._resolve_benchmark — picks index for alpha calc
@@ -673,6 +690,20 @@ class TestDeferredReflection:
assert "+5.0%" in entries[0]["raw"] assert "+5.0%" in entries[0]["raw"]
assert "+2.0%" in entries[0]["alpha"] assert "+2.0%" in entries[0]["alpha"]
def test_resolve_leaves_premature_entry_pending(self, tmp_path):
"""#1169: when the outcome can't be settled yet (_fetch_returns None),
the entry stays pending and the reflector is never called."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
mock_reflector = MagicMock()
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.memory_log = log
mock_graph.reflector = mock_reflector
mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None))
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
assert len(log.get_pending_entries()) == 1 # still pending
mock_reflector.reflect_on_final_decision.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Portfolio Manager injection: past_context in state and prompt # Portfolio Manager injection: past_context in state and prompt

View File

@@ -278,10 +278,11 @@ class TradingAgentsGraph:
``benchmark`` is the index used as the alpha baseline (resolved by the ``benchmark`` is the index used as the alpha baseline (resolved by the
caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return, caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return,
actual_holding_days, resolution_date)`` — where ``resolution_date`` is holding_days, resolution_date)`` — where ``resolution_date`` is the date
the date of the last price bar used, i.e. when the outcome became known of the last price bar used, i.e. when the outcome became known (#1251) —
(#1251) — or ``(None, None, None, None)`` if price data is unavailable or ``(None, None, None, None)`` when the outcome cannot be settled yet:
(too recent, delisted, or network error). the full holding window has not traded (#1169), or the symbol is delisted
or unreachable.
""" """
from tradingagents.dataflows.symbol_utils import normalize_symbol from tradingagents.dataflows.symbol_utils import normalize_symbol
@@ -296,23 +297,25 @@ class TradingAgentsGraph:
stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str) stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str)
bench = yf.Ticker(benchmark).history(start=trade_date, end=end_str) bench = yf.Ticker(benchmark).history(start=trade_date, end=end_str)
if len(stock) < 2 or len(bench) < 2: # Require the full holding window in both series. A rerun before it
# has traded leaves the entry pending to retry next run, rather than
# settling on a premature partial return (#1169).
if len(stock) <= holding_days or len(bench) <= holding_days:
return None, None, None, None return None, None, None, None
actual_days = min(holding_days, len(stock) - 1, len(bench) - 1)
raw = float( raw = float(
(stock["Close"].iloc[actual_days] - stock["Close"].iloc[0]) (stock["Close"].iloc[holding_days] - stock["Close"].iloc[0])
/ stock["Close"].iloc[0] / stock["Close"].iloc[0]
) )
bench_ret = float( bench_ret = float(
(bench["Close"].iloc[actual_days] - bench["Close"].iloc[0]) (bench["Close"].iloc[holding_days] - bench["Close"].iloc[0])
/ bench["Close"].iloc[0] / bench["Close"].iloc[0]
) )
alpha = raw - bench_ret alpha = raw - bench_ret
# The date of the last price bar used is when this outcome became # The date of the last price bar used is when this outcome became
# known — the point-in-time cutoff for injecting the lesson (#1251). # known — the point-in-time cutoff for injecting the lesson (#1251).
resolution_date = stock.index[actual_days].strftime("%Y-%m-%d") resolution_date = stock.index[holding_days].strftime("%Y-%m-%d")
return raw, alpha, actual_days, resolution_date return raw, alpha, holding_days, resolution_date
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Could not resolve outcome for %s on %s vs %s (will retry next run): %s", "Could not resolve outcome for %s on %s vs %s (will retry next run): %s",