diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index b47a8bab2..d446fd8a9 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -300,6 +300,16 @@ class TestTradingMemoryLogCore: assert "Buy entry 0" not in ctx assert "Buy entry 5" in ctx + def test_the_most_recent_decisions_are_the_latest_dated_not_the_latest_written(self, tmp_path): + """A backfill writes January after June; June is still the most recent lesson.""" + log = make_log(tmp_path) + _seed_completed(tmp_path, "NVDA", "2026-06-01", "Buy entry June.", "Correct.") + for i in range(5): + _seed_completed(tmp_path, "NVDA", f"2026-01-{i+1:02d}", f"Buy entry {i}.", "Correct.") + ctx = log.get_past_context("NVDA", n_same=5) + assert "Buy entry June" in ctx and "Buy entry 0" not in ctx + assert ctx.index("Buy entry June") < ctx.index("Buy entry 4") + def test_n_cross_limit_respected(self, tmp_path): """Only the n_cross most recent cross-ticker entries are included.""" log = make_log(tmp_path) @@ -341,6 +351,16 @@ class TestTradingMemoryLogCore: dates = [e["date"] for e in entries] assert dates == ["2026-01-03", "2026-01-04", "2026-01-05"] + def test_rotation_prunes_the_oldest_dated_not_the_first_written(self, tmp_path): + """A backfill writes older dates last; rotation still keeps the newest decisions.""" + log = TradingMemoryLog({ + "memory_log_path": str(tmp_path / "trading_memory.md"), + "memory_log_max_entries": 3, + }) + for date in ("2026-06-01", "2026-06-02", "2026-01-01", "2026-01-02", "2026-01-03"): + _resolve_entry(log, "NVDA", date, DECISION_BUY, f"Lesson {date}.") + assert sorted(e["date"] for e in log.load_entries()) == ["2026-01-03", "2026-06-01", "2026-06-02"] + def test_rotation_never_prunes_pending(self, tmp_path): """Pending entries (unresolved) are kept regardless of the cap.""" log = TradingMemoryLog({ diff --git a/tradingagents/memory/log.py b/tradingagents/memory/log.py index a8ce85a8a..fd6aa0d30 100644 --- a/tradingagents/memory/log.py +++ b/tradingagents/memory/log.py @@ -109,7 +109,10 @@ class TradingMemoryLog: return "" same, cross = [], [] - for e in reversed(entries): + # Most recent by decision date, then by the later write: a backfill writes + # older decisions last. + newest = sorted(range(len(entries)), key=lambda i: (entries[i]["date"], i), reverse=True) + for e in (entries[i] for i in newest): if len(same) >= n_same and len(cross) >= n_cross: break if e["ticker"] == ticker and len(same) < n_same: @@ -271,7 +274,7 @@ class TradingMemoryLog: return tag + "]" def _apply_rotation(self, blocks: list[str]) -> list[str]: - """Drop oldest resolved blocks when their count exceeds max_entries. + """Drop the oldest-dated resolved blocks when their count exceeds max_entries. Pending blocks are always kept (they represent unprocessed work). Returns ``blocks`` unchanged when rotation is disabled or under cap. @@ -279,12 +282,12 @@ class TradingMemoryLog: if not self._max_entries or self._max_entries <= 0: return blocks - # Tag each block with (kept, is_resolved) by parsing tag-line markers. + # Tag each block with (block, is_resolved, decision date) from its tag line. decisions = [] for block in blocks: stripped = block.strip() if not stripped: - decisions.append((block, False)) + decisions.append((block, False, "")) continue tag_line = stripped.splitlines()[0].strip() is_resolved = ( @@ -292,20 +295,18 @@ class TradingMemoryLog: and tag_line.endswith("]") and not tag_line.endswith("| pending]") ) - decisions.append((block, is_resolved)) + decisions.append((block, is_resolved, tag_line[1:].split("|")[0].strip())) - resolved_count = sum(1 for _, r in decisions if r) + resolved_count = sum(1 for _, r, _ in decisions if r) if resolved_count <= self._max_entries: return blocks - to_drop = resolved_count - self._max_entries - kept: list[str] = [] - for block, is_resolved in decisions: - if is_resolved and to_drop > 0: - to_drop -= 1 - continue - kept.append(block) - return kept + # The oldest by decision date, then by position: a backfill writes older + # decisions last. + resolved = [i for i, (_, is_resolved, _) in enumerate(decisions) if is_resolved] + dated = sorted(resolved, key=lambda i: (decisions[i][2], i)) + dropped = set(dated[:resolved_count - self._max_entries]) + return [block for i, (block, _, _) in enumerate(decisions) if i not in dropped] def _parse_entry(self, raw: str) -> dict | None: lines = raw.strip().splitlines()