fix(memory): settle decisions on final closes, across long market closures (#1548, #1539)

- the bar of a session still trading, by its market's date, is not scored
- a holding window that has passed without the stock or its benchmark trading through is fetched again through today
- a window that cannot have traded yet asks for no prices
This commit is contained in:
Yijia-Xiao
2026-10-27 07:57:45 +00:00
parent a5b0f217e3
commit eb4a486092
2 changed files with 103 additions and 12 deletions
+69
View File
@@ -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."""
+34 -12
View File
@@ -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