chore: release v0.2.4 — structured agents, checkpoint, memory log, providers

This release bundles substantial work since v0.2.3:

- Structured-output Research Manager, Trader, and Portfolio Manager
  (canonical with_structured_output pattern, single LLM call per agent,
  rendered markdown preserves the existing report shape).
- LangGraph checkpoint resume for crash recovery (--checkpoint flag).
- Persistent decision log replacing the per-agent BM25 memory, with
  deferred reflection driven by yfinance returns + alpha vs SPY.
- DeepSeek, Qwen, GLM, and Azure OpenAI provider support; dynamic
  OpenRouter model selection.
- Docker support; cache and logs moved to ~/.tradingagents/ to fix
  Docker permission issues.
- Windows UTF-8 encoding fix on every file I/O site.
- 5-tier rating consistency (Buy / Overweight / Hold / Underweight / Sell)
  across Research Manager, Portfolio Manager, signal processor, memory log.

Plus the small quality items in this commit:

1. Suppress noisy Pydantic serializer warnings from OpenAI Responses-API
   parse path by defaulting structured-output to method="function_calling"
   (root-cause fix, not a warnings filter — same typed result, no warnings).
2. Ship scripts/smoke_structured_output.py so contributors can verify
   their provider's structured-output path with one command.
3. Add opt-in memory_log_max_entries config — when set, oldest resolved
   memory log entries are pruned once the cap is exceeded; pending
   entries (unresolved) are never pruned.
4. backend_url default changed from the OpenAI URL to None so the
   per-provider client falls back to its native endpoint instead of
   leaking OpenAI's URL into Gemini / other clients.

CHANGELOG.md added with the full v0.2.4 entry. 92 tests pass without API keys.
This commit is contained in:
Yijia-Xiao
2026-04-25 21:54:30 +00:00
parent 4016fd4efa
commit 7c37249f80
8 changed files with 562 additions and 2 deletions
+43 -1
View File
@@ -17,11 +17,14 @@ class TradingMemoryLog:
_REFLECTION_RE = re.compile(r"REFLECTION:\n(.*?)$", re.DOTALL)
def __init__(self, config: dict = None):
cfg = config or {}
self._log_path = None
path = (config or {}).get("memory_log_path")
path = cfg.get("memory_log_path")
if path:
self._log_path = Path(path).expanduser()
self._log_path.parent.mkdir(parents=True, exist_ok=True)
# Optional cap on resolved entries. None disables rotation.
self._max_entries = cfg.get("memory_log_max_entries")
# --- Write path (Phase A) ---
@@ -153,6 +156,7 @@ class TradingMemoryLog:
if not updated:
return
new_blocks = self._apply_rotation(new_blocks)
new_text = self._SEPARATOR.join(new_blocks)
tmp_path = self._log_path.with_suffix(".tmp")
tmp_path.write_text(new_text, encoding="utf-8")
@@ -206,6 +210,7 @@ class TradingMemoryLog:
if not matched:
new_blocks.append(block)
new_blocks = self._apply_rotation(new_blocks)
new_text = self._SEPARATOR.join(new_blocks)
tmp_path = self._log_path.with_suffix(".tmp")
tmp_path.write_text(new_text, encoding="utf-8")
@@ -213,6 +218,43 @@ class TradingMemoryLog:
# --- Helpers ---
def _apply_rotation(self, blocks: List[str]) -> List[str]:
"""Drop oldest 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.
"""
if not self._max_entries or self._max_entries <= 0:
return blocks
# Tag each block with (kept, is_resolved) by parsing tag-line markers.
decisions = []
for block in blocks:
stripped = block.strip()
if not stripped:
decisions.append((block, False))
continue
tag_line = stripped.splitlines()[0].strip()
is_resolved = (
tag_line.startswith("[")
and tag_line.endswith("]")
and not tag_line.endswith("| pending]")
)
decisions.append((block, is_resolved))
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
def _parse_entry(self, raw: str) -> Optional[dict]:
lines = raw.strip().splitlines()
if not lines:
+4
View File
@@ -7,6 +7,10 @@ DEFAULT_CONFIG = {
"results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR", os.path.join(_TRADINGAGENTS_HOME, "logs")),
"data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR", os.path.join(_TRADINGAGENTS_HOME, "cache")),
"memory_log_path": os.getenv("TRADINGAGENTS_MEMORY_LOG_PATH", os.path.join(_TRADINGAGENTS_HOME, "memory", "trading_memory.md")),
# Optional cap on the number of resolved memory log entries. When set,
# the oldest resolved entries are pruned once this limit is exceeded.
# Pending entries are never pruned. None disables rotation entirely.
"memory_log_max_entries": None,
# LLM settings
"llm_provider": "openai",
"deep_think_llm": "gpt-5.4",
@@ -18,6 +18,22 @@ class NormalizedChatOpenAI(ChatOpenAI):
def invoke(self, input, config=None, **kwargs):
return normalize_content(super().invoke(input, config, **kwargs))
def with_structured_output(self, schema, *, method=None, **kwargs):
"""Wrap with structured output, defaulting to function_calling for OpenAI.
langchain-openai's Responses-API-parse path (the default for json_schema
when use_responses_api=True) calls response.model_dump(...) on the OpenAI
SDK's union-typed parsed response, which makes Pydantic emit ~20
PydanticSerializationUnexpectedValue warnings per call. The function-calling
path returns a plain tool-call shape that does not trigger that
serialization, so it is the cleaner choice for our combination of
use_responses_api=True + with_structured_output. Both paths use OpenAI's
strict mode and produce the same typed Pydantic instance.
"""
if method is None:
method = "function_calling"
return super().with_structured_output(schema, method=method, **kwargs)
# Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort",