diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index b7206afae..b47a8bab2 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -1138,6 +1138,75 @@ class TestSettlementWindow: assert alpha == pytest.approx(raw - (spy["2026-01-14"] / spy["2026-01-07"] - 1)) +@pytest.mark.unit +class TestSettlementUsesFinalCloses: + """A decision settles on closes that are final, whenever the pass runs.""" + + def test_a_bar_of_a_session_still_trading_is_not_scored(self, monkeypatch): + # 09:12 in Shanghai on Tue 13: Yahoo already serves that day's bar at the live price. + days = pd.bdate_range("2026-01-02", "2026-01-13", tz="Asia/Shanghai") + stock = pd.Series([100.0 + i for i in range(len(days))], index=days) + index = pd.Series([3000.0] * len(days), index=days) + monkeypatch.setattr(settlement, "get_closes", _closes({"600519.SS": stock, "000001.SS": index})) + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-13 01:12", tz="UTC")) + + # Decided Mon 5: entry Tue 6, exit five sessions later on Tue 13, today. + assert settlement.fetch_returns("600519.SS", "2026-01-05", 5, "000001.SS") == (None, None, None, None) + + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-14 01:12", tz="UTC")) + assert settlement.fetch_returns("600519.SS", "2026-01-05", 5, "000001.SS")[3] == "2026-01-13" + + def test_a_decision_before_a_long_market_closure_settles(self, monkeypatch): + # The exchange is shut Thu 1 to Wed 7 October. + days = pd.bdate_range("2026-09-21", "2026-10-30") + days = days[(days < "2026-10-01") | (days > "2026-10-07")] + stock = pd.Series([100.0 + i for i in range(len(days))], index=days) + index = pd.Series([3000.0 + i for i in range(len(days))], index=days) + monkeypatch.setattr(settlement, "get_closes", _closes({"600519.SS": stock, "000001.SS": index})) + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-11-02 08:00", tz="UTC")) + + raw, alpha, days_held, resolved = settlement.fetch_returns("600519.SS", "2026-09-29", 5, "000001.SS") + + # Entry Wed 30 September, then five sessions from Thu 8 October. + assert resolved == "2026-10-14" and days_held == 5 + + def test_a_market_whose_clocks_skip_midnight_still_settles(self, monkeypatch): + # Santiago springs forward at midnight: 2026-09-06 has no 00:00. + days = pd.bdate_range("2026-08-21", "2026-09-04", tz="America/Santiago") + stock = pd.Series([100.0 + i for i in range(len(days))], index=days) + index = pd.Series([6000.0 + i for i in range(len(days))], index=days) + monkeypatch.setattr(settlement, "get_closes", _closes({"SQM-B.SN": stock, "^IPSA": index})) + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-09-06 15:00", tz="UTC")) + + assert settlement.fetch_returns("SQM-B.SN", "2026-08-24", 5, "^IPSA")[3] == "2026-09-01" + + def test_a_benchmark_shut_past_the_window_is_fetched_again(self, monkeypatch): + days = pd.bdate_range("2026-09-14", "2026-10-30") + stock = pd.Series([100.0 + i for i in range(len(days))], index=days) + shut = days[(days < "2026-09-30") | (days > "2026-10-20")] + index = pd.Series([3000.0 + i for i in range(len(shut))], index=shut) + monkeypatch.setattr(settlement, "get_closes", _closes({"AAPL": stock, "000001.SS": index})) + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-11-02 08:00", tz="UTC")) + + assert settlement.fetch_returns("AAPL", "2026-09-29", 5, "000001.SS")[3] == "2026-10-07" + + def test_a_series_with_no_usable_close_stays_pending(self, monkeypatch): + # Yahoo can answer with a dated, time-zoned frame whose closes are all missing. + days = pd.bdate_range("2026-01-02", "2026-01-30", tz="America/New_York") + empty = pd.Series([float("nan")] * len(days), index=days) + index = pd.Series([400.0 + i for i in range(len(days))], index=days) + monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": empty, "SPY": index})) + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-02-02 12:00", tz="UTC")) + + assert settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") == (None, None, None, None) + + def test_a_window_that_cannot_have_traded_asks_for_no_prices(self, monkeypatch): + monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-08 22:00", tz="UTC")) + monkeypatch.setattr(settlement, "get_closes", lambda *a: pytest.fail("fetched prices")) + + assert settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") == (None, None, None, None) + + @pytest.mark.unit def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch): """A zero close would score as an infinite return and be written to the memory log.""" diff --git a/tradingagents/memory/settlement.py b/tradingagents/memory/settlement.py index c997b19f2..be2e196f8 100644 --- a/tradingagents/memory/settlement.py +++ b/tradingagents/memory/settlement.py @@ -5,6 +5,8 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timedelta +import pandas as pd + from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.market import get_closes @@ -35,18 +37,24 @@ def resolve_benchmark(ticker: str, config: dict) -> str: return benchmark_map.get("", "SPY") +def _now() -> pd.Timestamp: + return pd.Timestamp.now(tz="UTC") + + def _by_day(closes): - """Closes keyed by the calendar day of their bar, missing closes dropped. + """Final closes keyed by the calendar day of their bar, missing closes dropped. Yahoo stamps a daily bar at midnight in its market's time zone (UTC for a coin, New York for SPY), so two series meet on their dates, not their instants. A missing or non-positive close is no price at all, not a bar to - score on. + score on. Today's bar, in its market's time zone, is the session still + trading: its close is the live price, so it is not scored. """ closes = closes[closes > 0] - if getattr(closes.index, "tz", None) is not None: - closes = closes.tz_localize(None) - return closes + tz = getattr(closes.index, "tz", None) + if not closes.empty: + closes = closes[closes.index.date < _now().tz_convert(tz).date()] + return closes if tz is None else closes.tz_localize(None) def fetch_returns( @@ -67,18 +75,32 @@ def fetch_returns( to report. """ start = datetime.strptime(trade_date, "%Y-%m-%d") + today = _now().tz_localize(None).normalize() + # The entry and holding_days more sessions all close after the decision date + # and before today, with a day's slack for markets east of UTC: until then + # the window cannot have traded and no prices are asked for. + if (today - start).days < holding_days + 1: + return None, None, None, None # holding_days counts trading days after the entry session, so ask for the # calendar span they occupy (about 7 for every 5) plus a week for holidays. end = start + timedelta(days=round((holding_days + 1) * 7 / 5) + 7) - end_str = end.strftime("%Y-%m-%d") - - # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984), - # from the first session after the decision date. - stock = _by_day(get_closes(ticker, trade_date, end_str)) - stock = stock[stock.index > start] # From a week earlier, so the benchmark has a close on or before entry. bench_start = (start - timedelta(days=7)).strftime("%Y-%m-%d") - bench = _by_day(get_closes(benchmark, bench_start, end_str)) + + def closes(until): + # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984), + # from the first session after the decision date. + stock = _by_day(get_closes(ticker, trade_date, until.strftime("%Y-%m-%d"))) + return stock[stock.index > start], _by_day(get_closes(benchmark, bench_start, until.strftime("%Y-%m-%d"))) + + def traded(stock, bench): + return len(stock) > holding_days and not bench.empty and bench.index[-1] >= stock.index[holding_days] + + stock, bench = closes(end) + # A window that has passed without trading through spans a closure longer than + # the week allowed for holidays (a national holiday, a halt): ask through today. + if not traded(stock, bench) and end < today: + stock, bench = closes(today + timedelta(days=1)) # Require the full holding window to have traded. A rerun before it has # leaves the entry pending to retry next run, rather than settling on a