257 Commits

Author SHA1 Message Date
Yijia-Xiao
5a26ae17a1 harden(dataflows): bound the Reddit feed read before parsing
- ElementTree does not resolve external entities, so the reported XXE flag
  doesn't apply; the real residual is an unbounded read of untrusted network XML
- cap both the RSS and JSON reads at 5 MiB; overflow degrades to empty / RSS
  fallback through the existing failure paths #1206 #1276
2026-09-01 05:14:23 +00:00
Yijia-Xiao
a4acd8a174 fix(agents): stop the debate managers forcing a direction under ambiguity
- the managers reserved Hold only for "genuinely balanced" evidence and were
  told to "be decisive", pressuring a directional call on ambiguous, conflicting,
  or insufficient inputs; which side it landed on was model-prior-dependent
- allow Hold for balanced, conflicting, ambiguous, or insufficient evidence in
  both manager prompts and both structured rating fields, and weigh cases
  independent of speaking order; rating definitions and debate ordering unchanged #1196
2026-09-01 05:08:49 +00:00
Yijia-Xiao
2322dd9baa fix(dataflows): honour Reddit Retry-After: 0 and jitter 429 backoff
- a valid Retry-After: 0 means retry at once but was treated as absent
  (`or 5.0`) and waited 5s; honour it exactly now
- jitter our own headerless fallback and the inter-subreddit pacing so several
  analyses sharing an IP don't retry in lockstep and re-collide on the limit;
  keep the single-retry ceiling (more retries can't fix an exhausted IP budget) #1193
2026-09-01 05:02:36 +00:00
Yijia-Xiao
70b58c21dc fix(dataflows): clamp the FRED vintage pin to FRED's own clock
- the unconditional realtime pin 400s when curr_date is ahead of FRED's
  US-Central date (a live run's local date), which the router then degrades to
  a silent DATA_UNAVAILABLE — an Asia/Pacific run loses macro data
- clamp realtime_start/end to min(curr_date, FRED-today) via pytz Chicago;
  a past curr_date pins unchanged, so historical look-ahead safety is preserved
- name the vintage in the empty-result message: widening the window can't fix a
  series with no vintage coverage #1275
2026-09-01 04:58:53 +00:00
Yijia Xiao
2448d0a125 Merge pull request #1280 from TauricResearch/v0.4.0
Release v0.4.0
2026-08-30 22:07:21 -05:00
Yijia-Xiao
c95f83dfaf chore: release v0.4.0
- look-ahead / point-in-time fixes across FRED macro, social sentiment, and the
  decision-log memory; clearer decision signals; CLI checkpoint resume; Trader
  price grounding
- GPT-5.6 / GLM-5.3 models
2026-08-31 03:02:39 +00:00
Yijia-Xiao
ecbe3e3a21 feat(llm): add the GPT-5.6 family and GLM-5.3
- GPT-5.6 (sol/terra/luna) is GA and OpenAI's recommended default; add it and
  make gpt-5.6 (deep) / gpt-5.6-luna (quick) the defaults
- add GLM-5.3 and GLM-5.3-Flash, Zhipu's current flagship line
2026-08-31 02:55:21 +00:00
Yijia-Xiao
e93c5c53c2 fix(agents): ground the Trader in the technical market report
- the Trader received only the digested investment plan, so its entry / stop /
  sizing levels were not anchored to real price structure (ATR, support and
  resistance, current price)
- inject the market report and instruct the Trader to take concrete price levels
  from it and direction/strategy from the plan; when the market analyst was not
  selected the report is empty, so the section and grounding note are omitted #1167
2026-08-31 02:35:50 +00:00
Yijia-Xiao
45c1744b86 fix(llm): apply DeepSeek capabilities to OpenRouter-namespaced models
- OpenRouter exposes DeepSeek as deepseek/<id>, which matched neither the exact
  IDs nor the patterns, so a thinking model like deepseek/deepseek-v4-flash fell
  through to _DEFAULT and had object-form tool_choice forced on it
- strip the official deepseek/ namespace before lookup so it reuses the native
  quirks; deepseek/deepseek-chat still keeps tool_choice, and third-party
  finetunes on other publishers stay on _DEFAULT #1199
2026-08-31 02:15:32 +00:00
Yijia-Xiao
63be7fe7f1 fix(dataflows): don't silently drop the latest OHLCV bar
- the latest in-range bar with a NaN close was dropped before the curr_date
  cutoff, so the previous trading day looked like the latest; dates were also
  compared without timezone normalization
- normalize bar dates and curr_date to naive midnight (per element, so 5-year
  ranges spanning DST and non-US positive-offset markets keep their local date),
  then raise NoMarketDataError on a missing latest close rather than falling back
- split the fill step (_fill_price_gaps) from date/price normalization so the
  latest bar can be inspected before incomplete rows are dropped #1201
2026-08-31 02:08:31 +00:00
Yijia-Xiao
30d42abd5d 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
2026-08-31 01:55:31 +00:00
Yijia-Xiao
a2f51da917 chore: remove dead code found in the v0.4.0 review
- inline _in_news_window, a trivial passthrough left from extracting
  dataflows.date_window.in_window; call in_window directly
- drop SignalProcessor's orphaned quick_thinking_llm attribute (unused since
  rating extraction became a deterministic parse)
2026-08-31 01:29:14 +00:00
Yijia-Xiao
b43bc31479 fix(cli): resume a checkpoint without duplicating messages or leaking the saver
- on resume, the CLI and propagate re-passed the initial state to a thread with
  an existing checkpoint; nodes do not re-run, but the message reducer appended
  the initial messages again, duplicating them in the resumed state
- feed None on resume (checkpoint_input) so LangGraph continues the interrupted
  run, and wrap the CLI stream in try/finally so the checkpointer tears down even
  if the stream raises
- correct the _fetch_returns docstring to the 4-tuple return #1249
2026-08-31 01:29:14 +00:00
Yijia-Xiao
8db41f6bca fix(memory): gate past-context lessons to point-in-time in backtests
- get_past_context returned every resolved lesson regardless of the run date, so
  a historical run could learn from an outcome that had not happened yet
- record each resolved entry's resolution date (the last price bar used) and
  filter get_past_context(as_of=trade_date) on it for a historical run; a
  current-date run passes None so live behavior and pre-migration entries (no
  stored resolution date, conservatively excluded from backtests) are unaffected #1251
2026-08-30 07:03:06 +00:00
Yijia-Xiao
51a245dbe1 fix(cli): make --checkpoint actually resume on the CLI path
- checkpoint setup lived only inside propagate(); the CLI streamed the
  checkpointer-less graph with no thread_id, so --checkpoint neither saved nor
  resumed a run
- extract the lifecycle into reusable begin_checkpoint / end_checkpoint /
  clear_checkpoint_on_success (checkpoint_scope wraps them for propagate) and use
  them around the CLI stream #1249
2026-08-30 06:47:00 +00:00
Yijia-Xiao
43fc275b36 fix(rating): surface an unparseable rating as REVIEW, not a silent Hold
- an unrecognizable Portfolio Manager decision was coerced to Hold, emitting a
  tradeable neutral signal that masked a parsing failure; a fullwidth colon
  (Rating:X) defeated the label regex and hit the same path
- add extract_rating() -> str | None with NFKC normalization and whole-word
  matching; the graph signal now yields a REVIEW sentinel (with an is_review
  guard) when no rating is found
- parse_rating keeps its silent default for compat callers (e.g. the memory log) #1170
2026-08-30 06:38:15 +00:00
Yijia-Xiao
0ef56e6a33 feat(llm): add a configurable output-token cap
- some model/gateway combinations emit unbounded reasoning/output and hang or
  trip an idle timeout (e.g. some deepseek-v4-flash deployments)
- add an opt-in max_tokens config knob + TRADINGAGENTS_MAX_TOKENS, forwarded to
  every provider when set (Gemini takes it as max_output_tokens); int-coerced,
  rejects non-positive/boolean values #1204
2026-08-30 06:26:57 +00:00
Yijia-Xiao
539eae8fd6 fix(agents): stop debate openers from rebutting a nonexistent argument
- the first speaker in each debate round received an empty opponent response,
  yet the prompt demanded a rebuttal, so models fabricated the other side
- substitute an explicit opening marker when an opponent has not spoken, across
  all five debators (bull, bear, and the three risk analysts) #1176
2026-08-30 06:18:59 +00:00
Yijia-Xiao
9b98f09613 fix(dataflows): trim social sentiment sources to the analysis window
- StockTwits and Reddit were fetched with no date, so a historical run showed
  today's chatter as if it were from the as-of date
- pass the analysis window to both fetchers, filter to it, and emit a clear
  placeholder when nothing qualifies
- centralize the UTC half-open window rule in dataflows/date_window so news,
  StockTwits, and Reddit share one look-ahead-safe filter #1220
2026-08-30 06:18:59 +00:00
Yijia-Xiao
8b7ece8a3e fix(dataflows): pin the FRED data vintage to the as-of date
- FRED defaults both realtime bounds to today, so historical macro requests
  served the latest revision and leaked future information into backtests
- set realtime_start=realtime_end=curr_date on both the metadata and
  observations requests #1275
2026-08-30 06:18:59 +00:00
Yijia-Xiao
a33fd4c0f1 docs: streamline README header 2026-07-18 15:55:04 +00:00
Yijia-Xiao
7bbe33ab1d docs: add trending badge 2026-07-18 15:23:59 +00:00
Yijia-Xiao
030b434585 fix(agents): stop priming tool calls in schema-only structured agents
- with_structured_output binds a single tool (the schema), so a primed model emitted
  an unknown web_search call and the attempt was discarded for a free-text retry,
  costing an extra round trip and the typed output
- drop the tool-range wording from the no-tool sentiment analyst and state the
  constraint once via a shared NO_EXTERNAL_TOOLS #1130
2026-07-18 06:28:38 +00:00
Yijia-Xiao
3f6c082695 fix(cli): report an unusable terminal instead of a prompt_toolkit traceback
- Windows terminals without a console buffer raised NoConsoleScreenBufferError
  before the first prompt, surfacing a raw traceback with no guidance
- gate the Windows-only import on sys.platform so a broken prompt_toolkit still
  surfaces there, and the handler stays inert on other platforms #1138
2026-07-18 06:28:38 +00:00
Yijia-Xiao
d78c698d0e fix(dataflows): refresh the same-day OHLCV cache
- the per-day cache was reused unconditionally, so a run started before the day's
  bar was final served that snapshot to every later run, feeding a stale close
  into technical analysis
- a present row is not sufficient either, since Yahoo publishes a partial intraday
  candle; a TTL now governs every current-day cache while historical caches stay
  immutable #1150
2026-07-18 06:28:38 +00:00
Yijia-Xiao
40774ca042 fix(dataflows): make the Yahoo news window UTC and end-exclusive
- the upper bound was inclusive, so an article stamped exactly midnight after
  end_date leaked into a historical run
- flat epoch timestamps were parsed in host-local time and offset-aware stamps had
  tzinfo stripped without converting, making filtering machine-dependent
- normalize every operand to UTC and use a half-open [start, end + 1 day) #1126
2026-07-18 06:28:38 +00:00
Yijia-Xiao
01477f9afb chore: release v0.3.1
- correctness/stability patch: look-ahead filter, router crash-safety, checkpoint
  identity, crypto sentiment sources, configurable retries, Bedrock API-key auth
- adds Claude Sonnet 5 / Fable 5 support
2026-07-05 14:29:07 +00:00
Yijia-Xiao
0f70af2f31 feat(llm): add Claude Sonnet 5 and Fable 5 to the catalog
- refresh the Anthropic lineup to the current GA set (Fable 5, Opus 4.8,
  Sonnet 5, Opus 4.7, Haiku 4.5)
- extend the effort gate to single-number Claude 5 IDs (claude-sonnet-5,
  claude-fable-5) so their effort setting is honored
2026-07-05 14:29:07 +00:00
Yijia-Xiao
43bd32befa feat(llm): support Bedrock API-key auth via AWS_BEARER_TOKEN_BEDROCK
- pass the token to ChatBedrockConverse as api_key so langchain-aws prefers bearer
  auth and an ambient AWS_PROFILE can't override it; no AWS access keys required #1103
2026-07-05 14:29:07 +00:00
Yijia-Xiao
a102afa090 fix(dataflows): map crypto to StockTwits/Reddit sentiment symbols
- crypto reached StockTwits as Yahoo's BTC-USD (404) instead of BTC.X, and Reddit
  searched the dashed pair that barely matches; both now resolve the base via a
  shared crypto_base() helper, restoring crypto sentiment
- also fixes a StockTwits resilience test class that pytest never collected #1113
2026-07-05 14:29:07 +00:00
Yijia-Xiao
daf1da9c35 fix(graph): key checkpoints on graph shape and expose the LLM retry budget
- checkpoint resume keyed only by ticker+date silently continued the old graph
  under a different analyst selection / depth / asset mode; fold a run signature
  into the thread id #1089
- add llm_max_retries + TRADINGAGENTS_LLM_MAX_RETRIES, forwarded to every provider
  when set (int-coerced, rejects negatives/booleans), so a 429 burst can't abort
  a run #1091
2026-07-05 14:29:07 +00:00
Yijia-Xiao
b47a828a4f fix(graph): give the shared debate/risk routers a complete path_map
- should_continue_debate (2 edges) and should_continue_risk_analysis (3 edges)
  each returned more targets than any one edge mapped; a fall-through under
  prompt/i18n/refactor drift crashed LangGraph mid-run
- share a complete DEBATE_PATH_MAP / RISK_ANALYSIS_PATH_MAP across every edge #1088
2026-07-05 14:29:07 +00:00
Yijia-Xiao
622f99d28a fix(analysts): align the news prompt with the get_news tool signature
- prompt advertised get_news(query, ...) but the tool takes a ticker, so the
  model hallucinated free-text query calls
- advertise get_news(ticker, start_date, end_date) #1116
2026-07-05 14:29:07 +00:00
Yijia-Xiao
3570f2e1e6 fix(dataflows): apply the Alpha Vantage fundamentals look-ahead filter
- the payload is a JSON string, so the dict-only guard skipped filtering and
  future-dated reports leaked into historical runs, breaking the #475 guarantee
- parse before filtering; non-JSON bodies and an unset curr_date pass through #1115
2026-07-05 14:29:06 +00:00
Yijia-Xiao
85946c2f60 chore: release v0.3.0
- CI gate, unified verified data-access contract, provider and data-vendor registry
- env-over-CLI config precedence, current-generation model catalog
- programmatic report output, plus sweep fixes for data and structured output
2026-06-22 02:05:07 +00:00
Yijia-Xiao
cbd17ac3e0 docs: drop retired model IDs from the reproducibility note and smoke script
The README reproducibility example named gpt-4.1 and the structured-output smoke
script listed gemini-2.5-flash / deepseek-chat / qwen-plus / grok-4 — all retired
from the catalog. Generalize the note and refresh the smoke defaults.
2026-06-21 23:50:33 +00:00
Yijia-Xiao
8ab24f30af test: make the API-key fixture robust to empty-string env vars
A key left blank in a .env (var present but empty) bypassed the placeholder,
so local runs diverged from CI. Use 'or' instead of a .get default.
2026-06-21 23:50:33 +00:00
Yijia-Xiao
2b2d685df6 fix(prompts): put the current date at the top of analyst prompts
The date hint sat at the end of each analyst's system prompt, after a long
indicator block, so weaker models anchored to their training cutoff when
generating tool-call date ranges. Lead each prompt with it instead.
2026-06-21 23:50:33 +00:00
Yijia-Xiao
a0120e1805 feat(reporting): share the report-tree writer between the CLI and the API
The per-section markdown report tree was written only by the CLI, so programmatic
(TradingAgentsGraph) runs produced no saved reports.

- Extract the writer into tradingagents/reporting.write_report_tree.
- The CLI's save_report_to_disk delegates to it (no behavior change).
- Add TradingAgentsGraph.save_reports(final_state, ticker) so headless/API callers
  get the same report tree, defaulting under results_dir.
2026-06-21 23:22:30 +00:00
Yijia-Xiao
0b61effd6c chore(deps): remove the unused uv.lock
The committed lockfile is not consumed by the pip-based install or CI; it only
drifts. A deliberate dependency upgrade, if wanted, is its own scoped PR.
2026-06-21 22:31:35 +00:00
Yijia-Xiao
ec3974b84e chore(config): remove the no-op analyst_concurrency_limit knob
The knob was accepted but inert — analysts run strictly sequentially and the
value was never used. Remove it rather than ship a misleading config key.
Parallel analyst execution is tracked for v0.3 (#634/#671/#487).
2026-06-21 22:31:35 +00:00
Yijia-Xiao
0405168f20 fix(schema): coerce null-ish strings in optional float fields
A weak model can write a placeholder ('None', 'N/A') into an optional price
field, tripping schema validation. Coerce null-ish strings to None on the
trader/PM float fields; real numeric strings still parse.
2026-06-21 22:31:35 +00:00
Yijia-Xiao
709fe2b646 fix(graph): dedupe the trailing message in the debug stream
Nodes after the trader do not append to messages, so the debug stream reprinted
the same trailing message once per node. Print it only when it changes; the
returned state is unchanged.
2026-06-21 22:09:43 +00:00
Yijia-Xiao
517eeaf4b9 fix(structured): harden structured output for local servers and thinking models
- Local servers (LM Studio, vLLM) reject the object-form tool_choice langchain
  sends for function calling. The generic openai_compatible provider now binds
  the schema as a tool without forcing tool_choice.
- A structured call can return no parsed result (a thinking model answering in
  plain text); fall back to free text with a clear reason instead of an opaque
  render error.
2026-06-21 22:09:43 +00:00
Yijia-Xiao
9ad98c55c5 fix(data): normalize ticker on the news path
The yfinance news fetch queried the raw ticker while every other path uses the
canonical symbol, so broker/forex/crypto aliases silently returned no news.
Normalize it (XAUUSD -> GC=F) and keep the user's ticker in the report header.
2026-06-21 21:28:59 +00:00
Yijia-Xiao
ee1ece3347 fix(dataflows): degrade gracefully when an optional vendor fails
Optional enrichment vendors (FRED macro, Polymarket events) raised on a bad LLM
indicator, a missing key, or a network blip, which aborted the whole run.

- Router: mark macro_data and prediction_markets optional; a sole-vendor failure
  returns a sentinel instead of re-raising. Core categories still raise.
- FRED: reject a descriptive phrase up front and return guidance instead of
  400ing the API; an unknown series returns a not-found message, not a crash.
2026-06-21 21:28:59 +00:00
Yijia-Xiao
7bb16c5daa chore(models): retire deprecated models, simplify thinking config
Trim each provider to current-generation models and drop the special-casing
they required:

- OpenAI: remove gpt-4.1 (deprecated; the only non-reasoning model).
- Anthropic: remove Claude Sonnet 4.5 (legacy; the only Sonnet that 400s on effort).
- Google: remove the Gemini 2.5 line (superseded by 3.x).
- Gemini client: drop the integer thinking_budget mapping; 3.x takes the string
  thinking_level directly.

Effort/reasoning gates stay as defense in depth for custom model IDs. All kept
IDs verified against live APIs.
2026-06-21 21:03:05 +00:00
Yijia-Xiao
a420ad0f3b fix(cli): honor env precedence for LLM and run config
Interactive selections and flag defaults overrode TRADINGAGENTS_* env vars.
Rule: an explicit env value or CLI flag wins; otherwise the env-applied
default is kept.

- Research depth: skip the prompt when both round-count env vars are set, and
  stop overwriting them (#977).
- Checkpoint: --checkpoint/--no-checkpoint is tri-state; omitting it keeps
  TRADINGAGENTS_CHECKPOINT_ENABLED (#976).
- Docker ollama: use TRADINGAGENTS_LLM_PROVIDER + OLLAMA_BASE_URL, not a bare
  LLM_PROVIDER the overlay never reads (#975).
- Reasoning/thinking knobs: settable via env; the prompt is skipped when set.
- Effort gating: forward effort only to models that accept it (Anthropic
  Opus 4.5+/Sonnet 4.6+, OpenAI reasoning models); drop it elsewhere.
- Boolean env values: raise a named error on invalid input instead of
  silently becoming False.
2026-06-21 21:03:05 +00:00
Yijia-Xiao
c15200dc28 fix(cli): label OpenRouter prompts and shortlist mainstream models
Label each OpenRouter model prompt by mode (quick/deep) like the other
providers, so the two consecutive selections are distinguishable. Populate the
dropdown with the newest models from mainstream chat providers rather than the
universal-newest (which surfaced niche/experimental releases); Custom ID still
reaches anything. Cancelled required prompts now exit cleanly instead of
crashing, and the output-language prompt falls back to English.
2026-06-14 18:49:02 +00:00
Yijia-Xiao
7aef10acbd fix(sentiment): guide an informative, high-signal narrative
Add quality guidance to the narrative field so the sentiment report stays
informative and substantive, with each point adding new signal for the trader.
2026-06-14 18:12:57 +00:00
Yijia-Xiao
03600f3121 chore(models): refresh the model catalog to current provider lineups
Verified each provider's hard-coded list against current official docs:
- MiniMax: add MiniMax-M3 (1M ctx, multimodal) as the default; keep M2.7 line.
- Qwen: use the live qwen{3.7,3.6}-{plus,max} IDs.
- GLM: add glm-5.2 as the latest flagship.
- xAI: drop deprecated grok-4-fast-* / grok-4-0709 builds.
- DeepSeek: migrate to deepseek-v4-pro / deepseek-v4-flash (the chat/reasoner
  aliases are deprecated 2026-07-24 and now map to V4 Flash).
OpenAI, Anthropic, and Gemini were already current and are unchanged.
2026-06-14 17:03:17 +00:00
Yijia-Xiao
6b6177ebf7 ci: lint the full repository
With the tree clean, the lint job runs ruff check . on every push and PR rather
than only the files a PR changes, so a lint regression is caught anywhere.
2026-06-14 16:38:36 +00:00
Yijia-Xiao
e3bc872982 chore(lint): make the repository ruff-clean under the strict select
Clear the deferred full-repo lint backlog so the whole tree passes the strict
ruff select (E,W,F,I,B,UP,C4,SIM). Mechanical fixes dominate: import sorting,
pep585/604 annotations, dropped dead imports, and whitespace. The few semantic
changes are behavior-preserving: declare __all__ on the agent_utils and
alpha_vantage re-export hubs; expand 'from x import *' to explicit names; use
immutable tuple defaults instead of mutable list defaults; contextlib.suppress
for try/except/pass; and narrow an over-broad assertRaises.
2026-06-14 16:38:36 +00:00
Yijia-Xiao
cbc5f67d42 test(i18n): guard that every report agent applies the output language
The output-language instruction is applied across all report-producing agents
(analysts, researchers, risk debators, research manager, trader, portfolio
manager), but nothing enforced it, so agents had silently dropped it before. Add
a parametrized guard asserting each report agent calls get_language_instruction()
so a non-English run stays fully localized and the regression can't recur.
2026-06-14 15:56:59 +00:00
Yijia-Xiao
3cddf1e331 fix(llm): use the OpenAI Responses API only for native endpoints
The Responses API exists only on native OpenAI. When the openai provider is
pointed at a custom base_url (a proxy, gateway, or local server that speaks only
Chat Completions), keep the Responses API off so the call does not fail.
2026-06-14 07:23:19 +00:00
Yijia-Xiao
308757c999 fix(data): catch http.client transport errors in StockTwits
A truncated/incomplete chunked response raises http.client exceptions
(IncompleteRead/BadStatusLine) that are not OSErrors, so they bypassed the
existing handler and crashed the analysis. Broaden the catch so the fetch
degrades to its placeholder string like every other transport failure.
2026-06-14 07:23:19 +00:00
Yijia-Xiao
eeb84aa63b fix(reddit): go RSS-first with 429 backoff and robust transport errors
The JSON search endpoint is reliably WAF-blocked (403) for public clients, so
probing it on every call doubled request volume against Reddit's per-IP rate
limit and tripped 429 on the RSS fallback, blanking the sentiment feed. Fetch
the Atom/RSS feed directly (JSON kept as an opt-in path that still degrades to
RSS on 403), back off once on a 429 honouring Retry-After, and pace requests a
little wider. Also broaden the error handling to catch http.client chunked
transfer errors (IncompleteRead/BadStatusLine) alongside OSError, which on their
own slipped through and crashed the pipeline.
2026-06-14 07:23:19 +00:00
Yijia-Xiao
9fd54f8368 fix(data): reject stale yfinance OHLCV instead of reporting wrong prices
yfinance intermittently returns a year-old partial frame (e.g. June 2025 rows
for a June 2026 request) that still has rows and a Close, so it passed the
empty-check and silently fed a wrong close price and indicators into the report
(#1021). Add a freshness guard that rejects a frame whose latest row is far
older than the requested date, on both the raw OHLCV path and the indicator
path. It raises the existing NoMarketDataError with a stale-specific detail, so
the vendor router's try-next-vendor and single unavailable-signal handling apply
unchanged; the sentinel now surfaces that detail so the agent reports the
specific reason rather than fabricating a value.
2026-06-14 07:10:15 +00:00
Yijia-Xiao
7df18fc912 refactor(data): unify vendor errors under a VendorError hierarchy
Every condition where a vendor cannot return usable data now derives from a
single VendorError base (errors.py): NoMarketDataError, VendorRateLimitError,
and VendorNotConfiguredError (still a ValueError for back-compat). Vendor-named
errors subclass the generic bases, and the router catches the base types, so a
new vendor needs no new except clause. Not-configured now has explicit
try-next-vendor handling instead of falling through the generic catch-all. The
number of error types tracks the number of distinct router reactions, not the
number of causes.
2026-06-14 07:10:15 +00:00
Yijia-Xiao
db059034a2 feat(data): add Polymarket prediction markets as a keyless vendor
Surface live, market-implied probabilities for forward-looking events (Fed
decisions, recession, elections, geopolitics, crypto) to the news analyst via a
new get_prediction_markets tool and a prediction_markets vendor category. Backed
by Polymarket's public Gamma API (no key). Results are filtered to open,
forward-looking markets (closed and past-dated events excluded), ranked by
traded volume, and rendered with implied probability, volume, resolution date,
and the recent move. External errors degrade to a clear unavailable message
rather than interrupting the analyst.
2026-06-14 06:30:43 +00:00
Yijia-Xiao
ddfb840ecf feat(data): add FRED macro indicators as an optional vendor
Surface Federal Reserve Economic Data (rates, inflation, labor, growth) to the
news analyst via a new get_macro_indicators tool and a macro_data vendor
category. Friendly aliases (cpi, unemployment, fed_funds_rate, 10y_treasury,
yield_curve, ...) map to FRED series IDs; raw series IDs are accepted too. The
report gives the latest value, change over the window, and a recent observation
table. Windowing is lookahead-safe (observation_end = curr_date), missing values
are skipped, and a missing FRED_API_KEY surfaces as a clear not-configured
condition through the vendor router rather than a crash.
2026-06-14 06:08:31 +00:00
Yijia-Xiao
895ed130f9 feat(llm): add Amazon Bedrock as a first-class provider
Bedrock uses the Converse API (langchain-aws) and the AWS credential chain, so
it has its own client like Anthropic/Google rather than the OpenAI-compatible
registry. langchain-aws is an optional dependency (pip install ".[bedrock]"),
lazy-imported with a clear install hint; importing the package never requires
it. The model name is a Bedrock model ID / inference profile ID.
2026-06-14 04:24:54 +00:00
Yijia-Xiao
295e84cd54 feat(llm): add NVIDIA NIM, Kimi, Groq, and Mistral providers
Each is a one-row entry in the OpenAI-compatible provider registry (base_url,
key env, CLI option); the model is user-specified since they serve many models.
2026-06-14 04:13:39 +00:00
Yijia-Xiao
20d3b0782f feat(llm): unify OpenAI-compatible providers behind a registry + generic endpoint
The OpenAI-compatible family (openai, xAI, DeepSeek, Qwen, GLM, MiniMax,
OpenRouter, Ollama) all speak the same Chat Completions API and differ only by
base_url, key, and two narrow wire-format quirks already isolated in subclasses.
Replace the scattered base-URL dict, key handling, and client-class branches with
one ProviderSpec registry that get_llm and the factory drive off; provider quirks
stay in their subclasses. Add a generic "openai_compatible" provider for any
OpenAI-compatible server (vLLM, LM Studio, llama.cpp, relays) via backend_url +
optional key — adding a provider is now one registry row. Native Anthropic/Google
keep their own clients (genuinely different APIs). Also fixes the env backend URL
being ignored when the provider was chosen interactively (#978).
2026-06-14 03:22:24 +00:00
Yijia-Xiao
4e7821d574 fix(graph): register get_verified_market_snapshot in the market ToolNode
The market analyst is bound to call get_verified_market_snapshot and its prompt
requires it as the source of truth, but the tool was missing from the market
ToolNode executor — so the call failed and the model reported it "unavailable"
and skipped verification. Register it (with a regression guard) so the snapshot
actually runs and grounds the report.
2026-06-14 02:46:29 +00:00
Yijia-Xiao
0c1231a405 fix(data): keep future/undated news out of historical windows
The yfinance news date filter only ran when an article had a parsed date, so
flat-format and undated articles bypassed it and leaked future news into
historical/backtest runs. Parse the flat providerPublishTime, apply one
look-ahead-safe window rule across ticker and global news (undated kept only
when the window reaches the present), and return an informative message when
everything is filtered out.
2026-06-13 21:54:07 +00:00
Yijia-Xiao
e4be7cc5a3 fix(data): add Alpha Vantage request timeout and stop mislabeling bad keys
Alpha Vantage requests had no timeout (a stall could hang the run) and any
notice mentioning "API key" was raised as a rate limit — so an invalid/missing
key was mislabeled and silently treated as transient. Add a 30s request timeout
and classify rate-limit phrasing before key errors (rate-limit notices also
mention "API key"), surfacing a bad key as a real configuration error.
2026-06-13 21:47:06 +00:00
Yijia-Xiao
a597063747 fix(cli): correct invalid escape sequence in confirm_ollama_endpoint docstring
The docstring used \` (an invalid escape that raises SyntaxWarning and will
become a SyntaxError); use plain backticks.
2026-06-13 21:30:11 +00:00
Yijia-Xiao
dab07688fb fix(data): include the requested end date in yfinance fetches
yfinance treats end as exclusive, so get_YFin_data_online dropped the requested
end_date row and load_ohlcv dropped the current day. Request one day past the
end so the range is inclusive (look-ahead is still prevented by the curr_date
filter; the header still shows the requested range). Also correct the load_ohlcv
docstring to the 5-year window it actually downloads.
2026-06-13 21:30:11 +00:00
Yijia-Xiao
65608831f8 fix(data): respect the configured vendor chain and log vendor failures
The router silently extended every request to all available vendors regardless
of config, so an explicit single-vendor choice still fell back to others and
returned data from an unexpected source (#988, #289), and serious primary-vendor
errors were swallowed without a trace (#989). The configured vendor list is now
the exact chain (list several for ordered fallback; "default" uses all), unknown
vendors raise, and swallowed vendor errors are logged. Adds an autouse config
isolation fixture so vendor config can't leak between tests.
2026-06-13 21:11:25 +00:00
Yijia-Xiao
76add9048f fix(cli): unify ticker handling with the data-path symbol normalizer
The CLI validated, normalized, and classified tickers with its own logic that
diverged from the data layer: it rejected '=' symbols like GC=F (#980),
classified BTCUSD as a stock (#981), and accepted unpriceable BTC-USDT (#982).
Route the CLI through normalize_symbol (now mapping USDT/USDC crypto quotes to
Yahoo's -USD pair), so validation, classification, and pricing agree.
2026-06-13 20:50:21 +00:00
Yijia-Xiao
7c8fe2fe9f fix(data): normalize symbols on the identity and reflection paths
resolve_instrument_identity and the reflection return lookup queried Yahoo with
the raw ticker, so broker/forex/commodity symbols (XAUUSD, BTCUSD, EURUSD)
failed identity or could mismatch the priced instrument even though the price
path already normalized them. Route both through normalize_symbol (#983, #984).
2026-06-13 20:39:52 +00:00
Yijia-Xiao
2a58c2208f ci: add test/lint/smoke workflow, declare python-dotenv, recommend Python 3.12
GitHub Actions: pytest across Python 3.10-3.13, a clean-install import smoke
that catches undeclared runtime deps, and a strict Ruff gate (standard rule set)
scoped to the files each PR changes. Declares python-dotenv (imported by the CLI
but previously undeclared) and adds a [dev] extra. Recommends Python 3.12 for
setup, verified from a clean isolated install.
2026-06-13 20:29:24 +00:00
Yijia-Xiao
04f434e86d chore: README housekeeping and remove stale TODO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 02:02:47 +00:00
Yijia-Xiao
2e67782f20 feat(cli): skip interactive LLM selection when configured via environment (#873)
Setting the LLM env vars now skips the matching CLI selection step and uses
the value, so OpenAI-compatible endpoints (opencode, LM Studio, etc.) and
unattended runs work without prompting. Unset vars are chosen interactively
as before.

  TRADINGAGENTS_LLM_PROVIDER -> skips provider step (still verifies API key)
  TRADINGAGENTS_LLM_BACKEND_URL -> custom endpoint (else provider default)
  TRADINGAGENTS_DEEP_THINK_LLM / _QUICK_THINK_LLM -> skips model step
  TRADINGAGENTS_OUTPUT_LANGUAGE -> skips language step

Builds on the existing TRADINGAGENTS_* config overrides (which already feed
DEFAULT_CONFIG); this wires the CLI to honor them instead of re-prompting.
2026-05-31 22:38:48 +00:00
Yijia-Xiao
1ff3f07a73 fix: support commodity/forex/crypto tickers and never invent prices (#781)
Analyzing a symbol Yahoo Finance does not recognize (e.g. XAUUSD+) could
produce an invented price instead of an error. The agent now either prices
the correct instrument or clearly reports that data is unavailable.

Ticker support:
- Commodities/forex/crypto resolve to the symbol Yahoo actually serves, so
  you can enter the common form and it just works:
    XAUUSD / XAUUSD+ / GOLD  -> GC=F   (gold)
    USOIL                    -> CL=F   (WTI crude)
    EURUSD                   -> EURUSD=X
    BTCUSD                   -> BTC-USD
    SPX500 / NAS100          -> ^GSPC / ^NDX
  Native Yahoo symbols (AAPL, GC=F, ^GSPC) keep working unchanged. New
  instruments are added by extending the alias table.

Reliability:
- Unknown or delisted symbols now return a clear "data unavailable" result
  the agent reports verbatim, instead of a value the model fills in.
- A failed fetch no longer leaves a broken symbol cached until the cache is
  cleared by hand.
2026-05-31 22:38:47 +00:00
Yijia-Xiao
2f85be624e chore(llm): add latest models and default to GPT-5.5
Add Claude Opus 4.8, Gemini 3.5 Flash, Grok 4.3, and Qwen3.7-Max; default
deep model is now GPT-5.5.
2026-05-31 08:01:03 +00:00
Yijia-Xiao
c93b92c7a4 feat(markets): add China A-share benchmarks and document non-US tickers
A-shares already resolve through the Yahoo Finance vendor (Shanghai .SS,
Shenzhen .SZ) with correct identity and indicators; add the SSE/SZSE
composite benchmarks so their alpha isn't measured against SPY, and
document the exchange-suffix tickers we support (incl. A-shares, crypto).
2026-05-31 07:29:19 +00:00
Yijia-Xiao
d6762d6095 chore: gitignore .env.enterprise and reports/ 2026-05-31 06:28:06 +00:00
Yijia-Xiao
8694bd070d fix(llm): send MiniMax reasoning_split via extra_body so the openai SDK accepts it (#826) 2026-05-31 06:20:55 +00:00
Yijia-Xiao
2c9f1bfe65 fix(cli): consolidate duplicate get_ticker and only announce non-stock asset type 2026-05-31 06:13:35 +00:00
Yijia-Xiao
8a22594607 feat(config): expose sampling temperature and document reproducibility
Adds a cross-provider temperature config (and TRADINGAGENTS_TEMPERATURE),
forwarded to every LLM client when set, so runs can be made less variable
on models that honor it. Adds a README "Reproducibility" section that
separates the sources of run-to-run variation, what users can control
(temperature, non-reasoning model, pinned date), and what is inherent to
LLM-driven analysis, and notes that the identity and verified-data fixes
already removed the "different companies / fabricated prices" variance.

#178 #168
2026-05-31 03:51:50 +00:00
Yijia-Xiao
47cbb321fe feat(market): verified market-data snapshot to ground numeric claims
The market analyst could confabulate exact figures — citing a Bollinger
band or a "historically validated bounce" the data doesn't support (#830).
Add a deterministic get_verified_market_snapshot tool (latest OHLCV row,
common indicators, recent closes) the analyst must consult and treat as
the source of truth for any exact price/indicator claim, and instruct it
not to assert historical validation or support bounces without tool-backed
dates and prices.

#830
2026-05-31 01:58:32 +00:00
Yijia-Xiao
e80636fc0e feat(sentiment): structured output for the Sentiment Analyst
The analyst emitted free-form prose, so its sentiment header varied by
provider and run and downstream consumers needed drifting regex. Extend
the structured-output pattern the trio already uses: a SentimentReport
schema (band + 0-10 score + confidence + narrative) rendered to a
deterministic header, with a free-text fallback for providers that lack
native structured output.

#796
2026-05-31 01:45:25 +00:00
Yijia-Xiao
a66aa8fb94 fix(deps): require yfinance >=1.4.1 and tolerate non-Date index column
yfinance 1.4.0 regressed the daily-download index to unnamed, so
reset_index() produced an "index" column instead of "Date" and every
stockstats indicator silently failed (no SMA/RSI/MACD/Bollinger/ATR).
Verified across versions: 1.2.0 / 1.3.0 / 1.4.1 name it "Date"; only
1.4.0 is broken. Pin to >=1.4.1 (the upstream fix) and normalize the
date column defensively so a non-"Date" index can't silently drop
indicators on any build.

#890
2026-05-31 00:51:30 +00:00
Yijia-Xiao
3543e5397e fix(dataflows): fall back to Reddit RSS search when JSON 403s
Reddit blocks the anonymous JSON search endpoint, which silently emptied
the sentiment analyst's Reddit source. Fall back to the public RSS search
feed when JSON fails. RSS lacks score/comment counts, so those posts are
marked "via RSS feed" rather than shown with fake zeros.

#862
2026-05-31 00:14:37 +00:00
Yijia-Xiao
d7b40a2a5c fix(graph): resolve instrument identity to stop wrong-company hallucination
Agents had no ground-truth ticker→company mapping, so the market analyst
could pattern-match a price chart to the wrong company (e.g. TOTDY read as
"TotalEnergies"), and every downstream agent inherited the bad framing.

Resolve identity once at run start via a cached, fail-open yfinance lookup
and inject company/sector/exchange into the shared instrument context that
all twelve agents consume, with an explicit do-not-substitute instruction.
Resolution runs on both the propagate() and CLI entry points.

Also replaces the bare "Continue" message-clear placeholder, which some
OpenAI-compatible providers interpreted as the user task, with a
context-anchored placeholder carrying the resolved identity and date.

#814 #888
2026-05-30 23:56:32 +00:00
Yijia-Xiao
61522e103e fix(llm): skip Anthropic effort kwarg on non-supporting models (#831)
Haiku 4.5 rejects the effort parameter with 400. AnthropicClient.get_llm()
now drops effort when the model isn't in the supported set (Opus 4.5+,
Sonnet 4.5+, mythos-preview). Forward-compat regex catches future
claude-{opus,sonnet}-X-Y releases automatically; Haiku and unknown
models stay excluded conservatively.

14 tests cover Haiku exclusion, current Opus/Sonnet inclusion, future-
version inheritance via pattern, mythos-preview, unknown-default
exclusion, and other passthrough kwargs surviving the effort-skip path.
2026-05-17 07:54:06 +00:00
Yijia-Xiao
e848b5e812 fix(llm): gate MiniMax reasoning_split by model capability (#826)
MinimaxChatOpenAI unconditionally set reasoning_split=True, but the
kwarg is only valid on M2.x reasoning models. The openai SDK's strict
kwarg validation raised TypeError for Coding Plan and any other non-
reasoning MiniMax model.

Adds requires_reasoning_split to ModelCapabilities, gates the payload
injection on it, and only sets True for _MINIMAX_THINKING (M2.x exact
IDs and the ^MiniMax-M\d forward-compat pattern). Same shape as the
existing supports_tool_choice gate.

Regression tests cover both halves: M2.x models still receive the flag,
non-reasoning MiniMax models do not.
2026-05-17 07:49:42 +00:00
Yijia-Xiao
3e5e99b368 fix(graph): integrate #487 + #567 — sentiment label, route, propagate asset_type
- analyst_execution.py: rename "Social Analyst" / "Msg Clear Social"
  to "Sentiment Analyst" / "Msg Clear Sentiment" to match v0.2.5.
- conditional_logic.should_continue_social returns the renamed route.
- TradingAgentsGraph.propagate accepts asset_type and threads through
  to Propagator.create_initial_state.
- Regression test on the Sentiment Analyst label.

Verified end-to-end (NVDA stock + BTC-USD crypto) on gpt-5.4-mini.
2026-05-17 07:25:59 +00:00
Yijia Xiao
a2e7ac1599 Merge #567 — analysis-only crypto asset mode
feat: add analysis-only crypto asset mode
2026-05-17 00:01:49 -07:00
Yijia Xiao
b16fe53efe Merge #487 — analyst execution planning and timing hooks
refactor(graph): add analyst execution planning and timing hooks
2026-05-17 00:01:46 -07:00
Yijia-Xiao
a5cb7cbd61 chore: release v0.2.5 — sentiment analyst, env-var config, more providers
Headline themes in v0.2.5:

- Sentiment Analyst grounded in real data. Renamed from social_media_analyst
  and redesigned to pre-fetch Yahoo News, StockTwits, and Reddit before the
  LLM is invoked, ending the prior fabrication behavior.
- MiniMax provider with full M2.x catalog and dual-region split. Qwen and
  GLM also split into international + China regions with separate API keys
  and a clean secondary region prompt in the CLI.
- TRADINGAGENTS_* env-var overlay for DEFAULT_CONFIG with type-aware
  coercion; .env loading centralized so every entry point sees the user's
  keys. Interactive API-key detection prompts and persists missing keys
  to .env on the fly.
- OLLAMA_BASE_URL end-to-end for remote ollama-serve, plus a Custom model
  ID option in the Ollama dropdown.
- Configurable news-fetch parameters and configurable alpha benchmark for
  non-US tickers (.NS / .T / .HK / .L / .TO / .AX / .BO ship with sensible
  regional defaults).
- Multi-language output now propagates to every user-facing agent
  (researchers, risk debators, research manager, trader) instead of only
  the analysts and portfolio manager.
- Model catalog refresh across all providers (GPT-5.5 frontier, Claude
  Opus 4.7, Gemini 3.1 Flash-Lite GA, Grok 4.20, Qwen 3.6 line).
- Capability-dispatch table drives provider-specific structured-output
  quirks (DeepSeek V4/reasoner and MiniMax M2.x tool_choice rejection,
  MiniMax reasoning_split) so the general client stays clean.
- Fixes: ticker path-traversal validation (security), dotenv loading via
  console script, reports save bug, exchange-suffix truncation in the
  ticker prompt, Docker permission errors, deepcopy config isolation,
  max_recur_limit plumbing, clearer missing-API-key error.

See CHANGELOG.md for the full per-item list with issue/PR references.
2026-05-11 09:27:36 +00:00
Yijia-Xiao
78d063dc5c feat(reflection): configurable alpha benchmark for non-US tickers
SPY was hardcoded as the alpha benchmark in both the return-fetch
path and the reflection label, which produced meaningless alpha for
.NS / .T / .HK / .L / .TO / .AX / .BO listings — FX drift between a
local-currency stock and a USD index dominates the spread.

DEFAULT_CONFIG now exposes benchmark_ticker (explicit override) and
benchmark_map (suffix → regional index, with SPY as the empty-suffix
default). TRADINGAGENTS_BENCHMARK_TICKER joins the env-overlay table.
Trading graph resolves the benchmark once per ticker and threads it
through to both _fetch_returns and reflect_on_final_decision, so the
alpha label reads "Alpha vs ^N225" for Tokyo listings, "Alpha vs ^HSI"
for Hong Kong, etc., instead of the misleading "Alpha vs SPY".
2026-05-11 09:14:28 +00:00
Yijia-Xiao
819e813a14 docs(readme): Ollama line covers endpoint, pull, custom model
The Required APIs section now mentions the default endpoint,
OLLAMA_BASE_URL for remote ollama-serve, ollama pull, and the
Custom model ID dropdown option, replacing the previous one-liner
that left those details implicit.
2026-05-11 09:07:38 +00:00
Yijia-Xiao
800862405d feat(ollama): allow Custom model ID in the CLI dropdown
Users with other models pulled via `ollama pull` (beyond the three
suggested defaults) can now select "Custom model ID" and type any
model name. Matches the same pattern used for DeepSeek, GLM, Qwen,
and MiniMax — the existing _prompt_custom_model_id flow handles the
"custom" value generically, so this is a one-row catalog addition
plus regression coverage.
2026-05-11 09:03:06 +00:00
Yijia-Xiao
f10daa2824 feat(ollama): OLLAMA_BASE_URL end-to-end with endpoint confirmation
OLLAMA_BASE_URL now flows through both the CLI dropdown and the
programmatic client (call-time evaluation so tests behave). After
provider selection, the CLI prints the resolved endpoint and marks
when it came from the env var, plus a soft warning when the URL is
missing a scheme or non-default port. Drops the stale "(local)"
suffix from Ollama model labels since the endpoint is now dynamic.
2026-05-11 08:46:21 +00:00
CadeYu
249caba06f Merge remote-tracking branch 'upstream/main' into analyst-phase1-observability
# Conflicts:
#	tradingagents/default_config.py
#	tradingagents/graph/setup.py
2026-05-11 16:44:00 +08:00
CadeYu
a2f343bb54 Merge remote-tracking branch 'upstream/main' into crypto-analysis-mvp
# Conflicts:
#	cli/utils.py
#	tradingagents/agents/analysts/social_media_analyst.py
#	tradingagents/agents/researchers/bear_researcher.py
2026-05-11 16:41:09 +08:00
Yijia-Xiao
879e2bb5da refactor: align display label and docs with sentiment_analyst rename
The agent ingests news, StockTwits, and Reddit, but CLI labels, the
README description, and the legacy shim docstring still framed it as
social-media-only. Updates all user-visible surfaces so the name and
the implementation match.
2026-05-11 06:25:22 +00:00
Yijia-Xiao
9f7abfcbd5 feat(cli): detect missing provider API keys and persist to .env
Adds a canonical PROVIDER_API_KEY_ENV mapping (14 providers including
the three dual-region pairs) and an ensure_api_key() helper. When the
selected provider's key is absent from the environment, the CLI prompts
via questionary.password, writes the value to .env via python-dotenv's
set_key (preserves existing lines), and exports it into os.environ so
the run continues without restart. Wired into cli/main.py right after
the region prompts so qwen-cn, glm-cn, and minimax-cn each check their
own region-specific key. openai_client refactored to consult the same
mapping, eliminating its private duplicate of provider→env-var data.
2026-05-11 06:12:34 +00:00
Yijia-Xiao
d13e9b7946 feat(config): TRADINGAGENTS_* env-var overlay for DEFAULT_CONFIG
Adds a single _ENV_OVERRIDES table in default_config.py with type-aware
coercion (str/int/bool), so users can switch llm_provider, deep/quick
models, backend URL, output language, debate rounds, and the checkpoint
flag purely via .env. Centralizes load_dotenv in the package __init__
so the overlay applies for every entry point (CLI, main.py, programmatic).
Drops the hardcoded model assignments and duplicate dotenv loads in
main.py and cli/main.py. Verified live with OpenAI and Gemini.

#602
2026-05-11 06:12:31 +00:00
Yijia-Xiao
6b384f74f9 feat(i18n): localize researchers, risk debators, research mgr, trader
output_language config now propagates to every user-facing agent.
Previously only the four analysts and portfolio manager respected
the setting, producing partial-localization reports with English
debate text interleaved with non-English analyst sections. Verified
live: 7 agents produce Chinese output when config is set to Chinese.

#575
2026-05-11 05:41:42 +00:00
Yijia-Xiao
384fe1a3d2 feat(news): configurable fetch params via DEFAULT_CONFIG
Per-ticker article limit, global article limit, global lookback
window, and macro query list are now read from get_config()
instead of being hardcoded. Tool wrapper get_global_news passes
None defaults so config overrides flow through the LLM-tool path
too. Macro query defaults broadened from 4 US-centric strings to
5 covering Fed, S&P 500, geopolitics, ECB/BOJ/BOE, commodities.

#606 #558 #562
2026-05-11 05:30:52 +00:00
Yijia-Xiao
0fcf13624e feat(agents): rename to sentiment_analyst; integrate StockTwits + Reddit
Pre-fetches news + StockTwits + Reddit via no-auth public endpoints
and injects structured data blocks into the prompt with professional
analysis instructions. Replaces the prompt-vs-tool mismatch that
caused fabricated social-platform content. Backward-compat alias +
"social" CLI key preserved.

#557 #607
2026-05-11 05:20:07 +00:00
Yijia-Xiao
d0dd0420ad feat(llm): GLM dual-region split + catalog refresh
Zhipu serves GLM under two brands with separate accounts (Z.AI
international vs BigModel China); the CLI URL pointed at one while
the openai_client default pointed at the other. Split into glm +
glm-cn with secondary region prompt (same UX as Qwen + MiniMax).
Catalog adds glm-5-turbo and glm-4.5-air per docs.z.ai.
2026-05-11 04:19:50 +00:00
Yijia-Xiao
faaeebac70 feat(cli): collapse regional duplicates; refresh Qwen catalog
Qwen and MiniMax each had two main-dropdown entries (intl + CN);
consolidate to one entry per provider and prompt for region as a
secondary step. Internal provider keys (qwen-cn, minimax-cn) and
endpoint routing unchanged. Add qwen3.6-flash to the Qwen catalog
and drop the version-less aliases (qwen-flash, qwen-plus) that
auto-shift their backing model per Alibaba's docs.

#758
2026-05-11 04:16:11 +00:00
Yijia-Xiao
0011b5ebf5 feat(llm): align xAI catalog with docs — adopt grok-4.20 frontier
xAI's official docs lead with grok-4.20-reasoning and
grok-4.20-non-reasoning across all SDK examples. Replace the prior
grok-4-1-fast-* entries (hyphens where docs use dots, no literal
code example) with the verified grok-4.20 family. Keep grok-4-0709
and grok-4-fast variants that are still referenced.
2026-05-11 03:45:43 +00:00
Yijia-Xiao
4f057e290c feat(llm): swap Gemini 3.1 Flash-Lite to GA stable
gemini-3.1-flash-lite is now GA per ai.google.dev. Use the stable
version (fewer rate limits, stronger compat guarantees) instead of
the -preview suffix. Labels mark preview vs GA explicitly.
2026-05-11 03:32:00 +00:00
Yijia-Xiao
9e00c8117f feat(llm): bump Anthropic catalog to Claude Opus 4.7 frontier
Opus 4.7 is the current frontier per platform.claude.com (frontier
category, listed first). Demote Opus 4.6 to second deep-tier slot.
Polish quick-tier labels to match official wording; effort docstring
includes 4.7.
2026-05-11 02:56:59 +00:00
Yijia-Xiao
78fe77f4e6 feat(llm): bump OpenAI catalog to GPT-5.5 frontier
GPT-5.5 (Apr 2026, 1M ctx, $5/$30 per 1M) replaces GPT-5.4 as the
catalog flagship. GPT-5.5 Pro replaces 5.4 Pro in the most-capable
slot. GPT-5.4 demotes to previous-gen cost-effective option.
2026-05-11 02:49:57 +00:00
Yijia-Xiao
e1316686f8 fix(llm): MiniMax integration polish vs official docs
M2.x tool_choice is enum-only (none/auto), so route through the
no-tool_choice dispatch. MinimaxChatOpenAI injects reasoning_split
so <think> blocks stay out of content. Catalog rounded out to the
full official M2.x lineup plus forward-compat regex.
2026-05-11 02:40:33 +00:00
Yijia-Xiao
9482cae188 fix: bundle config/recursion/missing-key fixes
- dataflows/config: deepcopy + one-level dict merge so a partial
  set_config doesn't clobber sibling defaults
- graph: thread max_recur_limit from config to Propagator
- openai_client: name the missing env var in the API-key error

#788 #764 #680
2026-05-11 02:30:24 +00:00
Yijia-Xiao
19d22b54a9 feat(llm): add MiniMax as a built-in provider
Two regional endpoints (global api.minimax.io, China api.minimaxi.com)
with separate API keys. Models M2.7 / M2.5 plus -highspeed variants,
204K context. Follows the existing provider-preset pattern.

#789 #609 #577 #546 #395 #378
2026-05-11 02:03:27 +00:00
Yijia-Xiao
704b7627f2 fix(docker): pre-create .tradingagents dir with appuser ownership
useradd --create-home creates /home/appuser but not the
.tradingagents subdir, so cache writes fail with PermissionError
when docker-compose mounts a named volume there (the volume
inherits image-dir ownership on first init).

#627 #672 #771 #690 #714 #723 #780 #633 #773 #631
2026-05-11 01:34:45 +00:00
Yijia-Xiao
22bb91bd83 fix(llm): structured output for DeepSeek V4 and reasoner
DeepSeek V4 and reasoner reject tool_choice but accept tools.
Route via a per-model capability table that suppresses tool_choice
for thinking-mode models.

#678 #689
2026-05-11 01:12:28 +00:00
Yijia-Xiao
afdc6d4ec1 chore: suppress upstream langgraph allowed_objects deprecation noise
langgraph-checkpoint 4.0.3 calls Reviver() at module load without
allowed_objects, printing a pending-deprecation warning at every
CLI start. The upstream patch is merged
(langchain-ai/langgraph#7743) but not released; no app-side seam
fixes it. Install a surgical filter in package init (message regex
+ PendingDeprecationWarning category). Remove when we bump past
langgraph-checkpoint 4.0.3.
2026-05-10 19:39:57 +00:00
Yijia-Xiao
e2c850eb17 fix(cli): preserve exchange suffixes in ticker prompt
The typer.prompt-based input could lose .SH/.SZ/.SS/.HK suffixes on
some shells, so exchange-qualified tickers like 000404.SH arrived
truncated to 000404 and failed downstream lookups. Switch to
questionary.text which reads the raw line; keep SPY-on-empty
behavior and validate the allowed character set (alnum, ._-^) up
to 32 chars.

#770
2026-05-10 19:29:41 +00:00
Yijia-Xiao
c405867bde fix: merge streamed chunks into final_state so reports save correctly
graph.stream() yields per-node deltas, not the full state. Taking
trace[-1] only captured the last node's contribution, so reports
saved to disk were missing every section except the final decision.
Merge all chunks in both the CLI path and trading_graph._run_graph's
debug branch.

#719 #736
2026-05-10 19:20:23 +00:00
Yijia-Xiao
db7e0a67e2 fix(cli): load .env from user's CWD when run as console script
load_dotenv() with no arguments walks up from site-packages instead
of the user's CWD, so the installed tradingagents console script
silently misses the project's .env. Pass find_dotenv(usecwd=True)
so the search starts from CWD; same treatment for .env.enterprise.

#726 #755 #612 #747 #743 #753 #729 #728 #751
2026-05-10 09:49:07 +00:00
CadeYu
5bae826749 Merge remote-tracking branch 'upstream/main' into crypto-analysis-mvp
# Conflicts:
#	tradingagents/agents/researchers/bear_researcher.py
#	tradingagents/agents/researchers/bull_researcher.py
#	tradingagents/graph/propagation.py
2026-05-08 18:57:09 +08:00
Yijia-Xiao
7e9e7b83c7 feat: DeepSeek V4 thinking-mode round-trip via DeepSeekChatOpenAI subclass
Resolves #599: thinking-mode models require reasoning_content to be
echoed back across turns; multi-turn agent runs failed with HTTP 400.

The fix isolates DeepSeek's quirks (reasoning_content round-trip and
the deepseek-reasoner no-tool_choice limitation) into a subclass so
the general OpenAI-compatible client stays untouched. Adds DeepSeek
V4 Pro/Flash to the catalog. 9 new tests; rationale documented in
the class docstrings.

Design adapted from #600; #611 closed in favour of this approach.
2026-05-01 19:23:23 +00:00
Yijia-Xiao
2c97bad45c fix(security): validate ticker before using as path component (#618)
The ticker symbol reaches three filesystem-path construction sites
(load_ohlcv cache filename, checkpointer DB path, _log_state results
directory) without validation. A value containing path separators or
"../" escapes the configured cache / checkpoints / results directory.

Two attack vectors:
- Programmatic callers passing arbitrary ticker to propagate()
- Prompt injection via fetched news content steering the LLM into
  tool calls with attacker-chosen ticker

Fix: new safe_ticker_component() validator in tradingagents/dataflows/
utils.py applied at all three sites. Allows the standard ticker
character set ([A-Za-z0-9._\-\^], up to 32 chars) and explicitly
rejects dot-only values like "." and ".." which would otherwise pass
the regex but traverse parent directories. Seven test cases cover
the accepted formats (BRK-B, 7203.T, ^GSPC, etc.) and the rejected
inputs (path separators, null bytes, whitespace, empty values,
overlong strings, dot-only values).

Closes #618.
2026-05-01 18:56:36 +00:00
Yijia-Xiao
7c37249f80 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.
2026-04-25 22:16:09 +00:00
Yijia-Xiao
4016fd4efa fix: stop leaking OpenAI base_url into non-OpenAI provider clients
Default config had backend_url='https://api.openai.com/v1' which was
forwarded to every provider client, including Google. ChatGoogleGenerativeAI
constructed requests against that base, producing malformed URLs like
https://api.openai.com/v1/v1beta/models/gemini-2.5-flash:generateContent
that 404 with empty body.

Discovered while running propagate() against Gemini end-to-end. The
structured-output smoke worked because that path constructed the LLM
without going through the factory and without forwarding backend_url;
propagate() goes through TradingAgentsGraph.__init__ which forwards
config['backend_url'] to every provider.

Fix: default to None. Each provider client falls back to its own
endpoint (api.openai.com for OpenAI via _PROVIDER_CONFIG, Gemini's
default for Google, and so on). The CLI flow already sets backend_url
explicitly per provider when the user picks one, so that path is
unchanged.

Verified: full propagate() now passes end-to-end on both
OpenAI gpt-5.4-mini and Gemini gemini-3-flash-preview, with all nine
structure/log/signal checks green for each.
2026-04-25 20:54:19 +00:00
Yijia-Xiao
bba147798f feat: structured-output Trader and Research Manager (#434, finishes the trio)
Extends the canonical structured-output pattern from the Portfolio Manager
to the other two decision-making agents.  Each of the three agents now
returns a typed Pydantic instance via llm.with_structured_output() in a
single primary call, and a render helper turns the result into the same
markdown shape downstream agents and saved reports already consume.

- ResearchPlan: 5-tier recommendation, conversational rationale, concrete
  strategic actions for the trader.
- TraderProposal: 3-tier action (transaction direction is naturally Buy /
  Hold / Sell — position sizing happens later at the Portfolio Manager),
  reasoning, and optional entry_price / stop_loss / position_sizing.
  Rendered output preserves the trailing "FINAL TRANSACTION PROPOSAL:
  **BUY/HOLD/SELL**" line for backward compatibility with the analyst
  stop-signal text.
- PortfolioDecision: 5-tier rating, executive summary, investment thesis,
  optional price_target / time_horizon (unchanged).

The shared try-structured-then-fallback pattern is extracted into
tradingagents/agents/utils/structured.py (bind_structured +
invoke_structured_or_freetext) so all three agents go through the same
code path and log the same warning when a provider lacks structured
output and the agent falls back to free-text generation.

Net effect for users: every saved markdown report (research/manager.md,
trading/trader.md, portfolio/decision.md) now has consistent section
headers across runs and providers, easier to scan.

Net effect for the runtime: the rating extraction round-trip is gone —
the rating comes from the structured response itself, not a second
LLM call. SignalProcessor was already simplified to a heuristic adapter
in the previous commit.

11 new tests in tests/test_structured_agents.py cover the Trader and
Research Manager render functions, structured-output happy paths, and
free-text fallback. Full suite: 88 tests pass in ~2s without API keys.
2026-04-25 20:27:23 +00:00
Yijia-Xiao
0fda24515f feat: structured-output Portfolio Manager + 5-tier rating consistency (#434)
Three related changes that take the rating pipeline from heuristic-only
to type-safe at the source.

1) Research Manager prompt now uses the same 5-tier scale (Buy /
   Overweight / Hold / Underweight / Sell) as the Portfolio Manager,
   signal_processing, and the memory log.  The prior 3-tier wording
   (Buy / Sell / Hold) was the only remaining inconsistency in the
   pipeline.

2) Centralise the 5-tier vocabulary and the heuristic prose-rating
   parser into tradingagents/agents/utils/rating.py.  Both the memory
   log and the signal processor now share the same parser instead of
   duplicating regex and word-walker logic.

3) Make structured output a first-class part of the Portfolio Manager's
   primary call.  The PM uses llm.with_structured_output(PortfolioDecision)
   so each provider's native structured-output mode (json_schema for
   OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic,
   function_calling for OpenAI-compatible providers) yields a typed
   Pydantic instance directly.  A render helper turns that instance back
   into the same markdown shape downstream consumers (memory log, CLI
   display, saved reports) already expect, so no other code has to know
   the PM now produces structured output.  Providers without structured
   support fall back gracefully to free-text + the deterministic
   heuristic.

   The previous SignalProcessor had been making a second LLM call to
   re-extract the rating from the PM's prose; that round-trip is now
   eliminated.  SignalProcessor is a thin adapter over parse_rating(),
   makes zero LLM calls, and stays for backwards compatibility with
   process_signal() callers.

Schema (PortfolioDecision) captures rating + executive_summary +
investment_thesis + optional price_target + time_horizon, with field
descriptions doubling as output instructions.  Agent prose remains the
primary artifact; structured output is layered onto the PM only because
it is the one agent whose output has machine-readable downstream
consumers.

15 new tests cover the heuristic parser (markdown-bold edge cases that
had no coverage before), the structured PM happy path, the free-text
fallback path, and that SignalProcessor never invokes the LLM.  Full
suite: 77 tests pass in ~2s without API keys.
2026-04-25 19:57:26 +00:00
Yijia-Xiao
4cbd4b086f feat: add LangGraph checkpoint resume for crash recovery (#594)
Long analyses can take many minutes; a crash or interruption forced users
to re-run from scratch and re-pay every LLM call.  This adds an opt-in
checkpoint layer backed by per-ticker SQLite databases so the graph
resumes from the last successful node.

How to use:
- CLI:    tradingagents analyze --checkpoint
- CLI:    tradingagents analyze --clear-checkpoints
- Python: config["checkpoint_enabled"] = True

Lifecycle:
- propagate() recompiles the graph with a SqliteSaver when enabled and
  injects a deterministic thread_id derived from ticker+date so the
  same ticker+date resumes while a different date starts fresh.
- On successful completion the per-thread checkpoint rows are cleared.
- The context manager is closed in a try/finally so a crash never
  leaks the SQLite connection or leaves the graph in checkpoint mode.

Storage: ~/.tradingagents/cache/checkpoints/<TICKER>.db
(override via TRADINGAGENTS_CACHE_DIR).

The checkpointer module is new (tradingagents/graph/checkpointer.py)
and the GraphSetup now returns the uncompiled workflow so it can be
recompiled with a saver when needed.

Adds langgraph-checkpoint-sqlite>=2.0.0 dependency. 3 new tests verify
the crash/resume cycle and that a different date starts fresh.
2026-04-25 08:47:15 +00:00
Yijia-Xiao
ebd2e12e67 feat: replace per-agent BM25 memory with persistent decision log (#578, #563, #564, #579)
The previous per-agent BM25 memory was effectively dead code — its only
caller was a commented-out line in main.py. Replace it with a single
append-only markdown decision log driven by the propagate() lifecycle.

Lifecycle:
- store_decision() appends a pending entry at the end of every run
- _resolve_pending_entries() runs at the start of the next same-ticker
  run, fetches yfinance returns + alpha vs SPY, and writes one LLM
  reflection per resolved entry through an atomic temp-file rename
- Portfolio Manager consumes state["past_context"] (5 most recent
  same-ticker entries plus 3 cross-ticker reflection-only excerpts)

Storage at ~/.tradingagents/memory/trading_memory.md
(override: TRADINGAGENTS_MEMORY_LOG_PATH).

Tag schema:
- Pending:  [YYYY-MM-DD | TICKER | Rating | pending]
- Resolved: [YYYY-MM-DD | TICKER | Rating | +X.X% | +Y.Y% | Nd]

Removes rank-bm25 dependency and the legacy reflect_and_remember()
plumbing across reflection.py, trading_graph.py, and the agent factories.

49 new tests in tests/test_memory_log.py cover the storage, deferred
reflection, prompt injection, and legacy-removal paths. Full suite
(58 tests) passes in under 2 seconds without API keys.
2026-04-25 08:24:03 +00:00
Yijia-Xiao
f85f5d9f5d test: lazy-load LLM provider clients and add API-key fixtures so the test suite runs cleanly without credentials (#588) 2026-04-25 07:41:36 +00:00
Yijia-Xiao
8e7654f0df fix: drop past-memory directive and placeholder from agent prompts when memory is empty (#572) 2026-04-25 07:41:36 +00:00
Yijia-Xiao
872b063e69 fix: use explicit encoding="utf-8" for all file I/O so Windows users avoid cp1252 crashes (#543, #550, #576) 2026-04-25 07:25:32 +00:00
Zhigong Liu
6abc768c1d feat: replace per-agent BM25 memory with persistent append-only decision log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:43:14 -04:00
CadeYu
99ec63f966 merge upstream main into crypto-analysis-mvp 2026-04-18 21:07:54 +08:00
CadeYu
e7ec980021 feat: add analysis-only crypto asset mode 2026-04-18 20:42:11 +08:00
Zhigong Liu
8536ccacdd chore: ignore CLAUDE.md (local AI assistant context file)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:57:31 -04:00
Yijia-Xiao
fa4d01c23a fix: process all chunk messages for tool call logging, harden memory score normalization (#534, #531) 2026-04-13 07:21:33 +00:00
Yijia-Xiao
b0f6058299 feat: add DeepSeek, Qwen, GLM, and Azure OpenAI provider support 2026-04-13 07:12:07 +00:00
Yijia-Xiao
59d6b2152d fix: use ~/.tradingagents/ for cache and logs, resolving Docker permission issue (#519) 2026-04-13 05:26:04 +00:00
Yijia-Xiao
10c136f49c feat: add Docker support for cross-platform deployment 2026-04-04 08:14:01 +00:00
Yijia-Xiao
4f965bf46a feat: dynamic OpenRouter model selection with search (#482, #337) 2026-04-04 07:56:44 +00:00
Yijia-Xiao
bdb9c29d44 refactor: remove stale imports, use configurable results path (#499) 2026-04-04 07:35:35 +00:00
Yijia-Xiao
bdc5fc62d3 chore: bump langchain-google-genai minimum to 4.0.0 for thought signature support 2026-04-04 07:28:03 +00:00
Yijia-Xiao
78fb66aed1 fix: normalize indicator names to lowercase (#490) 2026-04-04 07:23:31 +00:00
Yijia-Xiao
7269f877c1 fix: portfolio manager reads trader's proposal and research plan (#503) 2026-04-04 07:22:01 +00:00
Yijia-Xiao
28d5cc661f fix: add missing pandas import in y_finance.py (#488) 2026-04-04 07:14:10 +00:00
Yijia-Xiao
7004dfe554 fix: remove hardcoded Google endpoint that caused 404 (#493, #496) 2026-04-04 07:07:53 +00:00
CadeYu
f4519bcb84 use execution plan metadata for first analyst 2026-03-31 10:09:57 +08:00
CadeYu
4300b68f19 merge upstream main into analyst-phase1-observability 2026-03-31 10:04:35 +08:00
CadeYu
2d2c9e6d66 add analyst execution planning and timing hooks 2026-03-31 09:55:33 +08:00
Yijia-Xiao
4641c03340 TradingAgents v0.2.3 2026-03-29 19:50:46 +00:00
Yijia-Xiao
e75d17bc51 chore: update model lists and defaults to GPT-5.4 family 2026-03-29 19:45:36 +00:00
Yijia-Xiao
6cddd26d6e feat: multi-language output support for analyst reports and final decision (#472) 2026-03-29 19:19:01 +00:00
Yijia Xiao
c61242a28c Merge pull request #464 from CadeYu/sync-validator-models
sync model validation with cli catalog
2026-03-29 11:07:51 -07:00
Yijia-Xiao
58e99421bd fix: pass base_url to Google and Anthropic clients for proxy support (#427) 2026-03-29 17:59:52 +00:00
Yijia Xiao
46e1b600b8 Merge pull request #453 from javierdejesusda/fix/standardize-google-api-key
fix(llm_clients): standardize Google API key to unified api_key param
2026-03-29 10:54:28 -07:00
Yijia-Xiao
ae8c8aebe8 fix: gracefully handle invalid indicator names in tool calls (#429) 2026-03-29 17:50:30 +00:00
Yijia-Xiao
f3f58bdbdc fix: add yf_retry to yfinance news fetchers (#445) 2026-03-29 17:42:24 +00:00
Yijia-Xiao
e1113880a1 fix: prevent look-ahead bias in backtesting data fetchers (#475) 2026-03-29 17:34:35 +00:00
CadeYu
bd6a5b75b5 fix model catalog typing and known-model helper 2026-03-25 21:46:56 +08:00
CadeYu
8793336dad sync model validation with cli catalog 2026-03-25 21:23:02 +08:00
javierdejesusda
047b38971c refactor: simplify api_key mapping and consolidate tests
Apply review suggestions: use concise `or` pattern for API key
resolution, consolidate tests into parameterized subTest, move
import to module level per PEP 8.
2026-03-24 14:52:51 +01:00
javierdejesusda
f5026009f9 fix(llm_clients): standardize Google API key to unified api_key param
GoogleClient now accepts the unified `api_key` parameter used by
OpenAI and Anthropic clients, mapping it to the provider-specific
`google_api_key` that ChatGoogleGenerativeAI expects. Legacy
`google_api_key` still works for backward compatibility.

Resolves TODO.md item #2 (inconsistent parameter handling).
2026-03-24 14:35:02 +01:00
Yijia-Xiao
589b351f2a TradingAgents v0.2.2 2026-03-22 23:47:56 +00:00
Yijia-Xiao
6c9c9ce1fd fix: set process-level UTF-8 default for cross-platform consistency 2026-03-22 23:42:37 +00:00
Yijia-Xiao
b8b2825783 refactor: standardize portfolio manager, five-tier rating scale, fix analyst status tracking 2026-03-22 23:30:29 +00:00
Yijia-Xiao
318adda0c6 refactor: five-tier rating scale and streamlined agent prompts 2026-03-22 23:07:20 +00:00
Yijia Xiao
c3ba3bf428 Merge pull request #413 from CadeYu/codex/exchange-qualified-tickers
fix: preserve exchange-qualified tickers across agent prompts
2026-03-22 15:36:14 -07:00
Yijia-Xiao
7cca9c924e fix: add exponential backoff retry for yfinance rate limits (#426) 2026-03-22 22:11:08 +00:00
Yijia-Xiao
bd9b1e5efa feat: add Anthropic effort level support for Claude models
Add effort parameter (high/medium/low) for Claude 4.5+ and 4.6 models,
consistent with OpenAI reasoning_effort and Google thinking_level.
Also add content normalization for Anthropic responses.
2026-03-22 21:57:05 +00:00
Yijia-Xiao
77755f0431 chore: consolidate install, fix CLI portability, normalize LLM responses
- Point requirements.txt to pyproject.toml as single source of truth
- Resolve welcome.txt path relative to module for CLI portability
- Include cli/static files in package build
- Extract shared normalize_content for OpenAI Responses API and
  Gemini 3 list-format responses into base_client.py
- Update README install and CLI usage instructions
2026-03-22 21:38:01 +00:00
Yijia-Xiao
0b13145dc0 fix: handle list content when writing report sections
Closes #400
2026-03-22 20:40:18 +00:00
Yijia-Xiao
3ff28f3559 fix: use OpenAI Responses API for native models
Enable use_responses_api for native OpenAI provider, which supports
reasoning_effort with function tools across all model families.
Removes the UnifiedChatOpenAI subclass workaround.

Closes #403
2026-03-22 20:34:03 +00:00
CadeYu
7d200d834a style: inline single-use instrument context vars 2026-03-21 21:31:38 +08:00
CadeYu
08bfe70a69 fix: preserve exchange-qualified tickers across agent prompts 2026-03-21 21:10:13 +08:00
Yijia Xiao
f362a160c3 Merge pull request #379 from yang1002378395-cmyk/fix-ssl-http-client-support
fix: add http_client support for SSL certificate customization
2026-03-15 16:53:04 -07:00
阳虎
64f07671b9 fix: add http_client support for SSL certificate customization
- Add http_client and http_async_client parameters to all LLM clients
- OpenAIClient, GoogleClient, AnthropicClient now support custom httpx clients
- Fixes SSL certificate verification errors on Windows Conda environments
- Users can now pass custom httpx.Client with verify=False or custom certs

Fixes #369
2026-03-16 07:41:20 +08:00
Yijia-Xiao
b19c5c18fb docs: add v0.2.1 release note to README 2026-03-15 23:39:05 +00:00
Yijia-Xiao
551fd7f074 chore: update model lists, bump to v0.2.1, fix package build
- OpenAI: add GPT-5.4, GPT-5.4 Pro; remove o-series and legacy GPT-4o
- Anthropic: add Claude Opus 4.6, Sonnet 4.6; remove legacy 4.1/4.0/3.x
- Google: add Gemini 3.1 Pro, 3.1 Flash Lite; remove deprecated
  gemini-3-pro-preview and Gemini 2.0 series
- xAI: clean up model list to match current API
- Simplify UnifiedChatOpenAI GPT-5 temperature handling
- Add missing tradingagents/__init__.py (fixes pip install building)
2026-03-15 23:34:50 +00:00
Yijia-Xiao
b0f9d180f9 fix: harden stock data parsing against malformed CSV and NaN values
Add _clean_dataframe() to normalize stock DataFrames before stockstats:
coerce invalid dates/prices, drop rows missing Close, fill price gaps.
Also add on_bad_lines="skip" to all cached CSV reads.
2026-03-15 18:29:43 +00:00
Yijia-Xiao
9cc283ac22 fix: add missing console import to cli/utils.py
Seven error-handling paths used console.print() but console was never
imported, causing NameError on invalid user input.
2026-03-15 18:21:05 +00:00
Yijia-Xiao
fe9c8d5d31 fix: handle comma-separated indicators in get_indicators tool
LLMs (especially smaller models) sometimes pass multiple indicator
names as a single comma-separated string instead of making separate
tool calls. Split and process each individually at the tool boundary.
2026-03-15 18:05:36 +00:00
Yijia-Xiao
eec6ca4b53 fix: initialize all debate state fields in propagation.py
InvestDebateState was missing bull_history, bear_history, judge_decision.
RiskDebateState was missing aggressive_history, conservative_history,
neutral_history, latest_speaker, judge_decision. This caused KeyError
in _log_state() and reflection, especially with edge-case config values.
2026-03-15 17:54:32 +00:00
Yijia-Xiao
3642f5917c fix: add explicit UTF-8 encoding to all file open() calls
Prevents UnicodeEncodeError on Windows where the default encoding
(cp1252/gbk) cannot handle Unicode characters in LLM output.

Closes #77, closes #114, closes #126, closes #215, closes #332
2026-03-15 16:44:23 +00:00
makk9
907bc8022a fix: pass debate round config to ConditionalLogic (#361)
* fix: pass max_debate_rounds and max_risk_discuss_rounds config to ConditionalLogic

* use config values
2026-03-15 09:31:59 -07:00
Yijia-Xiao
8a60662070 chore: remove unused chainlit dependency (CVE-2026-22218) 2026-03-15 16:16:42 +00:00
Yijia Xiao
f047f26df0 Merge pull request #341 from Ljx-007/fix/risk-manager-fundamental-report
fix(risk_manager): use correct state key for fundamentals report
2026-02-24 16:28:56 -08:00
Ljx-007
35856ff33e fix(risk_manager): 修复基本面报告数据源错误
- 修正了fundamentals_report从news_report获取数据的问题
- 确保fundamentals_report正确使用fundamentals_report数据源
2026-02-09 18:21:21 +08:00
Yijia Xiao
5fec171a1e chore: add build-system config and update version to 0.2.0 2026-02-07 08:26:51 +00:00
Yijia Xiao
50c82a25b5 chore: consolidate dependencies to pyproject.toml, remove setup.py 2026-02-07 08:18:46 +00:00
Yijia Xiao
8b3068d091 Merge pull request #335 from RinZ27/security/patch-langchain-core-vulnerability
security: Patch LangGrinch vulnerability (CVE-2025-68664) (#335)
2026-02-07 00:04:44 -08:00
RinZ27
66a02b3193 security: patch LangGrinch vulnerability in langchain-core 2026-02-05 11:01:53 +07:00
Yijia Xiao
e9470b69c4 TradingAgents v0.2.0: Multi-Provider LLM Support & Optimizations (#331)
Release v0.2.0: Multi-Provider LLM Support
2026-02-03 23:13:43 -08:00
Yijia Xiao
b4b133eb2d fix: add typer dependency 2026-02-04 00:39:15 +00:00
Yijia Xiao
80aab35119 docs: update README for v0.2.0 release
- TradingAgents v0.2.0 release
- Trading-R1 announcement
- Multi-provider LLM documentation
2026-02-04 00:13:10 +00:00
Yijia Xiao
393d4c6a1b chore: add data_cache to .gitignore 2026-02-03 23:30:55 +00:00
Yijia Xiao
aba1880c8c chore: update .gitignore to official Python template 2026-02-03 23:16:38 +00:00
Yijia Xiao
6cd35179fa chore: clean up dependencies and fix Ollama auth
- Remove unused packages: praw, feedparser, eodhd, akshare, tushare, finnhub
- Fix Ollama requiring API key
2026-02-03 23:08:12 +00:00
Yijia Xiao
102b026d23 refactor: clean up codebase and streamline documentation
- Remove debug prints from vendor routing (interface.py)
- Simplify vendor fallback to only handle rate limits
- Reorder CLI provider menu: OpenAI, Google, Anthropic, xAI, OpenRouter, Ollama
- Remove dead files: local.py, reddit_utils.py, openai.py, google.py, googlenews_utils.py, yfin_utils.py
2026-02-03 22:27:20 +00:00
Yijia Xiao
224941d8c2 feat: add post-analysis report saving and fix display truncation
- Add save prompt after analysis with organized subfolder structure
- Fix report truncation by using sequential panels instead of Columns
- Add optional full report display prompt
2026-02-03 22:27:20 +00:00
Yijia Xiao
93b87d5119 fix: analyst status tracking and message deduplication
- Add update_analyst_statuses() for unified status logic (pending/in_progress/completed)
- Normalize analyst selection to predefined ANALYST_ORDER for consistent execution
- Add message deduplication to prevent duplicates from stream_mode=values
- Restructure streaming loop so state handlers run on every chunk
2026-02-03 22:27:20 +00:00
Yijia Xiao
54cdb146d0 feat: add footer statistics tracking with LangChain callbacks
- Add StatsCallbackHandler for tracking LLM calls, tool calls, and tokens
- Integrate callbacks into TradingAgentsGraph and all LLM clients
- Dynamic agent/report counts based on selected analysts
- Fix report completion counting (tied to agent completion)
2026-02-03 22:27:20 +00:00
Yijia Xiao
b06936f420 fix: improve data vendor implementations and tool signatures
- Add get_global_news for Alpha Vantage
- Fix get_insider_transactions signature (remove unused curr_date param)
- Remove unnecessary default params from API calls (sort, limit, tab)
2026-02-03 22:27:20 +00:00
Yijia Xiao
b75940e901 feat: add announcements panel fetching from api.tauric.ai/v1/announcements 2026-02-03 22:27:20 +00:00
Yijia Xiao
3d040f8da4 feat: add yfinance support to accommodate community request for stability and quota 2026-02-03 22:27:20 +00:00
Yijia Xiao
50961b2477 refactor: rename risky/safe agents to aggressive/conservative 2026-02-03 22:27:20 +00:00
Yijia Xiao
a3761bdd66 feat: update Ollama and OpenRouter model options
- Ollama: Add Qwen3 (8B), GPT-OSS (20B), GLM-4.7-Flash (30B)
- OpenRouter: Add NVIDIA Nemotron 3 Nano, Z.AI GLM 4.5 Air
- Add explicit Ollama provider handling in OpenAI client for consistency
2026-02-03 22:27:20 +00:00
Yijia Xiao
d4dadb82fc feat: add multi-provider LLM support with thinking configurations
Models added:
- OpenAI: GPT-5.2, GPT-5.1, GPT-5, GPT-5 Mini, GPT-5 Nano, GPT-4.1
- Anthropic: Claude Opus 4.5/4.1, Claude Sonnet 4.5/4, Claude Haiku 4.5
- Google: Gemini 3 Pro/Flash, Gemini 2.5 Flash/Flash Lite
- xAI: Grok 4, Grok 4.1 Fast (Reasoning/Non-Reasoning)

Configs updated:
- Add unified thinking_level for Gemini (maps to thinking_level for Gemini 3,
  thinking_budget for Gemini 2.5; handles Pro's lack of "minimal" support)
- Add OpenAI reasoning_effort configuration
- Add NormalizedChatGoogleGenerativeAI for consistent response handling

Fixes:
- Fix Bull/Bear researcher display truncation
- Replace ChromaDB with BM25 for memory retrieval
2026-02-03 22:27:20 +00:00
Yijia Xiao
79051580b8 feat: add multi-provider LLM support with factory pattern
- Add tradingagents/llm_clients/ with unified factory pattern
- Support OpenAI, Anthropic, Google, xAI, OpenRouter, Ollama, vLLM
- Replace direct LLM imports in trading_graph.py with create_llm_client()
- Handle provider-specific params (reasoning_effort, thinking_config)
2026-02-03 22:27:20 +00:00
Edward Sun
13b826a31d Merge pull request #245 from TauricResearch/feat/tooloptim
Y Finance Tools Optimizations
2025-10-09 00:34:10 -07:00
Edward Sun
b2ef960da7 updated readme 2025-10-09 00:32:04 -07:00
Edward Sun
a5dcc7da45 update readme 2025-10-06 20:33:12 -07:00
Edward Sun
7bb2941b07 optimized yfin fetching to be much faster 2025-10-06 19:58:01 -07:00
Yijia Xiao
32be17c606 Merge pull request #235 from luohy15/data_vendor
Add Alpha Vantage API Integration and Refactor Data Provider Architecture
2025-10-05 16:01:30 -07:00
Edward Sun
c07dcf026b added fallbacks for tools 2025-10-03 22:40:09 -07:00
luohy15
d23fb539e9 minor fix 2025-09-30 13:27:48 +08:00
luohy15
b01051b9f4 Switch default data vendor
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:43:27 +08:00
luohy15
8fdbbcca3d alpha vantage api key url 2025-09-29 18:22:31 +08:00
luohy15
86bc0e793f minor fix 2025-09-27 00:04:59 +08:00
luohy15
7fc9c28a94 Add environment variable configuration support
- Add .env.example file with API key placeholders
- Update README.md with .env file setup instructions
- Add dotenv loading in main.py for environment variables

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 23:58:51 +08:00
luohy15
7bcc2cbd8a Update configuration documentation for Alpha Vantage data vendor
Add data vendor configuration examples in README and main.py showing how to configure Alpha Vantage as the primary data provider. Update documentation to reflect the current default behavior of using Alpha Vantage for real-time market data access.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 23:52:26 +08:00
luohy15
6211b1132a Improve Alpha Vantage indicator column parsing with robust mapping
- Replace hardcoded column indices with column name lookup
- Add mapping for all supported indicators to their expected CSV column names
- Handle missing columns gracefully with descriptive error messages
- Strip whitespace from header parsing for reliability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 23:36:36 +08:00
luohy15
8b04ec307f minor fix 2025-09-26 23:25:33 +08:00
luohy15
0ab323c2c6 Add Alpha Vantage API integration as primary data provider
- Replace FinnHub with Alpha Vantage API in README documentation
- Implement comprehensive Alpha Vantage modules:
  - Stock data (daily OHLCV with date filtering)
  - Technical indicators (SMA, EMA, MACD, RSI, Bollinger Bands, ATR)
  - Fundamental data (overview, balance sheet, cashflow, income statement)
  - News and sentiment data with insider transactions
- Update news analyst tools to use ticker-based news search
- Integrate Alpha Vantage vendor methods into interface routing
- Maintain backward compatibility with existing vendor system

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 22:57:50 +08:00
luohy15
a6734d71bc WIP 2025-09-26 16:17:50 +08:00
Yijia Xiao
a438acdbbd Merge pull request #89 from Mirza-Samad-Ahmed-Baig/fixes
Enhancement: agent reflection, logging improvement
2025-07-03 10:15:39 -04:00
Yijia Xiao
c73e374e7c Update main.py 2025-07-03 10:14:06 -04:00
mirza-samad-ahmed-baig
f704828f89 Fix: Prevent infinite loops, enable reflection, and improve logging 2025-07-03 17:43:40 +05:00
Edward Sun
fda4f664e8 Merge pull request #49 from Zhongyi-Lu/a
Exclude `.env` from Git.
2025-07-01 09:17:46 -07:00
Yijia Xiao
718df34932 Merge pull request #29 from ZeroAct/save_results
Save results
2025-06-26 00:28:30 -04:00
Max Wong
43aa9c5d09 Local Ollama (#53)
- Fix typo 'Start' 'End'
- Add llama3.1 selection
- Use 'quick_think_llm' model instead of hard-coding GPT
2025-06-26 00:27:01 -04:00
Yijia Xiao
26c5ba5a78 Revert "Docker support and Ollama support (#47)" (#57)
This reverts commit 78ea029a0b.
2025-06-26 00:07:58 -04:00
Geeta Chauhan
78ea029a0b Docker support and Ollama support (#47)
- Added support for running CLI and Ollama server via Docker
- Introduced tests for local embeddings model and standalone Docker setup
- Enabled conditional Ollama server launch via LLM_PROVIDER
2025-06-25 23:57:05 -04:00
Huijae Lee
ee3d499894 Merge branch 'TauricResearch:main' into save_results 2025-06-25 08:43:19 +09:00
Yijia Xiao
7abff0f354 Merge pull request #46 from AtharvSabde/patch-2
Updated requirements.txt based on latest commit
2025-06-23 20:40:58 -04:00
Yijia Xiao
b575bd0941 Merge pull request #52 from TauricResearch/dev
Merge dev into main. Add support for Anthropic and OpenRouter.
2025-06-23 20:38:14 -04:00
Zhongyi Lu
b8f712b170 Exclude .env from Git 2025-06-21 23:29:26 -07:00
Edward Sun
52284ce13c fixed anthropic support. Anthropic has different format of response when it has tool calls. Explicit handling added 2025-06-21 12:51:34 -07:00
Atharv Sabde
11804f88ff Updated requirements.txt based on latest commit
PULL REQUEST: Add support for other backends, such as OpenRouter and Ollama

it had two requirments missing. added those
2025-06-20 15:58:22 +05:30
Yijia Xiao
1e86e74314 Merge pull request #40 from RealMyth21/main
Updated README.md: Swap Trader and Management order.
2025-06-19 15:10:36 -04:00
Yijia Xiao
c2f897fc67 Merge pull request #43 from AtharvSabde/patch-1
fundamentals_analyst.py (spelling mistake in instruction: Makrdown -> Markdown)
2025-06-19 15:05:08 -04:00
Yijia Xiao
ed32081f57 Merge pull request #44 from TauricResearch/dev
Merge dev into main branch
2025-06-19 15:00:07 -04:00
Atharv Sabde
2af7ef3d79 fundamentals_analyst.py(spelling mistake.markdown) 2025-06-19 21:48:16 +05:30
Mithil Srungarapu
383deb72aa Updated README.md
The diagrams were switched, so I fixed it.
2025-06-18 19:08:10 -07:00
Edward Sun
7eaf4d995f update clear msg bc anthropic needs at least 1 msg in chat call 2025-06-15 23:14:47 -07:00
Edward Sun
da84ef43aa main works, cli bugs 2025-06-15 22:20:59 -07:00
Edward Sun
90b23e72f5 Merge pull request #25 from maxer137/main
Add support for other backends, such as OpenRouter and Ollama
2025-06-15 16:06:20 -07:00
ZeroAct
417b09712c refactor 2025-06-12 13:53:28 +09:00
saksham0161
570644d939 Fix ticker hardcoding in prompt (#28) 2025-06-11 19:43:39 -07:00
ZeroAct
9647359246 save reports & logs under results_dir 2025-06-12 11:25:07 +09:00
maxer137
99789f9cd1 Add support for other backends, such as OpenRouter and olama
This aims to offer alternative OpenAI capable api's.
This offers people to experiment with running the application locally
2025-06-11 14:19:25 +02:00
neo
a879868396 docs: add links to other language versions of README (#13)
Added language selection links to the README for easier access to translated versions: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
2025-06-09 15:51:06 -07:00
Yijia-Xiao
0013415378 Add star history 2025-06-09 15:14:41 -07:00
Edward Sun
0fdfd35867 Fix default python usage config code 2025-06-08 13:16:10 -07:00
Edward Sun
e994e56c23 Remove EODHD from readme 2025-06-07 15:04:43 -07:00
Yijia-Xiao
47176ba8a2 chore(release): v0.1.1 – TradingAgents cleaned release 2025-06-07 12:17:58 -07:00
Edward Sun
5f1c9c43cf removed static site 2025-06-05 11:14:05 -07:00
163 changed files with 17718 additions and 3745 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.venv
.env
.claude
.idea
.vscode
.DS_Store
__pycache__
*.egg-info
build
dist
results
eval_results
Dockerfile
docker-compose.yml

5
.env.enterprise.example Normal file
View File

@@ -0,0 +1,5 @@
# Azure OpenAI
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com/
AZURE_OPENAI_DEPLOYMENT_NAME=
# OPENAI_API_VERSION=2024-10-21 # optional, required for non-v1 API

72
.env.example Normal file
View File

@@ -0,0 +1,72 @@
# LLM Providers (set the one you use)
OPENAI_API_KEY=
GOOGLE_API_KEY=
ANTHROPIC_API_KEY=
XAI_API_KEY=
DEEPSEEK_API_KEY=
DASHSCOPE_API_KEY=
DASHSCOPE_CN_API_KEY=
ZHIPU_API_KEY=
ZHIPU_CN_API_KEY=
MINIMAX_API_KEY=
MINIMAX_CN_API_KEY=
OPENROUTER_API_KEY=
MISTRAL_API_KEY=
MOONSHOT_API_KEY=
GROQ_API_KEY=
NVIDIA_API_KEY=
# FRED (Federal Reserve macro data: rates, inflation, labor, growth). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
#FRED_API_KEY=
# Optional: a custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp,
# relay). Select provider "openai_compatible" and set the base URL; the key is
# optional (local servers need none).
#OPENAI_COMPATIBLE_API_KEY=
# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]").
# Auth: either a Bedrock API key (bearer token, no AWS access keys) OR the AWS
# credential chain (env keys / ~/.aws/credentials / IAM role / AWS_PROFILE). Set
# the region either way; a bearer token takes precedence when both are present.
#AWS_BEARER_TOKEN_BEDROCK=
#AWS_DEFAULT_REGION=us-west-2
#AWS_PROFILE=
# Optional: point at a remote Ollama server. When unset, defaults to
# the local instance at http://localhost:11434/v1. Convention follows
# the broader Ollama ecosystem; both the CLI dropdown and programmatic
# client pick this up.
#OLLAMA_BASE_URL=http://your-ollama-host:11434/v1
# Optional: override DEFAULT_CONFIG without editing code.
# Any TRADINGAGENTS_* variable below, when set, replaces the matching key
# in tradingagents/default_config.py. Values are coerced to the type of
# the existing default (bool / int / str), so "true"/"3" work as expected.
# In the CLI, setting the LLM provider / models / backend URL / language
# also skips the matching interactive selection step (useful for
# OpenAI-compatible endpoints like opencode or LM Studio, and unattended runs).
#TRADINGAGENTS_LLM_PROVIDER=openai
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini
#TRADINGAGENTS_LLM_BACKEND_URL=
#TRADINGAGENTS_OUTPUT_LANGUAGE=English
#TRADINGAGENTS_MAX_DEBATE_ROUNDS=1
#TRADINGAGENTS_MAX_RISK_ROUNDS=1
#TRADINGAGENTS_CHECKPOINT_ENABLED=false
# Sampling temperature (lower = less run-to-run variation on models that
# honor it). Unset leaves each provider at its default. See the README
# "Reproducibility" note — no setting makes LLM output fully deterministic.
#TRADINGAGENTS_TEMPERATURE=0.0
# LLM SDK retry budget forwarded to every provider. Unset leaves each SDK at its
# own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling
# on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run.
#TRADINGAGENTS_LLM_MAX_RETRIES=6
# Cap on output tokens forwarded to every provider (Gemini's max_output_tokens
# too). Unset leaves each provider at its default. Set it to bound a model that
# emits unbounded reasoning/output and hangs or trips a gateway idle timeout.
#TRADINGAGENTS_MAX_TOKENS=8192
# Provider-specific reasoning/thinking depth (optional; unset = provider
# default). Setting one also skips the matching interactive prompt.
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
#TRADINGAGENTS_GOOGLE_THINKING_LEVEL=high
#TRADINGAGENTS_ANTHROPIC_EFFORT=high

61
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install (with dev extras)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run test suite
run: pytest -q
smoke-install:
name: clean-install smoke
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Fresh install (no dev extras) and import
run: |
python -m pip install --upgrade pip
pip install .
# Catches undeclared runtime deps (e.g. #994 python-dotenv): a bare
# install must import the package and the CLI module.
python -c "import tradingagents, cli.main; print('clean-install import OK')"
lint:
name: ruff (strict, full repo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install "ruff>=0.15"
- name: Lint the repository
# The repo is fully clean under the strict select, so we lint everything
# (results/ and worklog/ are excluded via pyproject extend-exclude).
run: ruff check .

227
.gitignore vendored
View File

@@ -1,8 +1,223 @@
env/ # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
.DS_Store *.py[codz]
*.csv *$py.class
src/
eval_results/ # C extensions
eval_data/ *.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
# Cache
**/data_cache/
# Enterprise env file (secrets) and generated run reports
.env.enterprise
reports/

499
CHANGELOG.md Normal file
View File

@@ -0,0 +1,499 @@
# Changelog
All notable changes to TradingAgents are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Breaking changes within the 0.x line are called out explicitly.
## [0.4.0] — 2026-08-31
Look-ahead and point-in-time fixes across the data and memory layers, clearer
decision signals, working CLI checkpoint resume, and the GPT-5.6 / GLM-5.3 models.
### Fixed
- **FRED macro look-ahead.** Historical macro requests were served from today's
data vintage, leaking later revisions into a backtest; both the observations
and metadata requests now pin the vintage to the as-of date. (#1275)
- **Social sentiment look-ahead.** StockTwits and Reddit were fetched with no
date, so a historical run showed today's chatter as if it were from the as-of
date; the social path is now trimmed to the analysis window, via one shared
UTC half-open window rule (`dataflows/date_window`) used by news too. (#1220)
- **Memory point-in-time guard.** `get_past_context` returned every resolved
lesson regardless of the run date; each resolved entry now records the date
its outcome became known, and a historical run only sees lessons resolved by
the trade date. (#1251)
- **Premature reflection.** A decision was settled on a partial return if a rerun
happened before its holding window fully traded; resolution now waits for the
full window. (#1169)
- **Latest OHLCV bar dropped.** The newest bar with a NaN close was silently
dropped before the date cutoff, making the previous trading day look like the
latest; dates are normalized per element (DST- and non-US-market safe) and a
missing latest close raises rather than falling back. (#1201)
- **Debate opening fabrication.** The first speaker in each debate round rebutted
an empty opponent response, fabricating the other side; all five debators now
open with their own case when no opponent has spoken. (#1176)
- **Silent Hold.** An unparseable Portfolio Manager rating (including a fullwidth
colon) was coerced to a tradeable Hold; it now surfaces a `REVIEW` sentinel,
with `parse_rating` keeping its silent default for compatibility callers. (#1170)
- **`--checkpoint` was a no-op on the CLI.** Checkpoint setup lived only in
`propagate()`; the CLI streamed the checkpointer-less graph. The lifecycle is
now shared, and a resume feeds `None` so LangGraph continues the interrupted
run instead of duplicating messages. (#1249)
- **DeepSeek via OpenRouter.** `deepseek/<id>` fell through to default
capabilities and had object-form `tool_choice` forced on it; the official
namespace is stripped so it reuses the native DeepSeek quirks. (#1199)
- **Trader price grounding.** The Trader saw only the digested plan; it now also
receives the technical market report so entry/stop levels anchor to real price
structure. (#1167)
### Added
- **Configurable output-token cap.** `max_tokens` / `TRADINGAGENTS_MAX_TOKENS`,
forwarded to every provider (Gemini as `max_output_tokens`), so a model that
emits unbounded reasoning can be bounded instead of hanging. (#1204)
- **Latest models.** Added the GPT-5.6 family (`gpt-5.6` / `gpt-5.6-terra` /
`gpt-5.6-luna`) and GLM-5.3 (`glm-5.3`, `glm-5.3-flash`). The default models
are now `gpt-5.6` (deep) and `gpt-5.6-luna` (quick).
### Contributors
Thanks to everyone who reported these or sent a fix:
[@PyriteResearch](https://github.com/PyriteResearch), [@yiran1268](https://github.com/yiran1268), [@fabiolenine](https://github.com/fabiolenine), [@lx7720](https://github.com/lx7720), [@taro0915](https://github.com/taro0915), [@Jaswanth-Sriram-Veturi](https://github.com/Jaswanth-Sriram-Veturi), [@ariesy](https://github.com/ariesy), [@liangzj1999](https://github.com/liangzj1999), [@zkwang616](https://github.com/zkwang616), [@aniketshukla1](https://github.com/aniketshukla1), [@loulanyue](https://github.com/loulanyue), [@hudsonwa](https://github.com/hudsonwa), [@daleselaji-dev](https://github.com/daleselaji-dev), [@wolfoswald777-crypto](https://github.com/wolfoswald777-crypto).
## [0.3.1] — 2026-07-05
Correctness and stability patch: data look-ahead, graph-router crash-safety,
checkpoint identity, crypto sentiment sources, and configurable resilience.
### Fixed
- **Alpha Vantage look-ahead filter now runs.** The fundamentals payload is a
JSON string, so the dict-only guard skipped filtering and future-dated reports
leaked into historical runs; parse before filtering. (#1115, @zachthebird)
- **News analyst prompt matches the tool.** The prompt advertised
`get_news(query, ...)` but the tool takes a ticker; aligned to stop
hallucinated free-text query calls. (#1116, @shcheuk)
- **Shared debate/risk routers can't crash mid-run.** Both routers return more
targets than any one edge mapped; every edge now shares the complete path map,
so a fall-through under prompt/i18n/refactor drift stays routable.
(#1088, @Fr3ya, @sa7an7, @Sushanth012)
- **Checkpoint resume respects graph shape.** The thread id folds in selected
analysts, debate/risk depth, and asset mode, so a resume under different
choices no longer continues the wrong graph. (#1089, @bossjoker1, @Ghraven)
- **Crypto sentiment sources resolve.** StockTwits lists crypto as `<BASE>.X`
(Yahoo's `BTC-USD` 404s) and Reddit needs the base symbol to match; the social
path now maps crypto correctly for both. (#1113, @suremadoreai)
### Added
- **Configurable LLM retry budget.** `llm_max_retries` /
`TRADINGAGENTS_LLM_MAX_RETRIES` is forwarded to every provider, so a transient
429 burst no longer aborts a run. (#1091, @yanggaome)
- **Bedrock API-key auth.** `AWS_BEARER_TOKEN_BEDROCK` authenticates Amazon
Bedrock without AWS access keys and takes precedence over an ambient
`AWS_PROFILE`. (#1103, @praxstack)
- **Latest Claude models.** Added Claude Sonnet 5 (`claude-sonnet-5`) and
Fable 5 (`claude-fable-5`); effort control now covers the Claude 5 line.
## [0.3.0] — 2026-06-22
Stabilization and extensibility release: a CI gate, a unified verified
data-access contract, a provider and data-vendor registry, and a maintenance
sweep that hardened config precedence, the model catalog, data resilience, and
structured output.
### Added
- **CI gate.** GitHub Actions runs the pytest suite across Python 3.10-3.13,
strict `ruff`, and a clean-install smoke that imports the package and CLI to
catch undeclared dependencies. (#994, #197)
- **Provider registry.** OpenAI-compatible providers register as a single spec,
and a generic `openai_compatible` endpoint covers vLLM, LM Studio, and relays.
Adds NVIDIA NIM, Kimi, Groq, Mistral, and a native Amazon Bedrock client.
- **Macro and prediction-market vendors.** FRED macro indicators and Polymarket
event probabilities, surfaced to the news and macro analysts.
- **Programmatic report output.** `TradingAgentsGraph.save_reports()` writes the
same report tree the CLI produces, for headless and API runs. (#1037)
- **Env-configurable reasoning depth** via `TRADINGAGENTS_OPENAI_REASONING_EFFORT`,
`TRADINGAGENTS_GOOGLE_THINKING_LEVEL`, and `TRADINGAGENTS_ANTHROPIC_EFFORT`,
each gated to the models that accept it.
### Changed
- **Verified data-access contract.** Symbol normalization on every vendor path
(identity, returns, CLI, news); the configured vendor list is the exact
resolution chain with no silent fallback to unselected vendors; a typed
`VendorError` taxonomy; look-ahead-safe news windows; stale-OHLCV rejection;
inclusive yfinance date ranges.
- **Config precedence.** An explicit `TRADINGAGENTS_*` value or CLI flag now wins
over interactive defaults for debate and risk round counts,
`--checkpoint / --no-checkpoint`, and the Docker provider profile; invalid
boolean env values fail loudly. (#975, #976, #977)
- **Current-generation model catalog.** Refreshed provider lineups; retired
`gpt-4.1`, Claude Sonnet 4.5, and the Gemini 2.5 line.
- **Optional vendors degrade** instead of aborting a run: a failed macro or
prediction-market lookup returns a no-data sentinel.
- **Analyst prompts lead with the current date** so tool-call date ranges anchor
to the run date rather than the model's training cutoff. (#836)
### Fixed
- **Instrument identity.** Deterministic ticker-to-company resolution prevents
wrong-company hallucination, and a verified market-data snapshot grounds price
and indicator claims. (#814, #830)
- **Social and market data sources.** Reddit RSS-first with 429 backoff,
StockTwits transport hardening, and Alpha Vantage timeout plus
key-versus-rate-limit handling.
- **Structured output.** Local OpenAI-compatible servers no longer reject
object-form `tool_choice`; a thinking model that returns no parsed result falls
back to free text; null-ish strings in optional price fields coerce to `None`.
(#1038, #1051, #1057)
### Removed
- The no-op `analyst_concurrency_limit` config knob; parallel analyst execution
is planned for a later release. (#979)
- The unused committed `uv.lock`. (#1030)
### Contributors
Thanks to everyone who shaped this release through code, design, and reports:
[@CadeYu](https://github.com/CadeYu), [@Zavianx](https://github.com/Zavianx), [@weijianz-opc](https://github.com/weijianz-opc), [@naltun](https://github.com/naltun), [@brahmasky](https://github.com/brahmasky), [@nik2208](https://github.com/nik2208), [@thieucong98](https://github.com/thieucong98), [@Derekko-web](https://github.com/Derekko-web), [@LukiPrince](https://github.com/LukiPrince), [@Eddieargenal](https://github.com/Eddieargenal), [@Ghraven](https://github.com/Ghraven), [@ms32035](https://github.com/ms32035), [@yting27](https://github.com/yting27), [@nyxst4ck](https://github.com/nyxst4ck), [@KenCheung-AIxFinance](https://github.com/KenCheung-AIxFinance), [@yangyusheng2n](https://github.com/yangyusheng2n), [@fareloj](https://github.com/fareloj), [@haosenwang1018](https://github.com/haosenwang1018), [@octo-patch](https://github.com/octo-patch), [@seifenk](https://github.com/seifenk), [@CaoYuhaoCarl](https://github.com/CaoYuhaoCarl), [@mihailnica10](https://github.com/mihailnica10), [@Dado-hash](https://github.com/Dado-hash), [@Handsomemikezzz](https://github.com/Handsomemikezzz), [@ydhawesome](https://github.com/ydhawesome), [@macd2](https://github.com/macd2), [@AyushKar2005](https://github.com/AyushKar2005), [@wildhuman](https://github.com/wildhuman), [@robert23kim](https://github.com/robert23kim), [@bngness](https://github.com/bngness), [@tedix-rodrigo](https://github.com/tedix-rodrigo), [@malaccan](https://github.com/malaccan), [@rfalken78](https://github.com/rfalken78), [@dengli1971-droid](https://github.com/dengli1971-droid), [@proofconcept39](https://github.com/proofconcept39), [@prasta1](https://github.com/prasta1), [@liximin](https://github.com/liximin), [@jeffhuen](https://github.com/jeffhuen), [@mazar](https://github.com/mazar), [@soyangelromero](https://github.com/soyangelromero), [@CNQQC](https://github.com/CNQQC), [@dovetaill](https://github.com/dovetaill), [@fperdigon](https://github.com/fperdigon), [@gyx09212214-prog](https://github.com/gyx09212214-prog), [@RSXLX](https://github.com/RSXLX).
## [0.2.5] — 2026-05-11
### Added
- **Grounded Sentiment Analyst.** The renamed `sentiment_analyst` now reads
real Yahoo News, StockTwits, and Reddit data before generating its report,
replacing the prior flow that could fabricate social posts under prompt
pressure. (#557, #607)
- **MiniMax provider** with the full M2.x catalog (M2.7 / M2.5 / M2.1 / M2
plus highspeed variants, 204K context). Dual-region: Global
(`MINIMAX_API_KEY`) and China (`MINIMAX_CN_API_KEY`).
- **Dual-region Qwen and GLM** with separate keys per region — international
(`DASHSCOPE_API_KEY`, `ZHIPU_API_KEY`) and China (`DASHSCOPE_CN_API_KEY`,
`ZHIPU_CN_API_KEY`), selectable via a secondary region prompt. (#758)
- **`TRADINGAGENTS_*` env-var configurability for `DEFAULT_CONFIG`.** Override
`llm_provider`, deep/quick model IDs, `backend_url`, `output_language`,
debate-round counts, checkpoint flag, and benchmark ticker via `.env` with
type-aware coercion (string / int / bool). (#602)
- **Interactive API-key detection in the CLI.** When the selected provider's
key is missing, the CLI prompts for it and persists the value to `.env`
so the analysis run continues without restart.
- **Remote Ollama support.** `OLLAMA_BASE_URL` points the CLI and the
programmatic client at a remote `ollama-serve`. The CLI surfaces the
resolved endpoint and warns on common malformed inputs. Adds a
`"Custom model ID"` option for models pulled via `ollama pull`. (#648, #768)
- **Configurable news-fetch parameters** in `DEFAULT_CONFIG` — per-ticker
article limit, macro headline limit, lookback window, and macro search
queries. (#606, #683)
- **Configurable alpha benchmark** for non-US tickers. Replaces hardcoded
SPY with regional indices for `.NS` (^NSEI), `.T` (^N225), `.HK` (^HSI),
`.L` (^FTSE), `.TO` (^GSPTSE), `.AX` (^AXJO), `.BO` (^BSESN); explicit
`benchmark_ticker` override available. Eliminates FX drift dominating
alpha for non-USD listings. (#628, #684)
- **Multi-language output covers every user-facing agent** — researchers,
risk debators, research manager, and trader, ending the previous
partial-localization reports. (#575)
- **Model catalog refresh.** OpenAI GPT-5.5 frontier, Anthropic Claude Opus
4.7, Gemini 3.1 Flash-Lite GA, xAI Grok 4.20, Qwen 3.6 line. Versioned IDs
only; auto-shifting aliases moved to the `"Custom model ID"` option.
### Changed
- **Sentiment Analyst** is now consistently named across the CLI dropdown,
status panel, and final reports (previously the backend was renamed but
the CLI still said "Social Analyst"). The `AnalystType.SOCIAL = "social"`
wire value is kept for saved-config back-compat.
### Fixed
- **Structured output works on DeepSeek V4 / reasoner and MiniMax M2.x.**
Those providers reject `tool_choice` per their tool-calling docs; the
binding flow now skips it automatically via a capability table.
- **`pip install .` installations pick up the project `.env`** when running
the CLI as a console script. (#747)
- **Reports save end-to-end** — streamed chunks were previously dropped from
`complete_report.md`. (#719, #736)
- **Ticker prompt preserves exchange suffixes** (`.SH`, `.SZ`, `.SS`, `.HK`,
`.T`, etc.) for A-share, HK, Tokyo, and other non-US flows. (#770)
- **Docker permission errors** no longer block first-run write to
`~/.tradingagents/`. (#519, #627, #672, #771)
- **Config state no longer leaks between runs** when sub-dicts are mutated;
`set_config` partial updates preserve sibling defaults. (#788)
- **`max_recur_limit` config actually applies** — previously read but not
forwarded to the propagator. (#764)
- **Missing-API-key error** names the exact env var to set. (#680)
- **Quieter startup** — suppressed the noisy upstream
`LangChainPendingDeprecationWarning` from langgraph-checkpoint; will be
removed once that package ships its fix.
### Security
- **Ticker path-traversal validation** at every filesystem-path site (cache,
checkpoint database, results) so a malicious ticker cannot escape its
intended directory. (#618)
## [0.2.4] — 2026-04-25
### Added
- **Structured-output decision agents.** Research Manager, Trader, and Portfolio
Manager now use `llm.with_structured_output(Schema)` on their primary call
and return typed Pydantic instances. Each provider's native structured-output
mode is used (`json_schema` for OpenAI / xAI, `response_schema` for Gemini,
tool-use for Anthropic, function-calling for OpenAI-compatible providers).
Render helpers preserve the existing markdown shape so memory log, CLI
display, and saved reports keep working unchanged. (#434)
- **LangGraph checkpoint resume** — opt-in via `--checkpoint`. State is saved
after each node so crashed or interrupted runs resume from the last
successful step. Per-ticker SQLite databases under
`~/.tradingagents/cache/checkpoints/`. `--clear-checkpoints` resets them. (#594)
- **Persistent decision log** replacing the per-agent BM25 memory. Decisions
are stored automatically at the end of `propagate()`; the next same-ticker
run resolves prior pending entries with realised return, alpha vs SPY, and
a one-paragraph reflection. Override path with `TRADINGAGENTS_MEMORY_LOG_PATH`.
Optional `memory_log_max_entries` config caps resolved entries; pending
entries are never pruned. (#578, #563, #564, #579)
- **DeepSeek, Qwen (Alibaba DashScope), GLM (Zhipu), and Azure OpenAI**
providers, plus dynamic OpenRouter model selection.
- **Docker support** — multi-stage build with separate dev and runtime images.
- **`scripts/smoke_structured_output.py`** — diagnostic that exercises the
three structured-output agents against any provider so contributors can
verify their setup with one command.
- **5-tier rating scale** (Buy / Overweight / Hold / Underweight / Sell) used
consistently by Research Manager, Portfolio Manager, signal processor, and
the memory log; Trader keeps 3-tier (Buy / Hold / Sell) since transaction
direction is naturally ternary.
- **Pytest fixtures** — lazy LLM client imports plus placeholder API keys so
the test suite runs cleanly without credentials. (#588)
### Changed
- **`backend_url` default is now `None`** rather than the OpenAI URL. Each
provider client falls back to its native default. The previous default
leaked the OpenAI URL into non-OpenAI clients (e.g. Gemini), producing
malformed request URLs for Python users who switched providers without
overriding `backend_url`. The CLI flow is unaffected.
- All file I/O passes explicit `encoding="utf-8"` so Windows users no longer
hit `UnicodeEncodeError` with the cp1252 default. (#543, #550, #576)
- Cache and log directories moved to `~/.tradingagents/` to resolve Docker
permission issues. (#519)
- `SignalProcessor` reads the rating from the Portfolio Manager's rendered
markdown via a deterministic heuristic — no extra LLM call.
- OpenAI structured-output calls default to `method="function_calling"` to
avoid noisy `PydanticSerializationUnexpectedValue` warnings emitted by
langchain-openai's Responses-API parse path. Same typed result, no warnings.
### Fixed
- Empty memory no longer triggers fabricated past-lessons in agent prompts;
the memory-log redesign makes this structurally impossible since only the
Portfolio Manager consults memory and only when entries exist. (#572)
- Tool-call logging processes every chunk message, not just the last one, and
memory score normalization handles empty score arrays. (#534, #531)
### Removed
- `FinancialSituationMemory` (the per-agent BM25 system) and the dead
`reflect_and_remember()` plumbing; subsumed by the persistent decision log.
- Hardcoded Google endpoint that caused 404 when `langchain-google-genai`
changed its API path. (#493, #496)
### Contributors
Thanks to everyone who shaped this release through code, design, and reports:
- [@claytonbrown](https://github.com/claytonbrown) — checkpoint resume (#594), test fixtures (#588), design feedback on cost tracking (#582) and structured validation (#583)
- [@Bcardo](https://github.com/Bcardo) — memory-log redesign (#579), empty-memory hallucination report (#572), encoding fix proposal (#570)
- [@voidborne-d](https://github.com/voidborne-d) — memory persistence design (#564), portfolio manager state fix (#503)
- [@mannubaveja007](https://github.com/mannubaveja007) — structured-output feature request (#434)
- [@kelder66](https://github.com/kelder66) — RAM-only memory issue (#563)
- [@Gujiassh](https://github.com/Gujiassh) — tool-call logging fix (#534), test stub PR (#533)
- [@iuyup](https://github.com/iuyup) — memory score normalization fix (#531)
- [@kaihg](https://github.com/kaihg) — Google base_url fix (#496)
- [@32ryh98yfe](https://github.com/32ryh98yfe) — Gemini 404 report (#493)
- [@uppb](https://github.com/uppb) — OpenRouter dynamic model selection (#482)
- [@guoz14](https://github.com/guoz14) — OpenRouter limited-model report (#337)
- [@samchenku](https://github.com/samchenku) — indicator name normalization (#490)
- [@JasonOA888](https://github.com/JasonOA888) — y_finance pandas import fix (#488)
- [@tiffanychum](https://github.com/tiffanychum) — stale import cleanup (#499)
- [@zaizou](https://github.com/zaizou) — Docker permission issue (#519)
- [@Stosman123](https://github.com/Stosman123), [@mauropuga](https://github.com/mauropuga), [@hotwind2015](https://github.com/hotwind2015) — Windows encoding bug reports (#543, #550, #576)
- [@nnishad](https://github.com/nnishad), [@atharvajoshi01](https://github.com/atharvajoshi01) — encoding fix proposals (#568, #549)
## [0.2.3] — 2026-03-29
### Added
- **Multi-language output** for analyst reports and final decisions, with a
CLI selector. Internal agent debate stays in English for reasoning quality. (#472)
- **GPT-5.4 family models** in the default catalog, with deep/quick model split.
- **Unified model catalog** as a single source of truth for CLI options and
provider validation.
### Changed
- `base_url` is forwarded to Google and Anthropic clients so corporate proxies
work consistently across providers. (#427)
- Standardised the Google `api_key` parameter to the unified `api_key` form.
### Fixed
- Backtesting fetchers no longer leak look-ahead data when `curr_date` is in
the middle of a fetched window. (#475)
- Invalid indicator names from the LLM are caught at the tool boundary instead
of crashing the run. (#429)
- yfinance news fetchers respect the same exponential-backoff retry as price
fetchers. (#445)
### Contributors
- [@ahmedk20](https://github.com/ahmedk20) — multi-language output (#472)
- [@CadeYu](https://github.com/CadeYu) — model catalog typing (#464)
- [@javierdejesusda](https://github.com/javierdejesusda) — unified Google API key parameter (#453)
- [@voidborne-d](https://github.com/voidborne-d) — yfinance news retry (#445)
- [@kostakost2](https://github.com/kostakost2) — look-ahead bias report (#475)
- [@lu-zhengda](https://github.com/lu-zhengda) — proxy/base_url support request (#427)
- [@VamsiKrishna2021](https://github.com/VamsiKrishna2021) — invalid indicator crash report (#429)
## [0.2.2] — 2026-03-22
### Added
- **Five-tier rating scale** (Buy / Overweight / Hold / Underweight / Sell)
introduced for the Portfolio Manager.
- **Anthropic effort level** support for Claude models.
- **OpenAI Responses API** path for native OpenAI models.
### Changed
- `risk_manager` renamed to `portfolio_manager` to match the role description
shown in the CLI display.
- Exchange-qualified tickers (e.g. `7203.T`, `BRK.B`) preserved across all
agent prompts and tool calls.
- Process-level UTF-8 default attempted for cross-platform consistency
(note: this approach did not actually take effect; replaced in v0.2.4 with
explicit per-call `encoding="utf-8"` arguments).
### Fixed
- yfinance rate-limit errors are retried with exponential backoff. (#426)
- HTTP client SSL customisation is supported for environments that need
custom certificate bundles. (#379)
- Report-section writes handle list-of-string content gracefully.
### Contributors
- [@CadeYu](https://github.com/CadeYu) — exchange-qualified ticker preservation (#413)
- [@yang1002378395-cmyk](https://github.com/yang1002378395-cmyk) — HTTP client SSL customisation (#379)
## [0.2.1] — 2026-03-15
### Security
- Patched `langchain-core` vulnerability (LangGrinch). (#335)
- Removed `chainlit` dependency affected by CVE-2026-22218.
### Added
- `pyproject.toml` build-system configuration; the project now installs via
modern packaging tooling.
### Removed
- `setup.py` — dependencies consolidated to `pyproject.toml`.
### Fixed
- Risk manager reads the correct fundamental report source. (#341)
- All `open()` calls receive an explicit UTF-8 encoding (initial pass).
- `get_indicators` tool handles comma-separated indicator names from the LLM. (#368)
- `Propagation` initialises every debate-state field so risk debaters never
see missing keys.
- Stock data parsing tolerates malformed CSVs and NaN values.
- Conditional debate logic respects the configured round count. (#361)
### Contributors
- [@RinZ27](https://github.com/RinZ27) — `langchain-core` security patch (#335)
- [@Ljx-007](https://github.com/Ljx-007) — risk manager fundamental-report fix (#341)
- [@makk9](https://github.com/makk9) — debate-rounds config issue (#361)
## [0.2.0] — 2026-02-04
This is the largest release since the initial public version. The framework
moved from single-provider to a multi-provider architecture and grew several
production-ready surfaces.
### Added
- **Multi-provider LLM support** (OpenAI, Google, Anthropic, xAI, OpenRouter,
Ollama) via a factory pattern, with provider-specific thinking configurations.
- **Alpha Vantage** integration as a configurable primary data provider, with
yfinance as a community-stability fallback.
- **Footer statistics** in the CLI: real-time tracking of LLM calls, tool
calls, and token usage via LangChain callbacks.
- **Post-analysis report saving** — the framework writes per-section markdown
files (analyst reports, debate transcripts, final decision) when a run
completes.
- **Announcements panel** — fetches updates from `api.tauric.ai/v1/announcements`
for the CLI welcome screen.
- **Tool fallbacks** so a single vendor outage does not stop the pipeline.
### Changed
- Risky / Safe risk debaters renamed to **Aggressive / Conservative** for
consistency with the displayed agent labels.
- Default data vendor switched to balance reliability and quota across
community deployments.
- Ollama and OpenRouter model lists updated; default endpoints clarified.
### Fixed
- Analyst status tracking and message deduplication in the live display.
- Infinite-loop guard in the agent loop; reflection and logging hardened.
- Various data-vendor implementation bugs and tool-signature mismatches.
### Contributors
This release is the first with substantial outside contributions; many community
PRs from late 2025 also landed here.
- [@luohy15](https://github.com/luohy15) — Alpha Vantage data-vendor integration (#235)
- [@EdwardoSunny](https://github.com/EdwardoSunny) — yfinance fetching optimisations (#245)
- [@Mirza-Samad-Ahmed-Baig](https://github.com/Mirza-Samad-Ahmed-Baig) — infinite-loop guard, reflection, and logging fixes (#89)
- [@ZeroAct](https://github.com/ZeroAct) — saved results path support (#29)
- [@Zhongyi-Lu](https://github.com/Zhongyi-Lu) — `.env` gitignore (#49)
- [@csoboy](https://github.com/csoboy) — local Ollama setup (#53)
- [@chauhang](https://github.com/chauhang) — initial Docker support attempt (#47, later reverted; the merged Docker support shipped in v0.2.4)
## [0.1.1] — 2025-06-07
### Removed
- Static site assets that had been bundled with v0.1.0; the public site now
lives separately.
## [0.1.0] — 2025-06-05
### Added
- **Initial public release** of the TradingAgents multi-agent trading
framework: market / sentiment / news / fundamentals analysts; bull and bear
researchers; trader; aggressive, conservative, and neutral risk debaters;
portfolio manager. LangGraph orchestration, yfinance data, per-agent
BM25 memory, single-provider OpenAI integration, interactive CLI.
[0.2.4]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.3...v0.2.4
[0.2.3]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/TauricResearch/TradingAgents/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/TauricResearch/TradingAgents/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/TauricResearch/TradingAgents/releases/tag/v0.1.0

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /build
COPY . .
RUN pip install --no-cache-dir .
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN useradd --create-home appuser \
&& install -d -m 0755 -o appuser -g appuser /home/appuser/.tradingagents
USER appuser
WORKDIR /home/appuser/app
COPY --from=builder --chown=appuser:appuser /build .
ENTRYPOINT ["tradingagents"]

189
README.md
View File

@@ -5,19 +5,40 @@
<div align="center" style="line-height: 1;"> <div align="center" style="line-height: 1;">
<a href="https://arxiv.org/abs/2412.20138" target="_blank"><img alt="arXiv" src="https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv"/></a> <a href="https://arxiv.org/abs/2412.20138" target="_blank"><img alt="arXiv" src="https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv"/></a>
<a href="https://discord.com/invite/hk9PGKShPK" target="_blank"><img alt="Discord" src="https://img.shields.io/badge/Discord-TradingResearch-7289da?logo=discord&logoColor=white&color=7289da"/></a> <a href="https://discord.com/invite/hk9PGKShPK" target="_blank"><img alt="Discord" src="https://img.shields.io/badge/Discord-TradingResearch-7289da?logo=discord&logoColor=white&color=7289da"/></a>
<a href="./assets/wechat.png" target="_blank"><img alt="WeChat" src="https://img.shields.io/badge/WeChat-TauricResearch-brightgreen?logo=wechat&logoColor=white"/></a>
<a href="https://x.com/TauricResearch" target="_blank"><img alt="X Follow" src="https://img.shields.io/badge/X-TauricResearch-white?logo=x&logoColor=white"/></a> <a href="https://x.com/TauricResearch" target="_blank"><img alt="X Follow" src="https://img.shields.io/badge/X-TauricResearch-white?logo=x&logoColor=white"/></a>
<br> <a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/GitHub_Community-TauricResearch-14C290?logo=discourse"/></a>
<a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/Join_GitHub_Community-TauricResearch-14C290?logo=discourse"/></a> </div>
<br>
<div align="center">
<a href="https://github.com/TauricResearch" target="_blank"><img alt="TradingAgents #1 Repository of the Day" src="https://trendshift.io/api/badge/repositories/16192" width="250" height="55"/></a>
</div>
<br>
<div align="center">
<!-- Keep these links. Translations will automatically update with the README. -->
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=de">Deutsch</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=es">Español</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=fr">français</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=ja">日本語</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=ko">한국어</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=zh">中文</a>
</div> </div>
--- ---
# TradingAgents: Multi-Agents LLM Financial Trading Framework # TradingAgents: Multi-Agents LLM Financial Trading Framework
> 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community. ## News
> - [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. See [CHANGELOG.md](CHANGELOG.md) for the full list.
> So we decided to fully open-source the framework. Looking forward to building impactful projects with you! - [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support.
- [2026-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate.
- [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening.
- [2026-04] **TradingAgents v0.2.4** released with structured-output agents (Research Manager, Trader, Portfolio Manager), LangGraph checkpoint resume, persistent decision log, DeepSeek/Qwen/GLM/Azure provider support, Docker, and a Windows UTF-8 encoding fix.
- [2026-03] **TradingAgents v0.2.3** released with multi-language support, GPT-5.4 family models, unified model catalog, backtesting date fidelity, and proxy support.
- [2026-03] **TradingAgents v0.2.2** released with GPT-5.4/Gemini 3.1/Claude 4.6 model coverage, five-tier rating scale, OpenAI Responses API, Anthropic effort control, and cross-platform stability.
- [2026-02] **TradingAgents v0.2.0** released with multi-provider LLM support (GPT-5.x, Gemini 3.x, Claude 4.x, Grok 4.x) and improved system architecture.
- [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon.
<div align="center"> <div align="center">
@@ -25,6 +46,10 @@
</div> </div>
> 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community.
>
> So we decided to fully open-source the framework. Looking forward to building impactful projects with you!
## TradingAgents Framework ## TradingAgents Framework
TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy. TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy.
@@ -35,11 +60,11 @@ TradingAgents is a multi-agent trading framework that mirrors the dynamics of re
> TradingAgents framework is designed for research purposes. Trading performance may vary based on many factors, including the chosen backbone language models, model temperature, trading periods, the quality of data, and other non-deterministic factors. [It is not intended as financial, investment, or trading advice.](https://tauric.ai/disclaimer/) > TradingAgents framework is designed for research purposes. Trading performance may vary based on many factors, including the chosen backbone language models, model temperature, trading periods, the quality of data, and other non-deterministic factors. [It is not intended as financial, investment, or trading advice.](https://tauric.ai/disclaimer/)
Our framework decomposes complex trading tasks into specialized roles. This ensures the system achieves a robust, scalable approach to market analysis and decision-making. Our framework decomposes complex trading tasks into specialized roles.
### Analyst Team ### Analyst Team
- Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags. - Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags.
- Sentiment Analyst: Analyzes social media and public sentiment using sentiment scoring algorithms to gauge short-term market mood. - Sentiment Analyst: Aggregates news headlines, StockTwits, and Reddit chatter into a single sentiment read to gauge short-term market mood.
- News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions. - News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions.
- Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements. - Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements.
@@ -55,10 +80,10 @@ Our framework decomposes complex trading tasks into specialized roles. This ensu
</p> </p>
### Trader Agent ### Trader Agent
- Composes reports from the analysts and researchers to make informed trading decisions. It determines the timing and magnitude of trades based on comprehensive market insights. - Composes reports from the analysts and researchers to make informed trading decisions, determining the timing and magnitude of trades.
<p align="center"> <p align="center">
<img src="assets/risk.png" width="70%" style="display: inline-block; margin: 0 2%;"> <img src="assets/trader.png" width="70%" style="display: inline-block; margin: 0 2%;">
</p> </p>
### Risk Management and Portfolio Manager ### Risk Management and Portfolio Manager
@@ -66,7 +91,7 @@ Our framework decomposes complex trading tasks into specialized roles. This ensu
- The Portfolio Manager approves/rejects the transaction proposal. If approved, the order will be sent to the simulated exchange and executed. - The Portfolio Manager approves/rejects the transaction proposal. If approved, the order will be sent to the simulated exchange and executed.
<p align="center"> <p align="center">
<img src="assets/trader.png" width="70%" style="display: inline-block; margin: 0 2%;"> <img src="assets/risk.png" width="70%" style="display: inline-block; margin: 0 2%;">
</p> </p>
## Installation and CLI ## Installation and CLI
@@ -81,34 +106,79 @@ cd TradingAgents
Create a virtual environment in any of your favorite environment managers: Create a virtual environment in any of your favorite environment managers:
```bash ```bash
conda create -n tradingagents python=3.13 conda create -n tradingagents python=3.12
conda activate tradingagents conda activate tradingagents
``` ```
Install dependencies: Install the package and its dependencies:
```bash ```bash
pip install -r requirements.txt pip install .
```
### Docker
Alternatively, run with Docker:
```bash
cp .env.example .env # add your API keys
docker compose run --rm tradingagents
```
For local models with Ollama:
```bash
docker compose --profile ollama run --rm tradingagents-ollama
``` ```
### Required APIs ### Required APIs
You will also need the FinnHub API and EODHD API for financial data. All of our code is implemented with the free tier. TradingAgents supports multiple LLM providers. Set the API key for your chosen provider:
```bash ```bash
export FINNHUB_API_KEY=$YOUR_FINNHUB_API_KEY export OPENAI_API_KEY=... # OpenAI (GPT)
export GOOGLE_API_KEY=... # Google (Gemini)
export ANTHROPIC_API_KEY=... # Anthropic (Claude)
export XAI_API_KEY=... # xAI (Grok)
export DEEPSEEK_API_KEY=... # DeepSeek
export DASHSCOPE_API_KEY=... # Qwen — International (dashscope-intl.aliyuncs.com)
export DASHSCOPE_CN_API_KEY=... # Qwen — China (dashscope.aliyuncs.com)
export ZHIPU_API_KEY=... # GLM via Z.AI (international)
export ZHIPU_CN_API_KEY=... # GLM via BigModel (China, open.bigmodel.cn)
export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io)
export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com)
export OPENROUTER_API_KEY=... # OpenRouter
export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
``` ```
You will need the OpenAI API for all the agents. For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials.
For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-4-8-v1:0`.
For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull <name>`, and pick "Custom model ID" in the CLI for any model not listed by default.
For any other OpenAI-compatible server (vLLM, LM Studio, llama.cpp, or a custom relay), use `llm_provider: "openai_compatible"` and set the endpoint via `backend_url` (or `TRADINGAGENTS_LLM_BACKEND_URL`), e.g. `http://localhost:8000/v1` for vLLM or `http://localhost:1234/v1` for LM Studio. The model is whatever your server serves. No key is needed for local servers; set `OPENAI_COMPATIBLE_API_KEY` when the endpoint requires one.
Alternatively, copy `.env.example` to `.env` and fill in your keys:
```bash ```bash
export OPENAI_API_KEY=$YOUR_OPENAI_API_KEY cp .env.example .env
``` ```
### CLI Usage ### CLI Usage
You can also try out the CLI directly by running: Launch the interactive CLI:
```bash ```bash
python -m cli.main tradingagents # installed command
python -m cli.main # alternative: run directly from source
``` ```
You will see a screen where you can select your desired tickers, date, LLMs, research depth, etc. You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more.
### Markets and tickers
TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market.
- US: `AAPL`, `SPY`
- Hong Kong: `0700.HK` · Tokyo: `7203.T` · London: `AZN.L`
- India: `RELIANCE.NS`, `.BO` · Canada: `.TO` · Australia: `.AX`
- China A-shares: Shanghai `.SS`, Shenzhen `.SZ` (e.g. `600519.SS` for Kweichow Moutai)
- Crypto: `BTC-USD`, `ETH-USD`
<p align="center"> <p align="center">
<img src="assets/cli/cli_init.png" width="100%" style="display: inline-block; margin: 0 2%;"> <img src="assets/cli/cli_init.png" width="100%" style="display: inline-block; margin: 0 2%;">
@@ -128,7 +198,7 @@ An interface will appear showing results as they load, letting you track the age
### Implementation Details ### Implementation Details
We built TradingAgents with LangGraph to ensure flexibility and modularity. We utilize `o1-preview` and `gpt-4o` as our deep thinking and fast thinking LLMs for our experiments. However, for testing purposes, we recommend you use `o4-mini` and `gpt-4.1-mini` to save on costs as our framework makes **lots of** API calls. We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise.
### Python Usage ### Python Usage
@@ -136,11 +206,12 @@ To use TradingAgents inside your code, you can import the `tradingagents` module
```python ```python
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
ta = TradingAgentsGraph(debug=True, config=config) ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy())
# forward propagate # forward propagate
_, decision = ta.propagate("NVDA", "2024-05-10") _, decision = ta.propagate("NVDA", "2026-01-15")
print(decision) print(decision)
``` ```
@@ -150,28 +221,72 @@ You can also adjust the default configuration to set your own choice of LLMs, de
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
# Create a custom config
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
config["deep_think_llm"] = "gpt-4.1-nano" # Use a different model config["llm_provider"] = "openai" # e.g. openai, google, anthropic, deepseek, groq, ollama; openai_compatible covers any OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp, ...)
config["quick_think_llm"] = "gpt-4.1-nano" # Use a different model config["deep_think_llm"] = "gpt-5.6" # Model for complex reasoning
config["max_debate_rounds"] = 1 # Increase debate rounds config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks
config["online_tools"] = True # Use online tools or cached data config["max_debate_rounds"] = 2
# Initialize with custom config
ta = TradingAgentsGraph(debug=True, config=config) ta = TradingAgentsGraph(debug=True, config=config)
_, decision = ta.propagate("NVDA", "2026-01-15")
# forward propagate
_, decision = ta.propagate("NVDA", "2024-05-10")
print(decision) print(decision)
``` ```
> For `online_tools`, we recommend enabling them for experimentation, as they provide access to real-time data. The agents' offline tools rely on cached data from our **Tauric TradingDB**, a curated dataset we use for backtesting. We're currently in the process of refining this dataset, and we plan to release it soon alongside our upcoming projects. Stay tuned! See `tradingagents/default_config.py` for all configuration options.
You can view the full list of configurations in `tradingagents/default_config.py`. ## Persistence and Recovery
TradingAgents persists two kinds of state across runs.
### Decision log
The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw and alpha vs SPY), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't.
Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`.
### Checkpoint resume
Checkpoint resume is opt-in via `--checkpoint`. When enabled, LangGraph saves state after each node so a crashed or interrupted run resumes from the last successful step instead of starting over. On a resume run you will see `Resuming from step N for <TICKER> on <date>` in the logs; on a new run you will see `Starting fresh`. Checkpoints are cleared automatically on successful completion.
Per-ticker SQLite databases live at `~/.tradingagents/cache/checkpoints/<TICKER>.db` (override the base with `TRADINGAGENTS_CACHE_DIR`). Use `--clear-checkpoints` to reset all of them before a run.
```bash
tradingagents analyze --checkpoint # enable for this run
tradingagents analyze --clear-checkpoints # reset before running
```
```python
config = DEFAULT_CONFIG.copy()
config["checkpoint_enabled"] = True
ta = TradingAgentsGraph(config=config)
_, decision = ta.propagate("NVDA", "2026-01-15")
```
## Reproducibility
TradingAgents is LLM-driven, so two runs of the same ticker and date can differ. This is expected for a research tool built on language models, not a defect. The variation comes from a few distinct sources, and it helps to separate them.
Language model sampling is non-deterministic. Even at a fixed temperature, providers do not guarantee byte-identical output across calls, and reasoning models (the default GPT-5.x family, and any thinking-mode model) vary the most because their internal reasoning is itself sampled.
Live data moves. News, StockTwits, and Reddit return different content as time passes, so a run today sees different inputs than a run last week even for the same historical trade date. Pin the analysis date to hold the price and indicator window fixed, but the social and news sources still reflect "now".
To reduce variation you can lower the sampling temperature. Set `temperature` in your config (or `TRADINGAGENTS_TEMPERATURE` in `.env`); lower values make models that honor it more repeatable. The current curated models are reasoning-first and largely ignore temperature, so for tighter reproducibility use a non-reasoning model, which you can set explicitly via the Custom model ID option.
```python
config = DEFAULT_CONFIG.copy()
config["llm_provider"] = "openai"
config["temperature"] = 0.0
# Reasoning models ignore temperature. For tighter reproducibility, set a
# non-reasoning deep/quick model explicitly (e.g. via the Custom model ID option).
```
What does not vary anymore: the analyzed company identity is resolved deterministically from the ticker before any agent runs, and the market analyst grounds exact price and indicator claims in a verified data snapshot. Earlier reports of "different companies" or fabricated price levels across runs are addressed by these two mechanisms.
Backtest results are not guaranteed to match any published figure. Returns depend on the model, the temperature, the date range, data quality, and the sampling above. Treat the framework as a research scaffold for studying multi-agent analysis, not as a strategy with a fixed, replicable return.
## Contributing ## Contributing
We welcome contributions from the community! Whether it's fixing a bug, improving documentation, or suggesting a new feature, your input helps make this project better. If you are interested in this line of research, please consider joining our open-source financial AI research community [Tauric Research](https://tauric.ai/). Contributions are welcome: bug fixes, documentation, and feature ideas; past contributions are credited per release in [`CHANGELOG.md`](CHANGELOG.md).
## Citation ## Citation

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

52
cli/announcements.py Normal file
View File

@@ -0,0 +1,52 @@
import getpass
import requests
from rich.console import Console
from rich.panel import Panel
from cli.config import CLI_CONFIG
def fetch_announcements(url: str = None, timeout: float = None) -> dict:
"""Fetch announcements from endpoint. Returns dict with announcements and settings."""
endpoint = url or CLI_CONFIG["announcements_url"]
timeout = timeout or CLI_CONFIG["announcements_timeout"]
fallback = CLI_CONFIG["announcements_fallback"]
try:
response = requests.get(endpoint, timeout=timeout)
response.raise_for_status()
data = response.json()
return {
"announcements": data.get("announcements", [fallback]),
"require_attention": data.get("require_attention", False),
}
except Exception:
return {
"announcements": [fallback],
"require_attention": False,
}
def display_announcements(console: Console, data: dict) -> None:
"""Display announcements panel. Prompts for Enter if require_attention is True."""
announcements = data.get("announcements", [])
require_attention = data.get("require_attention", False)
if not announcements:
return
content = "\n".join(announcements)
panel = Panel(
content,
border_style="cyan",
padding=(1, 2),
title="Announcements",
)
console.print(panel)
if require_attention:
getpass.getpass("Press Enter to continue...")
else:
console.print()

6
cli/config.py Normal file
View File

@@ -0,0 +1,6 @@
CLI_CONFIG = {
# Announcements
"announcements_url": "https://api.tauric.ai/v1/announcements",
"announcements_timeout": 1.0,
"announcements_fallback": "[cyan]For more information, please visit[/cyan] [link=https://github.com/TauricResearch]https://github.com/TauricResearch[/link]",
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
from enum import Enum from enum import Enum
from typing import List, Optional, Dict
from pydantic import BaseModel
class AnalystType(str, Enum): class AnalystType(str, Enum):
MARKET = "market" MARKET = "market"
# Wire value stays "social" for saved-config and string-keyed-caller
# back-compat; the user-facing label is "Sentiment Analyst".
SOCIAL = "social" SOCIAL = "social"
NEWS = "news" NEWS = "news"
FUNDAMENTALS = "fundamentals" FUNDAMENTALS = "fundamentals"
class AssetType(str, Enum):
STOCK = "stock"
CRYPTO = "crypto"

76
cli/stats_handler.py Normal file
View File

@@ -0,0 +1,76 @@
import threading
from typing import Any
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import AIMessage
from langchain_core.outputs import LLMResult
class StatsCallbackHandler(BaseCallbackHandler):
"""Callback handler that tracks LLM calls, tool calls, and token usage."""
def __init__(self) -> None:
super().__init__()
self._lock = threading.Lock()
self.llm_calls = 0
self.tool_calls = 0
self.tokens_in = 0
self.tokens_out = 0
def on_llm_start(
self,
serialized: dict[str, Any],
prompts: list[str],
**kwargs: Any,
) -> None:
"""Increment LLM call counter when an LLM starts."""
with self._lock:
self.llm_calls += 1
def on_chat_model_start(
self,
serialized: dict[str, Any],
messages: list[list[Any]],
**kwargs: Any,
) -> None:
"""Increment LLM call counter when a chat model starts."""
with self._lock:
self.llm_calls += 1
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
"""Extract token usage from LLM response."""
try:
generation = response.generations[0][0]
except (IndexError, TypeError):
return
usage_metadata = None
if hasattr(generation, "message"):
message = generation.message
if isinstance(message, AIMessage) and hasattr(message, "usage_metadata"):
usage_metadata = message.usage_metadata
if usage_metadata:
with self._lock:
self.tokens_in += usage_metadata.get("input_tokens", 0)
self.tokens_out += usage_metadata.get("output_tokens", 0)
def on_tool_start(
self,
serialized: dict[str, Any],
input_str: str,
**kwargs: Any,
) -> None:
"""Increment tool call counter when a tool starts."""
with self._lock:
self.tool_calls += 1
def get_stats(self) -> dict[str, Any]:
"""Return current statistics."""
with self._lock:
return {
"llm_calls": self.llm_calls,
"tool_calls": self.tool_calls,
"tokens_in": self.tokens_in,
"tokens_out": self.tokens_out,
}

View File

@@ -1,21 +1,52 @@
import questionary import os
from typing import List, Optional, Tuple, Dict from pathlib import Path
from cli.models import AnalystType import questionary
from dotenv import find_dotenv, set_key
from rich.console import Console
from cli.models import AnalystType, AssetType
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.model_catalog import get_model_options
console = Console()
TICKER_INPUT_EXAMPLES = "SPY, 0700.HK, BTC-USD"
ANALYST_ORDER = [ ANALYST_ORDER = [
("Market Analyst", AnalystType.MARKET), ("Market Analyst", AnalystType.MARKET),
("Social Media Analyst", AnalystType.SOCIAL), ("Sentiment Analyst", AnalystType.SOCIAL),
("News Analyst", AnalystType.NEWS), ("News Analyst", AnalystType.NEWS),
("Fundamentals Analyst", AnalystType.FUNDAMENTALS), ("Fundamentals Analyst", AnalystType.FUNDAMENTALS),
] ]
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH")
def is_valid_ticker_input(value: str) -> bool:
"""Whether a ticker entry is acceptable (charset + length).
Allows the characters Yahoo symbols use, including ``=`` for futures/forex
like ``GC=F`` and ``EURUSD=X`` (#980), and ``^`` for indices. Empty input is
allowed (it defaults to SPY downstream).
"""
v = value.strip()
return not v or (all(ch.isalnum() or ch in "._-^=" for ch in v) and len(v) <= 32)
def get_ticker() -> str: def get_ticker() -> str:
"""Prompt the user to enter a ticker symbol.""" """Prompt the user to enter a ticker symbol, preserving exchange suffixes.
Uses questionary.text (not typer.prompt, which strips trailing dot-suffixes
like ``000404.SH`` on some shells) and validates the symbol charset so an
obvious typo is caught before the run starts.
"""
ticker = questionary.text( ticker = questionary.text(
"Enter the ticker symbol to analyze:", f"Enter ticker symbol (e.g. {TICKER_INPUT_EXAMPLES}):",
validate=lambda x: len(x.strip()) > 0 or "Please enter a valid ticker symbol.", validate=lambda x: (
is_valid_ticker_input(x)
or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK, GC=F."
),
style=questionary.Style( style=questionary.Style(
[ [
("text", "fg:green"), ("text", "fg:green"),
@@ -24,11 +55,48 @@ def get_ticker() -> str:
), ),
).ask() ).ask()
if not ticker: if ticker is None:
console.print("\n[red]No ticker symbol provided. Exiting...[/red]") console.print("\n[red]No ticker symbol provided. Exiting...[/red]")
exit(1) exit(1)
return ticker.strip().upper() return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY"
def normalize_ticker_symbol(ticker: str) -> str:
"""Resolve user input to its canonical Yahoo symbol (single source of truth).
Delegates to the data layer's ``normalize_symbol`` so the symbol the CLI
passes through the pipeline is exactly the one the data path will price
(e.g. ``BTCUSD`` -> ``BTC-USD``, ``XAUUSD`` -> ``GC=F``). Falls back to the
plain upper-case if the data layer is unavailable.
"""
try:
from tradingagents.dataflows.symbol_utils import normalize_symbol
return normalize_symbol(ticker)
except Exception:
return ticker.strip().upper()
def detect_asset_type(ticker: str) -> AssetType:
"""Classify on the canonical symbol so e.g. BTCUSD and BTC-USDT both read as
crypto (#981/#982), matching what the data path will actually fetch."""
canonical = normalize_ticker_symbol(ticker)
if canonical.endswith(CRYPTO_SUFFIXES):
return AssetType.CRYPTO
return AssetType.STOCK
def filter_analysts_for_asset_type(
analysts: list[AnalystType], asset_type: AssetType
) -> list[AnalystType]:
if asset_type != AssetType.CRYPTO:
return analysts
return [
analyst
for analyst in analysts
if analyst != AnalystType.FUNDAMENTALS
]
def get_analysis_date() -> str: def get_analysis_date() -> str:
@@ -64,12 +132,18 @@ def get_analysis_date() -> str:
return date.strip() return date.strip()
def select_analysts() -> List[AnalystType]: def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType]:
"""Select analysts using an interactive checkbox.""" """Select analysts using an interactive checkbox."""
available_analysts = filter_analysts_for_asset_type(
[value for _, value in ANALYST_ORDER],
asset_type,
)
choices = questionary.checkbox( choices = questionary.checkbox(
"Select Your [Analysts Team]:", "Select Your [Analysts Team]:",
choices=[ choices=[
questionary.Choice(display, value=value) for display, value in ANALYST_ORDER questionary.Choice(display, value=value)
for display, value in ANALYST_ORDER
if value in available_analysts
], ],
instruction="\n- Press Space to select/unselect analysts\n- Press 'a' to select/unselect all\n- Press Enter when done", instruction="\n- Press Space to select/unselect analysts\n- Press 'a' to select/unselect all\n- Press Enter when done",
validate=lambda x: len(x) > 0 or "You must select at least one analyst.", validate=lambda x: len(x) > 0 or "You must select at least one analyst.",
@@ -122,61 +196,115 @@ def select_research_depth() -> int:
return choice return choice
def select_shallow_thinking_agent() -> str: # Mainstream OpenRouter chat-LLM provider namespaces. We surface the newest
"""Select shallow thinking llm engine using an interactive selection.""" # models from these rather than the universal-newest, which is dominated by
# niche/experimental releases. These are the general-purpose chat providers;
# more enterprise/specialised namespaces (nvidia, cohere, amazon, ...) tend to
# ship research/safety variants as their newest, so they're left out of the
# shortlist. Provider names are stable (unlike model IDs), so this rarely needs
# touching; anything not here is still reachable via Custom ID.
_OPENROUTER_MAINSTREAM = {
"openai", "anthropic", "google", "deepseek", "qwen", "mistralai",
"meta-llama", "x-ai", "z-ai", "minimax", "moonshotai",
}
# Define shallow thinking llm engine options with their corresponding model names
SHALLOW_AGENT_OPTIONS = [ def _fetch_openrouter_models() -> list[tuple[str, str]]:
("GPT-4o-mini - Fast and efficient for quick tasks", "gpt-4o-mini"), """Fetch available models from the OpenRouter API."""
("GPT-4.1-nano - Ultra-lightweight model for basic operations", "gpt-4.1-nano"), import requests
("GPT-4.1-mini - Compact model with good performance", "gpt-4.1-mini"), try:
("GPT-4o - Standard model with solid capabilities", "gpt-4o"), resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
resp.raise_for_status()
models = resp.json().get("data", [])
# Newest first so the top-N shown really is the latest available — the
# API currently returns this order, but sort explicitly so the prompt's
# "latest available" label holds regardless of response ordering.
models.sort(key=lambda m: m.get("created") or 0, reverse=True)
return [(m.get("name") or m["id"], m["id"]) for m in models]
except Exception as e:
console.print(f"\n[yellow]Could not fetch OpenRouter models: {e}[/yellow]")
return []
def _require_text(message: str, hint: str) -> str:
"""Prompt for a required value; exit cleanly if the user cancels.
``questionary.text(...).ask()`` returns None on Ctrl-C/Esc; mirror the
exit-on-cancel behavior of the other required selections so a cancelled
prompt never returns an empty model/deployment that would fail downstream.
"""
response = questionary.text(
message,
validate=lambda x: len(x.strip()) > 0 or hint,
).ask()
if response is None:
console.print("\n[red]Cancelled. Exiting...[/red]")
exit(1)
return response.strip()
def select_openrouter_model(mode: str) -> str:
"""Select an OpenRouter model from the newest available, or enter a custom ID.
``mode`` ("quick"/"deep") labels the prompt so the two consecutive
OpenRouter selections are distinguishable, like the other providers (#1000).
"""
models = _fetch_openrouter_models() # newest first
# Prefer the newest from mainstream providers so the shortlist isn't crowded
# out by niche/experimental releases; fall back to all if none match.
mainstream = [
(name, mid) for name, mid in models
if not mid.startswith("~") # skip variant/alias duplicate routes
and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
] ]
top = (mainstream or models)[:5]
choices = [questionary.Choice(name, value=mid) for name, mid in top]
choices.append(questionary.Choice("Custom model ID", value="custom"))
choice = questionary.select( choice = questionary.select(
"Select Your [Quick-Thinking LLM Engine]:", f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):",
choices=[ choices=choices,
questionary.Choice(display, value=value)
for display, value in SHALLOW_AGENT_OPTIONS
],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style( style=questionary.Style([
[ ("selected", "fg:magenta noinherit"),
("selected", "fg:magenta noinherit"), ("highlighted", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"), ("pointer", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"), ]),
]
),
).ask() ).ask()
if choice is None: if choice is None:
console.print( console.print("\n[red]No model selected. Exiting...[/red]")
"\n[red]No shallow thinking llm engine selected. Exiting...[/red]" exit(1)
if choice == "custom":
return _require_text(
"Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
"Please enter a model ID.",
) )
exit(1)
return choice return choice
def select_deep_thinking_agent() -> str: def _prompt_custom_model_id() -> str:
"""Select deep thinking llm engine using an interactive selection.""" """Prompt user to type a custom model ID."""
return _require_text("Enter model ID:", "Please enter a model ID.")
# Define deep thinking llm engine options with their corresponding model names
DEEP_AGENT_OPTIONS = [ def _select_model(provider: str, mode: str) -> str:
("GPT-4.1-nano - Ultra-lightweight model for basic operations", "gpt-4.1-nano"), """Select a model for the given provider and mode (quick/deep)."""
("GPT-4.1-mini - Compact model with good performance", "gpt-4.1-mini"), if provider.lower() == "openrouter":
("GPT-4o - Standard model with solid capabilities", "gpt-4o"), return select_openrouter_model(mode)
("o4-mini - Specialized reasoning model (compact)", "o4-mini"),
("o3-mini - Advanced reasoning model (lightweight)", "o3-mini"), if provider.lower() == "azure":
("o3 - Full advanced reasoning model", "o3"), return _require_text(
("o1 - Premier reasoning and problem-solving model", "o1"), f"Enter Azure deployment name ({mode}-thinking):",
] "Please enter a deployment name.",
)
choice = questionary.select( choice = questionary.select(
"Select Your [Deep-Thinking LLM Engine]:", f"Select Your [{mode.title()}-Thinking LLM Engine]:",
choices=[ choices=[
questionary.Choice(display, value=value) questionary.Choice(display, value=value)
for display, value in DEEP_AGENT_OPTIONS for display, value in get_model_options(provider, mode)
], ],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style( style=questionary.Style(
@@ -189,7 +317,372 @@ def select_deep_thinking_agent() -> str:
).ask() ).ask()
if choice is None: if choice is None:
console.print("\n[red]No deep thinking llm engine selected. Exiting...[/red]") console.print(f"\n[red]No {mode} thinking llm engine selected. Exiting...[/red]")
exit(1) exit(1)
if choice == "custom":
return _prompt_custom_model_id()
return choice
def select_shallow_thinking_agent(provider) -> str:
"""Select shallow thinking llm engine using an interactive selection."""
return _select_model(provider, "quick")
def select_deep_thinking_agent(provider) -> str:
"""Select deep thinking llm engine using an interactive selection."""
return _select_model(provider, "deep")
def _llm_provider_table() -> list[tuple[str, str, str | None]]:
"""(display_name, provider_key, base_url) for every supported provider.
Shared by the interactive picker and by env-driven configuration so an
env-set provider resolves to the same default endpoint the menu uses.
Ollama users can point at a remote ollama-serve via OLLAMA_BASE_URL
(convention from the broader Ollama ecosystem); falls back to the
localhost default when unset.
"""
ollama_url = os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434/v1"
return [
("OpenAI", "openai", "https://api.openai.com/v1"),
("Google", "google", None),
("Anthropic", "anthropic", "https://api.anthropic.com/"),
("xAI", "xai", "https://api.x.ai/v1"),
("DeepSeek", "deepseek", "https://api.deepseek.com"),
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"),
("MiniMax", "minimax", "https://api.minimax.io/v1"),
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
("Mistral", "mistral", "https://api.mistral.ai/v1"),
("Kimi (Moonshot)", "kimi", "https://api.moonshot.ai/v1"),
("Groq", "groq", "https://api.groq.com/openai/v1"),
("NVIDIA NIM", "nvidia", "https://integrate.api.nvidia.com/v1"),
("Azure OpenAI", "azure", None),
("Amazon Bedrock", "bedrock", None),
("Ollama", "ollama", ollama_url),
("OpenAI-compatible (vLLM, LM Studio, llama.cpp, custom relay)", "openai_compatible", None),
]
def provider_default_url(provider_key: str) -> str | None:
"""Return the default backend URL for a provider key, or None if unknown."""
key = provider_key.lower()
for _, pk, url in _llm_provider_table():
if pk == key:
return url
return None
def resolve_backend_url(
provider: str, menu_url: str | None = None, env_url: str | None = None
) -> str | None:
"""Resolve the backend URL with the correct precedence.
An explicit env override (``env_url``, from ``TRADINGAGENTS_LLM_BACKEND_URL``
via ``DEFAULT_CONFIG['backend_url']``) is honored regardless of how the
provider was chosen — interactively or from the environment (#978).
Otherwise the menu/region URL, then the provider's default.
"""
return env_url or menu_url or provider_default_url(provider)
def prompt_openai_compatible_url() -> str:
"""Prompt for a custom OpenAI-compatible endpoint base URL."""
url = questionary.text(
"Enter the OpenAI-compatible base URL "
"(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):",
validate=lambda x: x.strip().startswith(("http://", "https://"))
or "Enter a URL starting with http:// or https://",
).ask()
if not url:
console.print("\n[red]No endpoint URL provided. Exiting...[/red]")
exit(1)
return url.strip()
def select_llm_provider() -> tuple[str, str | None]:
"""Select the LLM provider and its API endpoint."""
PROVIDERS = _llm_provider_table()
choice = questionary.select(
"Select your LLM Provider:",
choices=[
questionary.Choice(display, value=(provider_key, url))
for display, provider_key, url in PROVIDERS
],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
).ask()
if choice is None:
console.print("\n[red]No LLM provider selected. Exiting...[/red]")
exit(1)
provider, url = choice
return provider, url
def ask_openai_reasoning_effort() -> str:
"""Ask for OpenAI reasoning effort level."""
choices = [
questionary.Choice("Medium (Default)", "medium"),
questionary.Choice("High (More thorough)", "high"),
questionary.Choice("Low (Faster)", "low"),
]
return questionary.select(
"Select Reasoning Effort:",
choices=choices,
style=questionary.Style([
("selected", "fg:cyan noinherit"),
("highlighted", "fg:cyan noinherit"),
("pointer", "fg:cyan noinherit"),
]),
).ask()
def ask_anthropic_effort() -> str | None:
"""Ask for Anthropic effort level.
Controls token usage and response thoroughness on Claude 4.5 / 4.6 / 4.7
models. The API also accepts "max"; we expose low/medium/high as the
common selection range.
"""
return questionary.select(
"Select Effort Level:",
choices=[
questionary.Choice("High (recommended)", "high"),
questionary.Choice("Medium (balanced)", "medium"),
questionary.Choice("Low (faster, cheaper)", "low"),
],
style=questionary.Style([
("selected", "fg:cyan noinherit"),
("highlighted", "fg:cyan noinherit"),
("pointer", "fg:cyan noinherit"),
]),
).ask()
def ask_gemini_thinking_config() -> str | None:
"""Ask for Gemini thinking configuration.
Returns thinking_level: "high" or "minimal".
Client maps to appropriate API param based on model series.
"""
return questionary.select(
"Select Thinking Mode:",
choices=[
questionary.Choice("Enable Thinking (recommended)", "high"),
questionary.Choice("Minimal/Disable Thinking", "minimal"),
],
style=questionary.Style([
("selected", "fg:green noinherit"),
("highlighted", "fg:green noinherit"),
("pointer", "fg:green noinherit"),
]),
).ask()
def ask_glm_region() -> tuple[str, str]:
"""Ask which GLM platform (Z.AI international vs BigModel China) to use.
Zhipu serves the same GLM models under two brands with separate
accounts; keys aren't interchangeable. Returns (provider_key, backend_url).
"""
return questionary.select(
"Select GLM platform:",
choices=[
questionary.Choice(
"Z.AI — api.z.ai (international, uses ZHIPU_API_KEY)",
value=("glm", "https://api.z.ai/api/paas/v4/"),
),
questionary.Choice(
"BigModel — open.bigmodel.cn (China, uses ZHIPU_CN_API_KEY)",
value=("glm-cn", "https://open.bigmodel.cn/api/paas/v4/"),
),
],
style=questionary.Style([
("selected", "fg:cyan noinherit"),
("highlighted", "fg:cyan noinherit"),
("pointer", "fg:cyan noinherit"),
]),
).ask()
def ask_qwen_region() -> tuple[str, str]:
"""Ask which Qwen region (international vs China) to use.
Alibaba DashScope exposes two endpoints with separate accounts —
a key from one region does NOT authenticate against the other
(fixes #758). Returns (provider_key, backend_url).
"""
return questionary.select(
"Select Qwen region:",
choices=[
questionary.Choice(
"International — dashscope-intl.aliyuncs.com (uses DASHSCOPE_API_KEY)",
value=("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
),
questionary.Choice(
"China — dashscope.aliyuncs.com (uses DASHSCOPE_CN_API_KEY)",
value=("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
),
],
style=questionary.Style([
("selected", "fg:cyan noinherit"),
("highlighted", "fg:cyan noinherit"),
("pointer", "fg:cyan noinherit"),
]),
).ask()
def ask_minimax_region() -> tuple[str, str]:
"""Ask which MiniMax region (global vs China) to use.
MiniMax exposes two endpoints with separate accounts — a key from
one region does NOT authenticate against the other. Returns
(provider_key, backend_url).
"""
return questionary.select(
"Select MiniMax region:",
choices=[
questionary.Choice(
"Global — api.minimax.io (uses MINIMAX_API_KEY)",
value=("minimax", "https://api.minimax.io/v1"),
),
questionary.Choice(
"China — api.minimaxi.com (uses MINIMAX_CN_API_KEY)",
value=("minimax-cn", "https://api.minimaxi.com/v1"),
),
],
style=questionary.Style([
("selected", "fg:cyan noinherit"),
("highlighted", "fg:cyan noinherit"),
("pointer", "fg:cyan noinherit"),
]),
).ask()
def confirm_ollama_endpoint(url: str) -> None:
"""Show the resolved Ollama endpoint after provider selection.
Surfaces three things the user benefits from seeing before model
selection: which URL we'll actually hit, where it came from
(`OLLAMA_BASE_URL` vs default), and a soft warning if the URL is
missing the scheme/port that ollama-serve expects. The warning is
advisory only — we don't reject malformed input, since the user may
be doing something deliberately unusual (e.g. a reverse-proxy path).
"""
from_env = os.environ.get("OLLAMA_BASE_URL")
origin = " (from OLLAMA_BASE_URL)" if from_env and from_env == url else ""
console.print(f"[green]✓ Using Ollama at {url}{origin}[/green]")
if not url.startswith(("http://", "https://")):
console.print(
f"[yellow]Note: {url!r} is missing a scheme. "
f"Ollama-serve typically expects a URL like "
f"http://<host>:11434/v1.[/yellow]"
)
elif ":11434" not in url and "://localhost" not in url and "://127.0.0.1" not in url:
# Soft hint when the port differs from the ollama-serve default
# and the host isn't local (where users sometimes proxy on :80).
console.print(
f"[yellow]Note: {url!r} doesn't include port 11434. "
f"Make sure your remote ollama-serve listens on the port "
f"shown above.[/yellow]"
)
def ensure_api_key(provider: str) -> str | None:
"""Make sure the API key for `provider` is available in the environment.
If the env var is already set, returns its value untouched. Otherwise
interactively prompts the user, persists the value to the project's
.env file via python-dotenv's set_key (creating .env if needed), and
exports it into os.environ so the current process picks it up.
Returns None for providers that do not require a key (e.g. ollama)
and for providers not found in the canonical mapping.
"""
env_var = get_api_key_env(provider)
if env_var is None:
return None # ollama / unknown — no key check possible
# Key-optional providers (generic OpenAI-compatible / local servers) read the
# key when present but must never force an interactive prompt.
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
if spec is not None and spec.key_optional:
return os.environ.get(env_var)
existing = os.environ.get(env_var)
if existing:
return existing
console.print(
f"\n[yellow]{env_var} is not set in your environment.[/yellow]"
)
key = questionary.password(
f"Paste your {env_var} (will be saved to .env):",
style=questionary.Style([
("text", "fg:cyan"),
("highlighted", "noinherit"),
]),
).ask()
if not key:
console.print(
f"[red]Skipped. API calls will fail until {env_var} is set.[/red]"
)
return None
env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env")
Path(env_path).touch(exist_ok=True)
set_key(env_path, env_var, key)
os.environ[env_var] = key
console.print(f"[green]Saved {env_var} to {env_path}[/green]")
return key
def ask_output_language() -> str:
"""Ask for report output language."""
choice = questionary.select(
"Select Output Language:",
choices=[
questionary.Choice("English (default)", "English"),
questionary.Choice("Chinese (中文)", "Chinese"),
questionary.Choice("Japanese (日本語)", "Japanese"),
questionary.Choice("Korean (한국어)", "Korean"),
questionary.Choice("Hindi (हिन्दी)", "Hindi"),
questionary.Choice("Spanish (Español)", "Spanish"),
questionary.Choice("Portuguese (Português)", "Portuguese"),
questionary.Choice("French (Français)", "French"),
questionary.Choice("German (Deutsch)", "German"),
questionary.Choice("Arabic (العربية)", "Arabic"),
questionary.Choice("Russian (Русский)", "Russian"),
questionary.Choice("Custom language", "custom"),
],
style=questionary.Style([
("selected", "fg:yellow noinherit"),
("highlighted", "fg:yellow noinherit"),
("pointer", "fg:yellow noinherit"),
]),
).ask()
# Output language has a sensible default, so a cancel falls back to English
# rather than exiting the run (unlike the required model/provider prompts).
if choice is None:
return "English"
if choice == "custom":
return (questionary.text(
"Enter language name (e.g. Turkish, Vietnamese, Thai, Indonesian):",
validate=lambda x: len(x.strip()) > 0 or "Please enter a language name.",
).ask() or "").strip() or "English"
return choice return choice

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
tradingagents:
build: .
env_file:
- .env
volumes:
- tradingagents_data:/home/appuser/.tradingagents
tty: true
stdin_open: true
ollama:
image: ollama/ollama:latest
volumes:
- ollama_data:/root/.ollama
profiles:
- ollama
tradingagents-ollama:
build: .
env_file:
- .env
environment:
- TRADINGAGENTS_LLM_PROVIDER=ollama
- OLLAMA_BASE_URL=http://ollama:11434/v1
volumes:
- tradingagents_data:/home/appuser/.tradingagents
depends_on:
- ollama
tty: true
stdin_open: true
profiles:
- ollama
volumes:
tradingagents_data:
ollama_data:

View File

@@ -1,447 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="TradingAgents: Multi-Agents LLM Financial Trading Framework">
<meta name="keywords" content="TradingAgents, LLM, Financial Trading, Multi-Agent Systems, Financial Markets, AI Trading">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TradingAgents: Multi-Agents LLM Financial Trading Framework</title>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PYVRSFMDRL"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-PYVRSFMDRL');
</script>
<link href="https://fonts.googleapis.com/css?family=Google+Sans|Noto+Sans|Castoro" rel="stylesheet">
<link rel="stylesheet" href="./static/css/bulma.min.css">
<link rel="stylesheet" href="./static/css/bulma-carousel.min.css">
<link rel="stylesheet" href="./static/css/bulma-slider.min.css">
<link rel="stylesheet" href="./static/css/fontawesome.all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jpswalsh/academicons@1/css/academicons.min.css">
<link rel="stylesheet" href="./static/css/index.css">
<link rel="icon" href="./static/images/TradingAgents.png">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script defer src="./static/js/fontawesome.all.min.js"></script>
<script src="./static/js/bulma-carousel.min.js"></script>
<script src="./static/js/bulma-slider.min.js"></script>
<script src="./static/js/index.js"></script>
</head>
<body>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu">
<div class="navbar-start" style="flex-grow: 1; justify-content: center;">
<a class="navbar-item" href="https://yijia-xiao.github.io/">
<span class="icon">
<i class="fas fa-home"></i>
</span>
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">More Research</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="https://arxiv.org/abs/2408.11363">ProteinGPT</a>
<a class="navbar-item" href="https://arxiv.org/abs/2310.02469">PrivacyMind</a>
<a class="navbar-item" href="https://arxiv.org/abs/2412.20138">TradingAgents</a>
</div>
</div>
</div>
</div>
</nav>
<section class="hero">
<div class="hero-body">
<div class="container is-max-desktop">
<div class="columns is-centered">
<div class="column has-text-centered">
<h1 class="title is-1 publication-title">TradingAgents: Multi-Agents LLM Financial Trading Framework</h1>
<div class="is-size-5 publication-authors">
<a href="https://yijia-xiao.github.io/" target="_blank" rel="noopener noreferrer"><span class="author-block">Yijia Xiao<sup>1</sup>,</span></a>
<span class="author-block">Edward Sun<sup>1</sup>,</span>
<span class="author-block">Di Luo<sup>1,2</sup>,</span>
<span class="author-block">Wei Wang<sup>1</sup></span>
</div>
<div class="is-size-5 publication-authors">
<span class="author-block"><sup>1</sup>University of California, Los Angeles,</span>
<span class="author-block"><sup>2</sup>Massachusetts Institute of Technology</span>
</div>
<div class="column has-text-centered">
<div class="publication-links">
<span class="link-block"><a href="https://arxiv.org/abs/2412.20138" class="external-link button is-normal is-rounded is-dark"><span class="icon"><i class="fas fa-file-pdf"></i></span><span>Paper</span></a></span>
<span class="link-block"><a href="https://github.com/TradingAgents-AI" class="external-link button is-normal is-rounded is-dark"><span class="icon"><i class="fab fa-github"></i></span><span>Code</span></a></span>
<span class="link-block"><a href="#citation-ref" class="external-link button is-normal is-rounded is-dark"><span class="icon"><i class="fas fa-quote-right"></i></span><span>Citation</span></a></span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<div class="columns is-centered has-text-centered">
<div class="column is-four-fifths">
<h2 class="title is-3">Abstract</h2>
<div class="content has-text-justified">
<p>We introduce <strong>TradingAgents</strong>, a novel stock trading framework inspired by trading firms, utilizing multiple LLM-powered agents with specialized roles such as fundamental, sentiment, and technical analysts, as well as traders with diverse risk profiles. The system features Bull and Bear researchers evaluating market conditions, a risk management team overseeing exposure, and traders integrating insights from debates and historical data to make informed decisions. This collaborative, dynamic environment enhances trading performance, as demonstrated by our comprehensive experiments showing significant improvements in cumulative returns, Sharpe ratio, and maximum drawdown compared to baseline models. Our results highlight the effectiveness of multi-agent LLM frameworks in financial trading.</p>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<div class="columns is-centered">
<div class="column is-full-width">
<h2 class="title is-3">TradingAgents: Overview</h2>
<div class="content has-text-justified">
<p><strong>TradingAgents</strong> leverages a multi-agent framework to simulate a professional trading firm with distinct roles: fundamental, sentiment, and technical analysts; researchers; traders; and risk managers. These agents collaborate through structured communication and debates, enhancing decision-making and optimizing trading strategies.</p>
<figure class="image">
<img src="./static/images/schema.png" alt="TradingAgents Overall Framework Organization">
<figcaption class="has-text-centered"><strong>Figure 1:</strong> TradingAgents Overall Framework Organization.<br><strong>I. Analysts Team</strong>: Four analysts concurrently gather relevant market information.<br><strong>II. Research Team</strong>: The team discusses and evaluates the collected data.<br><strong>III. Trader</strong>: Based on the researchers' analysis, the trader makes the trading decision.<br><strong>IV. Risk Management Team</strong>: Risk guardians assess the decision against current market conditions to mitigate risks.<br><strong>V. Fund Manager</strong>: The fund manager approves and executes the trade.</em></figcaption>
</figure>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<div class="columns is-centered">
<div class="column is-full-width">
<h2 class="title is-3">TradingAgents: Role Specialization</h2>
<div class="content has-text-justified">
<p>Assigning specific roles to LLM agents allows complex trading objectives to be broken down into manageable tasks. Inspired by trading firms, <strong>TradingAgents</strong> features seven distinct roles: Fundamentals Analyst, Sentiment Analyst, News Analyst, Technical Analyst, Researcher, Trader, and Risk Manager. Each agent is equipped with specialized tools and constraints tailored to their function, ensuring comprehensive market analysis and informed decision-making.</p>
<h3 class="title is-4">Analyst Team</h3>
<div class="content has-text-justified">
<p>The Analyst Team gathers and analyzes market data across various domains:</p>
<ul>
<li><strong>Fundamental Analysts:</strong> Assess company fundamentals to identify undervalued or overvalued stocks.</li>
<li><strong>Sentiment Analysts:</strong> Analyze social media and public sentiment to gauge market mood.</li>
<li><strong>News Analysts:</strong> Evaluate news and macroeconomic indicators to predict market movements.</li>
<li><strong>Technical Analysts:</strong> Use technical indicators to forecast price trends and trading opportunities.</li>
</ul>
<p>Combined, their insights provide a holistic market view, feeding into the Researcher Team for further evaluation.</p>
<figure class="image">
<img src="./static/images/Analyst.png" alt="TradingAgents Analyst Team" style="width: 65%;">
<figcaption class="has-text-centered"><strong>Figure 2:</strong> TradingAgents Analyst Team</figcaption>
</figure>
</div>
<h3 class="title is-4">Researcher Team</h3>
<div class="content has-text-justified">
<p>The Researcher Team critically evaluates analyst data through a dialectical process involving bullish and bearish perspectives. This debate ensures balanced analysis, identifying both opportunities and risks to inform trading strategies.</p>
<div class="columns">
<div class="column">
<figure class="image">
<img src="./static/images/Researcher.png" alt="TradingAgents Researcher Team">
<figcaption class="has-text-centered"><strong>Figure 3:</strong> TradingAgents Researcher Team</figcaption>
</figure>
</div>
<div class="column">
<figure class="image">
<img src="./static/images/Trader.png" alt="TradingAgents Trader Decision-Making Process">
<figcaption class="has-text-centered"><strong>Figure 4:</strong> TradingAgents Trader Decision-Making Process</figcaption>
</figure>
</div>
<div class="column">
<figure class="image">
<img src="./static/images/RiskMGMT.png" alt="TradingAgents Risk Management Team Workflow">
<figcaption class="has-text-centered"><strong>Figure 5:</strong> TradingAgents Risk Management Workflow</figcaption>
</figure>
</div>
</div>
<ul>
<li><strong>Bullish Researchers:</strong> Highlight positive market indicators and growth potential.</li>
<li><strong>Bearish Researchers:</strong> Focus on risks and negative market signals.</li>
</ul>
<p>This process ensures a balanced understanding of market conditions, aiding Trader Agents in making informed decisions.</p>
</div>
<h3 class="title is-4">Trader Agents</h3>
<div class="content has-text-justified">
<p>Trader Agents execute decisions based on comprehensive analyses. They evaluate insights from analysts and researchers to determine optimal trading actions, balancing returns and risks in a dynamic market environment.</p>
<ul>
<li>Assessing analyst and researcher recommendations.</li>
<li>Determining trade timing and size.</li>
<li>Executing buy/sell orders.</li>
<li>Adjusting portfolios in response to market changes.</li>
</ul>
<p>Precision and strategic thinking are essential for their role in maximizing performance.</p>
</div>
<h3 class="title is-4">Risk Management Team</h3>
<div class="content has-text-justified">
<p>The Risk Management Team oversees the firm's exposure to market risks, ensuring trading activities stay within predefined limits.</p>
<ul>
<li>Assessing market volatility and liquidity.</li>
<li>Implementing risk mitigation strategies.</li>
<li>Advising Trader Agents on risk exposures.</li>
<li>Aligning portfolio with risk tolerance.</li>
</ul>
<p>They ensure financial stability and safeguard assets through effective risk control.</p>
<p>All agents utilize the ReAct prompting framework, facilitating a collaborative and dynamic decision-making process reflective of real-world trading systems.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<div class="columns is-centered">
<div class="column is-full-width">
<h2 class="title is-3">TradingAgents: Agent Workflow</h2>
<div class="content has-text-justified">
<h3 class="title is-4">Communication Protocol</h3>
<p>To enhance communication efficiency, <strong>TradingAgents</strong> employs a structured protocol that combines clear, structured outputs with natural language dialogue. This approach minimizes information loss and maintains context over long interactions, ensuring focused and effective communication among agents.</p>
<h3 class="title is-4">Types of Agent Interactions</h3>
<p>Unlike previous frameworks that rely heavily on unstructured dialogue, our agents communicate through structured reports and diagrams, preserving essential information and enabling direct queries from the global state.</p>
<ul>
<li><strong>Analyst Team:</strong> Compiles research into concise analysis reports.</li>
<li><strong>Traders:</strong> Review analyst reports and produce decision signals with detailed rationales.</li>
</ul>
<p>Natural language dialogue is reserved for specific interactions, such as debates within the Researcher and Risk Management teams, fostering deeper reasoning and balanced decision-making.</p>
<ul>
<li><strong>Researcher Team:</strong> Engages in debates to form balanced perspectives.</li>
<li><strong>Risk Management Team:</strong> Deliberates on trading plans from multiple risk perspectives.</li>
<li><strong>Fund Manager:</strong> Reviews and approves risk-adjusted trading decisions.</li>
</ul>
<h3 class="title is-4">Backbone LLMs</h3>
<p>We select LLMs based on task requirements, using quick-thinking models for data retrieval and deep-thinking models for in-depth analysis and decision-making. This strategic alignment ensures efficiency and robust reasoning, allowing <strong>TradingAgents</strong> to operate without the need for GPUs and enabling easy integration of alternative models in the future.</p>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<div class="columns is-centered">
<div class="column is-full-width">
<h2 class="title is-3">Experiments</h2>
<div class="content has-text-justified">
<p>We evaluated <strong>TradingAgents</strong> using a comprehensive experimental setup to assess its performance against various baselines.</p>
<h3 class="title is-4">Back Trading</h3>
<p>Our simulation utilized a multi-asset, multi-modal financial dataset including historical stock prices, news articles, social media sentiments, insider transactions, financial reports, and technical indicators from January to March 2024.</p>
<h3 class="title is-4">Simulation Setup</h3>
<p>The trading environment spanned from June to November 2024. Agents operated on a daily basis, making decisions based on available data without future information, ensuring unbiased results.</p>
<h3 class="title is-4">Baseline Models</h3>
<p>We compared <strong>TradingAgents</strong> against the following strategies:</p>
<ul>
<li><strong>Buy and Hold:</strong> Investing equally across selected stocks throughout the period.</li>
<li><strong>MACD:</strong> Momentum strategy based on MACD crossovers.</li>
<li><strong>KDJ & RSI:</strong> Combined momentum indicators for trading signals.</li>
<li><strong>ZMR:</strong> Mean reversion strategy based on price deviations.</li>
<li><strong>SMA:</strong> Trend-following strategy using moving average crossovers.</li>
</ul>
<h3 class="title is-4">Evaluation Metrics</h3>
<div class="columns">
<div class="column">
<figure class="image">
<img src="./static/images/CumulativeReturns_AAPL.png" alt="Cumulative Returns on AAPL">
<figcaption class="has-text-centered"><strong>(a)</strong> Cumulative Returns on AAPL</figcaption>
</figure>
</div>
<div class="column">
<figure class="image">
<img src="./static/images/TradingAgents_Transactions_AAPL.png" alt="TradingAgents Transactions for AAPL">
<figcaption class="has-text-centered">
<strong>(b)</strong> TradingAgents Transactions for AAPL.<br>
Green / Red Arrows for Long / Short Positions.
</figcaption>
</figure>
</div>
</div>
<table class="table is-striped is-fullwidth is-centered">
<thead>
<tr>
<th>Categories</th>
<th>Models</th>
<th colspan="4">AAPL</th>
<th></th>
<th colspan="4">GOOGL</th>
<th></th>
<th colspan="4">AMZN</th>
</tr>
<tr>
<th></th>
<th></th>
<th>CR%↑</th>
<th>ARR%↑</th>
<th>SR↑</th>
<th>MDD%↓</th>
<th></th>
<th>CR%↑</th>
<th>ARR%↑</th>
<th>SR↑</th>
<th>MDD%↓</th>
<th></th>
<th>CR%↑</th>
<th>ARR%↑</th>
<th>SR↑</th>
<th>MDD%↓</th>
</tr>
</thead>
<tbody>
<tr>
<td>Market</td>
<td>B&H</td>
<td>-5.23</td><td>-5.09</td><td>-1.29</td><td>11.90</td>
<td></td>
<td>7.78</td><td>8.09</td><td>1.35</td><td>13.04</td>
<td></td>
<td>17.1</td><td>17.6</td><td>3.53</td><td>3.80</td>
</tr>
<tr>
<td rowspan="4">Rule-based</td>
<td>MACD</td>
<td>-1.49</td><td>-1.48</td><td>-0.81</td><td>4.53</td>
<td></td>
<td>6.20</td><td>6.26</td><td>2.31</td><td>1.22</td>
<td></td>
<td>-</td><td>-</td><td>-</td><td>-</td>
</tr>
<tr>
<td>KDJ&RSI</td>
<td>2.05</td><td>2.07</td><td>1.64</td><td>1.09</td>
<td></td>
<td>0.4</td><td>0.4</td><td>0.02</td><td>1.58</td>
<td></td>
<td>-0.77</td><td>-0.76</td><td>-2.25</td><td>1.08</td>
</tr>
<tr>
<td>ZMR</td>
<td>0.57</td><td>0.57</td><td>0.17</td><td>0.86</td>
<td></td>
<td>-0.58</td><td>0.58</td><td>2.12</td><td>2.34</td>
<td></td>
<td>-0.77</td><td>-0.77</td><td>-2.45</td><td>0.82</td>
</tr>
<tr>
<td>SMA</td>
<td>-3.2</td><td>-2.97</td><td>-1.72</td><td>3.67</td>
<td></td>
<td>6.23</td><td>6.43</td><td>2.12</td><td>2.34</td>
<td></td>
<td>11.01</td><td>11.6</td><td>2.22</td><td>3.97</td>
</tr>
<tr>
<td rowspan="1">Ours</td>
<td><strong>TradingAgents</strong></td>
<td><strong style="color:green;">26.62</strong></td><td><strong style="color:green;">30.5</strong></td><td><strong style="color:green;">8.21</strong></td><td>0.91</td>
<td></td>
<td><strong style="color:green;">24.36</strong></td><td><strong style="color:green;">27.58</strong></td><td><strong style="color:green;">6.39</strong></td><td>1.69</td>
<td></td>
<td><strong style="color:green;">23.21</strong></td><td><strong style="color:green;">24.90</strong></td><td><strong style="color:green;">5.60</strong></td><td>2.11</td>
</tr>
<tr>
<td colspan="2">Improvement(%)</td>
<td>24.57</td><td>28.43</td><td>6.57</td><td>-</td>
<td></td>
<td>16.58</td><td>19.49</td><td>4.26</td><td>-</td>
<td></td>
<td>6.10</td><td>7.30</td><td>2.07</td><td>-</td>
</tr>
</tbody>
</table>
<p class="has-text-centered"><strong>Table 1:</strong> TradingAgents: Performance Metrics Comparison across AAPL, GOOGL, and AMZN.</p>
<h3 class="title is-4">Sharpe Ratio</h3>
<p><strong>TradingAgents</strong> achieves superior risk-adjusted returns, consistently outperforming all baselines across AAPL, GOOGL, and AMZN. The enhanced Sharpe Ratios demonstrate the framework's effectiveness in balancing returns with risk, highlighting its robustness in diverse market conditions.</p>
<h3 class="title is-4">Maximum Drawdown</h3>
<p>While rule-based strategies excel in controlling risk, <strong>TradingAgents</strong> maintains a low maximum drawdown without sacrificing high returns. This balance underscores the framework's ability to maximize profits while effectively managing risk.</p>
<h3 class="title is-4">Explainability</h3>
<p>Unlike traditional deep learning models, <strong>TradingAgents</strong> offers transparent decision-making through natural language explanations. Each agent's actions are accompanied by detailed reasoning and tool usage, making the system's operations easily interpretable and debuggable, which is crucial for real-world financial applications.</p>
</div>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<div class="columns is-centered">
<div class="column is-full-width">
<h2 class="title is-3">Conclusion</h2>
<div class="content has-text-justified">
<p>We presented <strong>TradingAgents</strong>, a multi-agent LLM-driven stock trading framework that emulates a realistic trading firm with specialized agents collaborating through debates and structured communication. Our framework leverages diverse data sources and multi-agent interactions to enhance trading decisions, achieving superior performance in cumulative returns, Sharpe ratio, and risk management compared to traditional strategies. Future work includes live deployment, expanding agent roles, and integrating real-time data processing to further improve performance.</p>
</div>
</div>
</div>
</div>
</section>
<section id="citation-ref" class="section">
<div class="container content">
<h2 class="title">BibTeX</h2>
<pre><code>@inproceedings{xiao2025tradingagentsmultiagentsllmfinancial,
title = {TradingAgents: Multi-Agents LLM Financial Trading Framework},
author = {Yijia Xiao and Edward Sun and Di Luo and Wei Wang},
booktitle = {Multi-Agent AI in the Real World @ AAAI 2025},
year = {2025},
eprint = {2412.20138},
archivePrefix= {arXiv},
primaryClass = {q-fin.TR},
url = {https://arxiv.org/abs/2412.20138},
note = {Workshop paper},
}</code></pre>
</div>
</section>
<footer class="footer">
<div class="container">
<div class="content has-text-centered">
<a class="icon-link" href="https://arxiv.org/abs/2412.20138"><i class="fas fa-file-pdf"></i></a>
<a class="icon-link" href="https://github.com/TradingAgents-AI"><i class="fab fa-github"></i></a>
</div>
<div class="columns is-centered">
<div class="column is-8">
<div class="content">
<p>This website is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>.</p>
</div>
</div>
</div>
</div>
</footer>
</body>
</html>

12
main.py
View File

@@ -1,12 +1,12 @@
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.graph.trading_graph import TradingAgentsGraph
# Create a custom config # DEFAULT_CONFIG already applies TRADINGAGENTS_* env-var overrides
# (llm_provider, deep_think_llm, quick_think_llm, backend_url, etc.),
# so users can switch models or endpoints purely via .env without
# editing this script. Override individual keys here only when you
# want a hard-coded value that should ignore the environment.
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
config["deep_think_llm"] = "gpt-4.1-nano" # Use a different model
config["quick_think_llm"] = "gpt-4.1-nano" # Use a different model
config["max_debate_rounds"] = 1 # Increase debate rounds
config["online_tools"] = True # Increase debate rounds
# Initialize with custom config # Initialize with custom config
ta = TradingAgentsGraph(debug=True, config=config) ta = TradingAgentsGraph(debug=True, config=config)

88
pyproject.toml Normal file
View File

@@ -0,0 +1,88 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "tradingagents"
version = "0.4.0"
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-core>=0.3.81",
"backtrader>=1.9.78.123",
"langchain-anthropic>=0.3.15",
"langchain-experimental>=0.3.4",
"langchain-google-genai>=4.0.0",
"langchain-openai>=0.3.23",
"langgraph>=0.4.8",
"langgraph-checkpoint-sqlite>=2.0.0",
"pandas>=2.3.0",
"parsel>=1.10.0",
"python-dotenv>=1.0.0",
"pytz>=2025.2",
"questionary>=2.1.0",
"redis>=6.2.0",
"requests>=2.32.4",
"rich>=14.0.0",
"typer>=0.21.0",
"setuptools>=80.9.0",
"stockstats>=0.6.5",
"tqdm>=4.67.1",
"typing-extensions>=4.14.0",
"yfinance>=1.4.1",
]
[project.optional-dependencies]
dev = [
"ruff>=0.15",
"pytest>=8.0",
"pytest-subtests>=0.13",
]
# Amazon Bedrock support (AWS SigV4 auth + boto3). Optional so the core install
# stays lean: pip install "tradingagents[bedrock]".
bedrock = [
"langchain-aws>=1.5.0",
]
[project.scripts]
tradingagents = "cli.main:app"
[tool.setuptools.packages.find]
include = ["tradingagents*", "cli*"]
[tool.setuptools.package-data]
cli = ["static/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"unit: fast isolated unit tests",
"integration: tests requiring external services",
"smoke: quick sanity-check tests",
]
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.ruff]
line-length = 100
target-version = "py310"
extend-exclude = ["results", "worklog"]
[tool.ruff.lint]
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +
# pyupgrade + comprehensions/simplify). Line length (E501) and layout are owned
# by the formatter; whole-repo `ruff format` adoption is deferred until the
# open-PR backlog clears, to avoid mass merge conflicts.
select = ["E", "W", "F", "I", "B", "UP", "C4", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"**/__init__.py" = ["F401"] # intentional re-exports
[tool.ruff.lint.isort]
# Keep multiple aliased names from one module in a single combined import block
# (e.g. the vendor re-exports in interface.py) instead of one statement per name.
combine-as-imports = true

View File

@@ -1,24 +1 @@
typing-extensions .
langchain-openai
langchain-experimental
pandas
yfinance
praw
feedparser
stockstats
eodhd
langgraph
chromadb
setuptools
backtrader
akshare
tushare
finnhub-python
parsel
requests
tqdm
pytz
redis
chainlit
rich
questionary

View File

@@ -0,0 +1,174 @@
"""End-to-end smoke for structured-output agents against a real LLM provider.
Runs the three decision-making agents (Research Manager, Trader, Portfolio
Manager) directly with their structured-output bindings and prints the
typed Pydantic instance + the rendered markdown for each. Use this to
verify a provider's native structured-output mode (json_schema for
OpenAI / xAI / DeepSeek / Qwen / GLM, response_schema for Gemini, tool-use
for Anthropic) returns clean instances on the schemas we ship.
Usage:
OPENAI_API_KEY=... python scripts/smoke_structured_output.py openai
GOOGLE_API_KEY=... python scripts/smoke_structured_output.py google
ANTHROPIC_API_KEY=... python scripts/smoke_structured_output.py anthropic
DEEPSEEK_API_KEY=... python scripts/smoke_structured_output.py deepseek
The script does NOT call propagate(), to keep the surface tight and the
cost low — it exercises only the three structured-output calls we just
added, plus the heuristic SignalProcessor.
"""
from __future__ import annotations
import argparse
import sys
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
from tradingagents.agents.managers.research_manager import create_research_manager
from tradingagents.agents.trader.trader import create_trader
from tradingagents.graph.signal_processing import SignalProcessor
from tradingagents.llm_clients import create_llm_client
PROVIDER_DEFAULTS = {
"openai": ("gpt-5.4-mini", None),
"google": ("gemini-3.5-flash", None),
"anthropic": ("claude-sonnet-4-6", None),
"deepseek": ("deepseek-v4-flash", None),
"qwen": ("qwen3.7-plus", None),
"glm": ("glm-5", None),
"xai": ("grok-4.3", None),
}
# Minimal but realistic state for the three agents.
DEBATE_HISTORY = """
Bull Analyst: NVDA's data-center revenue grew 60% YoY last quarter, driven by
Blackwell ramp; sovereign AI deals with multiple governments add a $40B+
multi-year tailwind. Margins remain above peer average.
Bear Analyst: Concentration risk is real — top three customers are >40% of
revenue. Any pause in hyperscaler capex would compress the multiple. China
export restrictions still cap a meaningful portion of demand.
"""
def _make_rm_state():
return {
"company_of_interest": "NVDA",
"investment_debate_state": {
"history": DEBATE_HISTORY,
"bull_history": "Bull Analyst: NVDA's data-center revenue grew 60% YoY...",
"bear_history": "Bear Analyst: Concentration risk is real...",
"current_response": "",
"judge_decision": "",
"count": 1,
},
}
def _make_trader_state(investment_plan: str):
return {
"company_of_interest": "NVDA",
"investment_plan": investment_plan,
}
def _make_pm_state(investment_plan: str, trader_plan: str):
return {
"company_of_interest": "NVDA",
"past_context": "",
"risk_debate_state": {
"history": "Aggressive: lean in. Conservative: trim. Neutral: balanced sizing.",
"aggressive_history": "Aggressive: ...",
"conservative_history": "Conservative: ...",
"neutral_history": "Neutral: ...",
"judge_decision": "",
"current_aggressive_response": "",
"current_conservative_response": "",
"current_neutral_response": "",
"count": 1,
},
"market_report": "Market report.",
"sentiment_report": "Sentiment report.",
"news_report": "News report.",
"fundamentals_report": "Fundamentals report.",
"investment_plan": investment_plan,
"trader_investment_plan": trader_plan,
}
def _print_section(title: str, content: str) -> None:
bar = "=" * 70
print(f"\n{bar}\n{title}\n{bar}\n{content}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("provider", choices=list(PROVIDER_DEFAULTS.keys()))
parser.add_argument("--deep-model", default=None, help="Override deep_think_llm")
parser.add_argument("--quick-model", default=None, help="Override quick_think_llm")
args = parser.parse_args()
default_model, _ = PROVIDER_DEFAULTS[args.provider]
deep_model = args.deep_model or default_model
quick_model = args.quick_model or default_model
print(f"Provider: {args.provider}")
print(f"Deep model: {deep_model}")
print(f"Quick model: {quick_model}")
# Build the LLM clients via the framework's factory.
deep_client = create_llm_client(provider=args.provider, model=deep_model)
quick_client = create_llm_client(provider=args.provider, model=quick_model)
deep_llm = deep_client.get_llm()
quick_llm = quick_client.get_llm()
# 1) Research Manager
rm = create_research_manager(deep_llm)
rm_result = rm(_make_rm_state())
investment_plan = rm_result["investment_plan"]
_print_section("[1] Research Manager — investment_plan", investment_plan)
# 2) Trader (consumes RM's plan)
trader = create_trader(quick_llm)
trader_result = trader(_make_trader_state(investment_plan))
trader_plan = trader_result["trader_investment_plan"]
_print_section("[2] Trader — trader_investment_plan", trader_plan)
# 3) Portfolio Manager (consumes both)
pm = create_portfolio_manager(deep_llm)
pm_result = pm(_make_pm_state(investment_plan, trader_plan))
final_decision = pm_result["final_trade_decision"]
_print_section("[3] Portfolio Manager — final_trade_decision", final_decision)
# 4) SignalProcessor extracts the rating with zero LLM calls.
sp = SignalProcessor()
rating = sp.process_signal(final_decision)
_print_section("[4] SignalProcessor → rating", rating)
# 5) Lightweight checks: each rendered output should carry the expected
# section headers so downstream consumers (memory log, CLI display,
# saved reports) keep working.
checks = [
("Research Manager", investment_plan, ["**Recommendation**:"]),
("Trader", trader_plan, ["**Action**:", "FINAL TRANSACTION PROPOSAL:"]),
("Portfolio Manager", final_decision, ["**Rating**:", "**Executive Summary**:", "**Investment Thesis**:"]),
]
print("\n" + "=" * 70 + "\nStructure checks\n" + "=" * 70)
failures = 0
for name, text, required in checks:
for marker in required:
ok = marker in text
print(f" {'PASS' if ok else 'FAIL'} {name}: contains {marker!r}")
failures += int(not ok)
print()
if failures:
print(f"Smoke FAILED: {failures} structure check(s) missing.")
return 1
print("Smoke PASSED: structured output → rendered markdown chain works for", args.provider)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,43 +0,0 @@
"""
Setup script for the TradingAgents package.
"""
from setuptools import setup, find_packages
setup(
name="tradingagents",
version="0.1.0",
description="Multi-Agents LLM Financial Trading Framework",
author="TradingAgents Team",
author_email="yijia.xiao@cs.ucla.edu",
url="https://github.com/TauricResearch",
packages=find_packages(),
install_requires=[
"langchain>=0.1.0",
"langchain-openai>=0.0.2",
"langchain-experimental>=0.0.40",
"langgraph>=0.0.20",
"numpy>=1.24.0",
"pandas>=2.0.0",
"praw>=7.7.0",
"stockstats>=0.5.4",
"yfinance>=0.2.31",
"typer>=0.9.0",
"rich>=13.0.0",
"questionary>=2.0.1",
],
python_requires=">=3.10",
entry_points={
"console_scripts": [
"tradingagents=cli.main:app",
],
},
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Financial and Trading Industry",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Topic :: Office/Business :: Financial :: Investment",
],
)

14
test.py Normal file
View File

@@ -0,0 +1,14 @@
import time
from tradingagents.dataflows.y_finance import (
get_stock_stats_indicators_window,
)
print("Testing optimized implementation with 30-day lookback:")
start_time = time.time()
result = get_stock_stats_indicators_window("AAPL", "macd", "2024-11-01", 30)
end_time = time.time()
print(f"Execution time: {end_time - start_time:.2f} seconds")
print(f"Result length: {len(result)} characters")
print(result)

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

67
tests/conftest.py Normal file
View File

@@ -0,0 +1,67 @@
"""Shared pytest fixtures that prevent CI hangs when API keys are absent."""
import os
from unittest.mock import MagicMock, patch
import pytest
def pytest_configure(config):
for marker in ("unit", "integration", "smoke"):
config.addinivalue_line("markers", f"{marker}: {marker}-level tests")
_API_KEY_ENV_VARS = (
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"ANTHROPIC_API_KEY",
"XAI_API_KEY",
"DEEPSEEK_API_KEY",
"DASHSCOPE_API_KEY",
"DASHSCOPE_CN_API_KEY",
"ZHIPU_API_KEY",
"ZHIPU_CN_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"OPENROUTER_API_KEY",
"AZURE_OPENAI_API_KEY",
"ALPHA_VANTAGE_API_KEY",
)
@pytest.fixture(autouse=True)
def _dummy_api_keys(monkeypatch):
for env_var in _API_KEY_ENV_VARS:
# `or` not a .get default: an env var present but empty (e.g. a key left
# blank in a .env copied from .env.example) must still get the placeholder.
monkeypatch.setenv(env_var, os.environ.get(env_var) or "placeholder")
@pytest.fixture(autouse=True)
def _isolate_config():
"""Reset the global dataflows config before and after each test.
``set_config`` merges (it never clears keys absent from the override), so a
test that sets e.g. ``tool_vendors`` would otherwise leak into later tests
and make routing behavior order-dependent. Replace the global outright so
every test starts from a clean DEFAULT_CONFIG.
"""
import copy
import tradingagents.dataflows.config as config_module
import tradingagents.default_config as default_config
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
yield
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
@pytest.fixture()
def mock_llm_client():
client = MagicMock()
client.get_llm.return_value = MagicMock()
with patch(
"tradingagents.llm_clients.factory.create_llm_client",
return_value=client,
):
yield client

View File

@@ -0,0 +1,96 @@
"""Alpha Vantage request hardening.
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
responses mislabeled as rate limits and silently treated as transient), and
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
string, not a dict).
"""
import json
import pytest
import tradingagents.dataflows.alpha_vantage_common as av
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
class _FakeResponse:
def __init__(self, text):
self.text = text
def raise_for_status(self):
pass
def _patched_get(body, capture=None):
def fake_get(url, params=None, **kwargs):
if capture is not None:
capture.update(kwargs)
return _FakeResponse(body)
return fake_get
@pytest.mark.unit
def test_request_passes_timeout(monkeypatch):
captured = {}
monkeypatch.setattr(av.requests, "get", _patched_get("Date,Close\n2025-01-02,1.0", captured))
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
assert captured.get("timeout") == av.REQUEST_TIMEOUT # #990
@pytest.mark.unit
def test_rate_limit_detected(monkeypatch):
body = '{"Information": "Our standard API rate limit is 25 requests per day. ... your API key ..."}'
monkeypatch.setattr(av.requests, "get", _patched_get(body))
with pytest.raises(av.AlphaVantageRateLimitError):
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
@pytest.mark.unit
def test_invalid_key_not_mislabeled_as_rate_limit(monkeypatch):
# AV's invalid-key notice mentions "API key"; it must NOT be treated as a
# (transient) rate limit, but surface as a real configuration error (#991).
body = ('{"Information": "the parameter apikey is invalid or missing. '
'Please claim your free API key on (https://www.alphavantage.co/support/#api-key)."}')
monkeypatch.setattr(av.requests, "get", _patched_get(body))
with pytest.raises(av.AlphaVantageNotConfiguredError):
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
with pytest.raises(av.AlphaVantageRateLimitError): # sanity: rate-limit path still distinct
monkeypatch.setattr(av.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}'))
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
_FUNDAMENTALS_JSON = json.dumps({
"symbol": "AAPL",
"annualReports": [
{"fiscalDateEnding": "2025-12-31", "totalAssets": "1"}, # future -> must drop
{"fiscalDateEnding": "2023-12-31", "totalAssets": "2"}, # past -> must keep
],
"quarterlyReports": [
{"fiscalDateEnding": "2024-06-30", "totalAssets": "3"}, # future -> must drop
{"fiscalDateEnding": "2023-09-30", "totalAssets": "4"}, # past -> must keep
],
})
@pytest.mark.unit
def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch):
# #1115: the payload arrives as a JSON *string*; the old dict-only guard let
# future-dated fiscal periods leak into historical runs.
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
out = avf.get_balance_sheet("AAPL", curr_date="2024-01-01")
assert isinstance(out, str) # callers still receive a str
parsed = json.loads(out)
assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"]
assert [r["fiscalDateEnding"] for r in parsed["quarterlyReports"]] == ["2023-09-30"]
@pytest.mark.unit
def test_fundamentals_no_curr_date_passes_through(monkeypatch):
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
assert avf.get_income_statement("AAPL") == _FUNDAMENTALS_JSON
@pytest.mark.unit
def test_fundamentals_non_json_body_unchanged(monkeypatch):
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json"

View File

@@ -0,0 +1,90 @@
import unittest
from tradingagents.graph.analyst_execution import (
AnalystWallTimeTracker,
build_analyst_execution_plan,
get_initial_analyst_node,
sync_analyst_tracker_from_chunk,
)
class AnalystExecutionPlanTests(unittest.TestCase):
def test_build_plan_preserves_selected_order(self):
plan = build_analyst_execution_plan(["news", "market"])
self.assertEqual([spec.key for spec in plan.specs], ["news", "market"])
self.assertEqual(plan.specs[0].agent_node, "News Analyst")
self.assertEqual(plan.specs[0].tool_node, "tools_news")
self.assertEqual(plan.specs[0].clear_node, "Msg Clear News")
def test_rejects_unknown_analyst_keys(self):
with self.assertRaises(ValueError):
build_analyst_execution_plan(["market", "macro"])
def test_get_initial_analyst_node_uses_plan_metadata(self):
plan = build_analyst_execution_plan(["fundamentals", "news"])
self.assertEqual(
get_initial_analyst_node(plan),
"Fundamentals Analyst",
)
def test_social_key_displays_as_sentiment_analyst(self):
# The wire key stays "social" for saved-config back-compat, but the
# user-visible agent_node label must match the v0.2.5 rename so the
# wall-time summary and any future consumer of agent_node says
# "Sentiment Analyst" rather than the legacy "Social Analyst".
plan = build_analyst_execution_plan(["social"])
spec = plan.specs[0]
self.assertEqual(spec.key, "social")
self.assertEqual(spec.agent_node, "Sentiment Analyst")
self.assertEqual(spec.report_key, "sentiment_report")
class AnalystWallTimeTrackerTests(unittest.TestCase):
def test_records_wall_time_when_analyst_completes(self):
plan = build_analyst_execution_plan(["market", "news"])
tracker = AnalystWallTimeTracker(plan)
tracker.mark_started("market", started_at=10.0)
tracker.mark_completed("market", completed_at=13.5)
self.assertEqual(tracker.get_wall_times(), {"market": 3.5})
def test_formats_summary_in_plan_order(self):
plan = build_analyst_execution_plan(["news", "market"])
tracker = AnalystWallTimeTracker(plan)
tracker.mark_started("market", started_at=20.0)
tracker.mark_completed("market", completed_at=22.25)
tracker.mark_started("news", started_at=10.0)
tracker.mark_completed("news", completed_at=14.0)
self.assertEqual(
tracker.format_summary(),
"Analyst wall time: News 4.00s | Market 2.25s",
)
def test_syncs_wall_time_from_sequential_chunks(self):
plan = build_analyst_execution_plan(["market", "news"])
tracker = AnalystWallTimeTracker(plan)
sync_analyst_tracker_from_chunk(tracker, {}, now=10.0)
self.assertEqual(tracker.get_wall_times(), {})
sync_analyst_tracker_from_chunk(
tracker,
{"market_report": "done"},
now=13.0,
)
self.assertEqual(tracker.get_wall_times(), {"market": 3.0})
sync_analyst_tracker_from_chunk(
tracker,
{"market_report": "done", "news_report": "done"},
now=18.0,
)
self.assertEqual(
tracker.get_wall_times(),
{"market": 3.0, "news": 5.0},
)

View File

@@ -0,0 +1,98 @@
"""Tests for Anthropic effort-parameter gating (#831).
Haiku (any version) and Sonnet 4.5 reject the ``effort`` parameter with a
400. Only Opus 4.5+ and Sonnet 4.6+ accept it. The gate uses a per-family
minimum version so future ``claude-{opus,sonnet}-X-Y`` releases inherit
support automatically.
"""
import pytest
from tradingagents.llm_clients import anthropic_client as mod
def _capture_kwargs(monkeypatch):
captured: dict = {}
monkeypatch.setattr(
mod, "NormalizedChatAnthropic",
lambda **kwargs: captured.setdefault("kwargs", kwargs),
)
return captured
@pytest.mark.unit
class TestEffortGate:
@pytest.mark.parametrize(
"model",
[
"claude-haiku-4-5", "claude-haiku-5-0", "claude-haiku-4-7-preview",
# Sonnet 4.5 (and earlier) 400 on effort — only Sonnet 4.6+ supports it.
"claude-sonnet-4-5", "claude-sonnet-4-0",
],
)
def test_unsupported_models_do_not_receive_effort(self, monkeypatch, model):
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(model=model, effort="medium", api_key="x").get_llm()
assert "effort" not in captured["kwargs"]
@pytest.mark.parametrize(
"model",
[
"claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7",
"claude-sonnet-4-6",
],
)
def test_current_opus_and_sonnet_receive_effort(self, monkeypatch, model):
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(model=model, effort="high", api_key="x").get_llm()
assert captured["kwargs"]["effort"] == "high"
@pytest.mark.parametrize(
"model",
["claude-opus-5-0", "claude-opus-4-8", "claude-sonnet-5-0"],
)
def test_future_opus_sonnet_inherit_effort_via_pattern(self, monkeypatch, model):
"""Forward-compat: new Opus/Sonnet versions don't need a code change."""
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(model=model, effort="low", api_key="x").get_llm()
assert captured["kwargs"]["effort"] == "low"
@pytest.mark.parametrize(
"model",
# Claude 5 family uses single-number version IDs; all are effort-capable.
["claude-sonnet-5", "claude-fable-5", "claude-mythos-5"],
)
def test_claude_5_family_receives_effort(self, monkeypatch, model):
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(model=model, effort="high", api_key="x").get_llm()
assert captured["kwargs"]["effort"] == "high"
def test_mythos_preview_receives_effort(self, monkeypatch):
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(
model="claude-mythos-preview", effort="medium", api_key="x"
).get_llm()
assert captured["kwargs"]["effort"] == "medium"
def test_unknown_anthropic_model_does_not_receive_effort(self, monkeypatch):
"""Default is conservative — unknown models don't get effort to avoid 400s."""
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(
model="claude-experimental-x", effort="medium", api_key="x"
).get_llm()
assert "effort" not in captured["kwargs"]
def test_other_kwargs_still_forwarded_when_effort_skipped(self, monkeypatch):
"""Skipping effort must not break other passthrough kwargs."""
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(
model="claude-haiku-4-5",
effort="medium",
api_key="placeholder",
max_tokens=1024,
timeout=30,
).get_llm()
assert captured["kwargs"]["api_key"] == "placeholder"
assert captured["kwargs"]["max_tokens"] == 1024
assert captured["kwargs"]["timeout"] == 30
assert "effort" not in captured["kwargs"]

148
tests/test_api_key_env.py Normal file
View File

@@ -0,0 +1,148 @@
"""Tests for the canonical provider->env-var mapping and the CLI key-prompt helper."""
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
from tradingagents.llm_clients.api_key_env import PROVIDER_API_KEY_ENV, get_api_key_env
# ---- Mapping coverage -----------------------------------------------------
def test_every_select_llm_provider_choice_has_an_entry():
"""select_llm_provider() must not present a provider the mapping doesn't know about."""
# Mirrors the dropdown order in cli/utils.select_llm_provider so the two
# stay in lockstep. Region-specific keys (qwen-cn / minimax-cn / glm-cn)
# are reached via the secondary region prompt, so they must also be present.
expected = {
"openai", "google", "anthropic", "xai", "deepseek",
"qwen", "qwen-cn",
"glm", "glm-cn",
"minimax", "minimax-cn",
"openrouter", "azure", "ollama",
}
assert expected.issubset(PROVIDER_API_KEY_ENV.keys())
@pytest.mark.parametrize(
"provider,env_var",
[
("openai", "OPENAI_API_KEY"),
("anthropic", "ANTHROPIC_API_KEY"),
("google", "GOOGLE_API_KEY"),
("azure", "AZURE_OPENAI_API_KEY"),
("xai", "XAI_API_KEY"),
("deepseek", "DEEPSEEK_API_KEY"),
("qwen", "DASHSCOPE_API_KEY"),
("qwen-cn", "DASHSCOPE_CN_API_KEY"),
("glm", "ZHIPU_API_KEY"),
("glm-cn", "ZHIPU_CN_API_KEY"),
("minimax", "MINIMAX_API_KEY"),
("minimax-cn", "MINIMAX_CN_API_KEY"),
("openrouter", "OPENROUTER_API_KEY"),
],
)
def test_known_providers_resolve(provider, env_var):
assert get_api_key_env(provider) == env_var
def test_ollama_has_no_key():
assert get_api_key_env("ollama") is None
def test_unknown_provider_returns_none():
assert get_api_key_env("not-a-real-provider") is None
def test_case_insensitive_lookup():
assert get_api_key_env("OpenAI") == "OPENAI_API_KEY"
assert get_api_key_env("QWEN-CN") == "DASHSCOPE_CN_API_KEY"
# ---- ensure_api_key behavior ---------------------------------------------
@pytest.fixture
def cli_utils(monkeypatch):
"""Import cli.utils with a fresh environment so module-level state is consistent."""
import importlib
import cli.utils as cli_utils_module
return importlib.reload(cli_utils_module)
def test_ensure_api_key_returns_existing(monkeypatch, cli_utils):
monkeypatch.setenv("OPENAI_API_KEY", "sk-already-set")
result = cli_utils.ensure_api_key("openai")
assert result == "sk-already-set"
def test_ensure_api_key_no_op_for_ollama(monkeypatch, cli_utils):
# Even with no env var set, ollama should not prompt and should return None.
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
with patch.object(cli_utils, "questionary") as mock_q:
result = cli_utils.ensure_api_key("ollama")
assert result is None
mock_q.password.assert_not_called()
def test_ensure_api_key_unknown_provider_no_prompt(monkeypatch, cli_utils):
with patch.object(cli_utils, "questionary") as mock_q:
result = cli_utils.ensure_api_key("totally-fake-provider")
assert result is None
mock_q.password.assert_not_called()
def test_ensure_api_key_prompts_and_writes_to_env(monkeypatch, tmp_path, cli_utils):
"""When key is missing, user-pasted value must be written to .env AND os.environ."""
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
monkeypatch.chdir(tmp_path)
fake_prompt = type("P", (), {"ask": staticmethod(lambda: "sk-deepseek-test")})()
with patch.object(cli_utils.questionary, "password", return_value=fake_prompt):
result = cli_utils.ensure_api_key("deepseek")
assert result == "sk-deepseek-test"
assert os.environ["DEEPSEEK_API_KEY"] == "sk-deepseek-test"
env_file = tmp_path / ".env"
assert env_file.exists()
assert "DEEPSEEK_API_KEY" in env_file.read_text()
assert "sk-deepseek-test" in env_file.read_text()
def test_ensure_api_key_user_cancels_returns_none(monkeypatch, tmp_path, cli_utils):
"""Empty prompt response (user cancelled) must not write to .env."""
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.chdir(tmp_path)
fake_prompt = type("P", (), {"ask": staticmethod(lambda: None)})()
with patch.object(cli_utils.questionary, "password", return_value=fake_prompt):
result = cli_utils.ensure_api_key("xai")
assert result is None
assert "XAI_API_KEY" not in os.environ
# .env may or may not exist depending on find_dotenv's walk, but if it
# does it must not contain the key.
env_file = tmp_path / ".env"
if env_file.exists():
assert "XAI_API_KEY" not in env_file.read_text()
def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_utils):
"""An existing .env with other keys must be preserved on writeback."""
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.chdir(tmp_path)
env_file = tmp_path / ".env"
env_file.write_text("OPENAI_API_KEY=sk-existing\nOTHER=value\n")
fake_prompt = type("P", (), {"ask": staticmethod(lambda: "sk-openrouter-new")})()
with patch.object(cli_utils.questionary, "password", return_value=fake_prompt):
cli_utils.ensure_api_key("openrouter")
content = env_file.read_text()
assert "OPENAI_API_KEY" in content and "sk-existing" in content
assert "OTHER=value" in content
assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content

View File

@@ -0,0 +1,80 @@
"""Amazon Bedrock — first-class native client via the optional langchain-aws extra.
Auth uses the AWS credential chain (no single key env); the model is a Bedrock
model ID / inference profile ID; langchain-aws is imported lazily with a clear
install hint when the [bedrock] extra is absent.
"""
import sys
import pytest
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.factory import create_llm_client
from tradingagents.llm_clients.validators import validate_model
@pytest.mark.unit
def test_factory_routes_bedrock():
client = create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0")
assert type(client).__name__ == "BedrockClient"
@pytest.mark.unit
def test_bedrock_any_model_and_no_key_env():
assert validate_model("bedrock", "any.model-id:0") is True
# Bedrock uses the AWS credential chain, so there is no single key env.
assert get_api_key_env("bedrock") is None
@pytest.mark.unit
def test_helpful_error_when_langchain_aws_absent(monkeypatch):
import tradingagents.llm_clients.bedrock_client as bc
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
monkeypatch.setitem(sys.modules, "langchain_aws", None) # force ImportError on import
with pytest.raises(ImportError, match=r"bedrock"):
create_llm_client("bedrock", "m").get_llm()
def _capture_kwargs(monkeypatch):
"""Stub _bedrock_class so the constructor kwargs are testable without the
optional langchain-aws extra installed."""
import tradingagents.llm_clients.bedrock_client as bc
captured = {}
class _FakeChat:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr(bc, "_bedrock_class", lambda: _FakeChat)
return captured
@pytest.mark.unit
def test_bearer_token_passed_as_api_key(monkeypatch):
# #1103: a Bedrock API key authenticates without AWS access keys.
captured = _capture_kwargs(monkeypatch)
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bt-secret")
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0").get_llm()
assert captured["api_key"] == "bt-secret"
assert captured["region_name"] == "us-east-1"
@pytest.mark.unit
def test_no_bearer_token_omits_api_key(monkeypatch):
# Without a token, fall back to the AWS credential chain (no api_key kwarg).
captured = _capture_kwargs(monkeypatch)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0").get_llm()
assert "api_key" not in captured
@pytest.mark.unit
def test_construction_when_extra_installed(monkeypatch):
pytest.importorskip("langchain_aws")
import tradingagents.llm_clients.bedrock_client as bc
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1")
llm = create_llm_client("bedrock", "us.anthropic.claude-sonnet-5").get_llm()
assert type(llm).__name__ == "NormalizedChatBedrockConverse"
assert llm.region_name == "eu-west-1"

155
tests/test_capabilities.py Normal file
View File

@@ -0,0 +1,155 @@
"""Unit tests for the LLM capability table."""
from dataclasses import FrozenInstanceError
import pytest
from tradingagents.llm_clients.capabilities import (
get_capabilities,
)
@pytest.mark.unit
class TestExactIdMatches:
def test_deepseek_chat_supports_tool_choice(self):
caps = get_capabilities("deepseek-chat")
assert caps.supports_tool_choice is True
def test_deepseek_reasoner_rejects_tool_choice(self):
caps = get_capabilities("deepseek-reasoner")
assert caps.supports_tool_choice is False
assert caps.requires_reasoning_content_roundtrip is True
def test_deepseek_v4_flash_rejects_tool_choice(self):
caps = get_capabilities("deepseek-v4-flash")
assert caps.supports_tool_choice is False
assert caps.requires_reasoning_content_roundtrip is True
def test_deepseek_v4_pro_rejects_tool_choice(self):
caps = get_capabilities("deepseek-v4-pro")
assert caps.supports_tool_choice is False
assert caps.requires_reasoning_content_roundtrip is True
@pytest.mark.unit
class TestPatternMatches:
"""Forward-compat regex patterns catch unknown DeepSeek and MiniMax variants."""
def test_future_deepseek_v5_inherits_thinking_quirks(self):
caps = get_capabilities("deepseek-v5-flash")
assert caps.supports_tool_choice is False
assert caps.requires_reasoning_content_roundtrip is True
def test_future_deepseek_v9_inherits_thinking_quirks(self):
caps = get_capabilities("deepseek-v9-anything")
assert caps.supports_tool_choice is False
def test_reasoner_variant_inherits_thinking_quirks(self):
caps = get_capabilities("deepseek-reasoner-pro")
assert caps.supports_tool_choice is False
def test_minimax_m3_inherits_thinking_quirks(self):
caps = get_capabilities("MiniMax-M3")
assert caps.supports_tool_choice is False
def test_future_minimax_m4_highspeed_inherits_thinking_quirks(self):
caps = get_capabilities("MiniMax-M4-highspeed")
assert caps.supports_tool_choice is False
@pytest.mark.unit
class TestMinimaxExactMatches:
"""MiniMax M2.x models reject langchain's function-spec dict tool_choice
(official API enum: none/auto only)."""
def test_m2_7_rejects_tool_choice(self):
caps = get_capabilities("MiniMax-M2.7")
assert caps.supports_tool_choice is False
assert caps.supports_json_mode is False # only MiniMax-Text-01 supports json_object
def test_m2_7_highspeed_rejects_tool_choice(self):
assert get_capabilities("MiniMax-M2.7-highspeed").supports_tool_choice is False
def test_m2_1_rejects_tool_choice(self):
assert get_capabilities("MiniMax-M2.1").supports_tool_choice is False
def test_m2_base_rejects_tool_choice(self):
assert get_capabilities("MiniMax-M2").supports_tool_choice is False
def test_m2_x_requires_reasoning_split(self):
# M2.x reasoning models need reasoning_split=True so <think> blocks
# land in reasoning_details instead of content (#826).
for model in ("MiniMax-M2.7", "MiniMax-M2.5-highspeed", "MiniMax-M2"):
assert get_capabilities(model).requires_reasoning_split is True
def test_future_m3_inherits_reasoning_split(self):
assert get_capabilities("MiniMax-M3-highspeed").requires_reasoning_split is True
def test_non_reasoning_minimax_does_not_get_reasoning_split(self):
# Coding Plan, MiniMax-Text-01, and any non-M2-prefixed MiniMax model
# reject the reasoning_split kwarg via the openai SDK's strict
# validation (#826). Default capability has it disabled.
for model in ("minimax-text-01", "MiniMax-Coding-Plan", "abab6.5-chat"):
assert get_capabilities(model).requires_reasoning_split is False
@pytest.mark.unit
class TestDefault:
"""Unknown / non-DeepSeek models get the permissive default."""
def test_gpt_default(self):
caps = get_capabilities("gpt-4.1")
assert caps.supports_tool_choice is True
assert caps.preferred_structured_method == "function_calling"
def test_grok_default(self):
caps = get_capabilities("grok-4-0709")
assert caps.supports_tool_choice is True
def test_unknown_model_default(self):
caps = get_capabilities("totally-made-up-model-id")
assert caps.supports_tool_choice is True
def test_exact_match_precedes_pattern(self):
"""deepseek-chat must NOT match the v\\d regex."""
caps = get_capabilities("deepseek-chat")
assert caps.supports_tool_choice is True
@pytest.mark.unit
class TestOpenRouterDeepSeekNamespace:
"""OpenRouter namespaces DeepSeek as ``deepseek/<id>``; strip it so the
same quirks apply as the native provider (#1199)."""
def test_prefixed_v4_flash_suppresses_tool_choice(self):
# Was falling through to _DEFAULT (tool_choice on) -> slow object-form call.
assert get_capabilities("deepseek/deepseek-v4-flash").supports_tool_choice is False
def test_prefixed_reasoner_suppresses_tool_choice(self):
assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False
def test_prefixed_chat_selects_deepseek_chat_not_default(self):
# Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False
# is what distinguishes them (both keep tool_choice).
caps = get_capabilities("deepseek/deepseek-chat")
assert caps.supports_tool_choice is True
assert caps.supports_json_schema is False # _DEEPSEEK_CHAT, not _DEFAULT
def test_only_official_namespace_is_stripped(self):
# A third-party publisher whose model name WOULD match a deepseek pattern
# must stay _DEFAULT: proves we strip only "deepseek/", not any "*/".
caps = get_capabilities("tngtech/deepseek-v4-flash")
assert caps.supports_tool_choice is True # not thinking
assert caps.supports_json_schema is True # _DEFAULT
def test_native_ids_unchanged(self):
assert get_capabilities("deepseek-v4-flash").supports_tool_choice is False
assert get_capabilities("deepseek-chat").supports_tool_choice is True
@pytest.mark.unit
def test_capabilities_dataclass_is_frozen():
"""Capability rows are immutable so they can be safely shared."""
caps = get_capabilities("deepseek-chat")
with pytest.raises(FrozenInstanceError):
caps.supports_tool_choice = False # type: ignore[misc]

View File

@@ -0,0 +1,155 @@
"""The checkpoint lifecycle is reusable so --checkpoint works on the CLI path (#1249).
Checkpoint setup previously lived only inside ``propagate``; the CLI streamed the
checkpointer-less graph, so ``--checkpoint`` neither saved nor resumed. The
lifecycle is now ``begin_checkpoint`` / ``end_checkpoint`` /
``clear_checkpoint_on_success`` on TradingAgentsGraph, used by both paths. These
tests drive that lifecycle exactly as the CLI does (begin -> stream self.graph ->
clear/end) and prove state is saved and resumed.
"""
from __future__ import annotations
import tempfile
from typing import TypedDict
import pytest
from langgraph.graph import END, StateGraph
from tradingagents.graph.checkpointer import checkpoint_step
from tradingagents.graph.trading_graph import TradingAgentsGraph
_should_crash = False
class _State(TypedDict):
count: int
def _node_a(state: _State) -> dict:
return {"count": state["count"] + 1}
def _node_b(state: _State) -> dict:
if _should_crash:
raise RuntimeError("simulated mid-stream crash")
return {"count": state["count"] + 10}
def _workflow() -> StateGraph:
b = StateGraph(_State)
b.add_node("analyst", _node_a)
b.add_node("trader", _node_b)
b.set_entry_point("analyst")
b.add_edge("analyst", "trader")
b.add_edge("trader", END)
return b
def _bare_graph(tmpdir, *, enabled=True):
g = object.__new__(TradingAgentsGraph)
g.config = {
"checkpoint_enabled": enabled, "data_cache_dir": tmpdir,
"max_debate_rounds": 1, "max_risk_discuss_rounds": 1,
}
g.selected_analysts = ("market",)
g.workflow = _workflow()
g.graph = g.workflow.compile()
g._checkpointer_ctx = None
return g
@pytest.mark.unit
def test_disabled_is_a_noop():
with tempfile.TemporaryDirectory() as tmp:
g = _bare_graph(tmp, enabled=False)
plain = g.graph
assert g.begin_checkpoint("AAPL", "2026-05-08", "stock") is None
assert g.graph is plain # graph not recompiled
g.end_checkpoint() # safe no-op
@pytest.mark.unit
def test_begin_returns_thread_id_and_recompiles():
with tempfile.TemporaryDirectory() as tmp:
g = _bare_graph(tmp)
plain = g.graph
tid = g.begin_checkpoint("AAPL", "2026-05-08", "stock")
try:
assert tid # a real thread_id
assert g.graph is not plain # recompiled with a checkpointer
finally:
g.end_checkpoint()
assert g._checkpointer_ctx is None # restored
@pytest.mark.unit
def test_checkpoint_input_is_none_only_when_resuming():
global _should_crash
with tempfile.TemporaryDirectory() as tmp:
init = {"count": 0}
args = ("AAPL", "2026-05-08", "stock")
# Fresh run: no checkpoint yet -> stream the initial state, then crash.
g1 = _bare_graph(tmp)
tid = g1.begin_checkpoint(*args)
try:
assert g1._resuming is False
assert g1.checkpoint_input(init) is init # not resuming -> initial state
_should_crash = True
with pytest.raises(RuntimeError):
for _ in g1.graph.stream(init, config={"configurable": {"thread_id": tid}}):
pass
finally:
g1.end_checkpoint()
assert g1.checkpoint_input(init) is init # reset after teardown
# A later run finds the checkpoint -> resume by feeding None, not the
# initial state (re-passing it would duplicate messages, #1249).
_should_crash = False
g2 = _bare_graph(tmp)
g2.begin_checkpoint(*args)
try:
assert g2._resuming is True
assert g2.checkpoint_input(init) is None
finally:
g2.end_checkpoint()
@pytest.mark.unit
def test_cli_style_usage_saves_then_resumes():
global _should_crash
with tempfile.TemporaryDirectory() as tmp:
cfg_args = ("AAPL", "2026-05-08", "stock")
# Run 1 (the CLI path): begin -> stream self.graph -> crash at 'trader'.
_should_crash = True
g1 = _bare_graph(tmp)
tid = g1.begin_checkpoint(*cfg_args)
args = {"config": {"configurable": {"thread_id": tid}}}
try:
with pytest.raises(RuntimeError):
for _ in g1.graph.stream({"count": 0}, **args):
pass
finally:
g1.end_checkpoint()
# A checkpoint was saved for this run signature (so --checkpoint works).
sig = g1._run_signature("stock")
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is not None
# Run 2 (fresh graph, as a new CLI invocation): resume and finish.
_should_crash = False
g2 = _bare_graph(tmp)
tid2 = g2.begin_checkpoint(*cfg_args)
assert tid2 == tid # stable id -> same thread resumes
try:
result = g2.graph.invoke(None, config={"configurable": {"thread_id": tid2}})
assert result["count"] == 11 # analyst(+1) resumed into trader(+10)
g2.clear_checkpoint_on_success(*cfg_args)
finally:
g2.end_checkpoint()
# Cleared on success -> a later run starts fresh.
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None

View File

@@ -0,0 +1,218 @@
"""Test checkpoint resume: crash mid-analysis, re-run resumes from last node."""
import tempfile
import unittest
from typing import TypedDict
from langgraph.graph import END, StateGraph
from tradingagents.graph.checkpointer import (
checkpoint_step,
clear_checkpoint,
get_checkpointer,
has_checkpoint,
thread_id,
)
# Mutable flag to simulate crash on first run
_should_crash = False
class _SimpleState(TypedDict):
count: int
def _node_a(state: _SimpleState) -> dict:
return {"count": state["count"] + 1}
def _node_b(state: _SimpleState) -> dict:
if _should_crash:
raise RuntimeError("simulated mid-analysis crash")
return {"count": state["count"] + 10}
def _build_graph() -> StateGraph:
builder = StateGraph(_SimpleState)
builder.add_node("analyst", _node_a)
builder.add_node("trader", _node_b)
builder.set_entry_point("analyst")
builder.add_edge("analyst", "trader")
builder.add_edge("trader", END)
return builder
class TestCheckpointResume(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.ticker = "TEST"
self.date = "2026-04-20"
def test_crash_and_resume(self):
"""Crash at 'trader' node, then resume from checkpoint."""
global _should_crash
builder = _build_graph()
tid = thread_id(self.ticker, self.date)
cfg = {"configurable": {"thread_id": tid}}
# Run 1: crash at trader node
_should_crash = True
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
with self.assertRaises(RuntimeError):
graph.invoke({"count": 0}, config=cfg)
# Checkpoint should exist at step 1 (analyst completed)
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
step = checkpoint_step(self.tmpdir, self.ticker, self.date)
self.assertEqual(step, 1)
# Run 2: resume — trader succeeds this time
_should_crash = False
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
result = graph.invoke(None, config=cfg)
# analyst added 1, trader added 10 → 11
self.assertEqual(result["count"], 11)
def test_clear_checkpoint_allows_fresh_start(self):
"""After clearing, the graph starts from scratch."""
global _should_crash
builder = _build_graph()
tid = thread_id(self.ticker, self.date)
cfg = {"configurable": {"thread_id": tid}}
# Create a checkpoint by crashing
_should_crash = True
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
with self.assertRaises(RuntimeError):
graph.invoke({"count": 0}, config=cfg)
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
# Clear it
clear_checkpoint(self.tmpdir, self.ticker, self.date)
self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, self.date))
# Fresh run succeeds from scratch
_should_crash = False
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
result = graph.invoke({"count": 0}, config=cfg)
self.assertEqual(result["count"], 11)
def test_different_date_starts_fresh(self):
"""A different date must NOT resume from an existing checkpoint."""
global _should_crash
builder = _build_graph()
date2 = "2026-04-21"
# Run with date1 — crash to leave a checkpoint
_should_crash = True
tid1 = thread_id(self.ticker, self.date)
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
with self.assertRaises(RuntimeError):
graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid1}})
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
# date2 should have no checkpoint
self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, date2))
# Run with date2 — should start fresh and succeed
_should_crash = False
tid2 = thread_id(self.ticker, date2)
self.assertNotEqual(tid1, tid2)
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
result = graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid2}})
# Fresh run: analyst +1, trader +10 = 11
self.assertEqual(result["count"], 11)
# Original date checkpoint still exists (untouched)
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
class TestCheckpointSignature(unittest.TestCase):
"""A different graph shape (analyst selection / depth / asset mode) must not
resume the previous run's checkpoint (#1089)."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.ticker = "TEST"
self.date = "2026-04-20"
def test_empty_signature_is_legacy_id(self):
self.assertEqual(
thread_id(self.ticker, self.date),
thread_id(self.ticker, self.date, ""),
)
def test_signature_changes_thread_id(self):
legacy = thread_id(self.ticker, self.date)
sig_a = thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
sig_b = thread_id(self.ticker, self.date, "analysts=market|asset=stock")
self.assertNotEqual(sig_a, sig_b) # different graph shapes differ
self.assertNotEqual(legacy, sig_a) # signature-keyed differs from legacy
self.assertEqual( # same inputs are stable
sig_a, thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
)
def test_different_signature_starts_fresh(self):
global _should_crash
builder = _build_graph()
sig1 = "analysts=market,news,fundamentals|asset=stock"
sig2 = "analysts=market|asset=stock" # dropped analysts -> different graph
_should_crash = True
tid1 = thread_id(self.ticker, self.date, sig1)
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
with self.assertRaises(RuntimeError):
graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid1}})
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
# A different graph shape has no checkpoint to resume from.
self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, self.date, sig2))
_should_crash = False
tid2 = thread_id(self.ticker, self.date, sig2)
self.assertNotEqual(tid1, tid2)
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
result = graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid2}})
self.assertEqual(result["count"], 11)
# sig1's checkpoint remains untouched.
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
def test_run_signature_captures_graph_shape(self):
from tradingagents.graph.trading_graph import TradingAgentsGraph
# Build a bare instance to exercise the pure helper without heavy __init__.
g = object.__new__(TradingAgentsGraph)
g.selected_analysts = ("market", "news")
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
base = g._run_signature("stock")
self.assertNotEqual(base, g._run_signature("crypto")) # asset mode
g.selected_analysts = ("market",)
self.assertNotEqual(base, g._run_signature("stock")) # analyst selection
g.selected_analysts = ("market", "news")
g.config = {"max_debate_rounds": 3, "max_risk_discuss_rounds": 1}
self.assertNotEqual(base, g._run_signature("stock")) # debate depth
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 5}
self.assertNotEqual(base, g._run_signature("stock")) # risk depth
# Stable for identical inputs.
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
self.assertEqual(base, g._run_signature("stock"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,69 @@
"""CLI config precedence (#976, #977).
An explicit environment override for the debate/risk round counts, or the
checkpoint flag, must win over the interactive research-depth selection — the CLI
must not clobber an env-configured value back to a prompt/flag default.
"""
from unittest import mock
import pytest
import cli.main as m
# Minimal selections dict shaped like get_user_selections()'s return value.
SELECTIONS = {
"research_depth": 5,
"shallow_thinker": "gpt-5.4-mini",
"deep_thinker": "gpt-5.5",
"backend_url": None,
"llm_provider": "openai",
"google_thinking_level": None,
"openai_reasoning_effort": None,
"anthropic_effort": None,
"output_language": "English",
}
def test_research_depth_sets_both_rounds_without_env(monkeypatch):
for var in ("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "TRADINGAGENTS_MAX_RISK_ROUNDS"):
monkeypatch.delenv(var, raising=False)
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
assert cfg["max_debate_rounds"] == 5
assert cfg["max_risk_discuss_rounds"] == 5
def test_env_round_counts_win_over_selection(monkeypatch):
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "2")
monkeypatch.setenv("TRADINGAGENTS_MAX_RISK_ROUNDS", "4")
# DEFAULT_CONFIG already reflects the env (applied at import); emulate that.
patched = dict(m.DEFAULT_CONFIG, max_debate_rounds=2, max_risk_discuss_rounds=4)
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
assert cfg["max_debate_rounds"] == 2 # env value, not research_depth=5
assert cfg["max_risk_discuss_rounds"] == 4
def test_partial_env_only_overrides_that_count(monkeypatch):
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "2")
monkeypatch.delenv("TRADINGAGENTS_MAX_RISK_ROUNDS", raising=False)
patched = dict(m.DEFAULT_CONFIG, max_debate_rounds=2)
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
assert cfg["max_debate_rounds"] == 2 # env wins
assert cfg["max_risk_discuss_rounds"] == 5 # falls through to research_depth
def test_checkpoint_none_preserves_env_default():
patched = dict(m.DEFAULT_CONFIG, checkpoint_enabled=True) # e.g. env-enabled
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
assert cfg["checkpoint_enabled"] is True # not clobbered back to False
@pytest.mark.parametrize("flag", [True, False])
def test_checkpoint_flag_overrides_env(flag):
patched = dict(m.DEFAULT_CONFIG, checkpoint_enabled=not flag)
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
cfg = m._build_run_config(SELECTIONS, checkpoint=flag)
assert cfg["checkpoint_enabled"] is flag

149
tests/test_cli_env_skip.py Normal file
View File

@@ -0,0 +1,149 @@
"""Tests for env-driven CLI behavior (#897, #873).
The config-layer override (TRADINGAGENTS_* -> DEFAULT_CONFIG) is covered by
test_env_overrides.py. These tests cover the CLI layer: an env-configured
provider/model/language must skip its interactive prompt and use the value.
"""
import os
import unittest
from unittest import mock
import pytest
@pytest.mark.unit
class TestProviderDefaultUrl(unittest.TestCase):
def test_known_providers_resolve(self):
from cli.utils import provider_default_url
self.assertEqual(provider_default_url("openai"), "https://api.openai.com/v1")
self.assertEqual(provider_default_url("DeepSeek"), "https://api.deepseek.com")
self.assertIsNone(provider_default_url("google")) # uses SDK default
def test_unknown_provider_returns_none(self):
from cli.utils import provider_default_url
self.assertIsNone(provider_default_url("not-a-provider"))
def test_ollama_honors_base_url_env(self):
from cli.utils import provider_default_url
with mock.patch.dict(os.environ, {"OLLAMA_BASE_URL": "http://host:1234/v1"}):
self.assertEqual(provider_default_url("ollama"), "http://host:1234/v1")
@pytest.mark.unit
class TestCliSkipsPromptsFromEnv(unittest.TestCase):
def test_env_config_skips_llm_prompts(self):
import cli.main as m
env = {
"TRADINGAGENTS_LLM_PROVIDER": "openai",
"TRADINGAGENTS_DEEP_THINK_LLM": "kimi-k2.5",
"TRADINGAGENTS_QUICK_THINK_LLM": "deepseek-v4-pro",
"TRADINGAGENTS_LLM_BACKEND_URL": "https://opencode.ai/zen/go/v1",
"TRADINGAGENTS_OUTPUT_LANGUAGE": "Japanese",
}
fake_cfg = dict(m.DEFAULT_CONFIG)
fake_cfg.update({
"llm_provider": "openai",
"backend_url": "https://opencode.ai/zen/go/v1",
"quick_think_llm": "deepseek-v4-pro",
"deep_think_llm": "kimi-k2.5",
"output_language": "Japanese",
})
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
mock.patch.object(m, "fetch_announcements", return_value=None), \
mock.patch.object(m, "display_announcements"), \
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
mock.patch.object(m, "select_analysts", return_value=[]), \
mock.patch.object(m, "select_research_depth", return_value=1), \
mock.patch.object(m, "ensure_api_key") as ensure_key, \
mock.patch.object(m, "select_llm_provider") as prompt_provider, \
mock.patch.object(m, "ask_output_language") as prompt_lang, \
mock.patch.object(m, "select_shallow_thinking_agent") as prompt_quick, \
mock.patch.object(m, "select_deep_thinking_agent") as prompt_deep:
sel = m.get_user_selections()
# None of the LLM selection prompts should have been shown.
prompt_provider.assert_not_called()
prompt_lang.assert_not_called()
prompt_quick.assert_not_called()
prompt_deep.assert_not_called()
# API key is still verified for the env-configured provider.
ensure_key.assert_called_once()
# The env values flow into the returned selections.
self.assertEqual(sel["llm_provider"], "openai")
self.assertEqual(sel["backend_url"], "https://opencode.ai/zen/go/v1")
self.assertEqual(sel["shallow_thinker"], "deepseek-v4-pro")
self.assertEqual(sel["deep_thinker"], "kimi-k2.5")
self.assertEqual(sel["output_language"], "Japanese")
@pytest.mark.unit
class TestResearchDepthSkippedFromEnv(unittest.TestCase):
def test_both_round_envs_skip_depth_prompt(self):
import cli.main as m
env = {
"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "2",
"TRADINGAGENTS_MAX_RISK_ROUNDS": "4",
}
fake_cfg = dict(m.DEFAULT_CONFIG)
fake_cfg.update({"max_debate_rounds": 2, "max_risk_discuss_rounds": 4})
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
mock.patch.object(m, "fetch_announcements", return_value=None), \
mock.patch.object(m, "display_announcements"), \
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
mock.patch.object(m, "select_analysts", return_value=[]), \
mock.patch.object(m, "select_research_depth") as prompt_depth, \
mock.patch.object(m, "ensure_api_key"), \
mock.patch.object(m, "select_llm_provider", return_value=("openai", None)), \
mock.patch.object(m, "ask_output_language", return_value="English"), \
mock.patch.object(m, "select_shallow_thinking_agent", return_value="gpt-5.4-mini"), \
mock.patch.object(m, "select_deep_thinking_agent", return_value="gpt-5.5"), \
mock.patch.object(m, "ask_openai_reasoning_effort", return_value=None):
sel = m.get_user_selections()
# The research-depth prompt is skipped; the value comes from the env config.
prompt_depth.assert_not_called()
self.assertEqual(sel["research_depth"], 2)
@pytest.mark.unit
class TestReasoningEffortSkippedFromEnv(unittest.TestCase):
def test_effort_env_skips_step8_prompt(self):
import cli.main as m
env = {"TRADINGAGENTS_OPENAI_REASONING_EFFORT": "high"}
fake_cfg = dict(m.DEFAULT_CONFIG)
fake_cfg.update({"openai_reasoning_effort": "high"})
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
mock.patch.object(m, "fetch_announcements", return_value=None), \
mock.patch.object(m, "display_announcements"), \
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
mock.patch.object(m, "select_analysts", return_value=[]), \
mock.patch.object(m, "select_research_depth", return_value=1), \
mock.patch.object(m, "ensure_api_key"), \
mock.patch.object(m, "select_llm_provider", return_value=("openai", None)), \
mock.patch.object(m, "ask_output_language", return_value="English"), \
mock.patch.object(m, "select_shallow_thinking_agent", return_value="gpt-5.4-mini"), \
mock.patch.object(m, "select_deep_thinking_agent", return_value="gpt-5.5"), \
mock.patch.object(m, "ask_openai_reasoning_effort") as prompt_effort:
sel = m.get_user_selections()
# The reasoning-effort prompt is skipped; the value comes from env config.
prompt_effort.assert_not_called()
self.assertEqual(sel["openai_reasoning_effort"], "high")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,57 @@
"""A terminal without a console buffer must fail with one actionable line (#1138).
prompt_toolkit raises NoConsoleScreenBufferError before the first prompt in
non-interactive Windows terminals; the CLI should not surface that traceback.
The Windows-only exception import must also stay inert on other platforms.
"""
from __future__ import annotations
import sys
from typer.testing import CliRunner
import cli.main as m
def test_no_console_error_tuple_matches_platform():
# Off Windows the win32 module is never imported (it asserts the platform),
# so the tuple is empty — which `except` accepts and never matches. On
# Windows it holds the real exception type, and a broken prompt_toolkit
# would raise at import rather than silently disabling the handler.
assert isinstance(m._NO_CONSOLE_ERRORS, tuple)
assert all(issubclass(e, BaseException) for e in m._NO_CONSOLE_ERRORS)
if sys.platform == "win32":
assert m._NO_CONSOLE_ERRORS, "Windows must resolve the console error type"
else:
assert m._NO_CONSOLE_ERRORS == ()
def test_missing_console_prints_actionable_message(monkeypatch):
class _NoConsole(Exception):
pass
# Simulate the Windows failure on any platform by registering a stand-in.
monkeypatch.setattr(m, "_NO_CONSOLE_ERRORS", (_NoConsole,))
def _boom(*a, **k):
raise _NoConsole("No Windows console found. Are you running cmd.exe?")
monkeypatch.setattr(m, "run_analysis", _boom)
result = CliRunner().invoke(m.app, [])
assert result.exit_code == 1
assert "no Windows console available" in result.output
# The raw prompt_toolkit traceback must not reach the user.
assert "Traceback" not in result.output
def test_unrelated_errors_still_propagate(monkeypatch):
# The handler must stay narrow: only the console error is translated.
monkeypatch.setattr(m, "_NO_CONSOLE_ERRORS", (RuntimeError,))
def _boom(*a, **k):
raise ValueError("unrelated")
monkeypatch.setattr(m, "run_analysis", _boom)
result = CliRunner().invoke(m.app, [])
assert isinstance(result.exception, ValueError)

View File

@@ -0,0 +1,62 @@
"""CLI symbol validation/classification must agree with the data path.
Regressions for #980 (validation rejected GC=F), #981 (BTCUSD misclassified as
stock), #982 (BTC-USDT accepted but unpriceable on Yahoo).
"""
import pytest
from cli.models import AssetType
from cli.utils import detect_asset_type, is_valid_ticker_input, normalize_ticker_symbol
from tradingagents.dataflows.symbol_utils import normalize_symbol
# --- #982: stablecoin-quoted crypto normalizes to Yahoo's -USD pair ---
@pytest.mark.parametrize("raw,expected", [
("BTCUSD", "BTC-USD"),
("BTCUSDT", "BTC-USD"),
("BTC-USDT", "BTC-USD"),
("BTC-USDC", "BTC-USD"),
("ethusdt", "ETH-USD"),
# non-crypto must be untouched
("AAPL", "AAPL"),
("GC=F", "GC=F"),
("600519.SS", "600519.SS"),
("EURUSD", "EURUSD=X"),
])
def test_normalize_symbol_crypto_and_passthrough(raw, expected):
assert normalize_symbol(raw) == expected
# --- #980: validation accepts Yahoo futures/forex symbols ---
@pytest.mark.parametrize("value,ok", [
("GC=F", True),
("EURUSD=X", True),
("AAPL", True),
("0700.HK", True),
("^GSPC", True),
("", True), # empty -> defaults to SPY downstream
("bad symbol!", False), # space + '!' rejected
("A" * 40, False), # too long
])
def test_ticker_input_validation(value, ok):
assert is_valid_ticker_input(value) is ok
# --- #981/#982: asset-type classified on the canonical symbol ---
@pytest.mark.parametrize("raw,expected", [
("BTCUSD", AssetType.CRYPTO),
("BTC-USDT", AssetType.CRYPTO),
("BTC-USD", AssetType.CRYPTO),
("ETHUSD", AssetType.CRYPTO),
("AAPL", AssetType.STOCK),
("GC=F", AssetType.STOCK),
("600519.SS", AssetType.STOCK),
])
def test_detect_asset_type(raw, expected):
assert detect_asset_type(raw) == expected
def test_cli_normalize_delegates_to_data_layer():
# CLI must produce the same canonical symbol the data path will price.
for raw in ("XAUUSD", "BTCUSD", "btc-usdt", "AAPL"):
assert normalize_ticker_symbol(raw) == normalize_symbol(raw)

View File

@@ -0,0 +1,56 @@
import unittest
from cli.models import AnalystType, AssetType
from cli.utils import detect_asset_type, filter_analysts_for_asset_type
from tradingagents.graph.propagation import Propagator
class CryptoAssetModeTests(unittest.TestCase):
def test_detects_crypto_pair_symbols(self):
self.assertEqual(detect_asset_type("BTC-USD"), AssetType.CRYPTO)
self.assertEqual(detect_asset_type("eth-usd"), AssetType.CRYPTO)
def test_defaults_non_crypto_symbols_to_stock(self):
self.assertEqual(detect_asset_type("AAPL"), AssetType.STOCK)
self.assertEqual(detect_asset_type("SPY"), AssetType.STOCK)
def test_filters_out_fundamentals_analyst_for_crypto(self):
analysts = [
AnalystType.MARKET,
AnalystType.SOCIAL,
AnalystType.NEWS,
AnalystType.FUNDAMENTALS,
]
self.assertEqual(
filter_analysts_for_asset_type(analysts, AssetType.CRYPTO),
[
AnalystType.MARKET,
AnalystType.SOCIAL,
AnalystType.NEWS,
],
)
def test_keeps_all_analysts_for_stock(self):
analysts = [
AnalystType.MARKET,
AnalystType.SOCIAL,
AnalystType.NEWS,
AnalystType.FUNDAMENTALS,
]
self.assertEqual(
filter_analysts_for_asset_type(analysts, AssetType.STOCK),
analysts,
)
def test_propagator_includes_asset_type_in_initial_state(self):
state = Propagator().create_initial_state(
"BTC-USD", "2026-04-18", asset_type=AssetType.CRYPTO.value
)
self.assertEqual(state["asset_type"], AssetType.CRYPTO.value)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,61 @@
"""Config isolation: get/set must not leak nested-dict references."""
import copy
import unittest
import pytest
import tradingagents.default_config as default_config
from tradingagents.dataflows.config import get_config, set_config
@pytest.mark.unit
class DataflowsConfigIsolationTests(unittest.TestCase):
def setUp(self):
set_config(copy.deepcopy(default_config.DEFAULT_CONFIG))
def test_get_config_returns_deep_copy(self):
cfg = get_config()
cfg["data_vendors"]["core_stock_apis"] = "alpha_vantage"
cfg["tool_vendors"]["get_stock_data"] = "alpha_vantage"
fresh = get_config()
self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "yfinance")
self.assertNotIn("get_stock_data", fresh["tool_vendors"])
def test_set_config_does_not_alias_caller_nested_dicts(self):
custom = copy.deepcopy(default_config.DEFAULT_CONFIG)
custom["data_vendors"]["core_stock_apis"] = "alpha_vantage"
custom["tool_vendors"]["get_stock_data"] = "alpha_vantage"
set_config(custom)
custom["data_vendors"]["core_stock_apis"] = "yfinance"
custom["tool_vendors"]["get_stock_data"] = "yfinance"
fresh = get_config()
self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage")
self.assertEqual(fresh["tool_vendors"]["get_stock_data"], "alpha_vantage")
def test_partial_nested_update_preserves_existing_defaults(self):
set_config(
{
"data_vendors": {
"core_stock_apis": "alpha_vantage",
}
}
)
fresh = get_config()
self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage")
self.assertEqual(fresh["data_vendors"]["technical_indicators"], "yfinance")
self.assertEqual(fresh["data_vendors"]["fundamental_data"], "yfinance")
self.assertEqual(fresh["data_vendors"]["news_data"], "yfinance")
def test_nested_dict_updates_merge_one_level_deep(self):
set_config({"tool_vendors": {"get_stock_data": "alpha_vantage"}})
set_config({"tool_vendors": {"get_news": "alpha_vantage"}})
fresh = get_config()
self.assertEqual(fresh["tool_vendors"]["get_stock_data"], "alpha_vantage")
self.assertEqual(fresh["tool_vendors"]["get_news"], "alpha_vantage")

View File

@@ -0,0 +1,61 @@
"""yfinance treats ``end`` as exclusive; we must request one extra day so the
requested end_date (and the current day) is actually included.
Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date
row omitted).
"""
import pandas as pd
import pytest
import tradingagents.dataflows.stockstats_utils as su
import tradingagents.dataflows.y_finance as yfin
from tradingagents.dataflows.config import set_config
@pytest.mark.unit
def test_get_yfin_requests_inclusive_end(monkeypatch):
captured = {}
class FakeTicker:
def __init__(self, symbol):
pass
def history(self, start, end):
captured["start"] = start
captured["end"] = end
idx = pd.to_datetime(["2025-05-08", "2025-05-09"])
return pd.DataFrame(
{"Open": [1.0, 2.0], "High": [1.0, 2.0], "Low": [1.0, 2.0],
"Close": [1.0, 2.0], "Volume": [1, 2]},
index=idx,
)
monkeypatch.setattr(yfin.yf, "Ticker", FakeTicker)
out = yfin.get_YFin_data_online("AAPL", "2025-05-01", "2025-05-09")
# end is requested one day past end_date so 2025-05-09 is included (#987).
assert captured["end"] == "2025-05-10"
# Header still reflects the requested range, not the internal +1 day.
assert "to 2025-05-09" in out
@pytest.mark.unit
def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
set_config({"data_cache_dir": str(tmp_path)})
captured = {}
def fake_download(symbol, start, end, **kwargs):
captured["end"] = end
idx = pd.to_datetime([pd.Timestamp.today().normalize()])
return pd.DataFrame(
{"Open": [100.0], "High": [100.0], "Low": [100.0],
"Close": [100.0], "Volume": [1]},
index=idx,
)
monkeypatch.setattr(su.yf, "download", fake_download)
today = pd.Timestamp.today().strftime("%Y-%m-%d")
su.load_ohlcv("AAPL", today)
expected_end = (pd.Timestamp.today() + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
assert captured["end"] == expected_end # tomorrow -> today's row included (#986)

View File

@@ -0,0 +1,111 @@
"""The first speaker in each debate must not rebut a nonexistent argument (#1176).
Each debate round's opening speaker receives an empty opponent response; the
prompt used to interpolate it into a "refute the opponent" instruction, so models
fabricated the other side's position. All five debators (bull, bear, and the
three risk analysts) now substitute an explicit opening marker when the opponent
has not spoken, and pass a real argument through unchanged.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from tradingagents.agents.researchers.bear_researcher import create_bear_researcher
from tradingagents.agents.researchers.bull_researcher import create_bull_researcher
from tradingagents.agents.risk_mgmt.aggressive_debator import create_aggressive_debator
from tradingagents.agents.risk_mgmt.conservative_debator import create_conservative_debator
from tradingagents.agents.risk_mgmt.neutral_debator import create_neutral_debator
from tradingagents.agents.utils.agent_utils import opponent_argument_or_opening
_REPORTS = {
"company_of_interest": "AAPL", "asset_type": "stock",
"market_report": "m", "sentiment_report": "s",
"news_report": "n", "fundamentals_report": "f",
}
def _capturing_llm(captured: dict):
llm = MagicMock()
llm.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or MagicMock(content="argument")
)
return llm
def _investment_state(current_response):
return {
**_REPORTS,
"count": 0,
"investment_debate_state": {
"history": "", "bull_history": "", "bear_history": "",
"current_response": current_response, "count": 0,
},
}
def _risk_state(**responses):
base = {
"current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "history": "", "aggressive_history": "",
"conservative_history": "", "neutral_history": "", "count": 0,
}
base.update(responses)
return {**_REPORTS, "trader_investment_plan": "plan", "risk_debate_state": base}
# --- shared helper ----------------------------------------------------------
@pytest.mark.unit
def test_helper_marks_empty_and_passes_through():
assert "has not spoken yet" in opponent_argument_or_opening("", "bear analyst")
assert opponent_argument_or_opening(" real point ", "bear") == "real point"
# --- researchers ------------------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize(
"factory,opponent",
[(create_bull_researcher, "bear"), (create_bear_researcher, "bull")],
)
def test_researcher_opening_has_no_phantom_opponent(factory, opponent):
captured = {}
factory(_capturing_llm(captured))(_investment_state(""))
assert "has not spoken yet" in captured["prompt"]
@pytest.mark.unit
def test_researcher_passes_real_opponent_argument():
captured = {}
state = _investment_state("Bear Analyst: valuation is stretched")
create_bull_researcher(_capturing_llm(captured))(state)
assert "valuation is stretched" in captured["prompt"]
assert "has not spoken yet" not in captured["prompt"]
# --- risk debators ----------------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize(
"factory", [create_aggressive_debator, create_conservative_debator, create_neutral_debator]
)
def test_risk_opening_has_no_phantom_opponent(factory):
captured = {}
factory(_capturing_llm(captured))(_risk_state())
# Both opponent slots were empty -> two opening markers, no fabricated args.
assert captured["prompt"].count("has not spoken yet") == 2
@pytest.mark.unit
def test_risk_passes_real_opponent_arguments():
captured = {}
state = _risk_state(
current_conservative_response="Conservative Analyst: trim risk",
current_neutral_response="Neutral Analyst: hold steady",
)
create_aggressive_debator(_capturing_llm(captured))(state)
assert "trim risk" in captured["prompt"]
assert "hold steady" in captured["prompt"]
assert "has not spoken yet" not in captured["prompt"]

View File

@@ -0,0 +1,239 @@
"""Tests for DeepSeekChatOpenAI thinking-mode behaviour.
Two pieces verified:
1. ``reasoning_content`` is captured on receive into the AIMessage's
``additional_kwargs`` and re-attached on send so DeepSeek's API
sees the same value across turns.
2. ``with_structured_output`` consults the capability table and
suppresses ``tool_choice`` for models that reject it (V4 + reasoner),
matching DeepSeek's official tool-calling pattern at
https://api-docs.deepseek.com/guides/tool_calls.
"""
import os
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompt_values import ChatPromptValue
from pydantic import BaseModel
from tradingagents.llm_clients.openai_client import (
DeepSeekChatOpenAI,
NormalizedChatOpenAI,
_input_to_messages,
)
# ---------------------------------------------------------------------------
# _input_to_messages — the helper that handles list / ChatPromptValue / other
# (Gemini bot review note: non-list inputs must also work)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestInputToMessages:
def test_list_input_returned_as_is(self):
msgs = [HumanMessage(content="hi")]
assert _input_to_messages(msgs) is msgs
def test_chat_prompt_value_unwrapped(self):
msgs = [HumanMessage(content="hi")]
prompt_value = ChatPromptValue(messages=msgs)
assert _input_to_messages(prompt_value) == msgs
def test_string_input_yields_empty_list(self):
# A bare string isn't a message-bearing input; the caller's normal
# langchain conversion happens upstream of _get_request_payload.
assert _input_to_messages("hello") == []
# ---------------------------------------------------------------------------
# Reasoning content propagation across turns
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeepSeekReasoningContent:
def _client(self):
os.environ.setdefault("DEEPSEEK_API_KEY", "placeholder")
return DeepSeekChatOpenAI(
model="deepseek-v4-flash",
api_key="placeholder",
base_url="https://api.deepseek.com",
)
def test_capture_on_receive(self):
"""When the response carries reasoning_content, it lands on the
AIMessage's additional_kwargs so the next turn can echo it back."""
client = self._client()
result = client._create_chat_result(
{
"model": "deepseek-v4-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Plan: buy NVDA.",
"reasoning_content": "Step 1: trend is up. Step 2: ...",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
)
ai = result.generations[0].message
assert ai.additional_kwargs["reasoning_content"] == "Step 1: trend is up. Step 2: ..."
def test_propagate_on_send(self):
"""When an outgoing AIMessage carries reasoning_content, the request
payload echoes it on the corresponding message dict."""
client = self._client()
prior = AIMessage(
content="Plan",
additional_kwargs={"reasoning_content": "weighed bull case"},
)
new_user = HumanMessage(content="Refine.")
payload = client._get_request_payload([prior, new_user])
# Find the assistant message in the payload
assistant_dicts = [m for m in payload["messages"] if m.get("role") == "assistant"]
assert assistant_dicts, "assistant message missing from outgoing payload"
assert assistant_dicts[0]["reasoning_content"] == "weighed bull case"
def test_propagate_through_chat_prompt_value(self):
"""Gemini bot review note: non-list inputs (ChatPromptValue) must
also propagate reasoning_content."""
client = self._client()
prior = AIMessage(
content="Plan",
additional_kwargs={"reasoning_content": "weighed bull case"},
)
prompt_value = ChatPromptValue(messages=[prior, HumanMessage(content="Refine.")])
payload = client._get_request_payload(prompt_value)
assistant_dicts = [m for m in payload["messages"] if m.get("role") == "assistant"]
assert assistant_dicts[0]["reasoning_content"] == "weighed bull case"
# ---------------------------------------------------------------------------
# Capability-driven structured output: tool_choice suppressed for V4 + reasoner
# ---------------------------------------------------------------------------
def _bound_kwargs(runnable):
"""Extract bind() kwargs from a with_structured_output result."""
first = runnable.steps[0] if hasattr(runnable, "steps") else runnable
return getattr(first, "kwargs", {})
@pytest.mark.unit
class TestStructuredOutputCapabilityDispatch:
"""DeepSeek V4 and reasoner reject the tool_choice parameter
(official guide: api-docs.deepseek.com/guides/tool_calls passes
tools=[...] without tool_choice). Verify the capability dispatch
suppresses tool_choice for those models and sends it for chat."""
class _Sample(BaseModel):
answer: str
def _client(self, model):
return DeepSeekChatOpenAI(
model=model, api_key="placeholder", base_url="https://api.deepseek.com",
)
def test_chat_sends_tool_choice(self):
bound = self._client("deepseek-chat").with_structured_output(self._Sample)
assert _bound_kwargs(bound).get("tool_choice") is not None
def test_reasoner_suppresses_tool_choice(self):
bound = self._client("deepseek-reasoner").with_structured_output(self._Sample)
# tool_choice is either absent or explicitly None — both are valid
# signals that langchain's bind_tools will skip the parameter.
assert _bound_kwargs(bound).get("tool_choice") in (None, ...) or \
"tool_choice" not in _bound_kwargs(bound)
def test_v4_flash_suppresses_tool_choice(self):
bound = self._client("deepseek-v4-flash").with_structured_output(self._Sample)
assert _bound_kwargs(bound).get("tool_choice") is None or \
"tool_choice" not in _bound_kwargs(bound)
def test_v4_pro_suppresses_tool_choice(self):
bound = self._client("deepseek-v4-pro").with_structured_output(self._Sample)
assert _bound_kwargs(bound).get("tool_choice") is None or \
"tool_choice" not in _bound_kwargs(bound)
def test_future_v_variant_via_regex(self):
"""Forward-compat: unknown deepseek-v\\d-* IDs inherit V4 quirks."""
bound = self._client("deepseek-v5-hypothetical").with_structured_output(self._Sample)
assert _bound_kwargs(bound).get("tool_choice") is None or \
"tool_choice" not in _bound_kwargs(bound)
def test_schema_is_still_bound_as_tool(self):
"""tool_choice is suppressed, but the schema is still bound as a tool —
exactly matching DeepSeek's official tool-calling examples."""
bound = self._client("deepseek-reasoner").with_structured_output(self._Sample)
kwargs = _bound_kwargs(bound)
tools = kwargs.get("tools", [])
assert any(
t.get("function", {}).get("name") == "_Sample" for t in tools
), f"schema not bound as a tool: {tools}"
# ---------------------------------------------------------------------------
# Live API: structured output round-trips against the real DeepSeek backend
# ---------------------------------------------------------------------------
def _has_real_deepseek_key():
key = os.environ.get("DEEPSEEK_API_KEY", "")
return bool(key) and key != "placeholder"
@pytest.mark.integration
@pytest.mark.skipif(
not _has_real_deepseek_key(),
reason="DEEPSEEK_API_KEY not set (or placeholder); skipping live API call",
)
class TestDeepSeekLiveStructuredOutput:
"""End-to-end: a real DeepSeek V4-flash call returns a typed instance.
Verifies the no-tool_choice path doesn't trigger the 400 reported in
issue #678 and that the structured-output binding still parses to a
Pydantic instance.
"""
class _Pick(BaseModel):
action: str
confidence: float
def test_v4_flash_returns_structured_output(self):
client = DeepSeekChatOpenAI(
model="deepseek-v4-flash",
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com",
timeout=60,
)
bound = client.with_structured_output(self._Pick)
result = bound.invoke(
"Pick BUY or SELL or HOLD for a tech stock with strong earnings. "
"Confidence is a float between 0 and 1."
)
assert isinstance(result, self._Pick)
assert result.action in {"BUY", "SELL", "HOLD"}
assert 0.0 <= result.confidence <= 1.0
# ---------------------------------------------------------------------------
# Base class isolation: NormalizedChatOpenAI does NOT have DeepSeek behaviour
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBaseClassIsolation:
def test_normalized_does_not_propagate_reasoning_content(self):
"""The general-purpose NormalizedChatOpenAI must not carry
DeepSeek-specific behaviour. Only the subclass does."""
assert not hasattr(NormalizedChatOpenAI, "_get_request_payload") or (
NormalizedChatOpenAI._get_request_payload
is NormalizedChatOpenAI.__bases__[0]._get_request_payload
)

129
tests/test_env_overrides.py Normal file
View File

@@ -0,0 +1,129 @@
"""Tests for TRADINGAGENTS_* env-var overlay onto DEFAULT_CONFIG."""
from __future__ import annotations
import importlib
import pytest
import tradingagents.default_config as default_config_module
def _reload_with_env(monkeypatch, **overrides):
"""Set/clear env vars then reload default_config to re-evaluate DEFAULT_CONFIG."""
for key in list(default_config_module._ENV_OVERRIDES):
monkeypatch.delenv(key, raising=False)
for key, val in overrides.items():
monkeypatch.setenv(key, val)
return importlib.reload(default_config_module)
def test_no_env_uses_built_in_defaults(monkeypatch):
dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["llm_provider"] == "openai"
assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-5.6"
assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.6-luna"
assert dc.DEFAULT_CONFIG["backend_url"] is None
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1
assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False
def test_string_overrides(monkeypatch):
dc = _reload_with_env(
monkeypatch,
TRADINGAGENTS_LLM_PROVIDER="google",
TRADINGAGENTS_DEEP_THINK_LLM="gemini-3-pro-preview",
TRADINGAGENTS_QUICK_THINK_LLM="gemini-3-flash-preview",
TRADINGAGENTS_LLM_BACKEND_URL="https://example.invalid/v1",
TRADINGAGENTS_OUTPUT_LANGUAGE="Chinese",
)
assert dc.DEFAULT_CONFIG["llm_provider"] == "google"
assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gemini-3-pro-preview"
assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gemini-3-flash-preview"
assert dc.DEFAULT_CONFIG["backend_url"] == "https://example.invalid/v1"
assert dc.DEFAULT_CONFIG["output_language"] == "Chinese"
def test_int_coercion(monkeypatch):
dc = _reload_with_env(
monkeypatch,
TRADINGAGENTS_MAX_DEBATE_ROUNDS="3",
TRADINGAGENTS_MAX_RISK_ROUNDS="2",
)
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 3
assert isinstance(dc.DEFAULT_CONFIG["max_debate_rounds"], int)
assert dc.DEFAULT_CONFIG["max_risk_discuss_rounds"] == 2
assert isinstance(dc.DEFAULT_CONFIG["max_risk_discuss_rounds"], int)
@pytest.mark.parametrize(
"raw,expected",
[
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
("false", False), ("False", False), ("0", False), ("no", False), ("off", False),
],
)
def test_bool_coercion(monkeypatch, raw, expected):
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw)
assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is expected
def test_reasoning_thinking_overrides(monkeypatch):
"""The provider reasoning/thinking knobs are env-configurable (non-interactive runs)."""
dc = _reload_with_env(
monkeypatch,
TRADINGAGENTS_OPENAI_REASONING_EFFORT="high",
TRADINGAGENTS_GOOGLE_THINKING_LEVEL="minimal",
TRADINGAGENTS_ANTHROPIC_EFFORT="low",
)
assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] == "high"
assert dc.DEFAULT_CONFIG["google_thinking_level"] == "minimal"
assert dc.DEFAULT_CONFIG["anthropic_effort"] == "low"
def test_reasoning_effort_defaults_to_none(monkeypatch):
"""Unset reasoning/thinking knobs stay None so each provider uses its own default."""
dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] is None
assert dc.DEFAULT_CONFIG["google_thinking_level"] is None
assert dc.DEFAULT_CONFIG["anthropic_effort"] is None
def test_empty_env_value_is_passthrough(monkeypatch):
"""Empty TRADINGAGENTS_* values must not clobber the built-in default."""
dc = _reload_with_env(
monkeypatch,
TRADINGAGENTS_LLM_PROVIDER="",
TRADINGAGENTS_MAX_DEBATE_ROUNDS="",
)
assert dc.DEFAULT_CONFIG["llm_provider"] == "openai"
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1
def test_invalid_int_raises(monkeypatch):
"""Garbage int values should surface a ValueError at import, not silently misconfigure."""
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "not-a-number")
with pytest.raises(ValueError, match="TRADINGAGENTS_MAX_DEBATE_ROUNDS"):
importlib.reload(default_config_module)
# Restore module state for subsequent tests in this process
monkeypatch.delenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", raising=False)
importlib.reload(default_config_module)
@pytest.mark.parametrize("bad", ["treu", "flase", "maybe", "2", "enabled"])
def test_invalid_bool_raises(monkeypatch, bad):
"""A misspelled boolean must fail loudly (like ints) instead of silently False."""
monkeypatch.setenv("TRADINGAGENTS_CHECKPOINT_ENABLED", bad)
with pytest.raises(ValueError, match="TRADINGAGENTS_CHECKPOINT_ENABLED"):
importlib.reload(default_config_module)
monkeypatch.delenv("TRADINGAGENTS_CHECKPOINT_ENABLED", raising=False)
importlib.reload(default_config_module)
def test_unknown_env_var_is_ignored(monkeypatch):
"""Env vars outside _ENV_OVERRIDES must not bleed into DEFAULT_CONFIG."""
dc = _reload_with_env(
monkeypatch,
TRADINGAGENTS_NONEXISTENT_KEY="oops",
)
assert "nonexistent_key" not in dc.DEFAULT_CONFIG

235
tests/test_fred.py Normal file
View File

@@ -0,0 +1,235 @@
"""FRED macro vendor: alias resolution, configuration errors, output formatting,
missing-value handling, lookahead-safe windowing, and router integration.
All API access is mocked, so these run without a network connection or a key.
"""
import copy
import unittest
from unittest import mock
import pytest
import tradingagents.dataflows.config as config_module
import tradingagents.default_config as default_config
from tradingagents.dataflows import fred, interface
from tradingagents.dataflows.config import set_config
# A small, stable set of observations to format against.
_META = {
"seriess": [
{
"title": "Unemployment Rate",
"units_short": "%",
"frequency": "Monthly",
"seasonal_adjustment_short": "SA",
}
]
}
_OBS = {
"observations": [
{"date": "2025-06-01", "value": "4.1"},
{"date": "2025-07-01", "value": "4.3"},
{"date": "2025-08-01", "value": "."}, # missing -> skipped
{"date": "2025-09-01", "value": "4.4"},
]
}
def _request_stub(meta=_META, obs=_OBS):
"""Build a _request replacement that dispatches on the endpoint path."""
def _impl(path, params):
if path == "series":
return meta
if path == "series/observations":
return obs
raise AssertionError(f"unexpected FRED path: {path}")
return _impl
@pytest.mark.unit
class FredResolutionTests(unittest.TestCase):
def test_alias_maps_to_series_id(self):
self.assertEqual(fred._resolve_series_id("cpi"), "CPIAUCSL")
self.assertEqual(fred._resolve_series_id("unemployment"), "UNRATE")
def test_alias_is_case_and_separator_insensitive(self):
self.assertEqual(fred._resolve_series_id("Fed Funds Rate"), "FEDFUNDS")
self.assertEqual(fred._resolve_series_id("10y-treasury"), "DGS10")
def test_unknown_alias_is_treated_as_raw_series_id(self):
# Power users can pass any FRED series ID; we uppercase by convention.
self.assertEqual(fred._resolve_series_id("dgs30"), "DGS30")
self.assertEqual(fred._resolve_series_id("MyCustomSeries"), "MYCUSTOMSERIES")
def test_descriptive_phrase_is_rejected(self):
# An LLM phrase (spaces / too long) is not a series ID — reject up front
# with guidance rather than 400ing the API.
for bad in ("bank of japan rate", "the unemployment number", "X" * 31):
with self.assertRaises(ValueError):
fred._resolve_series_id(bad)
def test_get_macro_data_returns_guidance_on_bad_indicator(self):
# Invalid indicator -> actionable message, not a crash (no API call).
out = fred.get_macro_data("bank of japan rate", "2026-01-01")
self.assertIn("FRED", out)
self.assertIn("not a known macro alias", out)
@pytest.mark.unit
class FredConfigTests(unittest.TestCase):
def test_missing_key_raises_not_configured(self):
with mock.patch.dict("os.environ", {}, clear=True), \
self.assertRaises(fred.FredNotConfiguredError):
fred.get_api_key()
def test_not_configured_is_a_value_error(self):
# Routing relies on this subclassing for "vendor unavailable" handling.
self.assertTrue(issubclass(fred.FredNotConfiguredError, ValueError))
@pytest.mark.unit
class FredFormattingTests(unittest.TestCase):
def test_report_has_header_latest_change_and_table(self):
with mock.patch.object(fred, "_request", side_effect=_request_stub()):
out = fred.get_macro_data("unemployment", "2025-09-30", 365)
self.assertIn("## FRED: Unemployment Rate (UNRATE)", out)
self.assertIn("Units: %", out)
self.assertIn("Frequency: Monthly (SA)", out)
self.assertIn("**Latest:** 4.4 (2025-09-01)", out)
# change over the window: 4.4 - 4.1 = +0.30
self.assertIn("+0.30", out)
self.assertIn("| 2025-06-01 | 4.1 |", out)
def test_missing_value_is_skipped(self):
with mock.patch.object(fred, "_request", side_effect=_request_stub()):
out = fred.get_macro_data("unemployment", "2025-09-30", 365)
# the "." observation must not appear as a row
self.assertNotIn("2025-08-01", out)
def test_empty_window_reports_no_observations(self):
empty = {"observations": []}
with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=empty)):
out = fred.get_macro_data("unemployment", "2025-09-30", 30)
self.assertIn("No observations", out)
def test_unknown_series_returns_not_found_message(self):
# A well-formed but unknown series ID returns guidance, not a crash, so
# the run is not aborted over an optional macro lookup.
no_series = {"seriess": []}
with mock.patch.object(fred, "_request", side_effect=_request_stub(meta=no_series)):
out = fred.get_macro_data("totally_unknown_xyz", "2025-09-30", 30)
self.assertIn("not found", out)
def test_long_series_is_truncated_but_change_uses_full_range(self):
# Build > MAX_ROWS observations deterministically.
obs = {
"observations": [
{"date": f"2025-01-{(i % 28) + 1:02d}", "value": str(i)}
for i in range(fred.MAX_ROWS + 10)
]
}
with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=obs)):
out = fred.get_macro_data("unemployment", "2025-12-31", 365)
self.assertIn(f"most recent {fred.MAX_ROWS}", out)
# change-over-window must reference the true first (0) and last value
self.assertIn("from 0 ", out)
body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")]
self.assertEqual(len(body_rows), fred.MAX_ROWS)
def test_window_is_lookahead_safe(self):
# observation_end must equal curr_date so a past date never pulls future data.
captured = {}
def _capture(path, params):
captured[path] = params
return _META if path == "series" else _OBS
with mock.patch.object(fred, "_request", side_effect=_capture):
fred.get_macro_data("unemployment", "2025-09-30", 90)
obs_params = captured["series/observations"]
self.assertEqual(obs_params["observation_end"], "2025-09-30")
self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back
def test_requests_pin_the_data_vintage(self):
# #1275: both the metadata and observations requests must pin the vintage
# to curr_date (clamped to FRED's today), or FRED serves the latest
# revision and revision-prone series leak future information. A past
# curr_date sits below FRED's today, so it pins through unchanged.
captured = {}
def _capture(path, params):
captured[path] = params
return _META if path == "series" else _OBS
with mock.patch.object(fred, "_fred_today", return_value="2026-01-01"), \
mock.patch.object(fred, "_request", side_effect=_capture):
fred.get_macro_data("cpi", "2025-09-30", 90)
for path in ("series", "series/observations"):
self.assertEqual(captured[path]["realtime_start"], "2025-09-30", path)
self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path)
def test_future_curr_date_clamps_vintage_to_fred_today(self):
# #1275 regression: on a live run curr_date is the caller's LOCAL date,
# which can be a day ahead of FRED's US-Central clock. Pinning the vintage
# to that future date 400s, and the routing layer then drops macro data
# silently. The pin must clamp to FRED's today; the observation window
# (future bars can't exist yet) stays at curr_date.
captured = {}
def _capture(path, params):
captured[path] = params
return _META if path == "series" else _OBS
with mock.patch.object(fred, "_fred_today", return_value="2026-08-31"), \
mock.patch.object(fred, "_request", side_effect=_capture):
fred.get_macro_data("cpi", "2026-09-01", 90) # local a day ahead of Chicago
for path in ("series", "series/observations"):
self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path)
self.assertEqual(captured[path]["realtime_end"], "2026-08-31", path)
# the observation window still tracks curr_date, not the clamped vintage
self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01")
@pytest.mark.unit
class FredRoutingTests(unittest.TestCase):
def setUp(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def tearDown(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def test_macro_category_routes_to_fred(self):
self.assertEqual(
interface.get_category_for_method("get_macro_indicators"), "macro_data"
)
set_config({"data_vendors": {"macro_data": "fred"}})
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_macro_indicators": {"fred": lambda *a, **k: "MACRO_OK"}},
clear=False,
):
out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
self.assertEqual(out, "MACRO_OK")
def test_not_configured_degrades_gracefully(self):
# macro_data is optional: with only fred and no key, the router degrades
# to a sentinel instead of aborting the run — a missing optional key must
# not crash an analysis.
set_config({"data_vendors": {"macro_data": "fred"}})
def _unconfigured(*a, **k):
raise fred.FredNotConfiguredError("FRED_API_KEY not set")
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_macro_indicators": {"fred": _unconfigured}},
clear=False,
):
out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
self.assertIn("DATA_UNAVAILABLE", out)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,31 @@
import unittest
from unittest.mock import patch
import pytest
from tradingagents.llm_clients.google_client import GoogleClient
@pytest.mark.unit
class TestGoogleApiKeyStandardization(unittest.TestCase):
"""Verify GoogleClient accepts unified api_key parameter."""
@patch("tradingagents.llm_clients.google_client.NormalizedChatGoogleGenerativeAI")
def test_api_key_handling(self, mock_chat):
test_cases = [
("unified api_key is mapped", {"api_key": "test-key-123"}, "test-key-123"),
("legacy google_api_key still works", {"google_api_key": "legacy-key-456"}, "legacy-key-456"),
("unified api_key takes precedence", {"api_key": "unified", "google_api_key": "legacy"}, "unified"),
]
for msg, kwargs, expected_key in test_cases:
with self.subTest(msg=msg):
mock_chat.reset_mock()
client = GoogleClient("gemini-3.5-flash", **kwargs)
client.get_llm()
call_kwargs = mock_chat.call_args[1]
self.assertEqual(call_kwargs.get("google_api_key"), expected_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
"""Gemini thinking_level forwarding (Gemini 3.x).
The catalog is Gemini 3.x only, which takes the string ``thinking_level``
directly. Pro accepts low/high; Flash also accepts minimal/medium — an
unsupported "minimal" on Pro is mapped to "low".
"""
from unittest import mock
import pytest
from tradingagents.llm_clients.google_client import GoogleClient
def _captured_kwargs(model, **kwargs):
captured = {}
with mock.patch.object(
__import__("tradingagents.llm_clients.google_client", fromlist=["x"]),
"NormalizedChatGoogleGenerativeAI",
lambda **kw: captured.setdefault("kw", kw),
):
GoogleClient(model, api_key="x", **kwargs).get_llm()
return captured["kw"]
@pytest.mark.parametrize("level", ["minimal", "low", "medium", "high"])
def test_flash_passes_thinking_level_through(level):
kw = _captured_kwargs("gemini-3.5-flash", thinking_level=level)
assert kw["thinking_level"] == level
assert "thinking_budget" not in kw # the 2.5-era param is gone
def test_pro_remaps_minimal_to_low():
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="minimal")
assert kw["thinking_level"] == "low" # Pro doesn't accept "minimal"
def test_pro_keeps_high():
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="high")
assert kw["thinking_level"] == "high"
def test_no_thinking_level_is_omitted():
kw = _captured_kwargs("gemini-3.5-flash")
assert "thinking_level" not in kw
assert "thinking_budget" not in kw

View File

@@ -0,0 +1,59 @@
"""Every report-producing agent must apply the configured output language
(#740/#801).
A non-English run should produce a fully localized report, not a mix of
languages. The bug originally happened because several agents silently omitted
the instruction (fixed in 6b384f7); this test codifies the invariant so a future
refactor can't quietly drop it again.
"""
from pathlib import Path
import pytest
from tradingagents.agents.utils.agent_utils import get_language_instruction
_AGENTS_DIR = Path(__file__).resolve().parents[1] / "tradingagents" / "agents"
# Every node whose text reaches the saved report. If you add a report-producing
# agent, add it here — and make it call get_language_instruction().
REPORT_AGENTS = [
"analysts/market_analyst.py",
"analysts/news_analyst.py",
"analysts/fundamentals_analyst.py",
"analysts/sentiment_analyst.py",
"researchers/bull_researcher.py",
"researchers/bear_researcher.py",
"managers/research_manager.py",
"managers/portfolio_manager.py",
"risk_mgmt/aggressive_debator.py",
"risk_mgmt/conservative_debator.py",
"risk_mgmt/neutral_debator.py",
"trader/trader.py",
]
@pytest.mark.unit
class TestLanguageInstruction:
def test_english_adds_no_tokens(self, monkeypatch):
from tradingagents.dataflows.config import set_config
set_config({"output_language": "English"})
assert get_language_instruction() == ""
def test_non_english_emits_directive(self):
from tradingagents.dataflows.config import set_config
set_config({"output_language": "中文"})
out = get_language_instruction()
assert "中文" in out
assert "entire response" in out
@pytest.mark.unit
@pytest.mark.parametrize("rel", REPORT_AGENTS)
def test_report_agent_applies_language_instruction(rel):
path = _AGENTS_DIR / rel
assert path.exists(), f"missing agent module: {rel}"
src = path.read_text(encoding="utf-8")
assert "get_language_instruction()" in src, (
f"{rel} does not apply get_language_instruction(); its output would "
f"ignore the configured output_language (#740/#801)."
)

View File

@@ -0,0 +1,170 @@
"""Tests for deterministic instrument-identity resolution (#814) and the
context-anchored message placeholder (#888)."""
import unittest
from unittest.mock import patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
create_msg_delete,
get_instrument_context_from_state,
resolve_instrument_identity,
)
@pytest.mark.unit
class ResolveInstrumentIdentityTests(unittest.TestCase):
def setUp(self):
resolve_instrument_identity.cache_clear()
def test_resolves_company_metadata_from_yfinance(self):
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
mock.return_value.info = {
"longName": "TOTO LTD.",
"shortName": "TOTO",
"sector": "Industrials",
"industry": "Building Products & Equipment",
"exchange": "PNK",
"quoteType": "EQUITY",
}
identity = resolve_instrument_identity("totdy")
mock.assert_called_once_with("TOTDY")
self.assertEqual(identity["company_name"], "TOTO LTD.")
self.assertEqual(identity["sector"], "Industrials")
self.assertEqual(identity["industry"], "Building Products & Equipment")
self.assertEqual(identity["exchange"], "PNK")
def test_falls_back_to_short_name(self):
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
mock.return_value.info = {"shortName": "TOTO", "sector": "Industrials"}
identity = resolve_instrument_identity("TOTDY")
self.assertEqual(identity["company_name"], "TOTO")
def test_skips_placeholder_values(self):
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
mock.return_value.info = {"longName": " ", "sector": "None", "industry": "n/a"}
identity = resolve_instrument_identity("TOTDY")
self.assertEqual(identity, {})
def test_fails_open_on_exception(self):
with patch(
"tradingagents.agents.utils.agent_utils.yf.Ticker",
side_effect=RuntimeError("rate limited"),
):
self.assertEqual(resolve_instrument_identity("TOTDY"), {})
def test_result_is_cached(self):
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
mock.return_value.info = {"longName": "TOTO LTD."}
first = resolve_instrument_identity("TOTDY")
second = resolve_instrument_identity("TOTDY")
mock.assert_called_once() # second call served from cache
self.assertEqual(first, second)
@pytest.mark.unit
class BuildInstrumentContextTests(unittest.TestCase):
def test_mentions_exact_symbol_without_identity(self):
context = build_instrument_context("7203.T")
self.assertIn("7203.T", context)
self.assertIn("exchange suffix", context)
self.assertNotIn("Resolved identity", context)
def test_injects_resolved_identity(self):
context = build_instrument_context(
"TOTDY", "stock",
{
"company_name": "TOTO LTD.",
"sector": "Industrials",
"industry": "Building Products & Equipment",
"exchange": "PNK",
},
)
self.assertIn("Company: TOTO LTD.", context)
self.assertIn("Industrials / Building Products & Equipment", context)
self.assertIn("Exchange: PNK", context)
self.assertIn("Do not substitute a different company", context)
def test_crypto_uses_name_label_and_keeps_hint(self):
context = build_instrument_context(
"BTC-USD", "crypto", {"company_name": "Bitcoin USD"}
)
self.assertIn("Name: Bitcoin USD", context)
self.assertIn("crypto asset rather than a company", context)
@pytest.mark.unit
class GetInstrumentContextFromStateTests(unittest.TestCase):
def test_prefers_precomputed_context(self):
state = {"company_of_interest": "TOTDY", "instrument_context": "PRECOMPUTED"}
self.assertEqual(get_instrument_context_from_state(state), "PRECOMPUTED")
def test_fallback_is_network_free_ticker_only(self):
# No instrument_context and no yfinance call — must not hit the network.
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
context = get_instrument_context_from_state(
{"company_of_interest": "NVDA", "asset_type": "stock"}
)
mock.assert_not_called()
self.assertIn("NVDA", context)
def test_fallback_respects_asset_type(self):
context = get_instrument_context_from_state(
{"company_of_interest": "BTC-USD", "asset_type": "crypto"}
)
self.assertIn("crypto asset", context)
@pytest.mark.unit
class ContextAnchoredPlaceholderTests(unittest.TestCase):
"""#888 — the message-clear placeholder must not be a bare 'Continue'."""
def _run(self, state_extra):
state = {
"messages": [
HumanMessage(content="old", id="h1"),
AIMessage(content="reply", id="a1"),
],
**state_extra,
}
return create_msg_delete()(state)
def test_placeholder_is_not_bare_continue(self):
result = self._run(
{"company_of_interest": "EC", "asset_type": "stock", "trade_date": "2026-05-28"}
)
placeholder = result["messages"][-1]
self.assertIsInstance(placeholder, HumanMessage)
self.assertNotEqual(placeholder.content.strip(), "Continue")
def test_placeholder_carries_resolved_identity(self):
result = self._run(
{
"company_of_interest": "EC",
"instrument_context": "The instrument to analyze is `EC`. Resolved identity: Company: Ecopetrol.",
"trade_date": "2026-05-28",
}
)
content = result["messages"][-1].content
self.assertIn("Ecopetrol", content)
self.assertIn("2026-05-28", content)
def test_old_messages_are_removed(self):
result = self._run({"company_of_interest": "EC", "trade_date": "2026-05-28"})
removals = [m for m in result["messages"] if isinstance(m, RemoveMessage)]
humans = [m for m in result["messages"] if isinstance(m, HumanMessage)]
self.assertEqual(len(removals), 2)
self.assertEqual(len(humans), 1)
def test_safe_defaults_when_state_minimal(self):
result = create_msg_delete()({"messages": [], "company_of_interest": "EC"})
placeholder = result["messages"][-1]
self.assertNotEqual(placeholder.content.strip(), "Continue")
self.assertIn("EC", placeholder.content)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,100 @@
"""Configurable LLM SDK retry budget (#1090/#1091).
A single transient 429 burst used to kill an otherwise-healthy multi-agent run
because each provider SDK's max_retries (default 2) was not exposed. This adds an
opt-in llm_max_retries knob forwarded to every provider chat client.
"""
from __future__ import annotations
import importlib
import pytest
import tradingagents.default_config as default_config_module
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_retries
# --- coercion / validation -------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize("value,expected", [(0, 0), (2, 2), (10, 10), ("6", 6)])
def test_coerce_accepts_non_negative_ints_and_numeric_strings(value, expected):
assert _coerce_max_retries(value) == expected
@pytest.mark.unit
@pytest.mark.parametrize("bad", [-1, "-3"])
def test_coerce_rejects_negative(bad):
with pytest.raises(ValueError, match=">= 0"):
_coerce_max_retries(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", [True, False])
def test_coerce_rejects_booleans(bad):
with pytest.raises(ValueError, match="boolean"):
_coerce_max_retries(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
def test_coerce_rejects_non_integers(bad):
with pytest.raises(ValueError, match="integer"):
_coerce_max_retries(bad)
# --- forwarding into provider kwargs --------------------------------------
def _bare_graph(config):
g = object.__new__(TradingAgentsGraph)
g.config = config
return g
@pytest.mark.unit
def test_not_forwarded_when_unset():
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": None})._get_provider_kwargs()
assert "max_retries" not in kwargs
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openai", "anthropic", "google"])
def test_forwarded_across_providers(provider):
kwargs = _bare_graph({"llm_provider": provider, "llm_max_retries": 6})._get_provider_kwargs()
assert kwargs["max_retries"] == 6
@pytest.mark.unit
def test_forwarded_env_string_is_coerced():
# env vars arrive as strings; the consumer coerces (like temperature)
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": "4"})._get_provider_kwargs()
assert kwargs["max_retries"] == 4
@pytest.mark.unit
def test_invalid_config_value_fails_loudly():
with pytest.raises(ValueError):
_bare_graph({"llm_provider": "openai", "llm_max_retries": -1})._get_provider_kwargs()
# --- env overlay -----------------------------------------------------------
def _reload_with_env(monkeypatch, **overrides):
for key in list(default_config_module._ENV_OVERRIDES):
monkeypatch.delenv(key, raising=False)
for key, val in overrides.items():
monkeypatch.setenv(key, val)
return importlib.reload(default_config_module)
@pytest.mark.unit
def test_default_is_none(monkeypatch):
dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["llm_max_retries"] is None
@pytest.mark.unit
def test_env_override_sets_config(monkeypatch):
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8")
# None-default key: env value arrives as a string and is coerced downstream.
assert dc.DEFAULT_CONFIG["llm_max_retries"] == "8"
assert _coerce_max_retries(dc.DEFAULT_CONFIG["llm_max_retries"]) == 8

View File

@@ -0,0 +1,122 @@
"""Configurable output-token cap (#1204).
Some model/gateway combinations (e.g. deepseek-v4-flash deployments) emit
unbounded reasoning/output and hang or trip an idle timeout. An opt-in
``max_tokens`` config knob is forwarded to every provider so a run can bound it;
Gemini names the parameter ``max_output_tokens``, so it is forwarded under the
right key per provider.
"""
from __future__ import annotations
import importlib
import pytest
import tradingagents.default_config as default_config_module
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_tokens
# --- coercion / validation -------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize("value,expected", [(1, 1), (8192, 8192), ("4096", 4096)])
def test_coerce_accepts_positive_ints_and_numeric_strings(value, expected):
assert _coerce_max_tokens(value) == expected
@pytest.mark.unit
@pytest.mark.parametrize("bad", [0, -1, "0", "-5"])
def test_coerce_rejects_non_positive(bad):
with pytest.raises(ValueError, match="> 0"):
_coerce_max_tokens(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", [True, False])
def test_coerce_rejects_booleans(bad):
with pytest.raises(ValueError, match="boolean"):
_coerce_max_tokens(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
def test_coerce_rejects_non_integers(bad):
with pytest.raises(ValueError, match="integer"):
_coerce_max_tokens(bad)
# --- forwarding into provider kwargs (right key per provider) --------------
def _bare_graph(config):
g = object.__new__(TradingAgentsGraph)
g.config = config
return g
@pytest.mark.unit
def test_not_forwarded_when_unset():
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": None})._get_provider_kwargs()
assert "max_tokens" not in kwargs
assert "max_output_tokens" not in kwargs
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openai", "anthropic", "deepseek", "openai_compatible"])
def test_forwarded_as_max_tokens_for_non_google(provider):
kwargs = _bare_graph({"llm_provider": provider, "max_tokens": 8192})._get_provider_kwargs()
assert kwargs["max_tokens"] == 8192
assert "max_output_tokens" not in kwargs
@pytest.mark.unit
def test_forwarded_as_max_output_tokens_for_google():
# Gemini's kwarg name differs; forwarding plain max_tokens would be rejected.
kwargs = _bare_graph({"llm_provider": "google", "max_tokens": 8192})._get_provider_kwargs()
assert kwargs["max_output_tokens"] == 8192
assert "max_tokens" not in kwargs
@pytest.mark.unit
def test_env_string_is_coerced():
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": "4096"})._get_provider_kwargs()
assert kwargs["max_tokens"] == 4096
@pytest.mark.unit
def test_invalid_value_fails_loudly():
with pytest.raises(ValueError):
_bare_graph({"llm_provider": "openai", "max_tokens": 0})._get_provider_kwargs()
# --- client-side allowlists carry the kwarg --------------------------------
@pytest.mark.unit
def test_openai_and_google_clients_accept_the_kwarg():
from tradingagents.llm_clients import openai_client
from tradingagents.llm_clients.google_client import GoogleClient # noqa: F401
assert "max_tokens" in openai_client._PASSTHROUGH_KWARGS
# Google client forwards max_output_tokens through construction.
llm = GoogleClient("gemini-3.5-flash", api_key="x", max_output_tokens=8192).get_llm()
assert getattr(llm, "max_output_tokens", None) == 8192
# --- env overlay -----------------------------------------------------------
def _reload_with_env(monkeypatch, **overrides):
for key in list(default_config_module._ENV_OVERRIDES):
monkeypatch.delenv(key, raising=False)
for key, val in overrides.items():
monkeypatch.setenv(key, val)
return importlib.reload(default_config_module)
@pytest.mark.unit
def test_default_is_none(monkeypatch):
dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["max_tokens"] is None
@pytest.mark.unit
def test_env_override_sets_config(monkeypatch):
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192")
assert dc.DEFAULT_CONFIG["max_tokens"] == "8192"
assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192

View File

@@ -0,0 +1,76 @@
"""Tests for the deterministic market-data verification snapshot (#830/#881)."""
from __future__ import annotations
import pandas as pd
import pytest
import tradingagents.dataflows.market_data_validator as validator
def _sample_ohlcv() -> pd.DataFrame:
dates = pd.bdate_range("2026-04-01", "2026-05-20")
closes = [100 + i for i in range(len(dates))]
return pd.DataFrame({
"Date": dates,
"Open": [c - 0.5 for c in closes],
"High": [c + 1.0 for c in closes],
"Low": [c - 1.0 for c in closes],
"Close": closes,
"Volume": [1_000_000 + i for i in range(len(dates))],
})
@pytest.mark.unit
class TestVerifiedSnapshot:
def test_excludes_future_rows(self, monkeypatch):
data = pd.concat([
_sample_ohlcv(),
pd.DataFrame({"Date": [pd.Timestamp("2026-06-01")], "Open": [999.0],
"High": [999.0], "Low": [999.0], "Close": [999.0], "Volume": [999]}),
], ignore_index=True)
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: data)
snap = validator.build_verified_market_snapshot("COF", "2026-05-13")
assert "Verified market data snapshot for COF" in snap
assert "Requested analysis date: 2026-05-13" in snap
assert "Latest trading row used: 2026-05-13" in snap
assert "999.00" not in snap # future row excluded
assert "boll_lb" in snap # indicators present
def test_uses_previous_trading_day_when_date_is_weekend(self, monkeypatch):
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
# 2026-05-16 is a Saturday; latest row should be Fri 2026-05-15
snap = validator.build_verified_market_snapshot("COF", "2026-05-16")
assert "Latest trading row used: 2026-05-15" in snap
assert "Recent verified closes" in snap
def test_raises_when_no_rows_on_or_before_date(self, monkeypatch):
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
with pytest.raises(ValueError):
validator.build_verified_market_snapshot("COF", "2020-01-01")
def test_raises_on_empty_data(self, monkeypatch):
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: pd.DataFrame())
with pytest.raises(ValueError):
validator.build_verified_market_snapshot("COF", "2026-05-13")
def test_look_back_window_capped_at_30(self, monkeypatch):
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
snap = validator.build_verified_market_snapshot("COF", "2026-05-20", look_back_days=999)
# last-N closes table has at most 30 data rows
close_rows = [ln for ln in snap.splitlines() if ln.startswith("| 2026-")]
assert 0 < len(close_rows) <= 30
@pytest.mark.unit
class TestTool:
def test_tool_delegates_to_builder(self, monkeypatch):
from tradingagents.agents.utils.market_data_validation_tools import (
get_verified_market_snapshot,
)
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
out = get_verified_market_snapshot.invoke(
{"symbol": "COF", "curr_date": "2026-05-20"}
)
assert "Verified market data snapshot for COF" in out

View File

@@ -0,0 +1,23 @@
"""The market analyst is bound (and prompt-instructed) to call
get_verified_market_snapshot; if the executor ToolNode doesn't register it, the
call fails and the model reports the tool "unavailable" and skips verification.
Regression guard for that wiring gap (snapshot bound to the LLM but missing from
the market ToolNode).
"""
import pytest
from tradingagents.graph.trading_graph import TradingAgentsGraph
@pytest.mark.unit
def test_market_toolnode_can_execute_verified_snapshot():
# _create_tool_nodes does not use self -> call unbound (avoids building LLMs).
nodes = TradingAgentsGraph._create_tool_nodes(None)
market_tools = set(nodes["market"].tools_by_name)
assert "get_verified_market_snapshot" in market_tools, (
"get_verified_market_snapshot is bound to the market analyst but not "
"registered in the market ToolNode, so the model's call fails."
)
# the other core market tools must remain too
assert {"get_stock_data", "get_indicators"} <= market_tools

910
tests/test_memory_log.py Normal file
View File

@@ -0,0 +1,910 @@
"""Tests for TradingMemoryLog — storage, deferred reflection, PM injection, legacy removal."""
from unittest.mock import MagicMock, patch
import pandas as pd
import pytest
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.graph.propagation import Propagator
from tradingagents.graph.reflection import Reflector
from tradingagents.graph.trading_graph import TradingAgentsGraph
_SEP = TradingMemoryLog._SEPARATOR
DECISION_BUY = "Rating: Buy\nEnter at $189-192, 6% portfolio cap."
DECISION_OVERWEIGHT = (
"Rating: Overweight\n"
"Executive Summary: Moderate position, await confirmation.\n"
"Investment Thesis: Strong fundamentals but near-term headwinds."
)
DECISION_SELL = "Rating: Sell\nExit position immediately."
DECISION_NO_RATING = (
"Executive Summary: Complex situation with multiple competing factors.\n"
"Investment Thesis: No clear directional signal at this time."
)
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def make_log(tmp_path, filename="trading_memory.md"):
config = {"memory_log_path": str(tmp_path / filename)}
return TradingMemoryLog(config)
def _seed_completed(tmp_path, ticker, date, decision_text, reflection_text, filename="trading_memory.md"):
"""Write a completed entry directly to file, bypassing the API."""
entry = (
f"[{date} | {ticker} | Buy | +1.0% | +0.5% | 5d]\n\n"
f"DECISION:\n{decision_text}\n\n"
f"REFLECTION:\n{reflection_text}"
+ _SEP
)
with open(tmp_path / filename, "a", encoding="utf-8") as f:
f.write(entry)
def _resolve_entry(log, ticker, date, decision, reflection="Good call."):
"""Store a decision then immediately resolve it via the API."""
log.store_decision(ticker, date, decision)
log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection)
def _price_df(prices, start="2026-01-05"):
"""Minimal DataFrame matching yfinance .history() output shape.
Uses a DatetimeIndex like real yfinance output, so resolution-date
extraction (stock.index[holding_days]) works (#1251).
"""
idx = pd.date_range(start=start, periods=len(prices), freq="D")
return pd.DataFrame({"Close": prices}, index=idx)
def _make_pm_state(past_context=""):
"""Minimal AgentState dict for portfolio_manager_node."""
return {
"company_of_interest": "NVDA",
"past_context": past_context,
"risk_debate_state": {
"history": "Risk debate history.",
"aggressive_history": "",
"conservative_history": "",
"neutral_history": "",
"judge_decision": "",
"current_aggressive_response": "",
"current_conservative_response": "",
"current_neutral_response": "",
"count": 1,
},
"market_report": "Market report.",
"sentiment_report": "Sentiment report.",
"news_report": "News report.",
"fundamentals_report": "Fundamentals report.",
"investment_plan": "Research plan.",
"trader_investment_plan": "Trader plan.",
}
def _structured_pm_llm(captured: dict, decision: PortfolioDecision | None = None):
"""Build a MagicMock LLM whose with_structured_output binding captures the
prompt and returns a real PortfolioDecision (so render_pm_decision works).
"""
if decision is None:
decision = PortfolioDecision(
rating=PortfolioRating.HOLD,
executive_summary="Hold the position; await catalyst.",
investment_thesis="Balanced view; neither side carried the debate.",
)
structured = MagicMock()
structured.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or decision
)
llm = MagicMock()
llm.with_structured_output.return_value = structured
return llm
# ---------------------------------------------------------------------------
# Core: storage and read path
# ---------------------------------------------------------------------------
class TestTradingMemoryLogCore:
def test_store_creates_file(self, tmp_path):
log = make_log(tmp_path)
assert not (tmp_path / "trading_memory.md").exists()
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert (tmp_path / "trading_memory.md").exists()
def test_store_appends_not_overwrites(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT)
entries = log.load_entries()
assert len(entries) == 2
assert entries[0]["ticker"] == "NVDA"
assert entries[1]["ticker"] == "AAPL"
def test_store_decision_idempotent(self, tmp_path):
"""Calling store_decision twice with same (ticker, date) stores only one entry."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert len(log.load_entries()) == 1
def test_batch_update_resolves_multiple_entries(self, tmp_path):
"""batch_update_with_outcomes resolves multiple pending entries in one write."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
log.store_decision("NVDA", "2026-01-12", DECISION_SELL)
updates = [
{"ticker": "NVDA", "trade_date": "2026-01-05",
"raw_return": 0.05, "alpha_return": 0.02, "holding_days": 5,
"reflection": "First correct."},
{"ticker": "NVDA", "trade_date": "2026-01-12",
"raw_return": -0.03, "alpha_return": -0.01, "holding_days": 5,
"reflection": "Second correct."},
]
log.batch_update_with_outcomes(updates)
entries = log.load_entries()
assert len(entries) == 2
assert all(not e["pending"] for e in entries)
assert entries[0]["reflection"] == "First correct."
assert entries[1]["reflection"] == "Second correct."
def test_pending_tag_format(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
text = (tmp_path / "trading_memory.md").read_text(encoding="utf-8")
assert "[2026-01-10 | NVDA | Buy | pending]" in text
# Rating parsing
def test_rating_parsed_buy(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert log.load_entries()[0]["rating"] == "Buy"
def test_rating_parsed_overweight(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT)
assert log.load_entries()[0]["rating"] == "Overweight"
def test_rating_fallback_hold(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING)
assert log.load_entries()[0]["rating"] == "Hold"
def test_rating_priority_over_prose(self, tmp_path):
"""'Rating: X' label wins even when an opposing rating word appears earlier in prose."""
decision = (
"The sell thesis is weak. The hold case is marginal.\n\n"
"Rating: Buy\n\n"
"Executive Summary: Strong fundamentals support the position."
)
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", decision)
assert log.load_entries()[0]["rating"] == "Buy"
# Delimiter robustness
def test_decision_with_markdown_separator(self, tmp_path):
"""LLM decision containing '---' must not corrupt the entry."""
decision = "Rating: Buy\n\n---\n\nRisk: elevated volatility."
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", decision)
entries = log.load_entries()
assert len(entries) == 1
assert "Risk: elevated volatility" in entries[0]["decision"]
# load_entries
def test_load_entries_empty_file(self, tmp_path):
log = make_log(tmp_path)
assert log.load_entries() == []
def test_load_entries_single(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
entries = log.load_entries()
assert len(entries) == 1
e = entries[0]
assert e["date"] == "2026-01-10"
assert e["ticker"] == "NVDA"
assert e["rating"] == "Buy"
assert e["pending"] is True
assert e["raw"] is None
def test_load_entries_multiple(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT)
log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING)
entries = log.load_entries()
assert len(entries) == 3
assert [e["ticker"] for e in entries] == ["NVDA", "AAPL", "MSFT"]
def test_decision_content_preserved(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert log.load_entries()[0]["decision"] == DECISION_BUY.strip()
# get_pending_entries
def test_get_pending_returns_pending_only(self, tmp_path):
log = make_log(tmp_path)
_seed_completed(tmp_path, "NVDA", "2026-01-05", "Buy NVDA.", "Correct.")
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
pending = log.get_pending_entries()
assert len(pending) == 1
assert pending[0]["ticker"] == "NVDA"
assert pending[0]["date"] == "2026-01-10"
# get_past_context
def test_get_past_context_empty(self, tmp_path):
log = make_log(tmp_path)
assert log.get_past_context("NVDA") == ""
def test_get_past_context_pending_excluded(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert log.get_past_context("NVDA") == ""
def test_get_past_context_same_ticker(self, tmp_path):
log = make_log(tmp_path)
_seed_completed(tmp_path, "NVDA", "2026-01-05", "Buy NVDA — AI capex thesis intact.", "Directionally correct.")
ctx = log.get_past_context("NVDA")
assert "Past analyses of NVDA" in ctx
assert "Buy NVDA" in ctx
def test_get_past_context_cross_ticker(self, tmp_path):
log = make_log(tmp_path)
_seed_completed(tmp_path, "AAPL", "2026-01-05", "Buy AAPL — Services growth.", "Correct.")
ctx = log.get_past_context("NVDA")
assert "Recent cross-ticker lessons" in ctx
assert "Past analyses of NVDA" not in ctx
def test_n_same_limit_respected(self, tmp_path):
"""Only the n_same most recent same-ticker entries are included."""
log = make_log(tmp_path)
for i in range(6):
_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 0" not in ctx
assert "Buy entry 5" in ctx
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)
for i, ticker in enumerate(["AAPL", "MSFT", "GOOG", "META"]):
_seed_completed(tmp_path, ticker, f"2026-01-{i+1:02d}", f"Buy {ticker}.", "Correct.")
ctx = log.get_past_context("NVDA", n_cross=3)
assert "AAPL" not in ctx
assert "META" in ctx
# No-op when config is None
def test_no_log_path_is_noop(self):
log = TradingMemoryLog(config=None)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert log.load_entries() == []
assert log.get_past_context("NVDA") == ""
# Rotation: opt-in cap on resolved entries
def test_rotation_disabled_by_default(self, tmp_path):
"""Without max_entries, all resolved entries are kept."""
log = make_log(tmp_path)
for i in range(7):
_resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.")
assert len(log.load_entries()) == 7
def test_rotation_prunes_oldest_resolved(self, tmp_path):
"""When max_entries is set and exceeded, oldest resolved entries are pruned."""
log = TradingMemoryLog({
"memory_log_path": str(tmp_path / "trading_memory.md"),
"memory_log_max_entries": 3,
})
# Resolve 5 entries; rotation should keep only the 3 most recent.
for i in range(5):
_resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.")
entries = log.load_entries()
assert len(entries) == 3
# Confirm the OLDEST were dropped, not the newest.
dates = [e["date"] for e in entries]
assert dates == ["2026-01-03", "2026-01-04", "2026-01-05"]
def test_rotation_never_prunes_pending(self, tmp_path):
"""Pending entries (unresolved) are kept regardless of the cap."""
log = TradingMemoryLog({
"memory_log_path": str(tmp_path / "trading_memory.md"),
"memory_log_max_entries": 2,
})
# 3 resolved + 2 pending. With cap=2, only 2 resolved survive; both pending stay.
for i in range(3):
_resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Resolved {i}.")
log.store_decision("NVDA", "2026-02-01", DECISION_BUY)
log.store_decision("NVDA", "2026-02-02", DECISION_OVERWEIGHT)
# Trigger rotation by resolving one more entry — pending entries must stay.
_resolve_entry(log, "NVDA", "2026-01-04", DECISION_BUY, "Resolved 3.")
entries = log.load_entries()
pending = [e for e in entries if e["pending"]]
resolved = [e for e in entries if not e["pending"]]
assert len(pending) == 2, "pending entries must never be pruned"
assert len(resolved) == 2, f"expected 2 resolved after rotation, got {len(resolved)}"
def test_rotation_under_cap_is_noop(self, tmp_path):
"""No rotation when resolved count <= max_entries."""
log = TradingMemoryLog({
"memory_log_path": str(tmp_path / "trading_memory.md"),
"memory_log_max_entries": 10,
})
for i in range(3):
_resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.")
assert len(log.load_entries()) == 3
# Rating parsing: markdown bold and numbered list formats
def test_rating_parsed_from_bold_markdown(self, tmp_path):
"""**Rating**: Buy — markdown bold around the label must not prevent parsing."""
decision = "**Rating**: Buy\nEnter at $190."
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", decision)
assert log.load_entries()[0]["rating"] == "Buy"
def test_rating_parsed_from_bold_value(self, tmp_path):
"""Rating: **Sell** — markdown bold around the value must not prevent parsing."""
decision = "Rating: **Sell**\nExit immediately."
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", decision)
assert log.load_entries()[0]["rating"] == "Sell"
def test_rating_label_wins_over_prose_with_markdown(self, tmp_path):
"""Rating: **Sell** must win even when prose contains a conflicting rating word."""
decision = (
"The buy thesis is weakened by guidance.\n"
"Rating: **Sell**\n"
"Exit before earnings."
)
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", decision)
assert log.load_entries()[0]["rating"] == "Sell"
def test_rating_parsed_from_numbered_list(self, tmp_path):
"""1. Rating: Buy — numbered list prefix must not prevent parsing."""
decision = "1. Rating: Buy\nEnter at $190."
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", decision)
assert log.load_entries()[0]["rating"] == "Buy"
# ---------------------------------------------------------------------------
# Deferred reflection: update_with_outcome, Reflector, _fetch_returns
# ---------------------------------------------------------------------------
class TestDeferredReflection:
# update_with_outcome
def test_update_replaces_pending_tag(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Momentum confirmed.")
text = (tmp_path / "trading_memory.md").read_text(encoding="utf-8")
assert "[2026-01-10 | NVDA | Buy | pending]" not in text
assert "+4.2%" in text
assert "+2.1%" in text
assert "5d" in text
def test_update_appends_reflection(self, tmp_path):
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Momentum confirmed.")
entries = log.load_entries()
assert len(entries) == 1
e = entries[0]
assert e["pending"] is False
assert e["reflection"] == "Momentum confirmed."
assert e["decision"] == DECISION_BUY.strip()
def test_update_preserves_other_entries(self, tmp_path):
"""Only the matching entry is modified; all other entries remain unchanged."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.store_decision("AAPL", "2026-01-11", "Rating: Hold\nHold AAPL.")
log.store_decision("MSFT", "2026-01-12", DECISION_SELL)
log.update_with_outcome("AAPL", "2026-01-11", 0.01, -0.01, 5, "Neutral result.")
entries = log.load_entries()
assert len(entries) == 3
nvda, aapl, msft = entries
assert nvda["ticker"] == "NVDA" and nvda["pending"] is True
assert aapl["ticker"] == "AAPL" and aapl["pending"] is False
assert aapl["reflection"] == "Neutral result."
assert msft["ticker"] == "MSFT" and msft["pending"] is True
def test_update_atomic_write(self, tmp_path):
"""A pre-existing .tmp file is overwritten; the log is correctly updated."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
stale_tmp = tmp_path / "trading_memory.tmp"
stale_tmp.write_text("GARBAGE CONTENT — should be overwritten", encoding="utf-8")
log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Correct.")
assert not stale_tmp.exists()
entries = log.load_entries()
assert len(entries) == 1
assert entries[0]["reflection"] == "Correct."
assert entries[0]["pending"] is False
def test_update_noop_when_no_log_path(self):
log = TradingMemoryLog(config=None)
log.update_with_outcome("NVDA", "2026-01-10", 0.05, 0.02, 5, "Reflection")
def test_formatting_roundtrip_after_update(self, tmp_path):
"""All fields intact and blank line between tag and DECISION preserved after update."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Momentum confirmed.")
entries = log.load_entries()
assert len(entries) == 1
e = entries[0]
assert e["pending"] is False
assert e["decision"] == DECISION_BUY.strip()
assert e["reflection"] == "Momentum confirmed."
assert e["raw"] == "+4.2%"
assert e["alpha"] == "+2.1%"
assert e["holding"] == "5d"
raw_text = (tmp_path / "trading_memory.md").read_text(encoding="utf-8")
assert "[2026-01-10 | NVDA | Buy | +4.2% | +2.1% | 5d]\n\nDECISION:" in raw_text
# Reflector.reflect_on_final_decision
def test_reflect_on_final_decision_returns_llm_output(self):
mock_llm = MagicMock()
mock_llm.invoke.return_value.content = "Directionally correct. Thesis confirmed."
reflector = Reflector(mock_llm)
result = reflector.reflect_on_final_decision(
final_decision=DECISION_BUY, raw_return=0.042, alpha_return=0.021
)
assert result == "Directionally correct. Thesis confirmed."
mock_llm.invoke.assert_called_once()
def test_reflect_on_final_decision_includes_returns_in_prompt(self):
"""Return figures are present in the human message sent to the LLM."""
mock_llm = MagicMock()
mock_llm.invoke.return_value.content = "Incorrect call."
reflector = Reflector(mock_llm)
reflector.reflect_on_final_decision(
final_decision=DECISION_SELL, raw_return=-0.08, alpha_return=-0.05
)
messages = mock_llm.invoke.call_args[0][0]
human_content = next(content for role, content in messages if role == "human")
assert "-8.0%" in human_content
assert "-5.0%" in human_content
assert "Exit position immediately." in human_content
# TradingAgentsGraph._fetch_returns
def test_fetch_returns_valid_ticker(self):
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.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
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 isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
assert days == 5
# resolution date = the bar `days` sessions after the trade date (#1251)
assert resolved == "2026-01-10"
def test_fetch_returns_too_recent(self):
"""Only 1 data point available → returns all-None, no crash."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls:
m = MagicMock()
m.history.return_value = _price_df([100.0])
mock_ticker_cls.return_value = m
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19")
assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_delisted(self):
"""Empty DataFrame → returns all-None, no crash."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls:
m = MagicMock()
m.history.return_value = pd.DataFrame({"Close": []})
mock_ticker_cls.return_value = m
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10")
assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_spy_shorter_than_stock(self):
"""SPY having fewer rows than the stock (but still a full window) must
not raise IndexError."""
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)
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
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
assert raw is not None and alpha is not None
assert days == 5 # full holding window used for both series
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
def test_resolve_benchmark_explicit_override(self):
"""config['benchmark_ticker'] wins for every ticker."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {
"benchmark_ticker": "QQQ",
"benchmark_map": {"": "SPY", ".T": "^N225"},
}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.T") == "QQQ"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "QQQ"
def test_resolve_benchmark_suffix_map(self):
"""Known suffixes route to their regional index."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {
"benchmark_ticker": None,
"benchmark_map": {
".T": "^N225", ".HK": "^HSI", ".NS": "^NSEI",
".L": "^FTSE", ".TO": "^GSPTSE", ".AX": "^AXJO",
".BO": "^BSESN", "": "SPY",
},
}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.T") == "^N225"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "0700.HK") == "^HSI"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE"
def test_resolve_benchmark_china_a_shares(self):
"""A-share tickers route to their exchange composite (uses the real
default benchmark_map, since A-share support relies on it)."""
from tradingagents.default_config import DEFAULT_CONFIG
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {"benchmark_ticker": None,
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SS") == "000001.SS"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "000001.SZ") == "399001.SZ"
def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
"""US tickers (no dotted suffix) take the empty-suffix entry."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {
"benchmark_ticker": None,
"benchmark_map": {"": "SPY", ".T": "^N225"},
}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "SPY"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AAPL") == "SPY"
def test_resolve_benchmark_unknown_suffix_falls_back(self):
"""Unrecognised suffix (BRK.B, FAKE.XX) falls back to SPY."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {
"benchmark_ticker": None,
"benchmark_map": {"": "SPY", ".T": "^N225"},
}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "FAKE.XX") == "SPY"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "BRK.B") == "SPY"
def test_resolve_benchmark_case_insensitive(self):
"""Suffix matching is case-insensitive so 7203.t resolves like 7203.T."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {
"benchmark_ticker": None,
"benchmark_map": {".T": "^N225", "": "SPY"},
}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.t") == "^N225"
def test_reflector_includes_benchmark_in_label(self):
"""benchmark_name appears in the prompt label, not 'SPY' hardcoded."""
mock_llm = MagicMock()
mock_llm.invoke.return_value.content = "Directionally correct."
reflector = Reflector(mock_llm)
reflector.reflect_on_final_decision(
final_decision=DECISION_BUY,
raw_return=0.05,
alpha_return=0.02,
benchmark_name="^N225",
)
messages = mock_llm.invoke.call_args[0][0]
human_content = next(content for role, content in messages if role == "human")
assert "Alpha vs ^N225:" in human_content
assert "Alpha vs SPY:" not in human_content
def test_reflector_defaults_to_spy_for_unupdated_callers(self):
"""Default benchmark_name keeps the SPY label for legacy callers."""
mock_llm = MagicMock()
mock_llm.invoke.return_value.content = "ok"
reflector = Reflector(mock_llm)
reflector.reflect_on_final_decision(
final_decision=DECISION_BUY,
raw_return=0.05,
alpha_return=0.02,
)
messages = mock_llm.invoke.call_args[0][0]
human_content = next(content for role, content in messages if role == "human")
assert "Alpha vs SPY:" in human_content
# TradingAgentsGraph._resolve_pending_entries
def test_resolve_skips_other_tickers(self, tmp_path):
"""Pending AAPL entry is not resolved when the run is for NVDA."""
log = make_log(tmp_path)
log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.memory_log = log
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
mock_graph._fetch_returns.assert_not_called()
assert len(log.get_pending_entries()) == 1
def test_resolve_marks_entry_completed(self, tmp_path):
"""After resolve, get_pending_entries() is empty and the entry has a REFLECTION."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
mock_reflector = MagicMock()
mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed."
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.memory_log = log
mock_graph.reflector = mock_reflector
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
assert log.get_pending_entries() == []
entries = log.load_entries()
assert len(entries) == 1
assert entries[0]["pending"] is False
assert entries[0]["reflection"] == "Momentum confirmed."
assert "+5.0%" in entries[0]["raw"]
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
# ---------------------------------------------------------------------------
class TestPortfolioManagerInjection:
# past_context in initial state
def test_past_context_in_initial_state(self):
propagator = Propagator()
state = propagator.create_initial_state("NVDA", "2026-01-10", past_context="some context")
assert "past_context" in state
assert state["past_context"] == "some context"
def test_past_context_defaults_to_empty(self):
propagator = Propagator()
state = propagator.create_initial_state("NVDA", "2026-01-10")
assert state["past_context"] == ""
# PM prompt
def test_pm_prompt_includes_past_context(self):
captured = {}
llm = _structured_pm_llm(captured)
pm_node = create_portfolio_manager(llm)
state = _make_pm_state(past_context="[2026-01-05 | NVDA | Buy | +5.0% | +2.0% | 5d]\nGreat call.")
pm_node(state)
assert "Lessons from prior decisions and outcomes" in captured["prompt"]
assert "Great call." in captured["prompt"]
def test_pm_no_past_context_no_section(self):
"""PM prompt omits the lessons section entirely when past_context is empty."""
captured = {}
llm = _structured_pm_llm(captured)
pm_node = create_portfolio_manager(llm)
state = _make_pm_state(past_context="")
pm_node(state)
assert "Lessons from prior decisions" not in captured["prompt"]
def test_pm_returns_rendered_markdown_with_rating(self):
"""The structured PortfolioDecision is rendered to markdown that
downstream consumers (memory log, signal processor, CLI display)
can parse without any extra LLM call."""
captured = {}
decision = PortfolioDecision(
rating=PortfolioRating.OVERWEIGHT,
executive_summary="Build position gradually over the next two weeks.",
investment_thesis="AI capex cycle remains intact; institutional flows constructive.",
price_target=215.0,
time_horizon="3-6 months",
)
llm = _structured_pm_llm(captured, decision)
pm_node = create_portfolio_manager(llm)
result = pm_node(_make_pm_state())
md = result["final_trade_decision"]
assert "**Rating**: Overweight" in md
assert "**Executive Summary**: Build position gradually" in md
assert "**Investment Thesis**: AI capex cycle" in md
assert "**Price Target**: 215.0" in md
assert "**Time Horizon**: 3-6 months" in md
def test_pm_falls_back_to_freetext_when_structured_unavailable(self):
"""If a provider does not support with_structured_output, the agent
falls back to a plain invoke and returns whatever prose the model
produced, so the pipeline never blocks."""
plain_response = "**Rating**: Sell\n\nExit ahead of guidance."
llm = MagicMock()
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
llm.invoke.return_value = MagicMock(content=plain_response)
pm_node = create_portfolio_manager(llm)
result = pm_node(_make_pm_state())
assert result["final_trade_decision"] == plain_response
# get_past_context ordering and limits
def test_same_ticker_prioritised(self, tmp_path):
"""Same-ticker entries in same-ticker section; cross-ticker entries in cross-ticker section."""
log = make_log(tmp_path)
_resolve_entry(log, "NVDA", "2026-01-05", DECISION_BUY, "Momentum confirmed.")
_resolve_entry(log, "AAPL", "2026-01-06", DECISION_SELL, "Overvalued.")
result = log.get_past_context("NVDA")
assert "Past analyses of NVDA" in result
assert "Recent cross-ticker lessons" in result
same_block, cross_block = result.split("Recent cross-ticker lessons")
assert "NVDA" in same_block
assert "AAPL" in cross_block
def test_cross_ticker_reflection_only(self, tmp_path):
"""Cross-ticker entries show only the REFLECTION text, not the full DECISION."""
log = make_log(tmp_path)
_resolve_entry(log, "AAPL", "2026-01-06", DECISION_SELL, "Overvalued correction.")
result = log.get_past_context("NVDA")
assert "Overvalued correction." in result
assert "Exit position immediately." not in result
def test_n_same_limit_respected(self, tmp_path):
"""More than 5 same-ticker completed entries → only 5 injected."""
log = make_log(tmp_path)
for i in range(7):
_resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.")
result = log.get_past_context("NVDA", n_same=5)
lessons_present = sum(1 for i in range(7) if f"Lesson {i}." in result)
assert lessons_present == 5
def test_n_cross_limit_respected(self, tmp_path):
"""More than 3 cross-ticker completed entries → only 3 injected."""
log = make_log(tmp_path)
tickers = ["AAPL", "MSFT", "TSLA", "AMZN", "GOOG"]
for i, ticker in enumerate(tickers):
_resolve_entry(log, ticker, f"2026-01-{i+1:02d}", DECISION_BUY, f"{ticker} lesson.")
result = log.get_past_context("NVDA", n_cross=3)
cross_count = sum(result.count(f"{t} lesson.") for t in tickers)
assert cross_count == 3
# Full A→B→C integration cycle
def test_full_cycle_store_resolve_inject(self, tmp_path):
"""store pending → resolve with outcome → past_context non-empty for PM."""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
assert len(log.get_pending_entries()) == 1
assert log.get_past_context("NVDA") == ""
log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "Correct call.")
assert log.get_pending_entries() == []
past_ctx = log.get_past_context("NVDA")
assert past_ctx != ""
assert "NVDA" in past_ctx
assert "Correct call." in past_ctx
assert "DECISION:" in past_ctx
assert "REFLECTION:" in past_ctx
# ---------------------------------------------------------------------------
# Legacy removal: BM25 / FinancialSituationMemory fully gone
# ---------------------------------------------------------------------------
class TestLegacyRemoval:
def test_financial_situation_memory_removed(self):
"""FinancialSituationMemory must not be importable from the memory module."""
import tradingagents.agents.utils.memory as m
assert not hasattr(m, "FinancialSituationMemory")
def test_bm25_not_imported(self):
"""rank_bm25 must not be present in the memory module namespace."""
import tradingagents.agents.utils.memory as m
assert not hasattr(m, "BM25Okapi")
def test_reflect_and_remember_removed(self):
"""TradingAgentsGraph must not expose reflect_and_remember."""
assert not hasattr(TradingAgentsGraph, "reflect_and_remember")
def test_portfolio_manager_no_memory_param(self):
"""create_portfolio_manager accepts only llm; passing memory= raises TypeError."""
mock_llm = MagicMock()
create_portfolio_manager(mock_llm)
with pytest.raises(TypeError):
create_portfolio_manager(mock_llm, memory=MagicMock())
def test_full_pipeline_no_regression(self, tmp_path):
"""propagate() completes and stores the decision after the redesign."""
import functools
fake_state = {
"final_trade_decision": "Rating: Buy\nBuy NVDA.",
"company_of_interest": "NVDA",
"trade_date": "2026-01-10",
"market_report": "",
"sentiment_report": "",
"news_report": "",
"fundamentals_report": "",
"investment_debate_state": {
"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "",
},
"investment_plan": "",
"trader_investment_plan": "",
"risk_debate_state": {
"aggressive_history": "", "conservative_history": "",
"neutral_history": "", "history": "", "judge_decision": "",
"current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "count": 1, "latest_speaker": "",
},
}
mock_graph = MagicMock()
mock_graph.memory_log = TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")})
mock_graph.log_states_dict = {}
mock_graph.debug = False
mock_graph.config = {"results_dir": str(tmp_path)}
mock_graph.graph.invoke.return_value = fake_state
mock_graph.propagator.create_initial_state.return_value = fake_state
mock_graph.propagator.get_graph_args.return_value = {}
mock_graph.signal_processor.process_signal.return_value = "Buy"
# Bind the real _run_graph so propagate's call to self._run_graph executes
# the actual write path instead of the auto-MagicMock.
mock_graph._run_graph = functools.partial(
TradingAgentsGraph._run_graph, mock_graph
)
TradingAgentsGraph.propagate(mock_graph, "NVDA", "2026-01-10")
entries = mock_graph.memory_log.load_entries()
assert len(entries) == 1
assert entries[0]["ticker"] == "NVDA"
assert entries[0]["pending"] is True

View File

@@ -0,0 +1,95 @@
"""Memory-log lessons must be point-in-time safe in a backtest (#1251).
get_past_context previously returned every resolved lesson regardless of the run
date, so a historical run could learn from an outcome that had not happened yet.
Resolved entries now record the date their outcome became known (``resolved:``),
and get_past_context(as_of=...) filters on it. Legacy entries without a
resolution date are excluded from a point-in-time query (conservative migration).
"""
from __future__ import annotations
import pytest
from tradingagents.agents.utils.memory import TradingMemoryLog
def _log(tmp_path):
return TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")})
def _resolve(log, ticker, date, resolution_date, reflection):
log.store_decision(ticker, date, f"Rating: Buy\n{reflection}")
log.update_with_outcome(
ticker, date, 0.05, 0.02, 5, reflection, resolution_date=resolution_date,
)
@pytest.mark.unit
def test_resolution_date_is_stored_and_parsed(tmp_path):
log = _log(tmp_path)
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "outcome known 01-10")
entry = log.load_entries()[0]
assert entry["resolved"] == "2026-01-10"
assert "resolved:2026-01-10" in (tmp_path / "mem.md").read_text()
@pytest.mark.unit
def test_as_of_excludes_lessons_resolved_after_the_run_date(tmp_path):
log = _log(tmp_path)
# Decision on 01-05, outcome only known on 01-10.
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
# A run as-of 01-07 must NOT see it (the outcome was still in the future).
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
# A run as-of 01-10 (and later) sees it.
assert "great trade" in log.get_past_context("NVDA", as_of="2026-01-10")
assert "great trade" in log.get_past_context("NVDA", as_of="2026-02-01")
@pytest.mark.unit
def test_no_as_of_is_unfiltered_live_behavior(tmp_path):
log = _log(tmp_path)
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
# Live run (no as_of): unchanged behavior, lesson is shown.
assert "great trade" in log.get_past_context("NVDA")
@pytest.mark.unit
def test_legacy_entry_without_resolution_date_excluded_in_backtest(tmp_path):
log = _log(tmp_path)
# Simulate a pre-migration resolved entry: no resolution_date recorded.
log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nlegacy lesson")
log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "legacy lesson")
entry = log.load_entries()[0]
assert entry["resolved"] is None
# Conservative: excluded from a point-in-time query (can't prove it was known)...
assert log.get_past_context("NVDA", as_of="2026-06-01") == ""
# ...but still available on a live (unfiltered) run.
assert "legacy lesson" in log.get_past_context("NVDA")
@pytest.mark.unit
def test_cross_ticker_lessons_are_also_gated(tmp_path):
log = _log(tmp_path)
_resolve(log, "AAPL", "2026-01-05", "2026-01-10", "cross lesson")
# Querying a different ticker as-of before resolution: no cross lesson leaks.
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
assert "cross lesson" in log.get_past_context("NVDA", as_of="2026-01-10")
@pytest.mark.unit
def test_memory_as_of_gates_historical_but_not_live():
# The graph filters only for a past trade date; a current-date run passes
# None so live behavior and legacy entries are unaffected (#1251).
from datetime import datetime, timedelta
from tradingagents.graph.trading_graph import TradingAgentsGraph
g = object.__new__(TradingAgentsGraph)
past = "2024-01-01"
today = datetime.now().strftime("%Y-%m-%d")
future = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
assert g._memory_as_of(past) == past # backtest -> filter on the trade date
assert g._memory_as_of(today) is None # live -> no filter
assert g._memory_as_of(future) is None # future-dated run -> no filter

73
tests/test_minimax.py Normal file
View File

@@ -0,0 +1,73 @@
"""Tests for MinimaxChatOpenAI quirks.
Verifies the subclass injects ``reasoning_split=True`` into outgoing
requests so M2.x reasoning models put their <think> block into
``reasoning_details`` instead of polluting ``message.content``.
"""
import os
import pytest
from langchain_core.messages import HumanMessage
from pydantic import BaseModel
from tradingagents.llm_clients.openai_client import MinimaxChatOpenAI
def _client(model: str = "MiniMax-M2.7"):
os.environ.setdefault("MINIMAX_API_KEY", "placeholder")
return MinimaxChatOpenAI(
model=model,
api_key="placeholder",
base_url="https://api.minimax.io/v1",
)
@pytest.mark.unit
class TestMinimaxReasoningSplit:
def test_reasoning_split_sent_via_extra_body_not_top_level(self):
# Must be in extra_body, not top-level: the openai SDK validates
# top-level params and rejects unknown ones like reasoning_split (#826).
payload = _client()._get_request_payload([HumanMessage(content="hi")])
assert payload.get("extra_body", {}).get("reasoning_split") is True
assert "reasoning_split" not in payload # never top-level
def test_non_reasoning_minimax_does_not_inject_reasoning_split(self):
"""Coding Plan / MiniMax-Text-01 / any non-M2-prefixed model must NOT
receive reasoning_split at all (top-level or extra_body) (#826)."""
for model in ("minimax-text-01", "MiniMax-Coding-Plan"):
payload = _client(model)._get_request_payload(
[HumanMessage(content="hi")]
)
assert "reasoning_split" not in payload
assert "reasoning_split" not in payload.get("extra_body", {})
@pytest.mark.unit
class TestMinimaxStructuredOutputDispatch:
"""M2.x models route through the capability table — tool_choice is
suppressed but the schema is still bound as a tool."""
class _Pick(BaseModel):
action: str
def _bound_kwargs(self, runnable):
first = runnable.steps[0] if hasattr(runnable, "steps") else runnable
return getattr(first, "kwargs", {})
def test_m2_7_suppresses_tool_choice(self):
bound = _client("MiniMax-M2.7").with_structured_output(self._Pick)
kwargs = self._bound_kwargs(bound)
assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs
def test_m2_7_highspeed_suppresses_tool_choice(self):
bound = _client("MiniMax-M2.7-highspeed").with_structured_output(self._Pick)
kwargs = self._bound_kwargs(bound)
assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs
def test_schema_still_bound_as_tool(self):
bound = _client("MiniMax-M2.7").with_structured_output(self._Pick)
tools = self._bound_kwargs(bound).get("tools", [])
assert any(
t.get("function", {}).get("name") == "_Pick" for t in tools
), f"schema not bound: {tools}"

View File

@@ -0,0 +1,55 @@
import unittest
import warnings
import pytest
from tradingagents.llm_clients.base_client import BaseLLMClient
from tradingagents.llm_clients.model_catalog import get_known_models
from tradingagents.llm_clients.validators import validate_model
class DummyLLMClient(BaseLLMClient):
def __init__(self, provider: str, model: str):
self.provider = provider
super().__init__(model)
def get_llm(self):
self.warn_if_unknown_model()
return object()
def validate_model(self) -> bool:
return validate_model(self.provider, self.model)
@pytest.mark.unit
class ModelValidationTests(unittest.TestCase):
def test_cli_catalog_models_are_all_validator_approved(self):
for provider, models in get_known_models().items():
if provider in ("ollama", "openrouter"):
continue
for model in models:
with self.subTest(provider=provider, model=model):
self.assertTrue(validate_model(provider, model))
def test_unknown_model_emits_warning_for_strict_provider(self):
client = DummyLLMClient("openai", "not-a-real-openai-model")
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
client.get_llm()
self.assertEqual(len(caught), 1)
self.assertIn("not-a-real-openai-model", str(caught[0].message))
self.assertIn("openai", str(caught[0].message))
def test_openrouter_and_ollama_accept_custom_models_without_warning(self):
for provider in ("openrouter", "ollama"):
client = DummyLLMClient(provider, "custom-model-name")
with self.subTest(provider=provider):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
client.get_llm()
self.assertEqual(caught, [])

View File

@@ -0,0 +1,25 @@
"""Guard the news analyst prompt against tool-signature drift (#1116).
The prompt used to advertise ``get_news(query, ...)`` while the tool takes a
``ticker``, tricking the LLM into hallucinating free-text query calls.
"""
import inspect
import pytest
import tradingagents.agents.analysts.news_analyst as na
from tradingagents.agents.utils.news_data_tools import get_news
@pytest.mark.unit
def test_get_news_takes_ticker_not_query():
arg_names = set(get_news.args.keys())
assert "ticker" in arg_names
assert "query" not in arg_names
@pytest.mark.unit
def test_news_prompt_matches_get_news_signature():
src = inspect.getsource(na)
assert "get_news(ticker, start_date, end_date)" in src
assert "get_news(query" not in src

View File

@@ -0,0 +1,105 @@
"""yfinance news must not leak future-dated (or undated, in a backtest) articles
into a historical window.
Regressions for #992 (flat articles bypassed the date filter), #1007 (global
news injected future articles), #993 (empty-after-filter returned a blank body),
and #1126 (inclusive upper bound leaked the midnight-after article; host-local
timestamp parsing made filtering machine-dependent).
"""
from datetime import datetime, timezone
import pytest
import tradingagents.dataflows.yfinance_news as ynews
from tradingagents.dataflows.date_window import in_window
def _epoch(date_str):
"""Epoch seconds for UTC midnight of ``date_str`` (host-timezone independent)."""
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
@pytest.mark.unit
def test_flat_article_publish_time_is_parsed():
# #992: flat articles now carry a pub_date (was always None -> unfilterable).
# #1126: parsed as UTC-aware, so the date can't shift with the host timezone.
data = ynews._extract_article_data(
{"title": "X", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-05-09")}
)
assert data["pub_date"] is not None
assert data["pub_date"].tzinfo is not None
assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc)
@pytest.mark.unit
def test_window_excludes_future_and_undated_in_backtest():
start = datetime(2025, 5, 1)
end = datetime(2025, 5, 9) # historical window (well in the past)
inside = datetime(2025, 5, 5)
future = datetime(2025, 6, 1)
assert in_window(inside, start, end) is True
assert in_window(future, start, end) is False # look-ahead blocked
assert in_window(None, start, end) is False # undated -> excluded in backtest
@pytest.mark.unit
def test_window_keeps_undated_in_live_window():
# Live window (reaches today): undated articles can't be "future", so keep them.
now = datetime.now(timezone.utc)
assert in_window(None, now, now) is True
@pytest.mark.unit
def test_upper_bound_is_exclusive():
# #1126: an article stamped exactly midnight AFTER end_date leaked in under
# the old inclusive bound; the whole of end_date itself must still be kept.
start = datetime(2025, 5, 1)
end = datetime(2025, 5, 9)
midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=timezone.utc)
last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=timezone.utc)
assert in_window(midnight_after, start, end) is False
assert in_window(last_moment, start, end) is True
@pytest.mark.unit
def test_offset_aware_timestamp_is_converted_not_truncated():
# #1126: 2025-05-10T01:00+05:00 is really 2025-05-09T20:00Z -> inside the
# window. Stripping tzinfo (old behavior) misread it as 05-10 and dropped it.
start = datetime(2025, 5, 1)
end = datetime(2025, 5, 9)
aware = datetime.fromisoformat("2025-05-10T01:00:00+05:00")
assert in_window(aware, start, end) is True
@pytest.mark.unit
def test_global_news_future_flat_article_excluded(monkeypatch):
# #1007: a flat, future-dated global article must not appear in a historical run.
future_article = {"title": "FUTURE EVENT", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2025-06-01")}
past_article = {"title": "PAST EVENT", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2025-05-05")}
class FakeSearch:
def __init__(self, *a, **k):
self.news = [future_article, past_article]
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
assert "PAST EVENT" in out
assert "FUTURE EVENT" not in out # #1007
@pytest.mark.unit
def test_global_news_empty_after_filter_is_informative(monkeypatch):
# #993: everything filtered out -> a clear message, not a blank-bodied report.
only_future = {"title": "FUTURE", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2025-06-01")}
class FakeSearch:
def __init__(self, *a, **k):
self.news = [only_future]
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
assert "No global news found" in out
assert "###" not in out # no empty article body

View File

@@ -0,0 +1,88 @@
"""Tests that empty vendor results never become fabricated data.
Covers two systematic fixes:
- load_ohlcv must not cache an empty download (cache poisoning), and must
raise NoMarketDataError instead of returning an empty frame.
- route_to_vendor must convert NoMarketDataError into a single explicit
"NO_DATA_AVAILABLE" sentinel after all vendors are exhausted.
"""
import os
import unittest
from unittest import mock
import pandas as pd
import pytest
from tradingagents.dataflows import interface, stockstats_utils
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.symbol_utils import NoMarketDataError
@pytest.mark.unit
class TestLoadOhlcvNoPoison(unittest.TestCase):
def setUp(self):
self._tmp = os.path.join(os.path.dirname(__file__), "_tmp_cache")
os.makedirs(self._tmp, exist_ok=True)
set_config({"data_cache_dir": self._tmp})
def tearDown(self):
for f in os.listdir(self._tmp):
os.remove(os.path.join(self._tmp, f))
os.rmdir(self._tmp)
def test_empty_download_raises_and_does_not_cache(self):
empty = pd.DataFrame()
with mock.patch.object(stockstats_utils.yf, "download", return_value=empty), \
self.assertRaises(NoMarketDataError):
stockstats_utils.load_ohlcv("FAKE", "2026-01-01")
# Nothing should have been written to the cache.
self.assertEqual(os.listdir(self._tmp), [])
# A second call must re-attempt the fetch (no poisoned cache served).
with mock.patch.object(stockstats_utils.yf, "download", return_value=empty) as dl2:
with self.assertRaises(NoMarketDataError):
stockstats_utils.load_ohlcv("FAKE", "2026-01-01")
self.assertTrue(dl2.called)
@pytest.mark.unit
class TestRouteToVendorSentinel(unittest.TestCase):
def test_no_data_from_all_vendors_returns_sentinel(self):
def raises_no_data(symbol, *a, **k):
raise NoMarketDataError(symbol, "GC=F", "no rows")
patched = {"yfinance": raises_no_data, "alpha_vantage": raises_no_data}
with mock.patch.dict(
interface.VENDOR_METHODS, {"get_stock_data": patched}, clear=False
):
result = interface.route_to_vendor(
"get_stock_data", "XAUUSD+", "2026-01-01", "2026-01-10"
)
self.assertIn("NO_DATA_AVAILABLE", result)
self.assertIn("XAUUSD+", result)
self.assertIn("GC=F", result)
self.assertIn("Do not estimate", result)
def test_unconfigured_fallback_does_not_mask_no_data(self):
# When the primary vendor reports no data and the fallback is simply
# unavailable (e.g. missing API key -> raises), the no-data sentinel
# must win rather than the fallback's incidental error crashing out.
def raises_no_data(symbol, *a, **k):
raise NoMarketDataError(symbol, symbol, "no rows")
def raises_unavailable(symbol, *a, **k):
raise ValueError("ALPHA_VANTAGE_API_KEY environment variable is not set.")
patched = {"yfinance": raises_no_data, "alpha_vantage": raises_unavailable}
with mock.patch.dict(
interface.VENDOR_METHODS, {"get_stock_data": patched}, clear=False
):
result = interface.route_to_vendor(
"get_stock_data", "FAKE", "2026-01-01", "2026-01-10"
)
self.assertIn("NO_DATA_AVAILABLE", result)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,109 @@
"""Same-day OHLCV cache must not serve a stale snapshot all day (#1150).
The cache file is keyed per day, so a run started before the day's bar was final
would be reused by every later run, feeding a stale close into technical
analysis. Two cases matter for a current-day request: the bar may be missing, or
present but still in progress (Yahoo publishes a partial daily candle intraday).
Refresh is bounded by a TTL so repeated runs cannot hammer the vendor.
"""
from __future__ import annotations
import os
import time
import pandas as pd
import pytest
import tradingagents.dataflows.stockstats_utils as su
TODAY = pd.Timestamp("2026-07-18")
STALE = su.OHLCV_CACHE_TTL_SECONDS + 60
def _write(tmp_path, name="cache.csv", age_seconds=0.0, last_date="2026-07-17"):
f = tmp_path / name
pd.DataFrame({"Date": [last_date], "Close": [1.0]}).to_csv(f, index=False)
if age_seconds:
old = time.time() - age_seconds
os.utime(f, (old, old))
return str(f)
@pytest.mark.unit
def test_current_day_cache_past_ttl_is_refreshed(tmp_path):
# Bar missing (rows stop at yesterday) and file older than the TTL -> refetch.
assert su._needs_same_day_refresh(_write(tmp_path, age_seconds=STALE), TODAY, TODAY) is True
@pytest.mark.unit
def test_partial_current_day_bar_is_still_refreshed(tmp_path):
# Today's row is present but may be an in-progress candle whose Close is not
# the closing price. Row inspection can't distinguish it, so the TTL governs.
f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18")
assert su._needs_same_day_refresh(f, TODAY, TODAY) is True
@pytest.mark.unit
def test_recent_cache_is_not_refetched(tmp_path):
# Written moments ago: don't hammer the vendor (weekend/holiday guard).
assert su._needs_same_day_refresh(_write(tmp_path), TODAY, TODAY) is False
@pytest.mark.unit
def test_historical_request_always_uses_cache(tmp_path):
# Past dates are immutable: never refetch, however old the file is.
past = pd.Timestamp("2026-05-01")
f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30")
assert su._needs_same_day_refresh(f, past, TODAY) is False
@pytest.mark.unit
def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
"""End-to-end: the helper is actually wired into load_ohlcv's cache branch.
Without this, the unit tests above would still pass if the helper were never
called from the real code path.
"""
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
# Pre-seed the cache file load_ohlcv will look for, aged past the TTL.
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
pd.DataFrame({"Date": ["2026-07-17"], "Close": [100.0]}).to_csv(cache_file, index=False)
old = time.time() - STALE
os.utime(cache_file, (old, old))
calls = []
def _fake_download(*a, **k):
calls.append(1)
return pd.DataFrame(
{"Date": pd.to_datetime(["2026-07-17", "2026-07-18"]), "Close": [100.0, 222.0]}
).set_index("Date")
monkeypatch.setattr(su.yf, "download", _fake_download)
out = su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))
assert calls, "stale same-day cache must trigger a refetch"
assert 222.0 in out["Close"].values, "refreshed close must reach the caller"
@pytest.mark.unit
def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch):
# Mirror image: a fresh cache must NOT trigger a download.
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
pd.DataFrame({"Date": ["2026-07-18"], "Close": [100.0]}).to_csv(cache_file, index=False)
def _fail_download(*a, **k):
raise AssertionError("fresh cache must not refetch")
monkeypatch.setattr(su.yf, "download", _fail_download)
su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))

View File

@@ -0,0 +1,136 @@
"""The latest trading day's bar must not silently vanish (#1201).
yfinance can return the newest in-range bar with a NaN close (an unsettled or
glitched session). The old path parsed dates without normalizing timezone and
dropped every NaN-close row before applying the curr_date cutoff, so the latest
bar disappeared and the previous trading day looked like the latest. Now dates
are normalized, and a latest in-range bar with no close raises rather than
silently falling back.
"""
from __future__ import annotations
import pandas as pd
import pytest
from tradingagents.dataflows import stockstats_utils as su
from tradingagents.dataflows.symbol_utils import NoMarketDataError
# --- date normalization -----------------------------------------------------
@pytest.mark.unit
def test_normalize_dates_strips_tz_and_normalizes_to_midnight():
aware = pd.Series(pd.to_datetime(
["2026-05-08 09:30:00-04:00", "2026-05-09 16:00:00-04:00"]
))
out = su._normalize_dates(aware)
assert out.dt.tz is None
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
@pytest.mark.unit
def test_normalize_dates_leaves_naive_dates_at_midnight():
naive = pd.Series(pd.to_datetime(["2026-05-08 14:30:00", "2026-05-09 00:00:00"]))
out = su._normalize_dates(naive)
assert out.dt.tz is None
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
@pytest.mark.unit
def test_normalize_dates_handles_mixed_dst_offsets():
# 5y of US bars span DST; via a cache CSV they arrive as mixed-offset
# strings, which pd.to_datetime can't unify. Each keeps its own local date.
mixed = pd.Series([
"2026-01-08 00:00:00-05:00", # EST
"2026-06-08 00:00:00-04:00", # EDT
"not-a-date", # -> NaT
])
out = su._normalize_dates(mixed)
assert out.iloc[0] == pd.Timestamp("2026-01-08")
assert out.iloc[1] == pd.Timestamp("2026-06-08")
assert pd.isna(out.iloc[2])
@pytest.mark.unit
def test_normalize_dates_keeps_positive_offset_local_date():
# A Tokyo bar at local midnight (+09:00) must stay on its own calendar day,
# not shift to the previous UTC day (which utc=True parsing would cause).
jst = pd.Series(["2026-05-08 00:00:00+09:00"])
assert su._normalize_dates(jst).iloc[0] == pd.Timestamp("2026-05-08")
# --- fill vs guard responsibilities ----------------------------------------
@pytest.mark.unit
def test_clean_dataframe_keeps_nan_close_for_the_caller_to_inspect():
# _clean_dataframe normalizes but no longer drops the NaN close itself.
df = pd.DataFrame({"Date": ["2026-05-08", "2026-05-09"], "Close": [100.0, float("nan")]})
cleaned = su._clean_dataframe(df)
assert len(cleaned) == 2
assert pd.isna(cleaned["Close"].iloc[-1])
@pytest.mark.unit
def test_fill_price_gaps_drops_nan_close_rows():
df = pd.DataFrame({"Date": pd.to_datetime(["2026-05-07", "2026-05-08"]),
"Close": [float("nan"), 100.0]})
filled = su._fill_price_gaps(df)
assert len(filled) == 1
assert filled["Close"].iloc[0] == 100.0
# --- load_ohlcv end-to-end (with a mocked cache read) -----------------------
def _run_load(monkeypatch, tmp_path, frame, curr_date):
"""Drive load_ohlcv against a pre-seeded cache frame (no network)."""
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
today = pd.Timestamp(curr_date)
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
(tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False))
def _fail_download(*a, **k):
raise AssertionError("should use the seeded cache, not download")
monkeypatch.setattr(su.yf, "download", _fail_download)
monkeypatch.setattr(su, "_assert_ohlcv_not_stale", lambda *a, **k: None)
return su.load_ohlcv("AAPL", curr_date)
@pytest.mark.unit
def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path):
# Newest bar (the curr_date) has no close -> raise, don't return Thursday.
frame = pd.DataFrame({
"Date": ["2026-05-07", "2026-05-08"],
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
})
with pytest.raises(NoMarketDataError, match="no closing price"):
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
@pytest.mark.unit
def test_older_nan_close_row_is_still_dropped(monkeypatch, tmp_path):
# A stale gap mid-series is dropped; the valid latest bar is served.
frame = pd.DataFrame({
"Date": ["2026-05-06", "2026-05-07", "2026-05-08"],
"Open": [100.0, 101.0, 102.0], "High": [101.0, 102.0, 103.0],
"Low": [99.0, 100.0, 101.0],
"Close": [100.5, float("nan"), 102.5], "Volume": [1_000_000, 1_000_000, 1_000_000],
})
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
assert out["Close"].iloc[-1] == 102.5
assert (out["Date"] == pd.Timestamp("2026-05-07")).sum() == 0 # the NaN row is gone
@pytest.mark.unit
def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path):
# A tz-aware/intraday latest bar on the cutoff day must not be filtered out
# by a naive-vs-aware comparison.
frame = pd.DataFrame({
"Date": ["2026-05-07 09:30:00-04:00", "2026-05-08 09:30:00-04:00"],
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
"Close": [100.5, 101.5], "Volume": [1_000_000, 1_000_000],
})
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
assert out["Close"].iloc[-1] == 101.5
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08")

View File

@@ -0,0 +1,188 @@
"""Tests for OLLAMA_BASE_URL env-var override across CLI and client paths."""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture(scope="module", autouse=True)
def _resync_reloaded_modules():
"""Restore module state after this file's importlib.reload() calls.
Several tests below reload ``cli.utils`` to re-evaluate OLLAMA_BASE_URL.
That leaves ``cli.main``'s star-imported names (e.g. get_ticker) bound to
the pre-reload module objects, which breaks identity checks in unrelated
tests that happen to run afterward. Re-sync once on teardown so the reload
doesn't leak across test modules.
"""
yield
import cli.main
import cli.utils
importlib.reload(cli.utils)
importlib.reload(cli.main)
# ---- openai_client side: registry-driven base_url resolution --------------
def _reload_client():
import tradingagents.llm_clients.openai_client as mod
return importlib.reload(mod)
def _base_url(mod, provider, **kwargs):
return str(mod.OpenAIClient(model="m", provider=provider, **kwargs).get_llm().openai_api_base)
def test_resolver_returns_default_when_env_unset(monkeypatch):
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
mod = _reload_client()
assert _base_url(mod, "ollama") == "http://localhost:11434/v1"
def test_resolver_returns_env_when_set(monkeypatch):
monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-ollama:11434/v1")
mod = _reload_client()
assert _base_url(mod, "ollama") == "http://remote-ollama:11434/v1"
def test_resolver_evaluation_is_call_time(monkeypatch):
"""Setting the env AFTER module import must still take effect."""
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
mod = _reload_client()
monkeypatch.setenv("OLLAMA_BASE_URL", "http://late-set:11434/v1")
assert _base_url(mod, "ollama") == "http://late-set:11434/v1"
def test_resolver_does_not_affect_other_providers(monkeypatch):
"""OLLAMA_BASE_URL should NOT leak into xai/deepseek/etc."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://elsewhere/v1")
mod = _reload_client()
assert _base_url(mod, "xai") == "https://api.x.ai/v1"
assert _base_url(mod, "deepseek") == "https://api.deepseek.com"
def test_client_get_llm_picks_up_env(monkeypatch):
"""End-to-end: OllamaClient.get_llm() respects OLLAMA_BASE_URL."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://my-ollama:11434/v1")
mod = _reload_client()
client = mod.OpenAIClient(model="llama3.1", provider="ollama")
llm = client.get_llm()
assert "my-ollama" in str(llm.openai_api_base)
def test_explicit_base_url_overrides_env(monkeypatch):
"""An explicit base_url passed to the client wins over the env var."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-set:11434/v1")
mod = _reload_client()
client = mod.OpenAIClient(
model="llama3.1",
provider="ollama",
base_url="http://explicit:11434/v1",
)
llm = client.get_llm()
assert "explicit" in str(llm.openai_api_base)
assert "env-set" not in str(llm.openai_api_base)
# ---- cli.utils side: select_llm_provider dropdown -------------------------
def test_cli_dropdown_uses_env(monkeypatch):
"""The Ollama entry in the CLI dropdown must reflect OLLAMA_BASE_URL."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://cli-remote:11434/v1")
import cli.utils as cli_utils
importlib.reload(cli_utils)
# Reach inside the function via the same env-read it does at call time
ollama_url = (
__import__("os").environ.get("OLLAMA_BASE_URL")
or "http://localhost:11434/v1"
)
assert ollama_url == "http://cli-remote:11434/v1"
def test_cli_dropdown_default_when_unset(monkeypatch):
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
import cli.utils as cli_utils
importlib.reload(cli_utils)
ollama_url = (
__import__("os").environ.get("OLLAMA_BASE_URL")
or "http://localhost:11434/v1"
)
assert ollama_url == "http://localhost:11434/v1"
# ---- confirm_ollama_endpoint UX -------------------------------------------
def test_confirm_endpoint_shows_default(monkeypatch, capsys):
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
out = capsys.readouterr().out
assert "http://localhost:11434/v1" in out
assert "OLLAMA_BASE_URL" not in out # not from env
assert "Note" not in out # no warnings for the canonical default
def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys):
monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host:11434/v1")
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
out = capsys.readouterr().out
assert "http://remote-host:11434/v1" in out
assert "OLLAMA_BASE_URL" in out
def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys):
"""If user sets OLLAMA_BASE_URL=0.0.0.128, advise on the expected shape."""
monkeypatch.setenv("OLLAMA_BASE_URL", "0.0.0.128")
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("0.0.0.128")
out = capsys.readouterr().out
assert "missing a scheme" in out
assert "http://<host>:11434/v1" in out
def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys):
"""A remote host with no :11434 gets a soft hint about port mismatch."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host/v1")
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
out = capsys.readouterr().out
assert "port 11434" in out
def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys):
"""Local host without port shouldn't trigger the remote-port hint."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost/v1")
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
out = capsys.readouterr().out
assert "Note" not in out # localhost is fine without explicit port
def test_ollama_model_labels_no_local_suffix():
"""Labels should no longer claim '(local)' since the endpoint is dynamic."""
from tradingagents.llm_clients.model_catalog import get_model_options
for mode in ("quick", "deep"):
labels = [label for label, _ in get_model_options("ollama", mode)]
assert all("local" not in label for label in labels), labels
def test_ollama_offers_custom_model_id():
"""Ollama users with custom-pulled models can pick 'Custom model ID'."""
from tradingagents.llm_clients.model_catalog import get_model_options
for mode in ("quick", "deep"):
entries = get_model_options("ollama", mode)
values = [v for _, v in entries]
assert "custom" in values, f"Ollama {mode!r} missing 'custom' option: {entries}"
# Custom option is last so it doesn't push the curated defaults off-screen
assert values[-1] == "custom", f"'custom' should be last entry: {values}"

View File

@@ -0,0 +1,100 @@
"""Generic OpenAI-compatible provider (vLLM / LM Studio / llama.cpp / relays).
Verifies the user-supplied base_url is required and honored, the key is optional
(keyless local default), Chat Completions (not the Responses API) is used, any
model name is accepted, and the env backend URL precedence (#978).
"""
import pytest
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.factory import create_llm_client
from tradingagents.llm_clients.validators import validate_model
# Note: assert by class NAME, not isinstance — other tests reload the
# openai_client module, which would otherwise create a second class identity.
@pytest.mark.unit
def test_factory_routes_to_openai_client():
client = create_llm_client(
provider="openai_compatible", model="my-model", base_url="http://localhost:8000/v1"
)
assert type(client).__name__ == "OpenAIClient"
@pytest.mark.unit
def test_base_url_required(monkeypatch):
monkeypatch.delenv("OPENAI_COMPATIBLE_API_KEY", raising=False)
with pytest.raises(ValueError, match="requires a base_url"):
create_llm_client(provider="openai_compatible", model="m").get_llm()
@pytest.mark.unit
def test_keyless_local_uses_placeholder_and_chat_completions(monkeypatch):
monkeypatch.delenv("OPENAI_COMPATIBLE_API_KEY", raising=False)
llm = create_llm_client(
provider="openai_compatible", model="qwen2.5", base_url="http://localhost:8000/v1"
).get_llm()
assert type(llm).__name__ == "LocalCompatibleChatOpenAI"
assert str(llm.openai_api_base) == "http://localhost:8000/v1"
# keyless local servers: a placeholder key is sent
key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key
assert key == "EMPTY"
# must use Chat Completions, not OpenAI's Responses API
assert getattr(llm, "use_responses_api", False) in (False, None)
@pytest.mark.unit
def test_optional_key_from_env(monkeypatch):
monkeypatch.setenv("OPENAI_COMPATIBLE_API_KEY", "sk-relay-123")
llm = create_llm_client(
provider="openai_compatible", model="m", base_url="https://relay.example/v1"
).get_llm()
key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key
assert key == "sk-relay-123"
@pytest.mark.unit
def test_any_model_accepted_no_forced_key():
assert validate_model("openai_compatible", "literally-anything") is True
# The key env exists (read for keyed relays) but the provider is marked
# key-optional, so the CLI never forces a prompt and keyless servers work.
assert get_api_key_env("openai_compatible") == "OPENAI_COMPATIBLE_API_KEY"
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
assert OPENAI_COMPATIBLE_PROVIDERS["openai_compatible"].key_optional is True
@pytest.mark.unit
def test_env_backend_url_precedence():
# #978: explicit env URL wins over the menu/default regardless of provider source.
from cli.utils import resolve_backend_url
assert resolve_backend_url("openai", "https://api.openai.com/v1", env_url="http://proxy/v1") == "http://proxy/v1"
assert resolve_backend_url("openai", "https://api.openai.com/v1", env_url=None) == "https://api.openai.com/v1"
assert resolve_backend_url("deepseek", None, None) == "https://api.deepseek.com"
@pytest.mark.unit
def test_structured_output_suppresses_object_tool_choice(monkeypatch):
# LM Studio / vLLM reject the object-form tool_choice langchain sends for
# function-calling structured output (#1057). The generic provider binds the
# schema as a tool but must not force tool_choice.
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
class Schema(BaseModel):
x: int
captured = {}
monkeypatch.setattr(
ChatOpenAI,
"with_structured_output",
lambda self, schema, method=None, **kw: captured.update({"method": method, **kw}) or "BOUND",
)
llm = create_llm_client(
provider="openai_compatible", model="local-llm-30b", base_url="http://localhost:1234/v1"
).get_llm()
out = llm.with_structured_output(Schema)
assert out == "BOUND"
assert captured["method"] == "function_calling"
assert captured["tool_choice"] is None # not the object form

View File

@@ -0,0 +1,42 @@
"""OpenAI ``reasoning_effort`` is gated to reasoning models.
Non-reasoning OpenAI models (gpt-4.1, gpt-4o, ...) 400 with "Unsupported
parameter: 'reasoning.effort'". The client must drop the kwarg for those rather
than forward it and crash the run. The GPT-5 family and the o-series accept it.
"""
import pytest
from tradingagents.llm_clients.openai_client import (
OpenAIClient,
_supports_reasoning_effort,
)
@pytest.mark.parametrize(
"model,expected",
[
("gpt-5.5", True), ("gpt-5.4", True), ("gpt-5.4-mini", True),
("gpt-5.5-pro", True), ("o1", True), ("o3-mini", True),
("gpt-4.1", False), ("gpt-4o", False), ("gpt-4o-mini", False),
("gpt-3.5-turbo", False),
],
)
def test_supports_reasoning_effort(model, expected):
assert _supports_reasoning_effort(model) is expected
def _effort_on(model, monkeypatch):
# A fake key lets get_llm() construct the client without a network call.
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
llm = OpenAIClient(model, provider="openai", reasoning_effort="low").get_llm()
return getattr(llm, "reasoning_effort", None)
def test_reasoning_model_receives_effort(monkeypatch):
assert _effort_on("gpt-5.4-mini", monkeypatch) == "low"
def test_non_reasoning_model_drops_effort(monkeypatch):
# gpt-4.1 would 400 with reasoning_effort — it must be dropped.
assert _effort_on("gpt-4.1", monkeypatch) is None

View File

@@ -0,0 +1,43 @@
"""The Responses API only exists on native OpenAI; a custom base_url on the
openai provider must fall back to Chat Completions (#1024)."""
from __future__ import annotations
import pytest
from tradingagents.llm_clients.openai_client import (
OpenAIClient,
_is_native_openai_base_url,
)
@pytest.mark.unit
class NativeBaseUrlTests:
def test_unset_is_native(self):
assert _is_native_openai_base_url(None) is True
assert _is_native_openai_base_url("") is True
def test_openai_hosts_are_native(self):
assert _is_native_openai_base_url("https://api.openai.com/v1") is True
assert _is_native_openai_base_url("api.openai.com/v1") is True
def test_custom_endpoints_are_not_native(self):
assert _is_native_openai_base_url("http://localhost:1234/v1") is False
assert _is_native_openai_base_url("https://my-gateway.example.com/v1") is False
assert _is_native_openai_base_url("https://api.openai.com.evil.com/v1") is False
@pytest.mark.unit
class ResponsesApiSelectionTests:
def test_native_openai_enables_responses_api(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
llm = OpenAIClient("gpt-5.5", provider="openai").get_llm()
assert getattr(llm, "use_responses_api", False) is True
def test_custom_base_url_disables_responses_api(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
llm = OpenAIClient(
"gpt-5.5", base_url="http://localhost:1234/v1", provider="openai"
).get_llm()
# use_responses_api should be absent/False so the client speaks Chat Completions.
assert getattr(llm, "use_responses_api", False) is False

View File

@@ -0,0 +1,122 @@
"""OpenRouter model selection: prompts are labeled by mode (#1000); required
prompts exit cleanly on cancel; the output-language prompt defaults to English
on cancel; and the OpenRouter list is newest-first."""
from unittest import mock
import pytest
from cli import utils
def _asks(value):
return mock.Mock(ask=mock.Mock(return_value=value))
@pytest.mark.unit
class TestOpenRouterPromptLabel:
@pytest.mark.parametrize("mode,label", [("quick", "Quick-Thinking"), ("deep", "Deep-Thinking")])
def test_prompt_states_the_mode(self, mode, label):
captured = {}
def fake_select(message, **kwargs):
captured["message"] = message
return _asks("openrouter/some-model")
with mock.patch.object(utils, "_fetch_openrouter_models",
return_value=[("Some Model", "openrouter/some-model")]), \
mock.patch.object(utils.questionary, "select", side_effect=fake_select):
out = utils.select_openrouter_model(mode)
assert label in captured["message"]
assert out == "openrouter/some-model"
@pytest.mark.unit
class TestOpenRouterLatestFirst:
def test_models_sorted_newest_first(self):
payload = {"data": [
{"id": "old/model", "name": "Old", "created": 1000},
{"id": "new/model", "name": "New", "created": 3000},
{"id": "mid/model", "name": "Mid", "created": 2000},
]}
resp = mock.Mock()
resp.json.return_value = payload
resp.raise_for_status = mock.Mock()
with mock.patch("requests.get", return_value=resp):
out = utils._fetch_openrouter_models()
assert [mid for _, mid in out] == ["new/model", "mid/model", "old/model"]
@pytest.mark.unit
class TestMainstreamFilter:
def test_dropdown_prefers_mainstream_over_niche(self):
# _fetch returns newest-first; the shortlist should drop niche namespaces.
models = [
("Fusion", "openrouter/fusion"),
("Niche", "nex-agi/nex-n2-pro:free"),
("Claude", "anthropic/claude-x"),
("GPT", "openai/gpt-x"),
]
captured = {}
def fake_select(message, **kwargs):
captured["values"] = [c.value for c in kwargs["choices"]]
return _asks("anthropic/claude-x")
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=models), \
mock.patch.object(utils.questionary, "select", side_effect=fake_select):
utils.select_openrouter_model("quick")
assert "anthropic/claude-x" in captured["values"]
assert "openai/gpt-x" in captured["values"]
assert "openrouter/fusion" not in captured["values"]
assert "nex-agi/nex-n2-pro:free" not in captured["values"]
assert "custom" in captured["values"] # escape hatch preserved
def test_falls_back_to_all_when_no_mainstream(self):
models = [("Niche", "nex-agi/x"), ("Other", "thedrummer/y")]
captured = {}
def fake_select(message, **kwargs):
captured["values"] = [c.value for c in kwargs["choices"]]
return _asks("nex-agi/x")
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=models), \
mock.patch.object(utils.questionary, "select", side_effect=fake_select):
utils.select_openrouter_model("deep")
assert "nex-agi/x" in captured["values"] # fallback keeps the list usable
@pytest.mark.unit
class TestCancelExitsCleanly:
def test_dropdown_cancel_exits(self):
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=[]), \
mock.patch.object(utils.questionary, "select", return_value=_asks(None)), \
pytest.raises(SystemExit):
utils.select_openrouter_model("quick")
def test_custom_id_cancel_exits(self):
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=[]), \
mock.patch.object(utils.questionary, "select", return_value=_asks("custom")), \
mock.patch.object(utils.questionary, "text", return_value=_asks(None)), \
pytest.raises(SystemExit):
utils.select_openrouter_model("deep")
def test_prompt_custom_model_id_cancel_exits(self):
with mock.patch.object(utils.questionary, "text", return_value=_asks(None)), \
pytest.raises(SystemExit):
utils._prompt_custom_model_id()
@pytest.mark.unit
class TestLanguageDefaultsToEnglish:
def test_select_cancel_defaults_english(self):
with mock.patch.object(utils.questionary, "select", return_value=_asks(None)):
assert utils.ask_output_language() == "English"
def test_custom_language_cancel_defaults_english(self):
with mock.patch.object(utils.questionary, "select", return_value=_asks("custom")), \
mock.patch.object(utils.questionary, "text", return_value=_asks(None)):
assert utils.ask_output_language() == "English"

129
tests/test_polymarket.py Normal file
View File

@@ -0,0 +1,129 @@
"""Polymarket prediction-market vendor: forward-looking filtering, volume
ranking, formatting, graceful degradation, and router integration.
All API access is mocked, so these run without a network connection.
"""
import copy
import unittest
from unittest import mock
import pytest
import requests
import tradingagents.dataflows.config as config_module
import tradingagents.default_config as default_config
from tradingagents.dataflows import interface, polymarket
from tradingagents.dataflows.config import set_config
def _market(question, prob, *, volume, end_date, closed=False, wk=None):
return {
"question": question,
"outcomes": '["Yes", "No"]',
"outcomePrices": f'["{prob}", "{round(1 - prob, 4)}"]',
"volumeNum": volume,
"endDate": end_date,
"closed": closed,
"oneWeekPriceChange": wk,
}
# One event with a mix: a high-volume open market, a closed one, a past-dated
# one, and a lower-volume open one. Far-future / far-past dates keep the test
# independent of the real clock.
_SEARCH = {
"events": [
{
"markets": [
_market("Open big?", 0.76, volume=5_000_000, end_date="2030-12-31T00:00:00Z", wk=-0.045),
_market("Resolved already?", 1.0, volume=9_000_000, end_date="2030-12-31T00:00:00Z", closed=True),
_market("Past event?", 0.5, volume=8_000_000, end_date="2020-01-01T00:00:00Z"),
_market("Open small?", 0.30, volume=1_000, end_date="2030-06-30T00:00:00Z"),
]
}
]
}
@pytest.mark.unit
class PolymarketFilterTests(unittest.TestCase):
def test_closed_and_past_markets_are_excluded(self):
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
out = polymarket.get_prediction_markets("anything", limit=10)
self.assertIn("Open big?", out)
self.assertIn("Open small?", out)
self.assertNotIn("Resolved already?", out) # closed
self.assertNotIn("Past event?", out) # endDate in the past
def test_ranked_by_volume(self):
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
out = polymarket.get_prediction_markets("anything", limit=10)
self.assertLess(out.index("Open big?"), out.index("Open small?"))
def test_limit_caps_results(self):
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
out = polymarket.get_prediction_markets("anything", limit=1)
self.assertIn("Open big?", out)
self.assertNotIn("Open small?", out)
@pytest.mark.unit
class PolymarketFormatTests(unittest.TestCase):
def test_probability_volume_and_weekly_change_render(self):
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
out = polymarket.get_prediction_markets("anything", limit=10)
self.assertIn("Yes 76%", out)
self.assertIn("$5,000,000 volume", out)
self.assertIn("resolves 2030-12-31", out)
self.assertIn("1-week -4.5pp", out) # -0.045 -> -4.5pp
def test_weekly_change_omitted_when_absent(self):
# "Open small?" has wk=None -> no 1-week clause on its line.
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
out = polymarket.get_prediction_markets("anything", limit=10)
small_line = next(ln for ln in out.splitlines() if "Open small?" in ln)
self.assertNotIn("1-week", small_line)
def test_no_matches_reports_clearly(self):
with mock.patch.object(polymarket, "_request", return_value={"events": []}):
out = polymarket.get_prediction_markets("obscure ticker", limit=6)
self.assertIn("No open prediction markets", out)
@pytest.mark.unit
class PolymarketResilienceTests(unittest.TestCase):
def test_network_error_degrades_gracefully(self):
# An external-service hiccup must not raise into the analyst.
with mock.patch.object(
polymarket, "_request", side_effect=requests.RequestException("boom")
):
out = polymarket.get_prediction_markets("Fed rate cut")
self.assertIn("unavailable", out.lower())
self.assertIn("Fed rate cut", out)
@pytest.mark.unit
class PolymarketRoutingTests(unittest.TestCase):
def setUp(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def tearDown(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def test_category_routes_to_polymarket(self):
self.assertEqual(
interface.get_category_for_method("get_prediction_markets"),
"prediction_markets",
)
set_config({"data_vendors": {"prediction_markets": "polymarket"}})
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_prediction_markets": {"polymarket": lambda *a, **k: "POLY_OK"}},
clear=False,
):
out = interface.route_to_vendor("get_prediction_markets", "fed", 5)
self.assertEqual(out, "POLY_OK")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,59 @@
"""The OpenAI-compatible provider registry is the single source of truth for the
family; this guards each provider's resolved config (base URL, subclass, auth,
Responses API) so a future edit can't silently break one.
"""
import pytest
from tradingagents.llm_clients.openai_client import (
OPENAI_COMPATIBLE_PROVIDERS,
DeepSeekChatOpenAI,
MinimaxChatOpenAI,
NormalizedChatOpenAI,
is_openai_compatible,
)
@pytest.mark.unit
def test_registry_membership():
assert is_openai_compatible("openai")
assert is_openai_compatible("openai_compatible") # the generic endpoint
# native (different API) clients are intentionally NOT in the registry
assert not is_openai_compatible("anthropic")
assert not is_openai_compatible("google")
assert not is_openai_compatible("azure")
@pytest.mark.unit
@pytest.mark.parametrize("provider,base_url,chat_class,responses", [
("openai", None, NormalizedChatOpenAI, True),
("xai", "https://api.x.ai/v1", NormalizedChatOpenAI, False),
("deepseek", "https://api.deepseek.com", DeepSeekChatOpenAI, False),
("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", NormalizedChatOpenAI, False),
("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1", NormalizedChatOpenAI, False),
("glm", "https://api.z.ai/api/paas/v4/", NormalizedChatOpenAI, False),
("glm-cn", "https://open.bigmodel.cn/api/paas/v4/", NormalizedChatOpenAI, False),
("minimax", "https://api.minimax.io/v1", MinimaxChatOpenAI, False),
("minimax-cn", "https://api.minimaxi.com/v1", MinimaxChatOpenAI, False),
("openrouter", "https://openrouter.ai/api/v1", NormalizedChatOpenAI, False),
("mistral", "https://api.mistral.ai/v1", NormalizedChatOpenAI, False),
("kimi", "https://api.moonshot.ai/v1", NormalizedChatOpenAI, False),
("groq", "https://api.groq.com/openai/v1", NormalizedChatOpenAI, False),
("nvidia", "https://integrate.api.nvidia.com/v1", NormalizedChatOpenAI, False),
("ollama", "http://localhost:11434/v1", NormalizedChatOpenAI, False),
])
def test_registry_spec(provider, base_url, chat_class, responses):
spec = OPENAI_COMPATIBLE_PROVIDERS[provider]
assert spec.base_url == base_url
assert spec.chat_class is chat_class
assert spec.use_responses_api is responses
@pytest.mark.unit
def test_key_optionality():
# Local/generic endpoints are key-optional; hosted APIs require a key.
assert OPENAI_COMPATIBLE_PROVIDERS["ollama"].key_optional is True
assert OPENAI_COMPATIBLE_PROVIDERS["openai_compatible"].key_optional is True
assert OPENAI_COMPATIBLE_PROVIDERS["openai_compatible"].require_base_url is True
assert OPENAI_COMPATIBLE_PROVIDERS["xai"].key_optional is False
# OLLAMA_BASE_URL is the only base-URL env override.
assert OPENAI_COMPATIBLE_PROVIDERS["ollama"].base_url_env == "OLLAMA_BASE_URL"

View File

@@ -0,0 +1,243 @@
"""Tests for the RSS-first Reddit fetcher, its 429 backoff, the opt-in JSON
path's degradation (#862), and chunked-transfer error handling (#1024)."""
from __future__ import annotations
import http.client
from unittest.mock import patch
from urllib.error import HTTPError
import pytest
from tradingagents.dataflows import reddit
_SAMPLE_ATOM = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>NVDA earnings beat, stock pops</title>
<published>2026-05-20T14:30:00+00:00</published>
<content type="html">&lt;!-- SC_OFF --&gt;&lt;div class="md"&gt;&lt;p&gt;Great &lt;b&gt;quarter&lt;/b&gt; for NVDA&amp;#39;s datacenter unit.&lt;/p&gt;&lt;/div&gt;&lt;!-- SC_ON --&gt;</content>
</entry>
<entry>
<title>Is NVDA overvalued?</title>
<published>2026-05-19T09:00:00Z</published>
<content type="html">&lt;p&gt;Forward P/E discussion&lt;/p&gt;</content>
</entry>
</feed>
"""
def _resp(read_fn):
"""A minimal context-manager response whose read() runs ``read_fn``."""
class _Resp:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *a):
return False
def read(self_inner, size=-1):
data = read_fn()
return data if size is None or size < 0 else data[:size]
return _Resp()
def _atom_resp():
return _resp(lambda: _SAMPLE_ATOM.encode("utf-8"))
def _raise(exc):
def _r():
raise exc
return _resp(_r)
@pytest.mark.unit
class TestIsoToTimestamp:
def test_parses_offset_and_z(self):
assert reddit._iso_to_timestamp("2026-05-20T14:30:00+00:00") > 0
assert reddit._iso_to_timestamp("2026-05-19T09:00:00Z") > 0
def test_none_and_garbage_return_none(self):
assert reddit._iso_to_timestamp(None) is None
assert reddit._iso_to_timestamp("not-a-date") is None
@pytest.mark.unit
class TestStripHtml:
def test_extracts_between_sc_markers_and_unescapes(self):
raw = "<!-- SC_OFF --><div class=\"md\"><p>Great <b>quarter</b> &amp; more</p></div><!-- SC_ON -->"
assert reddit._strip_html(raw) == "Great quarter & more"
def test_empty(self):
assert reddit._strip_html("") == ""
@pytest.mark.unit
class TestRssParsing:
def test_parses_atom_entries(self):
with patch.object(reddit, "urlopen", return_value=_atom_resp()):
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", limit=5, timeout=5.0)
assert len(posts) == 2
assert posts[0]["title"] == "NVDA earnings beat, stock pops"
assert posts[0]["source"] == "rss"
assert posts[0]["score"] is None
assert posts[0]["num_comments"] is None
assert posts[0]["created_utc"] > 0
assert "datacenter unit" in posts[0]["selftext"]
def test_malformed_xml_fails_open(self):
with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<<not xml>>")):
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
@pytest.mark.unit
class TestFetchSubredditIsRssFirst:
"""The default per-subreddit fetch goes straight to RSS — it must not hit
the WAF-blocked JSON endpoint, which only burned rate-limit budget."""
def test_delegates_to_rss_without_touching_json(self):
sentinel = [{"title": "x", "source": "rss", "score": None,
"num_comments": None, "created_utc": None, "selftext": ""}]
with patch.object(reddit, "_fetch_subreddit_rss", return_value=sentinel) as rss, \
patch.object(reddit, "urlopen",
side_effect=AssertionError("JSON endpoint must not be called")):
out = reddit._fetch_subreddit("NVDA", "stocks", 5, 5.0)
rss.assert_called_once()
assert out is sentinel
@pytest.mark.unit
class TestJsonPathFallsBackToRss:
"""The opt-in JSON path still degrades to RSS on a 403 (kept for #862)."""
def test_403_triggers_rss(self):
err = HTTPError("url", 403, "Blocked", {}, None)
rss_posts = [{"title": "x", "source": "rss", "score": None,
"num_comments": None, "created_utc": None, "selftext": ""}]
with patch.object(reddit, "urlopen", side_effect=err), \
patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts) as rss:
out = reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
rss.assert_called_once()
assert out and out[0]["source"] == "rss"
@pytest.mark.unit
class TestRss429Backoff:
def test_429_then_success_retries_once(self):
err = HTTPError("url", 429, "Too Many Requests", {}, None)
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]) as op, \
patch.object(reddit.time, "sleep") as slept:
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
assert op.call_count == 2 # original + exactly one retry
slept.assert_called_once() # backed off before retrying
assert len(posts) == 2
def test_429_twice_gives_up_after_one_retry(self):
err = HTTPError("url", 429, "Too Many Requests", {}, None)
with patch.object(reddit, "urlopen", side_effect=[err, err]) as op, \
patch.object(reddit.time, "sleep"):
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
assert op.call_count == 2 # one retry, then gives up cleanly
assert posts == []
def test_retry_after_header_is_honoured(self):
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, None)
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
patch.object(reddit.time, "sleep") as slept:
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
slept.assert_called_once_with(12.0)
def test_retry_after_zero_is_honoured_not_treated_as_absent(self):
# A valid "Retry-After: 0" means retry at once; it must not fall through
# to the fallback wait (the earlier `or 5.0` bug turned 0 into 5s).
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "0"}, None)
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
patch.object(reddit.time, "sleep") as slept:
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
slept.assert_called_once_with(0.0)
def test_headerless_429_fallback_is_jittered(self):
# No Retry-After -> our own ~5s fallback, jittered so concurrent runs
# don't retry in lockstep (kept within a tight band).
err = HTTPError("url", 429, "Too Many Requests", {}, None)
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
patch.object(reddit.time, "sleep") as slept:
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
slept.assert_called_once()
(wait,), _ = slept.call_args
assert 4.0 <= wait <= 6.0 # 5s +/-20% jitter
@pytest.mark.unit
class TestChunkedTransferErrorsHandled:
"""IncompleteRead/RemoteDisconnected come from http.client and are NOT
OSErrors, so they were previously uncaught and crashed the pipeline (#1024)."""
def test_rss_incomplete_read_degrades_to_empty(self):
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
def test_json_incomplete_read_falls_back_to_rss(self):
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))), \
patch.object(reddit, "_fetch_subreddit_rss", return_value=[]) as rss:
reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
rss.assert_called_once()
def test_oversized_rss_feed_is_refused_not_parsed(self):
# A hostile/misbehaving endpoint streaming an unbounded body must not be
# read into memory before parsing; overflow degrades to an empty feed.
big = _resp(lambda: b"x" * 100)
with patch.object(reddit, "_MAX_FEED_BYTES", 10), \
patch.object(reddit, "urlopen", return_value=big):
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
@pytest.mark.unit
class TestFormatterHandlesRssPosts:
def test_rss_posts_omit_fake_counts_and_note_source(self):
rss_posts = [{
"title": "NVDA pops", "score": None, "num_comments": None,
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
"selftext": "great quarter", "source": "rss",
}]
with patch.object(reddit, "_fetch_subreddit", return_value=rss_posts):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0)
assert "via RSS feed" in out
assert "" not in out # no fake score arrow
assert "NVDA pops" in out
assert "great quarter" in out
def test_json_posts_still_show_counts(self):
json_posts = [{
"title": "NVDA pops", "score": 1234, "num_comments": 56,
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
"selftext": "",
}]
with patch.object(reddit, "_fetch_subreddit", return_value=json_posts):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0)
assert "1234↑" in out
assert "56c" in out
assert "via RSS" not in out
@pytest.mark.unit
class TestCryptoSearchTerm:
"""A crypto pair (BTC-USD) barely matches Reddit text; search the base (#1113)."""
def _captured_ticker(self, ticker):
seen = {}
def fake_fetch(t, sub, limit, timeout):
seen["ticker"] = t
return []
with patch.object(reddit, "_fetch_subreddit", side_effect=fake_fetch):
reddit.fetch_reddit_posts(ticker, subreddits=("stocks",), inter_request_delay=0)
return seen["ticker"]
def test_crypto_pair_searches_base(self):
assert self._captured_ticker("BTC-USD") == "BTC"
def test_equity_passes_through(self):
assert self._captured_ticker("NVDA") == "NVDA"

50
tests/test_reporting.py Normal file
View File

@@ -0,0 +1,50 @@
"""Report parity: the shared writer produces the report tree for the CLI and the
programmatic API alike (#1037)."""
from types import SimpleNamespace
import pytest
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.reporting import write_report_tree
def _state():
return {
"market_report": "MKT",
"news_report": "NEWS",
"investment_debate_state": {"judge_decision": "RM PLAN"},
"trader_investment_plan": "TRADE",
"risk_debate_state": {"judge_decision": "PM DECISION"},
}
@pytest.mark.unit
def test_write_report_tree_creates_files(tmp_path):
out = write_report_tree(_state(), "AAPL", tmp_path)
assert out.name == "complete_report.md"
assert (tmp_path / "1_analysts" / "market.md").read_text() == "MKT"
assert (tmp_path / "1_analysts" / "news.md").read_text() == "NEWS"
assert (tmp_path / "2_research" / "manager.md").read_text() == "RM PLAN"
assert (tmp_path / "3_trading" / "trader.md").read_text() == "TRADE"
assert (tmp_path / "5_portfolio" / "decision.md").read_text() == "PM DECISION"
complete = out.read_text()
assert "Trading Analysis Report: AAPL" in complete
assert "MKT" in complete and "PM DECISION" in complete
@pytest.mark.unit
def test_save_reports_explicit_path(tmp_path):
# Unbound: with an explicit save_path, the method doesn't touch self/config.
out = TradingAgentsGraph.save_reports(None, _state(), "AAPL", save_path=tmp_path)
assert (tmp_path / "complete_report.md").exists()
assert out == tmp_path / "complete_report.md"
@pytest.mark.unit
def test_save_reports_defaults_under_results_dir(tmp_path):
mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)})
out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL")
assert out.exists()
assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_<stamp>/...
assert out.parent.name.startswith("AAPL_")

View File

@@ -0,0 +1,81 @@
"""Shared-router / path_map completeness (#1088).
Both `should_continue_risk_analysis` (three risk edges) and
`should_continue_debate` (two research-debate edges) are single routers whose
return set is larger than any one edge previously mapped. Each edge now shares a
complete path map (`RISK_ANALYSIS_PATH_MAP` / `DEBATE_PATH_MAP`), so a
fall-through return can never hit a missing entry -- which would crash LangGraph
mid-run on prompt/i18n/refactor drift in the speaker labels.
"""
import pytest
from tradingagents.graph.conditional_logic import ConditionalLogic
from tradingagents.graph.setup import DEBATE_PATH_MAP, RISK_ANALYSIS_PATH_MAP
def _state(latest_speaker, count=0):
return {"risk_debate_state": {"latest_speaker": latest_speaker, "count": count}}
def _debate_state(current_response, count=0):
return {"investment_debate_state": {"current_response": current_response, "count": count}}
@pytest.mark.unit
@pytest.mark.parametrize("latest_speaker", [
"Aggressive", "Aggressive Analyst",
"Conservative", "Conservative Analyst",
"Neutral", "Neutral Analyst",
"", # drift: empty label
"Aggressive Risk Analyst", # drift: node renamed
"Agresivo", # drift: i18n / translated label
])
def test_router_return_always_routable(latest_speaker):
logic = ConditionalLogic(max_risk_discuss_rounds=1)
target = logic.should_continue_risk_analysis(_state(latest_speaker))
assert target in RISK_ANALYSIS_PATH_MAP
@pytest.mark.unit
def test_router_terminates_at_round_limit():
logic = ConditionalLogic(max_risk_discuss_rounds=1)
# count >= 3 * rounds routes to the Portfolio Manager (debate ends)
assert logic.should_continue_risk_analysis(_state("Neutral", count=3)) == "Portfolio Manager"
@pytest.mark.unit
def test_path_map_covers_full_router_range():
logic = ConditionalLogic(max_risk_discuss_rounds=1)
returns = {
logic.should_continue_risk_analysis(_state(s, c))
for s in ("Aggressive", "Conservative", "Neutral", "drift")
for c in (0, 99)
}
# Every value the router can emit is a key in the shared map...
assert returns <= set(RISK_ANALYSIS_PATH_MAP)
# ...and the terminal target is reachable.
assert "Portfolio Manager" in returns
@pytest.mark.unit
@pytest.mark.parametrize("current_response", [
"Bull", "Bull Researcher", "Bear", "Bear Researcher",
"", # drift: empty label
"Optimista", # drift: i18n / translated label
])
def test_debate_router_return_always_routable(current_response):
logic = ConditionalLogic(max_debate_rounds=1)
target = logic.should_continue_debate(_debate_state(current_response))
assert target in DEBATE_PATH_MAP
@pytest.mark.unit
def test_debate_path_map_covers_full_router_range():
logic = ConditionalLogic(max_debate_rounds=1)
returns = {
logic.should_continue_debate(_debate_state(s, c))
for s in ("Bull", "Bear", "drift")
for c in (0, 99)
}
assert returns <= set(DEBATE_PATH_MAP)
assert "Research Manager" in returns # terminal reachable

View File

@@ -0,0 +1,57 @@
"""Tests for the ticker path-component validator that blocks directory traversal."""
import os
import unittest
import pytest
from tradingagents.dataflows.utils import safe_ticker_component
@pytest.mark.unit
class TestSafeTickerComponent(unittest.TestCase):
def test_accepts_common_ticker_formats(self):
for ticker in ("AAPL", "BRK-B", "BRK.A", "0700.HK", "7203.T", "BHP.AX", "^GSPC"):
self.assertEqual(safe_ticker_component(ticker), ticker)
def test_accepts_futures_and_forex_formats(self):
# Futures use '=' (GC=F gold, CL=F crude), forex/CFD symbols use '+'.
for ticker in ("GC=F", "CL=F", "ES=F", "XAUUSD+", "EURUSD+"):
self.assertEqual(safe_ticker_component(ticker), ticker)
def test_rejects_path_separators(self):
for bad in (".", "..", "../etc", "a/b", "a\\b", "/abs", "..\\..\\x"):
with self.assertRaises(ValueError):
safe_ticker_component(bad)
def test_rejects_null_byte_and_whitespace(self):
for bad in ("AAP L", "AAPL\x00", "AAPL\n", "\tAAPL"):
with self.assertRaises(ValueError):
safe_ticker_component(bad)
def test_rejects_empty_or_non_string(self):
for bad in ("", None, 123, b"AAPL"):
with self.assertRaises(ValueError):
safe_ticker_component(bad)
def test_rejects_overlong_input(self):
with self.assertRaises(ValueError):
safe_ticker_component("A" * 33)
def test_rejects_dot_only_values(self):
# '.' and '..' pass the regex but traverse when used as a path
# component (e.g. ``Path(results_dir) / ticker / "logs"``).
for bad in (".", "..", "...", "...."):
with self.assertRaises(ValueError):
safe_ticker_component(bad)
def test_traversal_string_does_not_escape_join(self):
"""Sanity: sanitized values stay within base when joined."""
base = os.path.realpath("/tmp/cache")
ticker = safe_ticker_component("AAPL")
joined = os.path.realpath(os.path.join(base, f"{ticker}.csv"))
self.assertTrue(joined.startswith(base + os.sep))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,140 @@
"""Tests for the shared rating heuristic and the SignalProcessor adapter.
The Portfolio Manager produces a typed PortfolioDecision via structured
output and renders it to markdown that always contains a ``**Rating**: X``
header. The deterministic heuristic in ``tradingagents.agents.utils.rating``
is therefore sufficient to extract the rating downstream — no second LLM
call is needed — and SignalProcessor is now a thin adapter that delegates
to it.
"""
import pytest
from tradingagents.agents.utils.rating import (
RATING_REVIEW,
RATINGS_5_TIER,
extract_rating,
is_review,
parse_rating,
)
from tradingagents.graph.signal_processing import SignalProcessor
# ---------------------------------------------------------------------------
# Heuristic parser
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestParseRating:
def test_explicit_label_buy(self):
assert parse_rating("Rating: Buy\nReasoning here.") == "Buy"
def test_explicit_label_overweight(self):
assert parse_rating("Rating: Overweight\nDetails.") == "Overweight"
def test_explicit_label_with_markdown_bold_value(self):
# Regression: Rating: **Sell** — markdown around the value.
assert parse_rating("Rating: **Sell**\nExit immediately.") == "Sell"
def test_explicit_label_with_markdown_bold_label(self):
assert parse_rating("**Rating**: Underweight\nTrim exposure.") == "Underweight"
def test_rendered_pm_markdown_shape(self):
# The exact shape produced by render_pm_decision must always parse.
text = (
"**Rating**: Buy\n\n"
"**Executive Summary**: Enter at $189-192, 6% portfolio cap.\n\n"
"**Investment Thesis**: AI capex cycle intact; institutional flows constructive."
)
assert parse_rating(text) == "Buy"
def test_explicit_label_wins_over_prose_with_markdown(self):
text = (
"The buy thesis is weakened by guidance.\n"
"Rating: **Sell**\n"
"Exit before earnings."
)
assert parse_rating(text) == "Sell"
def test_no_rating_returns_default(self):
assert parse_rating("No clear directional signal at this time.") == "Hold"
def test_no_rating_custom_default(self):
assert parse_rating("Plain prose.", default="Underweight") == "Underweight"
def test_all_five_tiers_recognised(self):
for r in RATINGS_5_TIER:
assert parse_rating(f"Rating: {r}") == r
# ---------------------------------------------------------------------------
# SignalProcessor: thin adapter over the heuristic
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSignalProcessor:
def test_returns_rating_from_pm_markdown(self):
sp = SignalProcessor()
md = "**Rating**: Overweight\n\n**Executive Summary**: Build gradually."
assert sp.process_signal(md) == "Overweight"
def test_makes_no_llm_calls(self):
"""SignalProcessor must not invoke the LLM it was constructed with —
the rating is parseable from the rendered PM markdown directly."""
from unittest.mock import MagicMock
llm = MagicMock()
sp = SignalProcessor(llm)
sp.process_signal("Rating: Buy\nDetails.")
llm.invoke.assert_not_called()
llm.with_structured_output.assert_not_called()
def test_unparseable_signal_is_review_not_silent_hold(self):
# #1170: an unrecognizable decision must surface REVIEW, not a fabricated
# tradeable Hold.
sp = SignalProcessor()
signal = sp.process_signal("Plain prose without a recommendation.")
assert signal == RATING_REVIEW
assert is_review(signal)
assert signal not in RATINGS_5_TIER
def test_fullwidth_colon_is_parsed_not_reviewed(self):
# #1170: `RatingOverweight` (fullwidth colon) used to defeat the regex
# and silently become Hold; NFKC normalization now parses it.
sp = SignalProcessor()
assert sp.process_signal("RatingOverweight\n理由はこちら。") == "Overweight"
@pytest.mark.unit
class TestExtractRating:
def test_returns_none_when_absent(self):
assert extract_rating("No directional call here.") is None
assert extract_rating("") is None
def test_whole_word_only(self):
# substrings inside larger words must not match
assert extract_rating("The buyer was holding shares.") is None
def test_parse_rating_keeps_silent_default_for_compat(self):
# parse_rating (used by the memory log) intentionally keeps Hold default.
assert parse_rating("No rating here.") == "Hold"
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
@pytest.mark.unit
class TestGraphSignalContract:
"""The graph-facing signal (TradingAgentsGraph.process_signal) honors the
documented "5-tier or REVIEW" contract, not just the parser in isolation."""
def _bare_graph(self):
from tradingagents.graph.trading_graph import TradingAgentsGraph
g = object.__new__(TradingAgentsGraph)
g.signal_processor = SignalProcessor()
return g
def test_graph_surfaces_review(self):
assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW
def test_graph_returns_rating(self):
assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell"

View File

@@ -0,0 +1,121 @@
"""Historical social sentiment must not leak current data into a backtest (#1220).
StockTwits and Reddit fetchers pull only recent items, so for a historical run
they must be trimmed to the analysis window (and yield a clear placeholder when
nothing qualifies) rather than showing today's chatter as if it were from the
as-of date. All three sources share dataflows.date_window.in_window.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
import pytest
from tradingagents.dataflows import reddit, stocktwits
from tradingagents.dataflows.date_window import in_window
class _JsonResp:
"""Minimal urlopen() context-manager stub returning a JSON body."""
def __init__(self, payload):
self._body = json.dumps(payload).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
# --- shared window helper ---------------------------------------------------
@pytest.mark.unit
def test_in_window_bounds_and_exclusive_upper():
start = datetime(2026, 5, 1)
end = datetime(2026, 5, 9)
assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True
assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True
# exactly midnight after end -> excluded (no leak)
assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False
# offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z
assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True
@pytest.mark.unit
def test_in_window_undated_excluded_in_backtest_kept_live():
old = datetime(2026, 5, 9)
assert in_window(None, datetime(2026, 5, 1), old) is False # historical
now = datetime.now(timezone.utc)
assert in_window(None, now, now) is True # live
# --- StockTwits -------------------------------------------------------------
def _msg(created_iso, sentiment=None):
return {
"created_at": created_iso,
"user": {"username": "u"},
"entities": {"sentiment": {"basic": sentiment}},
"body": "text",
}
@pytest.mark.unit
def test_stocktwits_historical_window_excludes_recent(monkeypatch):
# All messages are "today"; a run as-of a past week must show none of them.
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent}))
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
assert "no StockTwits messages" in out
assert "2026-05-01..2026-05-08" in out
assert "Bullish: 1" not in out # the recent bullish message did not leak
@pytest.mark.unit
def test_stocktwits_live_window_keeps_in_range(monkeypatch):
msgs = [_msg("2026-05-05T12:00:00Z", "Bullish"), _msg("2026-05-07T09:00:00Z", "Bearish")]
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
assert "Total: 2" in out
@pytest.mark.unit
def test_stocktwits_no_window_is_unfiltered(monkeypatch):
msgs = [_msg("2026-08-30T12:00:00Z", "Bullish")]
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
out = stocktwits.fetch_stocktwits_messages("AAPL") # live caller, no dates
assert "Total: 1" in out
# --- Reddit -----------------------------------------------------------------
def _epoch(date_str):
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
@pytest.mark.unit
def test_reddit_historical_window_excludes_recent(monkeypatch):
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",), inter_request_delay=0,
start_date="2026-05-01", end_date="2026-05-08",
)
assert "NOW" not in out
assert "no posts" in out.lower() or "no reddit posts" in out.lower()
@pytest.mark.unit
def test_reddit_live_window_keeps_in_range(monkeypatch):
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",), inter_request_delay=0,
start_date="2026-05-01", end_date="2026-05-08",
)
assert "INRANGE" in out

View File

@@ -0,0 +1,70 @@
"""Tests for tolerating a non-`Date` index column in stockstats_utils (#890).
Guards against a download frame whose date column is `index` or `Datetime`
instead of `Date`, which would otherwise silently drop every indicator.
"""
from __future__ import annotations
import pandas as pd
import pytest
from tradingagents.dataflows import stockstats_utils as su
def _ohlcv(date_col: str) -> pd.DataFrame:
"""OHLCV frame whose date column is named `date_col`."""
dates = pd.bdate_range("2026-04-01", periods=10)
return pd.DataFrame({
date_col: dates,
"Open": [100.0 + i for i in range(10)],
"High": [101.0 + i for i in range(10)],
"Low": [99.0 + i for i in range(10)],
"Close": [100.5 + i for i in range(10)],
"Volume": [1_000_000 + i for i in range(10)],
})
@pytest.mark.unit
class TestEnsureDateColumn:
def test_renames_index_column(self):
out = su._ensure_date_column(_ohlcv("index"))
assert "Date" in out.columns and "index" not in out.columns
def test_renames_datetime_and_date_variants(self):
assert "Date" in su._ensure_date_column(_ohlcv("Datetime")).columns
assert "Date" in su._ensure_date_column(_ohlcv("date")).columns
def test_leaves_existing_date_untouched(self):
df = _ohlcv("Date")
assert su._ensure_date_column(df) is df # no-op short-circuit
def test_no_datelike_column_is_left_alone(self):
df = pd.DataFrame({"Close": [1, 2, 3]})
out = su._ensure_date_column(df)
assert "Date" not in out.columns # nothing to rename; caller handles
@pytest.mark.unit
class TestCleanDataframeAcrossVersions:
def test_clean_handles_index_column(self):
"""A frame with `index` instead of `Date` must still clean to a
usable, date-parsed frame (was KeyError: 'Date')."""
cleaned = su._clean_dataframe(_ohlcv("index"))
assert "Date" in cleaned.columns
assert pd.api.types.is_datetime64_any_dtype(cleaned["Date"])
assert len(cleaned) == 10
def test_clean_handles_legacy_date_column(self):
cleaned = su._clean_dataframe(_ohlcv("Date"))
assert len(cleaned) == 10
def test_indicators_compute_after_index_rename(self):
"""stockstats must compute indicators on a frame whose date column
arrived as `index`, instead of erroring per indicator."""
from stockstats import wrap
cleaned = su._clean_dataframe(_ohlcv("index"))
df = wrap(cleaned)
df["close_5_sma"] # triggers calculation
assert "close_5_sma" in df.columns
assert df["close_5_sma"].notna().any()

View File

@@ -0,0 +1,77 @@
"""StockTwits fetch: transport-error resilience (#1024) and crypto symbol
mapping (#1113).
StockTwits lists crypto under ``<BASE>.X`` (Yahoo's ``BTC-USD`` 404s), and any
transport error must degrade to a placeholder rather than raise.
"""
from __future__ import annotations
import http.client
from unittest.mock import patch
from urllib.error import HTTPError
import pytest
from tradingagents.dataflows import stocktwits
def _raise(exc):
class _Resp:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *a):
return False
def read(self_inner):
raise exc
return _Resp()
@pytest.mark.unit
class TestStockTwitsResilience:
@pytest.mark.parametrize(
"exc",
[
http.client.IncompleteRead(b""),
HTTPError("url", 503, "down", {}, None),
TimeoutError("slow"),
],
)
def test_transport_errors_return_placeholder(self, exc):
with patch.object(stocktwits, "urlopen", return_value=_raise(exc)):
out = stocktwits.fetch_stocktwits_messages("NVDA")
assert "unavailable" in out.lower()
assert out.startswith("<stocktwits unavailable")
@pytest.mark.unit
class TestStockTwitsCryptoSymbols:
@pytest.mark.parametrize(
("ticker", "expected"),
[
("BTC-USD", "BTC.X"),
("eth-usd", "ETH.X"),
("SOL-USD", "SOL.X"),
("BTCUSD", "BTC.X"), # undashed broker form
("BTC-USDT", "BTC.X"), # stablecoin quote
("AMD", "AMD"),
("BRK-B", "BRK-B"), # dashed class share: untouched
("GOLD", "GOLD"), # real equity (aliases elsewhere): untouched here
("XYZ-USD", "XYZ-USD"), # unknown base: not treated as crypto
],
)
def test_symbol_mapping(self, ticker, expected):
assert stocktwits._stocktwits_symbol(ticker) == expected
def test_crypto_pair_requests_dot_x_endpoint(self):
seen = {}
def fake_urlopen(req, timeout=None):
seen["url"] = req.full_url
raise TimeoutError("stop after capturing the URL")
with patch.object(stocktwits, "urlopen", side_effect=fake_urlopen):
stocktwits.fetch_stocktwits_messages("BTC-USD")
assert "/symbol/BTC.X.json" in seen["url"]

View File

@@ -0,0 +1,148 @@
"""Agents on the schema-only structured-output path must not invite tool calls (#1130).
`with_structured_output` binds exactly one tool (the schema). A prompt that
primes tool use makes models emit an unknown `web_search` call, which discards
the structured attempt and forces a free-text retry — an extra LLM round trip
and the loss of typed output.
These assert the constraint reaches the *rendered* prompt each agent actually
sends, not merely that the constant is referenced in the module.
"""
from __future__ import annotations
import inspect
from unittest.mock import MagicMock
import pytest
import tradingagents.agents.analysts.sentiment_analyst as sentiment
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
from tradingagents.agents.managers.research_manager import create_research_manager
from tradingagents.agents.trader.trader import create_trader
from tradingagents.agents.utils.structured import NO_EXTERNAL_TOOLS
def _capturing_llm(captured: dict, result):
"""LLM whose structured binding records the prompt it was handed."""
structured = MagicMock()
structured.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or result
)
llm = MagicMock()
llm.with_structured_output.return_value = structured
return llm
def _prompt_text(prompt) -> str:
"""Flatten a captured prompt (str, message list, or objects) to text."""
if isinstance(prompt, str):
return prompt
parts = []
for m in prompt:
parts.append(m.get("content", "") if isinstance(m, dict) else getattr(m, "content", ""))
return "\n".join(str(p) for p in parts)
@pytest.mark.unit
def test_trader_prompt_states_constraint():
from tradingagents.agents.schemas import TraderAction, TraderProposal
captured = {}
llm = _capturing_llm(captured, TraderProposal(action=TraderAction.BUY, reasoning="x"))
create_trader(llm)({
"company_of_interest": "NVDA",
"investment_plan": "**Recommendation**: Buy",
"market_report": "Current price $189.5; ATR 4.2.",
})
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
@pytest.mark.unit
def test_research_manager_prompt_states_constraint():
from tradingagents.agents.schemas import PortfolioRating, ResearchPlan
captured = {}
llm = _capturing_llm(
captured,
ResearchPlan(
recommendation=PortfolioRating.BUY, rationale="x", strategic_actions="y"
),
)
create_research_manager(llm)({
"company_of_interest": "NVDA",
"investment_debate_state": {
"history": "h", "bull_history": "b", "bear_history": "r",
"current_response": "", "judge_decision": "", "count": 1,
},
})
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
@pytest.mark.unit
def test_portfolio_manager_prompt_states_constraint():
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
captured = {}
llm = _capturing_llm(
captured,
PortfolioDecision(
rating=PortfolioRating.HOLD,
executive_summary="x",
investment_thesis="y",
),
)
risk = {
"history": "h", "aggressive_history": "a", "conservative_history": "c",
"neutral_history": "n", "current_aggressive_response": "",
"current_conservative_response": "", "current_neutral_response": "",
"latest_speaker": "Neutral", "count": 1,
}
create_portfolio_manager(llm)({
"company_of_interest": "NVDA",
"risk_debate_state": risk,
"investment_plan": "plan",
"trader_investment_plan": "trader plan",
})
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
@pytest.mark.unit
def test_sentiment_prompt_states_constraint(monkeypatch):
from tradingagents.agents.schemas import SentimentBand, SentimentReport
# Pre-fetched sources are stubbed so the prompt builds without network I/O.
monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st")
monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd")
monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False)
captured = {}
llm = _capturing_llm(captured, SentimentReport(
overall_band=SentimentBand.BULLISH, overall_score=7.5,
confidence="high", narrative="n",
))
sentiment.create_sentiment_analyst(llm)({
"company_of_interest": "NVDA", "trade_date": "2026-01-15",
"asset_type": "stock", "messages": [],
})
text = _prompt_text(captured["prompt"])
assert NO_EXTERNAL_TOOLS in text
# This agent binds no tools, so tool-range wording must not reappear.
assert "tool-call date ranges" not in text
@pytest.mark.unit
def test_tool_using_analysts_keep_their_date_guidance():
# The analysts that really do call tools keep the wording that anchors their
# tool date ranges (#836) — this fix is scoped to no-tool agents.
import tradingagents.agents.analysts.market_analyst as market
import tradingagents.agents.analysts.news_analyst as news
for module in (market, news):
assert "tool-call date ranges" in inspect.getsource(module)
@pytest.mark.unit
def test_constraint_text_is_unambiguous():
assert "do not call external tools" in NO_EXTERNAL_TOOLS.lower()
# No template braces: it is embedded in ChatPromptTemplate strings, where
# braces would be parsed as input variables.
assert "{" not in NO_EXTERNAL_TOOLS and "}" not in NO_EXTERNAL_TOOLS

View File

@@ -0,0 +1,434 @@
"""Tests for structured-output agents (Trader, Research Manager, Sentiment Analyst).
The Portfolio Manager has its own coverage in tests/test_memory_log.py
(which exercises the full memory-log → PM injection cycle). This file
covers the parallel schemas, render functions, and graceful-fallback
behavior we added for the Trader, Research Manager, and Sentiment Analyst
so they share the same deterministic output shape.
"""
from unittest.mock import MagicMock
import pytest
from pydantic import ValidationError
from tradingagents.agents.analysts.sentiment_analyst import create_sentiment_analyst
from tradingagents.agents.managers.research_manager import create_research_manager
from tradingagents.agents.schemas import (
PortfolioDecision,
PortfolioRating,
ResearchPlan,
SentimentBand,
SentimentReport,
TraderAction,
TraderProposal,
render_research_plan,
render_sentiment_report,
render_trader_proposal,
)
from tradingagents.agents.trader.trader import create_trader
# ---------------------------------------------------------------------------
# Render functions
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRenderTraderProposal:
def test_minimal_required_fields(self):
p = TraderProposal(action=TraderAction.HOLD, reasoning="Balanced setup; no edge.")
md = render_trader_proposal(p)
assert "**Action**: Hold" in md
assert "**Reasoning**: Balanced setup; no edge." in md
# The trailing FINAL TRANSACTION PROPOSAL line is preserved for the
# analyst stop-signal text and any external code that greps for it.
assert "FINAL TRANSACTION PROPOSAL: **HOLD**" in md
def test_optional_fields_included_when_present(self):
p = TraderProposal(
action=TraderAction.BUY,
reasoning="Strong technicals + fundamentals.",
entry_price=189.5,
stop_loss=178.0,
position_sizing="6% of portfolio",
)
md = render_trader_proposal(p)
assert "**Action**: Buy" in md
assert "**Entry Price**: 189.5" in md
assert "**Stop Loss**: 178.0" in md
assert "**Position Sizing**: 6% of portfolio" in md
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in md
def test_optional_fields_omitted_when_absent(self):
p = TraderProposal(action=TraderAction.SELL, reasoning="Guidance cut.")
md = render_trader_proposal(p)
assert "Entry Price" not in md
assert "Stop Loss" not in md
assert "Position Sizing" not in md
assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md
@pytest.mark.unit
class TestNullishFloatCoercion:
"""A weak LLM may write "None"/"N/A" into an optional float field (#1058);
coerce those to None so the structured call validates instead of erroring."""
def test_trader_nullish_strings_coerce_to_none(self):
for sentinel in ("None", "N/A", "null", "-", "", "TBD"):
p = TraderProposal(
action=TraderAction.HOLD,
reasoning="x",
entry_price=sentinel,
stop_loss=sentinel,
)
assert p.entry_price is None
assert p.stop_loss is None
def test_trader_real_numeric_string_still_parses(self):
p = TraderProposal(action=TraderAction.BUY, reasoning="x", entry_price="189.5")
assert p.entry_price == 189.5
def test_pm_nullish_price_target_coerces_to_none(self):
d = PortfolioDecision(
rating=PortfolioRating.OVERWEIGHT,
executive_summary="s",
investment_thesis="t",
price_target="N/A",
)
assert d.price_target is None
@pytest.mark.unit
class TestRenderResearchPlan:
def test_required_fields(self):
p = ResearchPlan(
recommendation=PortfolioRating.OVERWEIGHT,
rationale="Bull case carried; tailwinds intact.",
strategic_actions="Build position over two weeks; cap at 5%.",
)
md = render_research_plan(p)
assert "**Recommendation**: Overweight" in md
assert "**Rationale**: Bull case carried" in md
assert "**Strategic Actions**: Build position" in md
def test_all_5_tier_ratings_render(self):
for rating in PortfolioRating:
p = ResearchPlan(
recommendation=rating,
rationale="r",
strategic_actions="s",
)
md = render_research_plan(p)
assert f"**Recommendation**: {rating.value}" in md
# ---------------------------------------------------------------------------
# Trader agent: structured happy path + fallback
# ---------------------------------------------------------------------------
def _make_trader_state():
return {
"company_of_interest": "NVDA",
"investment_plan": "**Recommendation**: Buy\n**Rationale**: ...\n**Strategic Actions**: ...",
"market_report": "Current price $189.5; 14-day ATR 4.2; support $178, resistance $196.",
}
def _structured_trader_llm(captured: dict, proposal: TraderProposal | None = None):
"""Build a MagicMock LLM whose with_structured_output binding captures the
prompt and returns a real TraderProposal so render_trader_proposal works.
"""
if proposal is None:
proposal = TraderProposal(
action=TraderAction.BUY,
reasoning="Strong setup.",
)
structured = MagicMock()
structured.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or proposal
)
llm = MagicMock()
llm.with_structured_output.return_value = structured
return llm
@pytest.mark.unit
def test_invoke_structured_falls_back_when_result_is_none():
# A thinking model can answer in plain text, leaving the parser with None.
# That must fall back to free text, not crash on render(None) (#1051).
from tradingagents.agents.utils.structured import invoke_structured_or_freetext
structured = MagicMock()
structured.invoke.return_value = None
plain = MagicMock()
plain.invoke.return_value = MagicMock(content="FREETEXT")
out = invoke_structured_or_freetext(
structured, plain, "prompt", render=lambda r: r.rating, agent_name="t"
)
assert out == "FREETEXT"
plain.invoke.assert_called_once()
@pytest.mark.unit
class TestTraderAgent:
def test_structured_path_produces_rendered_markdown(self):
captured = {}
proposal = TraderProposal(
action=TraderAction.BUY,
reasoning="AI capex cycle intact; institutional flows constructive.",
entry_price=189.5,
stop_loss=178.0,
position_sizing="6% of portfolio",
)
llm = _structured_trader_llm(captured, proposal)
trader = create_trader(llm)
result = trader(_make_trader_state())
plan = result["trader_investment_plan"]
assert "**Action**: Buy" in plan
assert "**Entry Price**: 189.5" in plan
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in plan
# The same rendered markdown is also added to messages for downstream agents.
assert plan in result["messages"][0].content
def test_prompt_includes_investment_plan(self):
captured = {}
llm = _structured_trader_llm(captured)
trader = create_trader(llm)
trader(_make_trader_state())
# The investment plan is in the user message of the captured prompt.
prompt = captured["prompt"]
assert any("Proposed Investment Plan" in m["content"] for m in prompt)
def test_prompt_includes_market_report_for_price_levels(self):
# #1167: the Trader must see the technical market report so entry/stop
# levels are grounded in real price structure, not just the digested plan.
captured = {}
trader = create_trader(_structured_trader_llm(captured))
trader(_make_trader_state())
user = " ".join(m["content"] for m in captured["prompt"] if m["role"] == "user")
system = " ".join(m["content"] for m in captured["prompt"] if m["role"] == "system")
assert "Technical Market Report:" in user
assert "14-day ATR 4.2" in user # the actual report content reached the Trader
assert "support $178, resistance $196" in user
assert "Ground concrete price levels" in system
def test_empty_market_report_omits_the_section_and_grounding(self):
# #1167: when the market analyst wasn't selected the report is empty, so
# don't tell the Trader to ground levels in a report it doesn't have.
captured = {}
state = _make_trader_state()
state["market_report"] = ""
create_trader(_structured_trader_llm(captured))(state)
text = " ".join(m["content"] for m in captured["prompt"])
assert "Technical Market Report:" not in text
assert "Ground concrete price levels" not in text
assert "Proposed Investment Plan" in text # still present
def test_falls_back_to_freetext_when_structured_unavailable(self):
plain_response = (
"**Action**: Sell\n\nGuidance cut hits margins.\n\n"
"FINAL TRANSACTION PROPOSAL: **SELL**"
)
llm = MagicMock()
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
llm.invoke.return_value = MagicMock(content=plain_response)
trader = create_trader(llm)
result = trader(_make_trader_state())
assert result["trader_investment_plan"] == plain_response
# ---------------------------------------------------------------------------
# Research Manager agent: structured happy path + fallback
# ---------------------------------------------------------------------------
def _make_rm_state():
return {
"company_of_interest": "NVDA",
"investment_debate_state": {
"history": "Bull and bear arguments here.",
"bull_history": "Bull says...",
"bear_history": "Bear says...",
"current_response": "",
"judge_decision": "",
"count": 1,
},
}
def _structured_rm_llm(captured: dict, plan: ResearchPlan | None = None):
if plan is None:
plan = ResearchPlan(
recommendation=PortfolioRating.HOLD,
rationale="Balanced view across both sides.",
strategic_actions="Hold current position; reassess after earnings.",
)
structured = MagicMock()
structured.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or plan
)
llm = MagicMock()
llm.with_structured_output.return_value = structured
return llm
@pytest.mark.unit
class TestResearchManagerAgent:
def test_structured_path_produces_rendered_markdown(self):
captured = {}
plan = ResearchPlan(
recommendation=PortfolioRating.OVERWEIGHT,
rationale="Bull case is stronger; AI tailwind intact.",
strategic_actions="Build position gradually over two weeks.",
)
llm = _structured_rm_llm(captured, plan)
rm = create_research_manager(llm)
result = rm(_make_rm_state())
ip = result["investment_plan"]
assert "**Recommendation**: Overweight" in ip
assert "**Rationale**: Bull case" in ip
assert "**Strategic Actions**: Build position" in ip
def test_prompt_uses_5_tier_rating_scale(self):
"""The RM prompt must list all five tiers so the schema enum matches user expectations."""
captured = {}
llm = _structured_rm_llm(captured)
rm = create_research_manager(llm)
rm(_make_rm_state())
prompt = captured["prompt"]
for tier in ("Buy", "Overweight", "Hold", "Underweight", "Sell"):
assert f"**{tier}**" in prompt, f"missing {tier} in prompt"
def test_falls_back_to_freetext_when_structured_unavailable(self):
plain_response = "**Recommendation**: Sell\n\n**Rationale**: ...\n\n**Strategic Actions**: ..."
llm = MagicMock()
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
llm.invoke.return_value = MagicMock(content=plain_response)
rm = create_research_manager(llm)
result = rm(_make_rm_state())
assert result["investment_plan"] == plain_response
# ---------------------------------------------------------------------------
# Sentiment Analyst: schema, render, structured happy path + fallback
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRenderSentimentReport:
def test_header_contains_band_and_score(self):
report = SentimentReport(
overall_band=SentimentBand.BULLISH,
overall_score=7.2,
confidence="high",
narrative="Source breakdown here.",
)
md = render_sentiment_report(report)
assert "**Overall Sentiment:** **Bullish**" in md
assert "(Score: 7.2/10)" in md
def test_header_contains_confidence(self):
report = SentimentReport(
overall_band=SentimentBand.NEUTRAL,
overall_score=5.0,
confidence="low",
narrative="Limited data.",
)
assert "**Confidence:** Low" in render_sentiment_report(report)
def test_narrative_preserved_in_output(self):
narrative = "## Breakdown\n\nStockTwits: 70% bullish.\n\n| Signal | Direction |\n|---|---|\n| News | Neutral |"
report = SentimentReport(
overall_band=SentimentBand.MILDLY_BULLISH,
overall_score=6.0,
confidence="medium",
narrative=narrative,
)
assert narrative in render_sentiment_report(report)
def test_all_six_bands_render(self):
for band in SentimentBand:
report = SentimentReport(
overall_band=band, overall_score=5.0,
confidence="medium", narrative="n",
)
assert band.value in render_sentiment_report(report)
def test_score_out_of_range_rejected(self):
with pytest.raises(ValidationError):
SentimentReport(
overall_band=SentimentBand.BULLISH, overall_score=11.0,
confidence="high", narrative="n",
)
def _make_sentiment_state():
return {
"company_of_interest": "NVDA",
"trade_date": "2026-01-15",
"asset_type": "stock",
"messages": [],
}
def _structured_sentiment_llm(captured: dict, report: SentimentReport | None = None):
"""MagicMock LLM whose structured binding captures the prompt and returns
a real SentimentReport so render_sentiment_report works."""
if report is None:
report = SentimentReport(
overall_band=SentimentBand.BULLISH, overall_score=7.5,
confidence="high",
narrative="StockTwits 75% bullish. News constructive. Reddit upbeat.",
)
structured = MagicMock()
structured.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or report
)
llm = MagicMock()
llm.with_structured_output.return_value = structured
return llm
@pytest.mark.unit
class TestSentimentAnalystAgent:
def test_structured_path_produces_rendered_markdown(self):
captured = {}
report = SentimentReport(
overall_band=SentimentBand.MILDLY_BEARISH, overall_score=4.0,
confidence="medium", narrative="Mixed signals across sources.",
)
analyst = create_sentiment_analyst(_structured_sentiment_llm(captured, report))
sr = analyst(_make_sentiment_state())["sentiment_report"]
assert "**Overall Sentiment:** **Mildly Bearish**" in sr
assert "(Score: 4.0/10)" in sr
assert "Mixed signals across sources." in sr
def test_sentiment_report_also_in_messages(self):
captured = {}
analyst = create_sentiment_analyst(_structured_sentiment_llm(captured))
result = analyst(_make_sentiment_state())
assert len(result["messages"]) == 1
assert result["sentiment_report"] == result["messages"][0].content
def test_prompt_contains_ticker(self):
captured = {}
create_sentiment_analyst(_structured_sentiment_llm(captured))(_make_sentiment_state())
assert any("NVDA" in str(m) for m in captured["prompt"])
def test_falls_back_to_freetext_when_structured_unavailable(self):
plain = "**Overall Sentiment:** **Bearish** (Score: 3.0/10)\n**Confidence:** Low\n\nLimited data."
llm = MagicMock()
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
llm.invoke.return_value = MagicMock(content=plain)
assert create_sentiment_analyst(llm)(_make_sentiment_state())["sentiment_report"] == plain
def test_falls_back_to_freetext_when_structured_call_fails(self):
plain = "Fallback free-text sentiment."
structured = MagicMock()
structured.invoke.side_effect = ValueError("bad JSON from model")
llm = MagicMock()
llm.with_structured_output.return_value = structured
llm.invoke.return_value = MagicMock(content=plain)
assert create_sentiment_analyst(llm)(_make_sentiment_state())["sentiment_report"] == plain

View File

@@ -0,0 +1,78 @@
"""Symbol normalization must apply on every yfinance path, not just price fetch.
Regression tests for #983 (instrument identity), #984 (reflection returns), and
the news path: a broker symbol like XAUUSD must resolve to the same Yahoo symbol
(GC=F) that the price path uses, so identity, realized-return, and news lookups
hit the right instrument instead of failing/mismatching.
"""
import pandas as pd
import tradingagents.agents.utils.agent_utils as au
import tradingagents.dataflows.yfinance_news as ynews
import tradingagents.graph.trading_graph as tg
from tradingagents.graph.trading_graph import TradingAgentsGraph
def test_identity_lookup_normalizes_symbol(monkeypatch):
seen = {}
class FakeTicker:
def __init__(self, symbol):
seen["symbol"] = symbol
@property
def info(self):
return {"longName": "Gold Futures", "quoteType": "FUTURE"}
monkeypatch.setattr(au.yf, "Ticker", FakeTicker)
au.resolve_instrument_identity.cache_clear()
identity = au.resolve_instrument_identity("XAUUSD")
assert seen["symbol"] == "GC=F" # normalized, not the raw broker symbol
assert identity.get("company_name") == "Gold Futures"
def test_fetch_returns_normalizes_symbol(monkeypatch):
queried = []
class FakeTicker:
def __init__(self, symbol):
queried.append(symbol)
def history(self, *args, **kwargs):
prices = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0]
idx = pd.date_range(start="2025-01-02", periods=len(prices), freq="D")
return pd.DataFrame({"Close": prices}, index=idx)
monkeypatch.setattr(tg.yf, "Ticker", FakeTicker)
# _fetch_returns does not use ``self``; call unbound to avoid building the graph.
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(
None, "XAUUSD", "2025-01-02", holding_days=5, benchmark="SPY"
)
assert queried[0] == "GC=F" # stock symbol normalized (#984)
assert queried[1] == "SPY" # benchmark left as the canonical symbol
assert raw is not None and days is not None
assert resolved == "2025-01-07" # resolution date recorded (#1251)
def test_news_lookup_normalizes_symbol(monkeypatch):
seen = {}
class FakeTicker:
def __init__(self, symbol):
seen["symbol"] = symbol
def get_news(self, count):
return []
monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker)
monkeypatch.setattr(ynews, "yf_retry", lambda fn: fn())
out = ynews.get_news_yfinance("XAUUSD", "2025-01-01", "2025-01-10")
assert seen["symbol"] == "GC=F" # news queried with the canonical symbol
assert "XAUUSD" in out # the user's ticker stays in the report
assert "GC=F" in out # provenance noted

102
tests/test_symbol_utils.py Normal file
View File

@@ -0,0 +1,102 @@
"""Tests for symbol normalization and the no-data routing sentinel."""
import unittest
import pytest
from tradingagents.dataflows.symbol_utils import (
NoMarketDataError,
crypto_base,
is_yahoo_safe,
normalize_symbol,
)
@pytest.mark.unit
class TestNormalizeSymbol(unittest.TestCase):
def test_plain_equities_unchanged(self):
for sym in ("AAPL", "MSFT", "TSM", "BRK.B", "0700.HK", "^GSPC", "GC=F"):
self.assertEqual(normalize_symbol(sym), sym)
def test_lowercases_are_upper(self):
self.assertEqual(normalize_symbol("aapl"), "AAPL")
self.assertEqual(normalize_symbol(" msft "), "MSFT")
def test_metal_aliases_map_to_futures(self):
self.assertEqual(normalize_symbol("XAUUSD"), "GC=F")
self.assertEqual(normalize_symbol("XAUUSD+"), "GC=F") # broker CFD suffix
self.assertEqual(normalize_symbol("xauusd+"), "GC=F")
self.assertEqual(normalize_symbol("GOLD"), "GC=F")
self.assertEqual(normalize_symbol("XAGUSD"), "SI=F")
def test_energy_and_index_aliases(self):
self.assertEqual(normalize_symbol("USOIL"), "CL=F")
self.assertEqual(normalize_symbol("SPX500"), "^GSPC")
self.assertEqual(normalize_symbol("NAS100"), "^NDX")
self.assertEqual(normalize_symbol("US30"), "^DJI")
def test_forex_pairs_get_x_suffix(self):
self.assertEqual(normalize_symbol("EURUSD"), "EURUSD=X")
self.assertEqual(normalize_symbol("GBPJPY"), "GBPJPY=X")
self.assertEqual(normalize_symbol("eurusd"), "EURUSD=X")
def test_crypto_pairs_get_dash_usd(self):
self.assertEqual(normalize_symbol("BTCUSD"), "BTC-USD")
self.assertEqual(normalize_symbol("ETHUSD"), "ETH-USD")
def test_six_letter_non_currency_left_alone(self):
# GOOGLE-style 6-letter tickers that aren't two currency codes
# must not be mangled into a fake forex pair.
self.assertEqual(normalize_symbol("ABCDEF"), "ABCDEF")
def test_empty_input_passthrough(self):
self.assertEqual(normalize_symbol(""), "")
@pytest.mark.unit
class TestNoMarketDataError(unittest.TestCase):
def test_message_includes_resolution(self):
err = NoMarketDataError("XAUUSD+", "GC=F", "no rows")
self.assertIn("XAUUSD+", str(err))
self.assertIn("GC=F", str(err))
self.assertEqual(err.symbol, "XAUUSD+")
self.assertEqual(err.canonical, "GC=F")
def test_canonical_defaults_to_symbol(self):
err = NoMarketDataError("FOOBAR")
self.assertEqual(err.canonical, "FOOBAR")
@pytest.mark.unit
class TestIsYahooSafe(unittest.TestCase):
def test_accepts_structural_chars(self):
for sym in ("AAPL", "GC=F", "^GSPC", "BRK.B", "BTC-USD"):
self.assertTrue(is_yahoo_safe(sym))
def test_rejects_slash_and_space(self):
for sym in ("a/b", "AA PL", ""):
self.assertFalse(is_yahoo_safe(sym))
@pytest.mark.unit
class TestCryptoBase(unittest.TestCase):
def test_resolves_known_crypto_forms(self):
for raw in ("BTC-USD", "BTCUSD", "btc-usdt", "BTC-USDC", "BTCUSD+"):
self.assertEqual(crypto_base(raw), "BTC")
self.assertEqual(crypto_base("ETH-USD"), "ETH")
self.assertEqual(crypto_base("sol-usd"), "SOL")
def test_non_crypto_returns_none(self):
# Plain equities, class shares, and real tickers that alias elsewhere
# (GOLD -> gold future on the Yahoo path) must NOT read as crypto.
for raw in ("AAPL", "BRK-B", "GOLD", "XYZ-USD", "EURUSD", "", None):
self.assertIsNone(crypto_base(raw))
def test_agrees_with_normalize_symbol(self):
# crypto_base is the shared primitive behind the -USD normalization.
self.assertEqual(normalize_symbol("BTCUSD"), "BTC-USD")
self.assertEqual(crypto_base("BTCUSD"), "BTC")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,83 @@
"""Tests for the configurable sampling temperature (#178/#168).
Temperature is a cross-provider knob: when set it must reach the underlying
chat client; when unset the provider keeps its own default.
"""
import importlib
import pytest
from tradingagents.llm_clients.factory import create_llm_client
@pytest.mark.unit
class TestTemperatureForwarding:
@pytest.mark.parametrize(
"provider,model",
[
# gpt-4.1 is intentionally a non-reasoning model: the GPT-5 family
# are reasoning models and correctly drop temperature (see
# test_openai_reasoning_effort), so forwarding is tested on gpt-4.1.
("openai", "gpt-4.1"),
("anthropic", "claude-sonnet-5"),
("google", "gemini-3.5-flash"),
("deepseek", "deepseek-chat"),
],
)
def test_temperature_reaches_client_when_set(self, provider, model):
llm = create_llm_client(
provider=provider, model=model, temperature=0.0, api_key="placeholder"
).get_llm()
assert llm.temperature == 0.0
def test_temperature_omitted_leaves_provider_default(self):
# Not passing temperature must not force it to a value.
llm = create_llm_client(
provider="openai", model="gpt-4.1", api_key="placeholder"
).get_llm()
# langchain's default is unset/None, not 0.0
assert llm.temperature is None
@pytest.mark.unit
class TestTemperatureEnvOverlay:
def test_env_sets_temperature(self, monkeypatch):
import tradingagents.default_config as dc
monkeypatch.setenv("TRADINGAGENTS_TEMPERATURE", "0.2")
importlib.reload(dc)
# Stored on config (string from env is fine; consumed via float()).
assert dc.DEFAULT_CONFIG["temperature"] in ("0.2", 0.2)
assert float(dc.DEFAULT_CONFIG["temperature"]) == 0.2
monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False)
importlib.reload(dc)
def test_default_temperature_is_none(self, monkeypatch):
import tradingagents.default_config as dc
monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False)
importlib.reload(dc)
assert dc.DEFAULT_CONFIG["temperature"] is None
@pytest.mark.unit
class TestProviderKwargsTemperature:
"""_get_provider_kwargs float-coerces and forwards temperature, or omits it."""
def _kwargs_for(self, temperature):
from tradingagents.graph.trading_graph import TradingAgentsGraph
# Call the method without constructing the full graph.
graph = TradingAgentsGraph.__new__(TradingAgentsGraph)
graph.config = {"llm_provider": "openai", "temperature": temperature}
return TradingAgentsGraph._get_provider_kwargs(graph)
def test_float_string_coerced(self):
assert self._kwargs_for("0.3")["temperature"] == 0.3
def test_float_passthrough(self):
assert self._kwargs_for(0.0)["temperature"] == 0.0
def test_none_omitted(self):
assert "temperature" not in self._kwargs_for(None)
def test_empty_string_omitted(self):
assert "temperature" not in self._kwargs_for("")

View File

@@ -0,0 +1,29 @@
import unittest
import pytest
from cli.utils import normalize_ticker_symbol
from tradingagents.agents.utils.agent_utils import build_instrument_context
@pytest.mark.unit
class TickerSymbolHandlingTests(unittest.TestCase):
def test_normalize_ticker_symbol_preserves_exchange_suffix(self):
self.assertEqual(normalize_ticker_symbol(" cnc.to "), "CNC.TO")
def test_build_instrument_context_mentions_exact_symbol(self):
context = build_instrument_context("7203.T")
self.assertIn("7203.T", context)
self.assertIn("exchange suffix", context)
def test_single_get_ticker_no_shadow(self):
# Regression: cli/main.py had a duplicate get_ticker with an empty
# questionary prompt (rendered as a bare "?") that shadowed the
# descriptive one in cli/utils. Keep a single canonical definition.
import cli.main
import cli.utils
self.assertIs(cli.main.get_ticker, cli.utils.get_ticker)
if __name__ == "__main__":
unittest.main()

105
tests/test_vendor_errors.py Normal file
View File

@@ -0,0 +1,105 @@
"""The vendor data-error hierarchy: every "vendor couldn't return usable data"
condition derives from VendorError, so the router catches base types and any
vendor slots in without new handling.
"""
import copy
import unittest
from unittest import mock
import pytest
import tradingagents.dataflows.config as config_module
import tradingagents.default_config as default_config
from tradingagents.dataflows import interface
from tradingagents.dataflows.alpha_vantage_common import (
AlphaVantageNotConfiguredError,
AlphaVantageRateLimitError,
)
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.errors import (
NoMarketDataError,
VendorError,
VendorNotConfiguredError,
VendorRateLimitError,
)
from tradingagents.dataflows.fred import FredNotConfiguredError
@pytest.mark.unit
class HierarchyTests(unittest.TestCase):
def test_all_conditions_derive_from_vendor_error(self):
for cls in (NoMarketDataError, VendorRateLimitError, VendorNotConfiguredError):
self.assertTrue(issubclass(cls, VendorError))
def test_not_configured_is_still_a_value_error(self):
# Back-compat: existing `except ValueError` callers keep working.
self.assertTrue(issubclass(VendorNotConfiguredError, ValueError))
def test_vendor_named_errors_subclass_the_generic_bases(self):
self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorRateLimitError))
self.assertTrue(issubclass(AlphaVantageNotConfiguredError, VendorNotConfiguredError))
self.assertTrue(issubclass(FredNotConfiguredError, VendorNotConfiguredError))
# ... and therefore still ValueErrors
self.assertTrue(issubclass(FredNotConfiguredError, ValueError))
def test_symbol_utils_reexports_no_market_data_error(self):
from tradingagents.dataflows.symbol_utils import (
NoMarketDataError as ReExported,
)
self.assertIs(ReExported, NoMarketDataError)
@pytest.mark.unit
class RouterHandlesBaseTypesTests(unittest.TestCase):
def setUp(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def tearDown(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def test_rate_limit_subclass_caught_by_base(self):
# A vendor-named rate-limit error skips to the next vendor in the chain.
set_config({"data_vendors": {"core_stock_apis": "alpha_vantage,yfinance"}})
def _throttled(*a, **k):
raise AlphaVantageRateLimitError("slow down")
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_stock_data": {"alpha_vantage": _throttled, "yfinance": lambda *a, **k: "YF"}},
clear=False,
):
out = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertEqual(out, "YF")
def test_not_configured_falls_through_to_next_vendor(self):
set_config({"data_vendors": {"core_stock_apis": "alpha_vantage,yfinance"}})
def _unconfigured(*a, **k):
raise AlphaVantageNotConfiguredError("no key")
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_stock_data": {"alpha_vantage": _unconfigured, "yfinance": lambda *a, **k: "YF"}},
clear=False,
):
out = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertEqual(out, "YF")
def test_sole_unconfigured_vendor_surfaces_the_error(self):
# With no fallback, the not-configured condition must surface (not vanish).
set_config({"data_vendors": {"core_stock_apis": "alpha_vantage"}})
def _unconfigured(*a, **k):
raise AlphaVantageNotConfiguredError("no key")
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_stock_data": {"alpha_vantage": _unconfigured}},
clear=False,
), self.assertRaises(AlphaVantageNotConfiguredError):
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,123 @@
"""Vendor router must respect the configured chain and never silently hide a
broken primary.
Regressions for #988 (explicit single-vendor config still fell back to others),
#289 (fallback ran for unchosen vendors), and #989 (serious primary failures
were swallowed without a trace).
"""
import copy
import unittest
from unittest import mock
import pytest
import tradingagents.dataflows.config as config_module
import tradingagents.default_config as default_config
from tradingagents.dataflows import interface
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.symbol_utils import NoMarketDataError
def _reset_config():
# Hard reset: set_config() merges, so empty DEFAULT dicts (e.g. tool_vendors)
# don't clear keys leaked by other tests. Replace the global outright.
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def _no_data(symbol, *a, **k):
raise NoMarketDataError(symbol, symbol, "no rows")
def _returns(value):
def impl(symbol, *a, **k):
return value
return impl
def _raises(exc):
def impl(symbol, *a, **k):
raise exc
return impl
@pytest.mark.unit
class VendorRoutingTests(unittest.TestCase):
def setUp(self):
_reset_config()
def tearDown(self):
_reset_config()
def _route(self, vendors_for_get_stock_data):
return mock.patch.dict(
interface.VENDOR_METHODS,
{"get_stock_data": vendors_for_get_stock_data},
clear=False,
)
def test_explicit_single_vendor_does_not_fall_back(self):
# #988: with yfinance pinned, a healthy alpha_vantage must NOT be used.
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
av = mock.Mock(side_effect=_returns("AV_DATA"))
with self._route({"yfinance": _no_data, "alpha_vantage": av}):
result = interface.route_to_vendor("get_stock_data", "FAKE", "2026-01-01", "2026-01-10")
self.assertIn("NO_DATA_AVAILABLE", result)
av.assert_not_called() # the unchosen vendor was never tried
def test_explicit_multi_vendor_falls_back_within_chain(self):
# Listing both vendors opts in to ordered fallback.
set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}})
with self._route({"yfinance": _no_data, "alpha_vantage": _returns("AV_DATA")}):
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertEqual(result, "AV_DATA")
def test_primary_error_is_logged_not_masked(self):
# #989: primary errors + fallback no-data -> NO_DATA, but the failure
# must be visible in logs (broken primary not hidden).
set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}})
with self._route({"yfinance": _raises(ValueError("boom")), "alpha_vantage": _no_data}), \
self.assertLogs("tradingagents.dataflows.interface", level="WARNING") as cm:
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertIn("NO_DATA_AVAILABLE", result)
joined = "\n".join(cm.output)
self.assertIn("boom", joined) # the real error surfaced in logs
self.assertIn("yfinance", joined)
def test_unknown_configured_vendor_raises(self):
set_config({"data_vendors": {"core_stock_apis": "bogus_vendor"}})
with self.assertRaises(ValueError) as ctx:
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertIn("bogus_vendor", str(ctx.exception))
def test_default_sentinel_uses_all_vendors(self):
# No explicit choice ("default") keeps the resilient full-chain behavior.
set_config({"data_vendors": {"core_stock_apis": "default"}})
with self._route({"yfinance": _no_data, "alpha_vantage": _returns("AV_DATA")}):
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
self.assertEqual(result, "AV_DATA")
def _route_method(self, method, vendors):
return mock.patch.dict(interface.VENDOR_METHODS, {method: vendors}, clear=False)
def test_optional_category_degrades_instead_of_raising(self):
# An optional enrichment vendor (FRED macro) that raises must NOT abort
# the run — the router returns a sentinel so the analysis proceeds.
set_config({"data_vendors": {"macro_data": "fred"}})
with self._route_method(
"get_macro_indicators", {"fred": _raises(ValueError("FRED 400: bad series"))}
):
result = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-01-01")
self.assertIn("DATA_UNAVAILABLE", result)
self.assertIn("macro_data", result)
def test_core_category_still_raises_on_error(self):
# A core category (single configured vendor) propagates the error so a
# broken primary is loud, not silently degraded.
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
with self._route({"yfinance": _raises(ValueError("boom"))}), \
self.assertRaises(ValueError):
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,113 @@
"""Stale OHLCV guard (#1021): a vendor returning a year-old partial frame must
be rejected, not fed into the report as if it were current.
The guard raises NoMarketDataError with a stale-specific detail, so the router's
existing try-next-vendor + single-sentinel handling applies and the sentinel
surfaces the reason.
"""
import copy
import unittest
from unittest import mock
import pandas as pd
import pytest
import tradingagents.dataflows.config as config_module
import tradingagents.dataflows.y_finance as y_finance
import tradingagents.default_config as default_config
from tradingagents.dataflows import interface
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.stockstats_utils import _assert_ohlcv_not_stale
from tradingagents.dataflows.symbol_utils import NoMarketDataError
def _frame(date):
return pd.DataFrame(
{
"Date": [pd.Timestamp(date)],
"Open": [330.0],
"High": [332.0],
"Low": [328.0],
"Close": [330.58],
"Volume": [1_000_000],
}
)
@pytest.mark.unit
class StaleGuardUnitTests(unittest.TestCase):
def test_recent_prior_trading_day_is_accepted(self):
# 1 day before curr_date — well within the freshness window.
_assert_ohlcv_not_stale(_frame("2026-06-10"), "2026-06-11", "CB")
def test_year_old_row_is_rejected_with_detail(self):
with self.assertRaises(NoMarketDataError) as ctx:
_assert_ohlcv_not_stale(_frame("2025-06-11"), "2026-06-11", "CB", "CB")
msg = str(ctx.exception)
self.assertIn("2025-06-11", msg)
self.assertIn("2026-06-11", msg)
self.assertIn("stale", msg)
def test_empty_frame_is_left_to_caller(self):
# Empty is a no-data condition handled elsewhere, not a staleness one.
_assert_ohlcv_not_stale(
pd.DataFrame(columns=["Date", "Close"]), "2026-06-11", "X"
)
def test_long_holiday_gap_within_threshold_is_accepted(self):
_assert_ohlcv_not_stale(_frame("2026-06-02"), "2026-06-11", "X") # 9 days
@pytest.mark.unit
class StaleGuardPropagationTests(unittest.TestCase):
def test_get_yfin_data_online_raises_on_stale_frame(self):
stale = pd.DataFrame(
{
"Open": [280.0], "High": [286.0], "Low": [278.0],
"Close": [284.45], "Volume": [1_000_000],
},
index=pd.DatetimeIndex([pd.Timestamp("2025-06-11")], name="Date"),
)
class DummyTicker:
def __init__(self, symbol):
pass
def history(self, start, end):
return stale
with mock.patch.object(y_finance.yf, "Ticker", DummyTicker), \
self.assertRaises(NoMarketDataError):
y_finance.get_YFin_data_online("CB", "2026-06-01", "2026-06-11")
@pytest.mark.unit
class StaleGuardRoutingTests(unittest.TestCase):
def setUp(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def tearDown(self):
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
def test_router_sentinel_surfaces_stale_reason(self):
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
def _stale(symbol, *a, **k):
raise NoMarketDataError(
symbol, symbol, "latest row is 2025-06-11, 365 days before ... (stale)"
)
with mock.patch.dict(
interface.VENDOR_METHODS,
{"get_stock_data": {"yfinance": _stale}},
clear=False,
):
out = interface.route_to_vendor(
"get_stock_data", "CB", "2026-06-01", "2026-06-11"
)
self.assertIn("NO_DATA_AVAILABLE", out)
self.assertIn("stale", out) # the typed detail is surfaced to the agent
if __name__ == "__main__":
unittest.main()

37
tradingagents/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
import contextlib
import warnings
# Load .env files at package import so DEFAULT_CONFIG's env-var overlay
# (and every llm_clients consumer) sees the user's keys regardless of
# which entry point started the process. find_dotenv(usecwd=True) walks
# from the CWD, so the installed `tradingagents` console script picks up
# the project's .env instead of stepping up from site-packages.
# load_dotenv defaults to override=False, so it never clobbers values
# the caller has already exported.
try:
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True))
load_dotenv(find_dotenv(".env.enterprise", usecwd=True), override=False)
except ImportError:
pass
# langchain-core 1.3.3 calls surface_langchain_deprecation_warnings() in
# its own __init__, which prepends default-action filters for its
# subclassed warning categories. To suppress a specific warning we must
# install our filter AFTER langchain-core has installed its own, so import
# it first. The package is a guaranteed transitive dep via langgraph.
with contextlib.suppress(ImportError):
import langchain_core # noqa: F401
# langgraph-checkpoint 4.0.3 calls Reviver() at module load without an
# explicit allowed_objects, which triggers a noisy pending-deprecation
# warning from langchain-core 1.3.3 on every interpreter start. The fix
# is already merged upstream (langchain-ai/langgraph#7743, 2026-05-08)
# and will arrive in the next langgraph-checkpoint release. Remove this
# block (and the langchain_core preload above) when we bump past it.
warnings.filterwarnings(
"ignore",
message=r"The default value of `allowed_objects`.*",
category=PendingDeprecationWarning,
)

View File

@@ -1,27 +1,22 @@
from .utils.agent_utils import Toolkit, create_msg_delete
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
from .utils.memory import FinancialSituationMemory
from .analysts.fundamentals_analyst import create_fundamentals_analyst from .analysts.fundamentals_analyst import create_fundamentals_analyst
from .analysts.market_analyst import create_market_analyst from .analysts.market_analyst import create_market_analyst
from .analysts.news_analyst import create_news_analyst from .analysts.news_analyst import create_news_analyst
from .analysts.social_media_analyst import create_social_media_analyst from .analysts.sentiment_analyst import (
create_sentiment_analyst,
create_social_media_analyst, # deprecated alias kept for back-compat
)
from .managers.portfolio_manager import create_portfolio_manager
from .managers.research_manager import create_research_manager
from .researchers.bear_researcher import create_bear_researcher from .researchers.bear_researcher import create_bear_researcher
from .researchers.bull_researcher import create_bull_researcher from .researchers.bull_researcher import create_bull_researcher
from .risk_mgmt.aggressive_debator import create_aggressive_debator
from .risk_mgmt.aggresive_debator import create_risky_debator from .risk_mgmt.conservative_debator import create_conservative_debator
from .risk_mgmt.conservative_debator import create_safe_debator
from .risk_mgmt.neutral_debator import create_neutral_debator from .risk_mgmt.neutral_debator import create_neutral_debator
from .managers.research_manager import create_research_manager
from .managers.risk_manager import create_risk_manager
from .trader.trader import create_trader from .trader.trader import create_trader
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
from .utils.agent_utils import create_msg_delete
__all__ = [ __all__ = [
"FinancialSituationMemory",
"Toolkit",
"AgentState", "AgentState",
"create_msg_delete", "create_msg_delete",
"InvestDebateState", "InvestDebateState",
@@ -33,9 +28,10 @@ __all__ = [
"create_market_analyst", "create_market_analyst",
"create_neutral_debator", "create_neutral_debator",
"create_news_analyst", "create_news_analyst",
"create_risky_debator", "create_aggressive_debator",
"create_risk_manager", "create_portfolio_manager",
"create_safe_debator", "create_conservative_debator",
"create_social_media_analyst", "create_sentiment_analyst",
"create_social_media_analyst", # deprecated; will be removed in a future version
"create_trader", "create_trader",
] ]

View File

@@ -1,28 +1,32 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json from tradingagents.agents.utils.agent_utils import (
get_balance_sheet,
get_cashflow,
get_fundamentals,
get_income_statement,
get_instrument_context_from_state,
get_language_instruction,
)
def create_fundamentals_analyst(llm, toolkit): def create_fundamentals_analyst(llm):
def fundamentals_analyst_node(state): def fundamentals_analyst_node(state):
current_date = state["trade_date"] current_date = state["trade_date"]
ticker = state["company_of_interest"] instrument_context = get_instrument_context_from_state(state)
company_name = state["company_of_interest"]
if toolkit.config["online_tools"]: tools = [
tools = [toolkit.get_fundamentals_openai] get_fundamentals,
else: get_balance_sheet,
tools = [ get_cashflow,
toolkit.get_finnhub_company_insider_sentiment, get_income_statement,
toolkit.get_finnhub_company_insider_transactions, ]
toolkit.get_simfin_balance_sheet,
toolkit.get_simfin_cashflow,
toolkit.get_simfin_income_stmt,
]
system_message = ( system_message = (
"You are a researcher tasked with analyzing fundamental information over the past week about a company. Please write a comprehensive report of the company's fundamental information such as financial documents, company profile, basic company financials, company financial history, insider sentiment and insider transactions to gain a full view of the company's fundamental information to inform traders. Make sure to include as much detail as possible. Do not simply state the trends are mixed, provide detailed and finegrained analysis and insights that may help traders make decisions." "You are a researcher tasked with analyzing fundamental information over the past week about a company. Please write a comprehensive report of the company's fundamental information such as financial documents, company profile, basic company financials, and company financial history to gain a full view of the company's fundamental information to inform traders. Make sure to include as much detail as possible. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
+ " Make sure to append a Makrdown table at the end of the report to organize key points in the report, organized and easy to read.", + " Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."
+ " Use the available tools: `get_fundamentals` for comprehensive company analysis, `get_balance_sheet`, `get_cashflow`, and `get_income_statement` for specific financial statements."
+ get_language_instruction(),
) )
prompt = ChatPromptTemplate.from_messages( prompt = ChatPromptTemplate.from_messages(
@@ -35,8 +39,9 @@ def create_fundamentals_analyst(llm, toolkit):
" will help where you left off. Execute what you can to make progress." " will help where you left off. Execute what you can to make progress."
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
" You have access to the following tools: {tool_names}.\n{system_message}" " You have access to the following tools: {tool_names}."
"For your reference, the current date is {current_date}. The company we want to look at is {ticker}", " Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
"{system_message}",
), ),
MessagesPlaceholder(variable_name="messages"), MessagesPlaceholder(variable_name="messages"),
] ]
@@ -45,15 +50,20 @@ def create_fundamentals_analyst(llm, toolkit):
prompt = prompt.partial(system_message=system_message) prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(current_date=current_date)
prompt = prompt.partial(ticker=ticker) prompt = prompt.partial(instrument_context=instrument_context)
chain = prompt | llm.bind_tools(tools) chain = prompt | llm.bind_tools(tools)
result = chain.invoke(state["messages"]) result = chain.invoke(state["messages"])
report = ""
if len(result.tool_calls) == 0:
report = result.content
return { return {
"messages": [result], "messages": [result],
"fundamentals_report": result.content, "fundamentals_report": report,
} }
return fundamentals_analyst_node return fundamentals_analyst_node

View File

@@ -1,25 +1,25 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json from tradingagents.agents.utils.agent_utils import (
get_indicators,
get_instrument_context_from_state,
get_language_instruction,
get_stock_data,
get_verified_market_snapshot,
)
def create_market_analyst(llm, toolkit): def create_market_analyst(llm):
def market_analyst_node(state): def market_analyst_node(state):
current_date = state["trade_date"] current_date = state["trade_date"]
ticker = state["company_of_interest"] instrument_context = get_instrument_context_from_state(state)
company_name = state["company_of_interest"]
if toolkit.config["online_tools"]: tools = [
tools = [ get_stock_data,
toolkit.get_YFin_data_online, get_indicators,
toolkit.get_stockstats_indicators_report_online, get_verified_market_snapshot,
] ]
else:
tools = [
toolkit.get_YFin_data,
toolkit.get_stockstats_indicators_report,
]
system_message = ( system_message = (
"""You are a trading assistant tasked with analyzing financial markets. Your role is to select the **most relevant indicators** for a given market condition or trading strategy from the following list. The goal is to choose up to **8 indicators** that provide complementary insights without redundancy. Categories and each category's indicators are: """You are a trading assistant tasked with analyzing financial markets. Your role is to select the **most relevant indicators** for a given market condition or trading strategy from the following list. The goal is to choose up to **8 indicators** that provide complementary insights without redundancy. Categories and each category's indicators are:
@@ -46,8 +46,13 @@ Volatility Indicators:
Volume-Based Indicators: Volume-Based Indicators:
- vwma: VWMA: A moving average weighted by volume. Usage: Confirm trends by integrating price action with volume data. Tips: Watch for skewed results from volume spikes; use in combination with other volume analyses. - vwma: VWMA: A moving average weighted by volume. Usage: Confirm trends by integrating price action with volume data. Tips: Watch for skewed results from volume spikes; use in combination with other volume analyses.
- Select indicators that provide diverse and complementary information. Avoid redundancy (e.g., do not select both rsi and stochrsi). Also briefly explain why they are suitable for the given market context. When you tool call, please use the exact name of the indicators provided above as they are defined parameters, otherwise your call will fail. Please make sure to call get_YFin_data first to retrieve the CSV that is needed to generate indicators. Write a very detailed and nuanced report of the trends you observe. Do not simply state the trends are mixed, provide detailed and finegrained analysis and insights that may help traders make decisions.""" - Select indicators that provide diverse and complementary information. Avoid redundancy (e.g., do not select both rsi and stochrsi). Also briefly explain why they are suitable for the given market context. When you tool call, please use the exact name of the indicators provided above as they are defined parameters, otherwise your call will fail. Please make sure to call get_stock_data first to retrieve the CSV that is needed to generate indicators. Then use get_indicators with the specific indicator names.
Before writing the final report, call get_verified_market_snapshot for this ticker and the current date, and treat it as the source of truth for any exact OHLCV, price-level, or indicator-value claim. If another tool's output conflicts with the verified snapshot, flag the discrepancy rather than inventing a reconciled number. Do not claim historical validation, support/resistance bounces, or exact percentage moves unless they are directly supported by tool output with concrete dates and prices.
Write a very detailed and nuanced report of the trends you observe. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."""
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""" + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
+ get_language_instruction()
) )
prompt = ChatPromptTemplate.from_messages( prompt = ChatPromptTemplate.from_messages(
@@ -60,8 +65,9 @@ Volume-Based Indicators:
" will help where you left off. Execute what you can to make progress." " will help where you left off. Execute what you can to make progress."
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
" You have access to the following tools: {tool_names}.\n{system_message}" " You have access to the following tools: {tool_names}."
"For your reference, the current date is {current_date}. The company we want to look at is {ticker}", " Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
"{system_message}",
), ),
MessagesPlaceholder(variable_name="messages"), MessagesPlaceholder(variable_name="messages"),
] ]
@@ -70,15 +76,20 @@ Volume-Based Indicators:
prompt = prompt.partial(system_message=system_message) prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(current_date=current_date)
prompt = prompt.partial(ticker=ticker) prompt = prompt.partial(instrument_context=instrument_context)
chain = prompt | llm.bind_tools(tools) chain = prompt | llm.bind_tools(tools)
result = chain.invoke(state["messages"]) result = chain.invoke(state["messages"])
report = ""
if len(result.tool_calls) == 0:
report = result.content
return { return {
"messages": [result], "messages": [result],
"market_report": result.content, "market_report": report,
} }
return market_analyst_node return market_analyst_node

View File

@@ -1,25 +1,33 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json from tradingagents.agents.utils.agent_utils import (
get_global_news,
get_instrument_context_from_state,
get_language_instruction,
get_macro_indicators,
get_news,
get_prediction_markets,
)
def create_news_analyst(llm, toolkit): def create_news_analyst(llm):
def news_analyst_node(state): def news_analyst_node(state):
current_date = state["trade_date"] current_date = state["trade_date"]
ticker = state["company_of_interest"] asset_type = state.get("asset_type", "stock")
asset_label = "company" if asset_type == "stock" else "asset"
instrument_context = get_instrument_context_from_state(state)
if toolkit.config["online_tools"]: tools = [
tools = [toolkit.get_global_news_openai, toolkit.get_google_news] get_news,
else: get_global_news,
tools = [ get_macro_indicators,
toolkit.get_finnhub_news, get_prediction_markets,
toolkit.get_reddit_news, ]
toolkit.get_google_news,
]
system_message = ( system_message = (
"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Look at news from EODHD, and finnhub to be comprehensive. Do not simply state the trends are mixed, provide detailed and finegrained analysis and insights that may help traders make decisions." f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(ticker, start_date, end_date) for {asset_label}-specific news by ticker symbol, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
+ """ Make sure to append a Makrdown table at the end of the report to organize key points in the report, organized and easy to read.""" + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
+ get_language_instruction()
) )
prompt = ChatPromptTemplate.from_messages( prompt = ChatPromptTemplate.from_messages(
@@ -32,8 +40,9 @@ def create_news_analyst(llm, toolkit):
" will help where you left off. Execute what you can to make progress." " will help where you left off. Execute what you can to make progress."
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
" You have access to the following tools: {tool_names}.\n{system_message}" " You have access to the following tools: {tool_names}."
"For your reference, the current date is {current_date}. We are looking at the company {ticker}", " Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
"{system_message}",
), ),
MessagesPlaceholder(variable_name="messages"), MessagesPlaceholder(variable_name="messages"),
] ]
@@ -42,14 +51,19 @@ def create_news_analyst(llm, toolkit):
prompt = prompt.partial(system_message=system_message) prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(current_date=current_date)
prompt = prompt.partial(ticker=ticker) prompt = prompt.partial(instrument_context=instrument_context)
chain = prompt | llm.bind_tools(tools) chain = prompt | llm.bind_tools(tools)
result = chain.invoke(state["messages"]) result = chain.invoke(state["messages"])
report = ""
if len(result.tool_calls) == 0:
report = result.content
return { return {
"messages": [result], "messages": [result],
"news_report": result.content, "news_report": report,
} }
return news_analyst_node return news_analyst_node

View File

@@ -0,0 +1,214 @@
"""Sentiment analyst — multi-source sentiment analysis for a target ticker.
Previously named ``social_media_analyst``. Renamed and redesigned because
the old version had a prompt that demanded social-media analysis but the
only tool available was Yahoo Finance news — which led LLMs to fabricate
Reddit/X/StockTwits content under prompt pressure (verified live).
The redesigned agent pre-fetches three complementary data sources before
the LLM is invoked and injects them into the prompt as structured blocks:
1. News headlines — Yahoo Finance (institutional framing)
2. StockTwits messages — retail-trader posts indexed by cashtag, with
user-labeled Bullish/Bearish sentiment tags
3. Reddit posts — r/wallstreetbets, r/stocks, r/investing
The agent does not use tool-calling; the data is in the prompt from
turn 0. Output uses the structured-output pattern (json_schema for
OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic), falling
back to free-text generation for providers that lack native support, so
the sentiment header (band + score + confidence) is deterministic across
runs and providers instead of free-form per-model prose.
See: https://github.com/TauricResearch/TradingAgents/issues/557
See: https://github.com/TauricResearch/TradingAgents/issues/796
"""
from datetime import datetime, timedelta
from langchain_core.messages import AIMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.schemas import SentimentReport, render_sentiment_report
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
get_news,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
from tradingagents.dataflows.reddit import fetch_reddit_posts
from tradingagents.dataflows.stocktwits import fetch_stocktwits_messages
def _seven_days_back(trade_date: str) -> str:
return (datetime.strptime(trade_date, "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d")
def create_sentiment_analyst(llm):
"""Create a sentiment analyst node for the trading graph.
Pre-fetches news + StockTwits + Reddit data, injects them into the
prompt as structured blocks, and produces a deterministic sentiment
report via structured output (with a free-text fallback for providers
that do not support it).
"""
structured_llm = bind_structured(llm, SentimentReport, "Sentiment Analyst")
def sentiment_analyst_node(state):
ticker = state["company_of_interest"]
end_date = state["trade_date"]
start_date = _seven_days_back(end_date)
instrument_context = get_instrument_context_from_state(state)
# Pre-fetch all three sources. Each fetcher degrades gracefully and
# returns a string (no exceptions surface from here), so the LLM
# always sees something — either real data or a clear placeholder.
news_block = get_news.func(ticker, start_date, end_date)
# Pass the analysis window so a historical run trims social posts to it
# instead of leaking today's chatter into a backtest (#1220).
stocktwits_block = fetch_stocktwits_messages(
ticker, limit=30, start_date=start_date, end_date=end_date
)
reddit_block = fetch_reddit_posts(ticker, start_date=start_date, end_date=end_date)
system_message = _build_system_message(
ticker=ticker,
start_date=start_date,
end_date=end_date,
news_block=news_block,
stocktwits_block=stocktwits_block,
reddit_block=reddit_block,
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a helpful AI assistant, collaborating with other assistants."
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
# No tool-calling here: the data is pre-fetched into the
# prompt, so tool-range wording would only invite a
# hallucinated tool call (#1130).
" Today's date is {current_date}; treat it as 'now' for all analysis. {instrument_context}"
" " + NO_EXTERNAL_TOOLS +
"\n{system_message}",
),
MessagesPlaceholder(variable_name="messages"),
]
)
prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(current_date=end_date)
prompt = prompt.partial(instrument_context=instrument_context)
# Format the template into a concrete message list so the structured
# and free-text paths receive the same input. No bind_tools — the
# data is already in the prompt.
formatted_messages = prompt.format_messages(messages=state["messages"])
report_text = invoke_structured_or_freetext(
structured_llm,
llm,
formatted_messages,
render_sentiment_report,
"Sentiment Analyst",
)
return {
"messages": [AIMessage(content=report_text)],
"sentiment_report": report_text,
}
return sentiment_analyst_node
def _build_system_message(
*,
ticker: str,
start_date: str,
end_date: str,
news_block: str,
stocktwits_block: str,
reddit_block: str,
) -> str:
"""Assemble the sentiment-analyst system message with structured data blocks."""
return f"""You are a financial market sentiment analyst. Your task is to produce a comprehensive sentiment report for {ticker} covering the period from {start_date} to {end_date}, drawing on three complementary data sources that have already been collected for you.
## Data sources (pre-fetched, in this prompt)
### News headlines — Yahoo Finance, past 7 days
Institutional framing. Fact-driven, slower-moving signal.
<start_of_news>
{news_block}
<end_of_news>
### StockTwits messages — retail-trader social platform indexed by cashtag
Fast-moving signal. Each message carries a user-labeled sentiment tag (Bullish / Bearish / no-label) plus the message body.
<start_of_stocktwits>
{stocktwits_block}
<end_of_stocktwits>
### Reddit posts — r/wallstreetbets, r/stocks, r/investing (past 7 days)
Community discussion. Engagement signal via upvote score and comment count. Subreddit character matters (r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term).
<start_of_reddit>
{reddit_block}
<end_of_reddit>
## How to analyze this data (best practices)
1. **Read the StockTwits Bullish/Bearish ratio as a leading retail-sentiment signal.** A 70/30 bullish/bearish split is moderately bullish; ≥90/10 may indicate over-extension and contrarian risk; 50/50 is uncertainty. Sample size matters — base rates on the actual message count, not percentages alone.
2. **Look for cross-source divergences.** If news framing is bearish but StockTwits is overwhelmingly bullish, that mismatch is itself a signal — it can mean retail is leaning into a thesis the news flow hasn't caught up to (or vice versa, that retail is chasing while institutions are cautious).
3. **Weight Reddit posts by engagement.** A 400-upvote / 200-comment thread reflects community attention; a 3-upvote post is noise. Read the body excerpts for context — the title alone often misleads.
4. **Distinguish opinion from event.** A news headline ("Nvidia announces $500M Corning deal") is an event; a StockTwits post ("buying NVDA, this is going to moon") is opinion. Both are inputs but should be weighted differently in your conclusions.
5. **Identify recurring narrative themes.** What topic keeps coming up across sources? That's the dominant narrative driving current sentiment.
6. **Be honest about data limits.** If StockTwits returned only a handful of messages, or one or more sources returned an "<unavailable>" placeholder, the sentiment read is less robust — flag this explicitly in the `confidence` field and the narrative. If the sources are silent on a given subreddit, say so.
7. **Identify catalysts and risks** that emerge across sources — news of upcoming earnings, product launches, competitive threats, macro headlines, etc.
8. **Past sentiment is not predictive.** Frame your conclusions as signal for the trader to weigh alongside fundamentals and technicals, not as a price call.
## Output fields
Fill the following fields:
- **overall_band**: Exactly one of Bullish / Mildly Bullish / Neutral / Mixed / Mildly Bearish / Bearish. Use Mixed when sources point in clearly different directions; Neutral only when all sources are genuinely silent.
- **overall_score**: A number from 0 (maximally bearish) to 10 (maximally bullish); 5 is neutral. Keep it consistent with overall_band.
- **confidence**: low / medium / high, based on data quality and sample size.
- **narrative**: Full source-by-source breakdown, divergences, dominant narrative themes, catalysts and risks, and a markdown summary table of key sentiment signals (direction, source, supporting evidence).
{get_language_instruction()}"""
# ---------------------------------------------------------------------------
# Backwards-compatibility shim
# ---------------------------------------------------------------------------
def create_social_media_analyst(llm):
"""Deprecated alias for :func:`create_sentiment_analyst`.
Kept so existing code that imports ``create_social_media_analyst``
continues to work.
.. deprecated::
Import :func:`create_sentiment_analyst` directly instead.
"""
import warnings
warnings.warn(
"create_social_media_analyst is deprecated and will be removed in a "
"future version. Use create_sentiment_analyst instead.",
DeprecationWarning,
stacklevel=2,
)
return create_sentiment_analyst(llm)

View File

@@ -1,55 +1,23 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder """Backwards-compatibility shim for the renamed module.
import time
import json
The agent is now ``sentiment_analyst`` and aggregates Yahoo Finance news,
StockTwits cashtag streams, and Reddit posts into a single sentiment
report. Import from ``tradingagents.agents.analysts.sentiment_analyst``
going forward; this module will be removed in a future release.
def create_social_media_analyst(llm, toolkit): See: https://github.com/TauricResearch/TradingAgents/issues/557
def social_media_analyst_node(state): """
current_date = state["trade_date"]
ticker = state["company_of_interest"]
company_name = state["company_of_interest"]
if toolkit.config["online_tools"]: import warnings as _warnings
tools = [toolkit.get_stock_news_openai]
else:
tools = [
toolkit.get_reddit_stock_info,
]
system_message = ( from tradingagents.agents.analysts.sentiment_analyst import ( # noqa: F401
"You are a social media and company specific news researcher/analyst tasked with analyzing social media posts, recent company news, and public sentiment for a specific company over the past week. You will be given a company's name your objective is to write a comprehensive long report detailing your analysis, insights, and implications for traders and investors on this company's current state after looking at social media and what people are saying about that company, analyzing sentiment data of what people feel each day about the company, and looking at recent company news. Try to look at all sources possible from social media to sentiment to news. Do not simply state the trends are mixed, provide detailed and finegrained analysis and insights that may help traders make decisions." create_sentiment_analyst,
+ """ Make sure to append a Makrdown table at the end of the report to organize key points in the report, organized and easy to read.""", create_social_media_analyst,
) )
prompt = ChatPromptTemplate.from_messages( _warnings.warn(
[ "tradingagents.agents.analysts.social_media_analyst is deprecated. "
( "Import from tradingagents.agents.analysts.sentiment_analyst instead.",
"system", DeprecationWarning,
"You are a helpful AI assistant, collaborating with other assistants." stacklevel=2,
" Use the provided tools to progress towards answering the question." )
" If you are unable to fully answer, that's OK; another assistant with different tools"
" will help where you left off. Execute what you can to make progress."
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
" You have access to the following tools: {tool_names}.\n{system_message}"
"For your reference, the current date is {current_date}. The current company we want to analyze is {ticker}",
),
MessagesPlaceholder(variable_name="messages"),
]
)
prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
prompt = prompt.partial(current_date=current_date)
prompt = prompt.partial(ticker=ticker)
chain = prompt | llm.bind_tools(tools)
result = chain.invoke(state["messages"])
return {
"messages": [result],
"sentiment_report": result.content,
}
return social_media_analyst_node

View File

@@ -0,0 +1,95 @@
"""Portfolio Manager: synthesises the risk-analyst debate into the final decision.
Uses LangChain's ``with_structured_output`` so the LLM produces a typed
``PortfolioDecision`` directly, in a single call. The result is rendered
back to markdown for storage in ``final_trade_decision`` so memory log,
CLI display, and saved reports continue to consume the same shape they do
today. When a provider does not expose structured output, the agent falls
back gracefully to free-text generation.
"""
from __future__ import annotations
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
def create_portfolio_manager(llm):
structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager")
def portfolio_manager_node(state) -> dict:
instrument_context = get_instrument_context_from_state(state)
history = state["risk_debate_state"]["history"]
risk_debate_state = state["risk_debate_state"]
research_plan = state["investment_plan"]
trader_plan = state["trader_investment_plan"]
past_context = state.get("past_context", "")
lessons_line = (
f"- Lessons from prior decisions and outcomes:\n{past_context}\n"
if past_context
else ""
)
prompt = f"""As the Portfolio Manager, synthesize the risk analysts' debate and deliver the final trading decision.
{instrument_context}
---
**Rating Scale** (use exactly one):
- **Buy**: Strong conviction to enter or add to position
- **Overweight**: Favorable outlook, gradually increase exposure
- **Hold**: Maintain current position, no action needed
- **Underweight**: Reduce exposure, take partial profits
- **Sell**: Exit position or avoid entry
**Context:**
- Research Manager's investment plan: **{research_plan}**
- Trader's transaction proposal: **{trader_plan}**
{lessons_line}
**Risk Analysts Debate History:**
{history}
---
Ground every conclusion in specific evidence from the analysts. Commit to a directional call only when the evidence clearly supports one; choose Hold when the case is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure, rather than forcing a direction to appear decisive. Weigh the analysts on their merits, independent of speaking order.
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
final_trade_decision = invoke_structured_or_freetext(
structured_llm,
llm,
prompt,
render_pm_decision,
"Portfolio Manager",
)
new_risk_debate_state = {
"judge_decision": final_trade_decision,
"history": risk_debate_state["history"],
"aggressive_history": risk_debate_state["aggressive_history"],
"conservative_history": risk_debate_state["conservative_history"],
"neutral_history": risk_debate_state["neutral_history"],
"latest_speaker": "Judge",
"current_aggressive_response": risk_debate_state["current_aggressive_response"],
"current_conservative_response": risk_debate_state["current_conservative_response"],
"current_neutral_response": risk_debate_state["current_neutral_response"],
"count": risk_debate_state["count"],
}
return {
"risk_debate_state": new_risk_debate_state,
"final_trade_decision": final_trade_decision,
}
return portfolio_manager_node

View File

@@ -1,55 +1,70 @@
import time """Research Manager: turns the bull/bear debate into a structured investment plan for the trader."""
import json
from __future__ import annotations
from tradingagents.agents.schemas import ResearchPlan, render_research_plan
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
def create_research_manager(llm, memory): def create_research_manager(llm):
structured_llm = bind_structured(llm, ResearchPlan, "Research Manager")
def research_manager_node(state) -> dict: def research_manager_node(state) -> dict:
instrument_context = get_instrument_context_from_state(state)
history = state["investment_debate_state"].get("history", "") history = state["investment_debate_state"].get("history", "")
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
investment_debate_state = state["investment_debate_state"] investment_debate_state = state["investment_debate_state"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}" prompt = f"""As the Research Manager and debate facilitator, your role is to critically evaluate this round of debate and deliver a clear, actionable investment plan for the trader.
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = "" {instrument_context}
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""As the portfolio manager and debate facilitator, your role is to critically evaluate this round of debate and make a definitive decision: align with the bear analyst, the bull analyst, or choose Hold only if it is strongly justified based on the arguments presented. ---
Summarize the key points from both sides concisely, focusing on the most compelling evidence or reasoning. Your recommendation—Buy, Sell, or Hold—must be clear and actionable. Avoid defaulting to Hold simply because both sides have valid points; commit to a stance grounded in the debate's strongest arguments. **Rating Scale** (use exactly one):
- **Buy**: Strong conviction in the bull thesis; recommend taking or growing the position
- **Overweight**: Constructive view; recommend gradually increasing exposure
- **Hold**: Balanced view; recommend maintaining the current position
- **Underweight**: Cautious view; recommend trimming exposure
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
Additionally, develop a detailed investment plan for the trader. This should include: Commit to a directional stance only when the debate's strongest arguments clearly warrant one. Choose Hold when the evidence is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure; do not manufacture a direction merely to appear decisive. Weigh the bull and bear cases on their merits, independent of which side spoke first or last.
Your Recommendation: A decisive stance supported by the most convincing arguments. ---
Rationale: An explanation of why these arguments lead to your conclusion.
Strategic Actions: Concrete steps for implementing the recommendation.
Take into account your past mistakes on similar situations. Use these insights to refine your decision-making and ensure you are learning and improving. Present your analysis conversationally, as if speaking naturally, without special formatting.
Here are your past reflections on mistakes: **Debate History:**
\"{past_memory_str}\" {history}
Here is the debate: {NO_EXTERNAL_TOOLS}""" + get_language_instruction()
Debate History:
{history}""" investment_plan = invoke_structured_or_freetext(
response = llm.invoke(prompt) structured_llm,
llm,
prompt,
render_research_plan,
"Research Manager",
)
new_investment_debate_state = { new_investment_debate_state = {
"judge_decision": response.content, "judge_decision": investment_plan,
"history": investment_debate_state.get("history", ""), "history": investment_debate_state.get("history", ""),
"bear_history": investment_debate_state.get("bear_history", ""), "bear_history": investment_debate_state.get("bear_history", ""),
"bull_history": investment_debate_state.get("bull_history", ""), "bull_history": investment_debate_state.get("bull_history", ""),
"current_response": response.content, "current_response": investment_plan,
"count": investment_debate_state["count"], "count": investment_debate_state["count"],
} }
return { return {
"investment_debate_state": new_investment_debate_state, "investment_debate_state": new_investment_debate_state,
"investment_plan": response.content, "investment_plan": investment_plan,
} }
return research_manager_node return research_manager_node

View File

@@ -1,66 +0,0 @@
import time
import json
def create_risk_manager(llm, memory):
def risk_manager_node(state) -> dict:
company_name = state["company_of_interest"]
history = state["risk_debate_state"]["history"]
risk_debate_state = state["risk_debate_state"]
market_research_report = state["market_report"]
news_report = state["news_report"]
fundamentals_report = state["news_report"]
sentiment_report = state["sentiment_report"]
trader_plan = state["investment_plan"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""As the Risk Management Judge and Debate Facilitator, your goal is to evaluate the debate between three risk analysts—Risky, Neutral, and Safe/Conservative—and determine the best course of action for the trader. Your decision must result in a clear recommendation: Buy, Sell, or Hold. Choose Hold only if strongly justified by specific arguments, not as a fallback when all sides seem valid. Strive for clarity and decisiveness.
Guidelines for Decision-Making:
1. **Summarize Key Arguments**: Extract the strongest points from each analyst, focusing on relevance to the context.
2. **Provide Rationale**: Support your recommendation with direct quotes and counterarguments from the debate.
3. **Refine the Trader's Plan**: Start with the trader's original plan, **{trader_plan}**, and adjust it based on the analysts' insights.
4. **Learn from Past Mistakes**: Use lessons from **{past_memory_str}** to address prior misjudgments and improve the decision you are making now to make sure you don't make a wrong BUY/SELL/HOLD call that loses money.
Deliverables:
- A clear and actionable recommendation: Buy, Sell, or Hold.
- Detailed reasoning anchored in the debate and past reflections.
---
**Analysts Debate History:**
{history}
---
Focus on actionable insights and continuous improvement. Build on past lessons, critically evaluate all perspectives, and ensure each decision advances better outcomes."""
response = llm.invoke(prompt)
new_risk_debate_state = {
"judge_decision": response.content,
"history": risk_debate_state["history"],
"risky_history": risk_debate_state["risky_history"],
"safe_history": risk_debate_state["safe_history"],
"neutral_history": risk_debate_state["neutral_history"],
"latest_speaker": "Judge",
"current_risky_response": risk_debate_state["current_risky_response"],
"current_safe_response": risk_debate_state["current_safe_response"],
"current_neutral_response": risk_debate_state["current_neutral_response"],
"count": risk_debate_state["count"],
}
return {
"risk_debate_state": new_risk_debate_state,
"final_trade_decision": response.content,
}
return risk_manager_node

View File

@@ -1,28 +1,33 @@
from langchain_core.messages import AIMessage from tradingagents.agents.utils.agent_utils import (
import time get_instrument_context_from_state,
import json get_language_instruction,
opponent_argument_or_opening,
)
def create_bear_researcher(llm, memory): def create_bear_researcher(llm):
def bear_node(state) -> dict: def bear_node(state) -> dict:
investment_debate_state = state["investment_debate_state"] investment_debate_state = state["investment_debate_state"]
history = investment_debate_state.get("history", "") history = investment_debate_state.get("history", "")
bear_history = investment_debate_state.get("bear_history", "") bear_history = investment_debate_state.get("bear_history", "")
current_response = investment_debate_state.get("current_response", "") current_response = opponent_argument_or_opening(
investment_debate_state.get("current_response", ""), "bull analyst"
)
market_research_report = state["market_report"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
asset_type = state.get("asset_type", "stock")
target_label = "stock" if asset_type == "stock" else "asset"
fundamentals_label = (
"Company fundamentals report"
if asset_type == "stock"
else "Asset fundamentals report (may be unavailable for crypto)"
)
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}" prompt = f"""You are a Bear Analyst making the case against investing in the {target_label}. Your goal is to present a well-reasoned argument emphasizing risks, challenges, and negative indicators. Leverage the provided research and data to highlight potential downsides and counter bullish arguments effectively.
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""You are a Bear Analyst making the case against investing in the stock. Your goal is to present a well-reasoned argument emphasizing risks, challenges, and negative indicators. Leverage the provided research and data to highlight potential downsides and counter bullish arguments effectively.
Key points to focus on: Key points to focus on:
@@ -34,15 +39,15 @@ Key points to focus on:
Resources available: Resources available:
{instrument_context}
Market research report: {market_research_report} Market research report: {market_research_report}
Social media sentiment report: {sentiment_report} Social media sentiment report: {sentiment_report}
Latest world affairs news: {news_report} Latest world affairs news: {news_report}
Company fundamentals report: {fundamentals_report} {fundamentals_label}: {fundamentals_report}
Conversation history of the debate: {history} Conversation history of the debate: {history}
Last bull argument: {current_response} Last bull argument: {current_response}
Reflections from similar situations and lessons learned: {past_memory_str} Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the {target_label}.
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the stock. You must also address reflections and learn from lessons and mistakes you made in the past. """ + get_language_instruction()
"""
response = llm.invoke(prompt) response = llm.invoke(prompt)

View File

@@ -1,28 +1,33 @@
from langchain_core.messages import AIMessage from tradingagents.agents.utils.agent_utils import (
import time get_instrument_context_from_state,
import json get_language_instruction,
opponent_argument_or_opening,
)
def create_bull_researcher(llm, memory): def create_bull_researcher(llm):
def bull_node(state) -> dict: def bull_node(state) -> dict:
investment_debate_state = state["investment_debate_state"] investment_debate_state = state["investment_debate_state"]
history = investment_debate_state.get("history", "") history = investment_debate_state.get("history", "")
bull_history = investment_debate_state.get("bull_history", "") bull_history = investment_debate_state.get("bull_history", "")
current_response = investment_debate_state.get("current_response", "") current_response = opponent_argument_or_opening(
investment_debate_state.get("current_response", ""), "bear analyst"
)
market_research_report = state["market_report"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
asset_type = state.get("asset_type", "stock")
target_label = "stock" if asset_type == "stock" else "asset"
fundamentals_label = (
"Company fundamentals report"
if asset_type == "stock"
else "Asset fundamentals report (may be unavailable for crypto)"
)
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}" prompt = f"""You are a Bull Analyst advocating for investing in the {target_label}. Your task is to build a strong, evidence-based case emphasizing growth potential, competitive advantages, and positive market indicators. Leverage the provided research and data to address concerns and counter bearish arguments effectively.
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""You are a Bull Analyst advocating for investing in the stock. Your task is to build a strong, evidence-based case emphasizing growth potential, competitive advantages, and positive market indicators. Leverage the provided research and data to address concerns and counter bearish arguments effectively.
Key points to focus on: Key points to focus on:
- Growth Potential: Highlight the company's market opportunities, revenue projections, and scalability. - Growth Potential: Highlight the company's market opportunities, revenue projections, and scalability.
@@ -32,15 +37,15 @@ Key points to focus on:
- Engagement: Present your argument in a conversational style, engaging directly with the bear analyst's points and debating effectively rather than just listing data. - Engagement: Present your argument in a conversational style, engaging directly with the bear analyst's points and debating effectively rather than just listing data.
Resources available: Resources available:
{instrument_context}
Market research report: {market_research_report} Market research report: {market_research_report}
Social media sentiment report: {sentiment_report} Social media sentiment report: {sentiment_report}
Latest world affairs news: {news_report} Latest world affairs news: {news_report}
Company fundamentals report: {fundamentals_report} {fundamentals_label}: {fundamentals_report}
Conversation history of the debate: {history} Conversation history of the debate: {history}
Last bear argument: {current_response} Last bear argument: {current_response}
Reflections from similar situations and lessons learned: {past_memory_str} Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position.
Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position. You must also address reflections and learn from lessons and mistakes you made in the past. """ + get_language_instruction()
"""
response = llm.invoke(prompt) response = llm.invoke(prompt)

View File

@@ -1,55 +0,0 @@
import time
import json
def create_risky_debator(llm):
def risky_node(state) -> dict:
risk_debate_state = state["risk_debate_state"]
history = risk_debate_state.get("history", "")
risky_history = risk_debate_state.get("risky_history", "")
current_safe_response = risk_debate_state.get("current_safe_response", "")
current_neutral_response = risk_debate_state.get("current_neutral_response", "")
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
trader_decision = state["trader_investment_plan"]
prompt = f"""As the Risky Risk Analyst, your role is to actively champion high-reward, high-risk opportunities, emphasizing bold strategies and competitive advantages. When evaluating the trader's decision or plan, focus intently on the potential upside, growth potential, and innovative benefits—even when these come with elevated risk. Use the provided market data and sentiment analysis to strengthen your arguments and challenge the opposing views. Specifically, respond directly to each point made by the conservative and neutral analysts, countering with data-driven rebuttals and persuasive reasoning. Highlight where their caution might miss critical opportunities or where their assumptions may be overly conservative. Here is the trader's decision:
{trader_decision}
Your task is to create a compelling case for the trader's decision by questioning and critiquing the conservative and neutral stances to demonstrate why your high-reward perspective offers the best path forward. Incorporate insights from the following sources into your arguments:
Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report}
Company Fundamentals Report: {fundamentals_report}
Here is the current conversation history: {history} Here are the last arguments from the conservative analyst: {current_safe_response} Here are the last arguments from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints, do not halluncinate and just present your point.
Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting."""
response = llm.invoke(prompt)
argument = f"Risky Analyst: {response.content}"
new_risk_debate_state = {
"history": history + "\n" + argument,
"risky_history": risky_history + "\n" + argument,
"safe_history": risk_debate_state.get("safe_history", ""),
"neutral_history": risk_debate_state.get("neutral_history", ""),
"latest_speaker": "Risky",
"current_risky_response": argument,
"current_safe_response": risk_debate_state.get("current_safe_response", ""),
"current_neutral_response": risk_debate_state.get(
"current_neutral_response", ""
),
"count": risk_debate_state["count"] + 1,
}
return {"risk_debate_state": new_risk_debate_state}
return risky_node

View File

@@ -0,0 +1,64 @@
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
opponent_argument_or_opening,
)
def create_aggressive_debator(llm):
def aggressive_node(state) -> dict:
risk_debate_state = state["risk_debate_state"]
history = risk_debate_state.get("history", "")
aggressive_history = risk_debate_state.get("aggressive_history", "")
current_conservative_response = opponent_argument_or_opening(
risk_debate_state.get("current_conservative_response", ""), "conservative analyst"
)
current_neutral_response = opponent_argument_or_opening(
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
)
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
trader_decision = state["trader_investment_plan"]
prompt = f"""As the Aggressive Risk Analyst, your role is to actively champion high-reward, high-risk opportunities, emphasizing bold strategies and competitive advantages. When evaluating the trader's decision or plan, focus intently on the potential upside, growth potential, and innovative benefits—even when these come with elevated risk. Use the provided market data and sentiment analysis to strengthen your arguments and challenge the opposing views. Specifically, respond directly to each point made by the conservative and neutral analysts, countering with data-driven rebuttals and persuasive reasoning. Highlight where their caution might miss critical opportunities or where their assumptions may be overly conservative. Here is the trader's decision:
{trader_decision}
Your task is to create a compelling case for the trader's decision by questioning and critiquing the conservative and neutral stances to demonstrate why your high-reward perspective offers the best path forward. Incorporate insights from the following sources into your arguments:
{instrument_context}
Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report}
Company Fundamentals Report: {fundamentals_report}
Here is the current conversation history: {history} Here are the last arguments from the conservative analyst: {current_conservative_response} Here are the last arguments from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data.
Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction()
response = llm.invoke(prompt)
argument = f"Aggressive Analyst: {response.content}"
new_risk_debate_state = {
"history": history + "\n" + argument,
"aggressive_history": aggressive_history + "\n" + argument,
"conservative_history": risk_debate_state.get("conservative_history", ""),
"neutral_history": risk_debate_state.get("neutral_history", ""),
"latest_speaker": "Aggressive",
"current_aggressive_response": argument,
"current_conservative_response": risk_debate_state.get("current_conservative_response", ""),
"current_neutral_response": risk_debate_state.get(
"current_neutral_response", ""
),
"count": risk_debate_state["count"] + 1,
}
return {"risk_debate_state": new_risk_debate_state}
return aggressive_node

View File

@@ -1,52 +1,60 @@
from langchain_core.messages import AIMessage from tradingagents.agents.utils.agent_utils import (
import time get_instrument_context_from_state,
import json get_language_instruction,
opponent_argument_or_opening,
)
def create_safe_debator(llm): def create_conservative_debator(llm):
def safe_node(state) -> dict: def conservative_node(state) -> dict:
risk_debate_state = state["risk_debate_state"] risk_debate_state = state["risk_debate_state"]
history = risk_debate_state.get("history", "") history = risk_debate_state.get("history", "")
safe_history = risk_debate_state.get("safe_history", "") conservative_history = risk_debate_state.get("conservative_history", "")
current_risky_response = risk_debate_state.get("current_risky_response", "") current_aggressive_response = opponent_argument_or_opening(
current_neutral_response = risk_debate_state.get("current_neutral_response", "") risk_debate_state.get("current_aggressive_response", ""), "aggressive analyst"
)
current_neutral_response = opponent_argument_or_opening(
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
)
market_research_report = state["market_report"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
trader_decision = state["trader_investment_plan"] trader_decision = state["trader_investment_plan"]
prompt = f"""As the Safe/Conservative Risk Analyst, your primary objective is to protect assets, minimize volatility, and ensure steady, reliable growth. You prioritize stability, security, and risk mitigation, carefully assessing potential losses, economic downturns, and market volatility. When evaluating the trader's decision or plan, critically examine high-risk elements, pointing out where the decision may expose the firm to undue risk and where more cautious alternatives could secure long-term gains. Here is the trader's decision: prompt = f"""As the Conservative Risk Analyst, your primary objective is to protect assets, minimize volatility, and ensure steady, reliable growth. You prioritize stability, security, and risk mitigation, carefully assessing potential losses, economic downturns, and market volatility. When evaluating the trader's decision or plan, critically examine high-risk elements, pointing out where the decision may expose the firm to undue risk and where more cautious alternatives could secure long-term gains. Here is the trader's decision:
{trader_decision} {trader_decision}
Your task is to actively counter the arguments of the Risky and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision: Your task is to actively counter the arguments of the Aggressive and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision:
{instrument_context}
Market Research Report: {market_research_report} Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report} Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report} Latest World Affairs Report: {news_report}
Company Fundamentals Report: {fundamentals_report} Company Fundamentals Report: {fundamentals_report}
Here is the current conversation history: {history} Here is the last response from the risky analyst: {current_risky_response} Here is the last response from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints, do not halluncinate and just present your point. Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data.
Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting.""" Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction()
response = llm.invoke(prompt) response = llm.invoke(prompt)
argument = f"Safe Analyst: {response.content}" argument = f"Conservative Analyst: {response.content}"
new_risk_debate_state = { new_risk_debate_state = {
"history": history + "\n" + argument, "history": history + "\n" + argument,
"risky_history": risk_debate_state.get("risky_history", ""), "aggressive_history": risk_debate_state.get("aggressive_history", ""),
"safe_history": safe_history + "\n" + argument, "conservative_history": conservative_history + "\n" + argument,
"neutral_history": risk_debate_state.get("neutral_history", ""), "neutral_history": risk_debate_state.get("neutral_history", ""),
"latest_speaker": "Safe", "latest_speaker": "Conservative",
"current_risky_response": risk_debate_state.get( "current_aggressive_response": risk_debate_state.get(
"current_risky_response", "" "current_aggressive_response", ""
), ),
"current_safe_response": argument, "current_conservative_response": argument,
"current_neutral_response": risk_debate_state.get( "current_neutral_response": risk_debate_state.get(
"current_neutral_response", "" "current_neutral_response", ""
), ),
@@ -55,4 +63,4 @@ Engage by questioning their optimism and emphasizing the potential downsides the
return {"risk_debate_state": new_risk_debate_state} return {"risk_debate_state": new_risk_debate_state}
return safe_node return conservative_node

Some files were not shown because too many files have changed in this diff Show More