95 Commits

Author SHA1 Message Date
Yijia-Xiao
7fe2252244 release: 0.5.0
- point-in-time integrity across every dated path, and a vendor failure reported as a vendor failure
- SEC EDGAR fundamentals served as filed
- backtesting over a ticker and date grid, and the caller's portfolio as run input
- current model lineups across every provider
2026-09-18 05:42:23 +00:00
Yijia-Xiao
76a93d615a test(cli): read the backtest help without terminal styling
- colour splits "--start" across escape sequences, so the assertion strips them
2026-09-18 05:42:23 +00:00
Yijia-Xiao
c039e39056 fix(dataflows): tell an outage from an unknown symbol on every Yahoo path
- prices, indicators and insider filings now use the same check as the statements
2026-09-18 04:02:57 +00:00
Yijia-Xiao
4acdc16513 chore: drop three more dependencies nothing imports
- langchain-experimental, parsel, tqdm
2026-09-18 01:49:52 +00:00
Yijia-Xiao
d5ba41bac3 fix(dataflows): report a vendor failure as a vendor failure
- yfinance returned its errors as text, which the router counted as an answer, so the chain stopped and the text reached the analyst
- an empty result is checked against the vendor being reachable, so an outage is not reported as a company with no data
- a chain where every vendor is unavailable says so instead of ending the run
2026-09-18 01:49:52 +00:00
Yijia-Xiao
10cc070fa3 feat(llm): refresh the model lists to each provider's current lineup
- xAI: Grok 4.6 and 4.5; DeepSeek: deepseek-flash (V4.1); Qwen: the 3.8 series; Mistral: the current dated snapshots
- every provider can name a model the picker does not list
- retired IDs stay valid, so existing configs run without a warning
2026-09-18 01:14:05 +00:00
Yijia-Xiao
ed6eae44b6 chore: drop dependencies nothing imports
- backtrader, redis, and setuptools at runtime
2026-09-18 00:04:53 +00:00
Yijia-Xiao
aef4af90e3 fix(dataflows): let the next vendor serve what Alpha Vantage cannot
- an indicator it does not carry raises instead of returning prose the router counts as an answer
- ticker news asks for the configured article limit
2026-09-18 00:04:53 +00:00
Yijia-Xiao
bbcd6661af fix(graph): remove the database sidecars when clearing checkpoints
- SQLite keeps committed state in -wal and -shm beside the database
2026-09-18 00:04:53 +00:00
Yijia-Xiao
f8042efdde fix(agents): keep one unreadable price from discarding the decision
- a price written as a range or a hedge is dropped like any other unusable value
- a field the model did not give is named as not provided, rather than omitted
2026-09-18 00:04:53 +00:00
Yijia-Xiao
d0881f081e docs: document the path and benchmark settings 2026-09-18 00:04:52 +00:00
Yijia-Xiao
8bde10cf44 docs: correct the claims that no longer match the code
- the custom model option does not exist for every provider; name any model ID instead
- alpha is measured against the regional benchmark, not always SPY
- the environment overrides a fixed set of config keys
- list the providers the picker offers, and the macro data key
- drop the example call to a method that was removed
2026-09-17 23:44:14 +00:00
Yijia-Xiao
9683194793 fix(cli): finish the run surface
- backtest takes --run-id, so an interrupted sweep continues
- an inverted date range or an empty ticker list is an error, not a clean zero-cell run
- a setup failure in a sweep reports one line instead of a traceback
- the remembered endpoint URL is offered back
- a round count set in the environment says it overrode the chosen research depth
- the run directory validates the ticker, and the report saves under results_dir
- the run says whether it resumed a saved run or started fresh
2026-09-17 23:44:14 +00:00
Yijia-Xiao
04b691804c fix(dataflows): include the analysis day in the Alpha Vantage news window
- time_to was midnight starting the end date, dropping that day's news
2026-09-17 23:37:43 +00:00
Yijia-Xiao
c3bb991974 fix(dataflows): date insider rows by the trade, and say when it became public
- rows carry the transaction date; a Form 4 is filed up to two business days later
- an indicator that could not be read is unavailable, not a blank value for the day
2026-09-17 23:37:43 +00:00
Yijia-Xiao
f0a1cf6290 fix(agents): say when the resolved identity is today's, not the run date's
- the vendor profile has no historical vintage, and every agent is told to anchor to it
2026-09-17 23:37:43 +00:00
Yijia-Xiao
de7e43fc4a fix(dataflows): quote the prices the vendor reported in the verification snapshot
- gap filling keeps indicators on a continuous series, but put the previous session's open, high and low under an unsettled bar's date
- load_ohlcv takes fill_gaps, and the snapshot reads the frame as reported
2026-09-17 23:37:43 +00:00
Yijia-Xiao
13b35e8aeb docs: state the behaviour without the worked examples 2026-09-17 07:31:44 +00:00
Yijia-Xiao
008ac655a9 fix(cli): send GLM traffic to the platform its key belongs to
- the provider table named the China endpoint while the key mapping and client registry named Z.AI
2026-09-17 07:31:44 +00:00
Yijia-Xiao
2ddfe4ceb5 fix(agents): give every prompt honest inputs
- the fundamentals brief reached the model as a Python tuple
- analysts no longer emit a trade call that nothing reads
- a report that was not produced says so instead of appearing as a blank section
2026-09-17 07:31:44 +00:00
Yijia-Xiao
85d9137437 feat(graph): measure an outcome over a configurable window
- holding_period_days sets it; the price request covers the calendar span those trading days occupy
- reflection states the window it judges, so a short one does not read as a failed thesis
2026-09-17 07:31:44 +00:00
Yijia-Xiao
2ca59cc795 fix(backtest): score a decision against the direction it claimed
- a Sell that fell counted as a miss and a Sell that rose as a hit
- Hold claims no direction, so it reports mean alpha and no hit rate
- the report names the window the outcomes were measured over
2026-09-17 07:31:44 +00:00
Yijia-Xiao
63989515c9 fix(cli): show what the run actually produced (#649, #784)
- a non-blank message is text, whatever it would mean as a Python literal
- the live layout renders on the alternate screen, so a tall layout does not scroll
- a run with no readable rating says so instead of looking like a normal result
- the state log keeps non-ASCII readable (#1081)
2026-09-17 05:00:08 +00:00
Yijia-Xiao
486dec1710 fix(agents): state the output shape in the decision prompts
- the field list lived only in the schema, which a provider without structured output never sees
- the trader maps the five-tier recommendation onto its three actions
2026-09-17 05:00:08 +00:00
Yijia-Xiao
8d30fee06b fix(agents): record the decision that was made, or flag it for review
- the labelled rating decides, whatever dash separates it, and a scale the model echoed is not one
- prose naming several ratings is reviewed rather than read as the first word in the text
- an unreadable decision is tagged REVIEW everywhere instead of a tradeable Hold
- unrated decisions are counted apart from the backtest figures
2026-09-17 05:00:08 +00:00
Yijia-Xiao
3244a568ed docs: document fundamentals as filed 2026-09-17 02:37:15 +00:00
Yijia-Xiao
b6dad747e9 fix(dataflows): state the vintage rule on yfinance statements
- periods are cut at the fiscal period end and the vendor reports no filing date, so the newest period may not have been published yet
2026-09-17 02:37:15 +00:00
Yijia-Xiao
f881c4a111 feat(dataflows): serve US statements as filed, from SEC EDGAR
- statements are limited to facts filed by the run's date, at the values filed then
- quarterly figures are picked by span, so a year to date total is not read as a quarter
- a fourth quarter is reported unavailable rather than derived
- opt in with sec_edgar in the fundamental_data chain; a non-filer falls through
2026-09-17 02:37:15 +00:00
Yijia-Xiao
8ac4371387 fix(llm): suppress object-form tool_choice for Ollama (#1062)
- Ollama takes the local-compatible client, like the generic endpoint
2026-09-17 01:10:48 +00:00
Yijia-Xiao
60dcf64723 fix(graph): keep a failed reflection from stopping the next run (#645)
- settling a past decision is per entry; a provider error leaves it pending
- an explicitly configured benchmark ticker is normalized like any other symbol (#1075)
2026-09-17 01:10:48 +00:00
Yijia-Xiao
8d64416436 fix(dataflows): trim global news to the window before the limit (#1356)
- out-of-window articles no longer spend the article budget or cut the remaining searches short
2026-09-17 01:10:48 +00:00
Yijia-Xiao
c4152d6946 docs: note the backtest command and remembered selections 2026-09-16 21:45:08 +00:00
Yijia-Xiao
375af054b7 feat(cli): offer the previous run's selections as defaults (#1236, #920)
- analysts, depth, provider, models and language prefill; prompts still shown
- values no longer offered by the current catalog are dropped
- environment variables keep skipping their step
2026-09-16 21:45:08 +00:00
Yijia-Xiao
d8eceb6571 feat(cli): add a backtest command (#1234)
- analysis stays the bare invocation; backtest runs a ticker and date grid
- selections name the models as the config does
- the pending note prints only when a cell is pending
2026-09-16 21:45:08 +00:00
Yijia-Xiao
63738c8f2c docs: drop the analyze subcommand from the CLI examples 2026-09-16 20:19:49 +00:00
Yijia-Xiao
7bf2117491 docs: document the portfolio input and decision evaluation 2026-09-16 20:19:49 +00:00
Yijia-Xiao
8721b92f65 feat: evaluate decisions over a grid of tickers and dates (#1234)
- run_backtest runs the pipeline per cell into its own decision log and resumes by skipping logged cells
- summarize scores settled cells by rating on realized alpha
- settle_pending settles a ticker whose last decision would otherwise stay open
2026-09-16 20:19:49 +00:00
Yijia-Xiao
9794a90734 fix(memory): keep a settled decision from being logged twice
- the duplicate guard matched only pending entries, so re-running a settled ticker and date appended a second one
2026-09-16 20:19:49 +00:00
Yijia-Xiao
6436d1ff30 feat(graph): accept the caller's portfolio as run input (#1304, #1166)
- PortfolioContext input, rendered once into state and read by the trader, risk and portfolio agents
- --portfolio JSON file on the CLI
- a run without it is never treated as a flat book
- the checkpoint signature keys on the portfolio
2026-09-16 20:19:49 +00:00
Yijia-Xiao
dffff22951 fix(dataflows): use configured defaults for omitted Alpha Vantage global-news options (#1326) 2026-09-15 01:33:48 +00:00
Yijia-Xiao
ef52d4452b docs: add uv install and Docker rebuild notes (#1173, #1297)
- uv alternative to conda and pip
- rebuild the Docker image after updating
2026-09-15 01:33:48 +00:00
Yijia-Xiao
2942655f70 fix(dataflows): map Shanghai .SH tickers to Yahoo's .SS (#1260)
- normalize_symbol maps 600519.SH to 600519.SS
- the alpha benchmark is resolved from the normalized symbol
2026-09-15 00:04:25 +00:00
Yijia-Xiao
fadc698e20 fix(dataflows): bound insider filings and prediction markets by the trade date
- insider transactions are filtered to filings on or before the run date
- prediction-market odds are withheld from a historical run
2026-09-15 00:00:26 +00:00
Yijia-Xiao
29e331a9af fix(dataflows): keep one OHLCV cache file per symbol (#1330)
- the cache file is keyed by symbol and serves only on the day it was written
2026-09-14 23:51:48 +00:00
Yijia-Xiao
99313bfdbc fix(dataflows): zero-pad HK ticker codes to Yahoo's 4-digit form (#1342)
- normalize_symbol maps 09992.HK to 9992.HK and 700.HK to 0700.HK (#957)
2026-09-14 23:37:48 +00:00
Yijia-Xiao
d04693a208 fix(agents): bound tool dates by the run's trade date
- dated tools read trade_date from graph state and clamp later or missing dates #1331
- propagate() rejects non-canonical and future trade dates #1319
2026-09-14 23:24:31 +00:00
Yijia-Xiao
4a9f196e92 fix(cli): read and write the decision log on the CLI path
- shared create_run_state and record_decision for propagate() and the CLI #1332 #1347
2026-09-14 23:12:17 +00:00
Yijia-Xiao
2c1ba388d6 chore: remove two unused scripts
- test.py
- scripts/smoke_structured_output.py
2026-09-14 23:12:17 +00:00
Yijia-Xiao
b9666f6e09 docs: use a current date in the usage examples 2026-09-14 22:38:19 +00:00
Yijia-Xiao
34899bd320 fix(cli): save prompted API keys to an owner-only .env
- the key prompt created .env with the default umask, typically readable by
  other local users
- create it 0600 and tighten an existing file before writing the key; a
  read-only file is still updated
2026-09-14 22:38:19 +00:00
Yijia-Xiao
b20c8e60a4 fix(dataflows): keep vendor API keys out of request errors
- FRED and Alpha Vantage authenticate with a query parameter, and requests
  quotes the full URL in HTTP, connection and timeout errors, so the key was
  written into any log or traceback that recorded one
- route both vendors through one request helper that re-raises a requests error
  as the same class with the key removed, carrying no request, response or
  exception chain, each of which still held the URL #1324
2026-09-14 22:38:19 +00:00
Yijia-Xiao
241638da68 fix(dataflows): search all subreddits in one Reddit request
- anonymous RSS allows about one request per minute per IP, so fetching each
  subreddit separately hit a 429 and a 60s back-off on nearly every run
- search the subreddits as one combined feed at Reddit's full page size and
  group posts by the subreddit each entry names; each subreddit keeps its own
  limit, and a full page is not taken as evidence of absence
- drop the unused JSON search path, still blocked with a 403, and the
  per-subreddit pacing; arguments after subreddits are keyword-only
- the sentiment prompt no longer asks for vote and comment counts, which the
  RSS feed does not carry
2026-09-14 22:07:42 +00:00
Yijia-Xiao
2e38b47dca fix(dataflows): report windows a feed cannot observe as unavailable
- Yahoo news and the Reddit and StockTwits feeds serve only recent items, so a
  historical window trimmed to nothing was reported as "no news" or "no
  posts", and the sentiment analyst scored that silence as a neutral signal
- judge each empty window in one shared rule: it is a real absence only when
  the feed's coverage reaches the window's first day and the window ends by
  today; otherwise report it unavailable with where coverage starts
- coverage comes from the returned timestamps, which are newest-first on these
  feeds, plus Reddit's one-week search lookback; merged global-news searches
  prove no continuity and are bounded by the present alone
- state in the sentiment analyst that historical sentiment inputs are not
  guaranteed to be point-in-time
2026-09-14 22:07:41 +00:00
Yijia-Xiao
673abb3c68 feat(llm): add GPT-6 Astra, Gemini 3.8 Flash and the current Claude models
- OpenAI: gpt-6-astra
- Google: gemini-3.8-flash and gemini-3.5-flash-lite
- Anthropic: claude-opus-5 and claude-fable-5-1
- models taken out of the picker stay valid through LEGACY_MODELS, so configs
  that name them run without an unknown-model warning
2026-09-14 18:09:33 +00:00
Yijia-Xiao
9b4c741d33 fix(llm): forward reasoning effort to GPT-6 and gate minimal thinking
- reasoning_effort was forwarded only to IDs matching gpt-5 or the o-series, so
  GPT-6 models silently dropped the configured effort; match GPT-5 and later,
  with a version boundary so unrelated IDs do not match
- Gemini Pro, 3.8+ and the -latest aliases reject thinking_level "minimal"
  with a 400; send it only to numbered Flash models before 3.8 and map it to
  "low" elsewhere, since aliases move between generations
2026-09-14 18:09:33 +00:00
Yijia-Xiao
62d3479217 fix(agents): stop conflict alone from defaulting the verdict to Hold
- both manager prompts and both rating fields listed "materially conflicting"
  as a Hold condition, and every bull/bear and risk debate is conflicting, so
  a modest directional edge collapsed to Hold
- state one rule at all four sites: conflict alone is not a reason to Hold,
  commit to the stronger side sized by how decisively it wins, and Hold only
  when the evidence is still balanced after weighing or too thin for a call
- a modest bull edge now resolves to Overweight; a genuinely balanced debate
  still resolves to Hold #1321
2026-09-14 18:09:33 +00:00
Tauric-Research
be952b8ecc Merge pull request #1310 from TauricResearch/v0.4.2
Point-in-time fixes, honest failure reporting, and housekeeping
2026-09-07 17:30:55 -05:00
Yijia-Xiao
821848bb82 docs: tighten the comments on the point-in-time guards
- keep what the code cannot state itself: which session a closeless bar is,
  where the drop actually happens, and why the trim must stay unguarded
- drop the field-by-field enumeration, the account of what the previous
  behaviour got wrong, and the restatements of adjacent calls
2026-09-07 22:21:20 +00:00
Yijia-Xiao
d6ca23aee5 feat(llm): add the current Kimi models to the picker
- list kimi-k3 (flagship, 1M context) and kimi-k2.6 (256K, thinking modes) on
  both tiers, replacing the custom-only entry
- keep Custom model ID for models newer than the list
- omit the k2.7-code variants: coding specialists, not analysis models
2026-09-07 21:52:29 +00:00
Yijia-Xiao
ef383df8f4 fix(dataflows): don't report a symbol as unavailable over an unsettled bar
- a newest bar with no close made load_ohlcv reject the whole frame, so the
  routing layer answered with its no-data sentinel: the caller lost the entire
  price history and was told the symbol may be invalid, delisted or not
  covered, when only the latest session had not settled
- treat a closeless newest bar as an unsettled session instead. The gap fill
  already drops it, here and mid-series alike, so the frame ends at the last
  settled bar; only a range with no close anywhere is still no data
- the staleness check keeps deciding whether what remains is recent enough, so
  falling back cannot resurrect a long-dead series
- log which bars had no close and which date is being used as the latest close
2026-09-07 21:42:25 +00:00
Yijia-Xiao
d58b838081 test: keep the Ollama endpoint assertions off the terminal colour
- the console highlights numbers and URLs, so with colour enabled the rendered
  output splits asserted substrings with escape codes and two tests fail
- strip the codes before asserting so the result no longer depends on where the
  suite runs
2026-09-07 21:28:52 +00:00
Yijia-Xiao
ffd5d9a180 chore(dataflows): use the module logger and drop dead helpers
- four modules wrote to stdout with print() while ten others use a module
  logger; a warning printed into the rendered CLI output is effectively
  invisible, which is how the trim failure above went unnoticed
- convert the remaining calls to logger.warning with lazy formatting
- remove save_output, SavePathType, decorate_all_methods and get_next_weekday
  from utils, none of which had a caller, along with the pandas and typing
  imports that only they needed
2026-09-07 21:28:52 +00:00
Yijia-Xiao
16f7fd613c fix(dataflows): fail closed when the Alpha Vantage date trim fails
- get_stock requests the full daily series up to today, so trimming to the
  requested window is the only thing keeping bars after end_date out of a
  historical run
- the trim caught every exception, warned, and returned the untrimmed body, so
  a parse failure fed future prices into a backtest with no usable signal that
  it had happened
- let a parse failure propagate instead: the routing layer already logs the
  vendor failure, falls through to the next vendor, and surfaces the real error
  if none can serve
2026-09-07 21:28:52 +00:00
Yijia-Xiao
260c899c72 chore: drop the lint exclude for a directory not in the repo
- ruff's extend-exclude listed a path that is not part of the repository, so
  the entry never matched anything in CI
- keep the generated results/ exclude, which is a real runtime output directory
2026-09-07 20:54:19 +00:00
Yijia-Xiao
94113c8d11 test: stop the sentiment-analyst tests hitting the live network
- create_sentiment_analyst pre-fetches news, StockTwits and Reddit before
  prompting, and TestSentimentAnalystAgent invoked it unstubbed, so every run
  made live requests and a real 429 stalled the suite for minutes
- stub the three sources as the sibling prompt tests already do; the file drops
  from ~84s to under a second, and the suite no longer depends on Reddit or
  Yahoo being reachable
2026-09-07 20:54:19 +00:00
Yijia-Xiao
7cc478ad07 fix(dataflows): report a failed Reddit fetch as unavailable, not silence
- a failed fetch and an empty search both returned [], so a 429 rendered as
  'no posts found' and the sentiment analyst read throttling as real silence;
  when every subreddit was throttled the summary asserted it outright
- a failed fetch now returns None and renders as unavailable, and the summary
  only claims silence for subreddits actually searched
- raise the headerless-429 back-off to 60s, which is where a retry starts
  succeeding; pay it at most once per run so three throttled subreddits do not
  stall the analysis, and match the Retry-After cap to it #1295
2026-09-07 20:54:19 +00:00
Yijia-Xiao
1c44dd1ffc fix(agents): require absolute price levels from the Trader
- asking the Trader for concrete entry/stop levels invited a percentage
  (stop_loss: '15%'), which is not a price and failed the whole structured
  parse, dropping the run to a free-text retry
- state the requirement in the prompt and in both field descriptions
- a percentage now nulls that field instead of failing the proposal; it is
  never salvaged, since 15% must not become a 15 stop. Human-formatted
  prices with a currency symbol or thousands separator parse #1288
2026-09-07 20:54:19 +00:00
Yijia-Xiao
96111aa368 fix(dataflows): withhold the live profile from historical fundamentals
- both fundamentals vendors accepted curr_date and ignored it, serving a
  present-day company profile into a run dated in the past: yfinance via
  Ticker.info, Alpha Vantage via OVERVIEW
- that profile has no historical vintage, not even name/sector/industry (which
  move when a company renames or is reclassified), so a past curr_date now
  withholds it and says why; live runs are unchanged
- the rule lives once in date_window next to the existing look-ahead helpers,
  so switching data_vendors between the two cannot reintroduce the leak, and
  the guard runs before the request rather than discarding a paid-for response
- point-in-time fundamentals for a past date already come from the balance
  sheet, income statement and cash flow tools, which filter on curr_date #1300
2026-09-07 20:54:19 +00:00
Yijia Xiao
9dee508c44 Merge pull request #1285 from TauricResearch/v0.4.1
Post-v0.4.0 fixes: FRED vintage, Reddit 429, debate neutrality, feed bound
2026-09-01 00:38:45 -05:00
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
109 changed files with 7371 additions and 1131 deletions

View File

@@ -16,53 +16,49 @@ 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
# SEC EDGAR (US company filings, point-in-time). No key; a contact address SEC can reach you at.
#SEC_EDGAR_USER_AGENT=Your Name your@email.com
# FRED (Federal Reserve macro data). 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).
# Custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp). Local servers need no key.
#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 Bedrock (pip install ".[bedrock]"). Bearer token, or the AWS credential chain; set the region either way.
#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.
# Remote Ollama server. Unset uses http://localhost:11434/v1.
#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).
# Override these DEFAULT_CONFIG keys. Provider, models, language and round counts also skip their CLI prompt.
#TRADINGAGENTS_LLM_PROVIDER=openai
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.6
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.6-luna
#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.
# Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index.
#TRADINGAGENTS_RESULTS_DIR=
#TRADINGAGENTS_CACHE_DIR=
#TRADINGAGENTS_MEMORY_LOG_PATH=
#TRADINGAGENTS_BENCHMARK_TICKER=SPY
# Lower temperature means less run-to-run variation on models that honor it.
#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.
# Retry budget for every LLM SDK. Raise it to ride out 429 throttling.
#TRADINGAGENTS_LLM_MAX_RETRIES=6
# Provider-specific reasoning/thinking depth (optional; unset = provider
# default). Setting one also skips the matching interactive prompt.
# Cap output tokens to bound a model that runs long and trips a timeout.
#TRADINGAGENTS_MAX_TOKENS=8192
# Reasoning depth per provider; setting one skips its prompt.
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
#TRADINGAGENTS_GOOGLE_THINKING_LEVEL=high
#TRADINGAGENTS_ANTHROPIC_EFFORT=high

View File

@@ -57,5 +57,5 @@ jobs:
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).
# (generated results/ is excluded via pyproject extend-exclude).
run: ruff check .

View File

@@ -6,6 +6,129 @@ 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.5.0] — 2026-09-18
Point-in-time integrity across every dated path, decisions that are recorded as
they were made, backtesting over a grid of tickers and dates, the caller's
portfolio as run input, and SEC EDGAR fundamentals served as filed.
### Highlights
- **Fundamentals as filed.** SEC EDGAR serves US company statements as they stood on the run's date: a period that has ended but has not been filed is not served, and a figure restated later still reads as first reported. Keyless, opt-in via the vendor chain.
- **Backtesting.** `run_backtest` runs the pipeline over a ticker and date grid into its own decision log, and `summarize` scores the settled cells; `tradingagents backtest` does the same from the CLI.
- **Portfolio context.** `propagate(..., portfolio=...)` and `--portfolio` let the trader, risk and portfolio agents size against real holdings. A run without one is never treated as a flat book.
- **Decisions are recorded as made.** An unreadable decision is flagged for review everywhere instead of becoming a tradeable Hold, and a rating argued against is no longer read as the call.
### Point-in-time and honest attribution
- Dated tools take the run's date from graph state, so an omitted or later date cannot reach a vendor. (#1331, #1319, #1118)
- Insider filings and prediction-market odds are bounded by the run date; insider rows state that a trade becomes public when its Form 4 is filed.
- A feed that never observed a window reports it as unavailable rather than as an absence, across news, Reddit and StockTwits.
- The resolved company identity says when it describes today rather than the run date.
- The verification snapshot quotes the prices the vendor reported, never a gap-filled value.
- A vendor failure is a vendor failure: yfinance raises instead of returning its errors as text, an outage is not reported as a company with no data, and a chain where every vendor is unavailable says so instead of ending the run.
- The macro vintage pin is clamped to the vendor's own clock, so a run dated today cannot ask for a vintage it does not have.
- A historical run is not served a present-day company profile by either fundamentals vendor. (#1300)
### Decisions and evaluation
- The labelled rating decides, whatever separates it, and prose naming several ratings is reviewed rather than guessed.
- Decision prompts state the shape of their answer, so a provider without structured output still returns a readable decision.
- A report that was not produced says so, instead of appearing as an empty section.
- Backtest scoring reads the direction each rating claimed: a Sell that fell is a hit, and Hold reports no hit rate.
- The outcome window is configurable (`holding_period_days`), and reflection states the window it judges.
- A settled decision is not logged twice, and a failed reflection no longer stops the next run. (#645)
- The trader states entry and stop levels as prices, so a percentage no longer fails the whole proposal. (#1288)
### CLI
- `tradingagents backtest`, with `--run-id` to continue an interrupted sweep. (#1234)
- The previous run's selections come back as prompt defaults. (#1236, #920)
- A run with no readable rating says so; the live view no longer scrolls; messages that read like Python values are shown. (#649, #784)
- The state log keeps non-ASCII readable. (#1081)
### Data sources
- SEC EDGAR fundamentals vendor (US filers, keyless).
- Hong Kong and Shanghai tickers resolve to the symbols Yahoo serves. (#1342, #957, #1260)
- Reddit is fetched as one combined request per run. (#1286)
- One OHLCV cache file per symbol. (#1330)
### Models
- Current lineups for every provider: GPT-6 Astra and the GPT-5.6 family, Gemini 3.8 Flash, Claude Opus 5 and Fable 5.1, Grok 4.6, DeepSeek Flash, Qwen 3.8, GLM-5.3, MiniMax M3, Kimi K3 and the current Mistral snapshots.
- Every provider accepts a model ID the picker does not list.
- GLM traffic goes to the platform its key belongs to, and Ollama structured output no longer sends a tool_choice it rejects. (#1062)
### Changed
- The memory log records `REVIEW` for a decision with no readable rating, where it previously recorded `Hold`.
- Optional fields the model did not provide are named as such rather than omitted.
- Removed dependencies nothing imports: backtrader, redis, setuptools, langchain-experimental, parsel, tqdm. (#1353, #1070)
### Contributors
Thanks to everyone who reported these or sent a fix:
[@akashkpfreelancer](https://github.com/akashkpfreelancer), [@angziii](https://github.com/angziii), [@anupamme](https://github.com/anupamme), [@AyushKar2005](https://github.com/AyushKar2005), [@bulkypanda](https://github.com/bulkypanda), [@CadeYu](https://github.com/CadeYu), [@chiang21fcb](https://github.com/chiang21fcb), [@dajiaohuang](https://github.com/dajiaohuang), [@dewrama](https://github.com/dewrama), [@DogInfantry](https://github.com/DogInfantry), [@emitov](https://github.com/emitov), [@farukerdem34](https://github.com/farukerdem34), [@flydragon2018](https://github.com/flydragon2018), [@fusshell](https://github.com/fusshell), [@Ganesh1729-ui](https://github.com/Ganesh1729-ui), [@gyx09212214-prog](https://github.com/gyx09212214-prog), [@hamzabudeir](https://github.com/hamzabudeir), [@ihsieh31](https://github.com/ihsieh31), [@jaylew20250206](https://github.com/jaylew20250206), [@kaushik-yadav](https://github.com/kaushik-yadav), [@kbnnf](https://github.com/kbnnf), [@kevinkda](https://github.com/kevinkda), [@LudwigJMarx](https://github.com/LudwigJMarx), [@lx7720](https://github.com/lx7720), [@malandrindev](https://github.com/malandrindev), [@mhd325ic-hash](https://github.com/mhd325ic-hash), [@minhdn90](https://github.com/minhdn90), [@miznan](https://github.com/miznan), [@mmssix](https://github.com/mmssix), [@mrbob-git](https://github.com/mrbob-git), [@newnewself](https://github.com/newnewself), [@prithvirajrh](https://github.com/prithvirajrh), [@PyriteResearch](https://github.com/PyriteResearch), [@Rajatendu1](https://github.com/Rajatendu1), [@Recnelis0](https://github.com/Recnelis0), [@Rodvask](https://github.com/Rodvask), [@samhoooo](https://github.com/samhoooo), [@sheiun-xu](https://github.com/sheiun-xu), [@shivsin25](https://github.com/shivsin25), [@SmileShaun](https://github.com/SmileShaun), [@SonnyRajagopalan](https://github.com/SonnyRajagopalan), [@taro0915](https://github.com/taro0915), [@wupengbo125](https://github.com/wupengbo125), [@wxggzz](https://github.com/wxggzz), [@Yixiang-Wu](https://github.com/Yixiang-Wu), [@ZahirBodrike](https://github.com/ZahirBodrike), [@ZHUYAWEI](https://github.com/ZHUYAWEI), [@zkwang616](https://github.com/zkwang616).
## [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,

135
README.md
View File

@@ -5,12 +5,14 @@
<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://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>
<br>
<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>
<a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/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> |
@@ -28,7 +30,13 @@
# TradingAgents: Multi-Agents LLM Financial Trading Framework
## News
- [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. See [CHANGELOG.md](CHANGELOG.md) for the full list.
- [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider. See [CHANGELOG.md](CHANGELOG.md) for the full list.
- [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.
- [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.
<details>
<summary>Earlier releases</summary>
- [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.
@@ -37,19 +45,7 @@
- [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">
<a href="https://www.star-history.com/#TauricResearch/TradingAgents&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date" />
<img alt="TradingAgents Star History" src="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date" style="width: 80%; height: auto;" />
</picture>
</a>
</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!
</details>
<div align="center">
@@ -57,6 +53,10 @@
</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 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.
@@ -117,7 +117,13 @@ conda create -n tradingagents python=3.12
conda activate tradingagents
```
Install the package and its dependencies:
Or with [uv](https://docs.astral.sh/uv/):
```bash
uv venv --python 3.12
source .venv/bin/activate
```
Install the package and its dependencies (`uv pip install .` with uv):
```bash
pip install .
```
@@ -130,6 +136,8 @@ cp .env.example .env # add your API keys
docker compose run --rm tradingagents
```
After updating the repository, rebuild the image with `docker compose build`.
For local models with Ollama:
```bash
docker compose --profile ollama run --rm tradingagents-ollama
@@ -152,6 +160,11 @@ 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 MISTRAL_API_KEY=... # Mistral
export MOONSHOT_API_KEY=... # Kimi (Moonshot)
export GROQ_API_KEY=... # Groq
export NVIDIA_API_KEY=... # NVIDIA NIM
export FRED_API_KEY=... # FRED macro data (free, optional)
export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
```
@@ -175,7 +188,7 @@ Launch the interactive CLI:
tradingagents # installed command
python -m cli.main # alternative: run directly from source
```
You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more.
You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely.
### Markets and tickers
@@ -218,7 +231,7 @@ from tradingagents.default_config import DEFAULT_CONFIG
ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy())
# forward propagate
_, decision = ta.propagate("NVDA", "2026-01-15")
_, decision = ta.propagate("NVDA", "2026-09-01")
print(decision)
```
@@ -230,45 +243,103 @@ from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy()
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["deep_think_llm"] = "gpt-5.5" # Model for complex reasoning
config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks
config["deep_think_llm"] = "gpt-5.6" # Model for complex reasoning
config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks
config["max_debate_rounds"] = 2
ta = TradingAgentsGraph(debug=True, config=config)
_, decision = ta.propagate("NVDA", "2026-01-15")
_, decision = ta.propagate("NVDA", "2026-09-01")
print(decision)
```
See `tradingagents/default_config.py` for all configuration options.
### Fundamentals as filed
US company statements can come from SEC EDGAR, which records the date every figure was filed. A run dated in the past then reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B.
EDGAR needs no account or API key. Add the vendor to the chain:
```python
config["data_vendors"]["fundamental_data"] = "sec_edgar,yfinance"
```
SEC asks callers to identify themselves and refuses requests that carry no contact address, so a default one is sent. Set your own so SEC can reach you rather than the project:
```bash
SEC_EDGAR_USER_AGENT="Your Name your@email.com"
```
It covers companies that file with the SEC, including foreign companies listed in the US. Anything else, such as Hong Kong or A-share listings, falls through to the next vendor in the chain. EDGAR's machine-readable filings begin in 2009, and a fourth quarter is reported as unavailable rather than derived, because filers publish it only inside the annual figure.
### Current holdings
By default the agents do not know what you hold, so their guidance is written for a reader who applies it to their own position. Pass a portfolio to have the trader, the risk analysts and the portfolio manager work against your actual book.
```python
from tradingagents.portfolio import PortfolioContext
portfolio = PortfolioContext.model_validate({
"cash": 25000.0,
"currency": "USD",
"positions": [{"ticker": "NVDA", "quantity": 120, "average_price": 150.0}],
})
_, decision = ta.propagate("NVDA", "2026-09-01", portfolio=portfolio)
```
The CLI takes the same content as a JSON file: `tradingagents --portfolio my_book.json`.
An empty `positions` list means a flat book, which is different from passing nothing. A run without a portfolio is never treated as flat.
## 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.
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 against the instrument's regional benchmark), 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.
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. The run view says whether it resumed a saved run or started 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
tradingagents --checkpoint # enable for this run
tradingagents --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")
_, decision = ta.propagate("NVDA", "2026-09-01")
```
## Evaluating decisions over time
One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a decision log of its own, and scores the decisions whose holding window has since traded.
```python
from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.agents.utils.memory import TradingMemoryLog
dates = iter_grid("2026-06-01", "2026-08-01", every_n_days=7)
result = run_backtest(["NVDA", "AAPL"], dates, config, selected_analysts=["market", "news"])
print(summarize(TradingMemoryLog({"memory_log_path": str(result.log_path)})).render())
```
From the CLI:
```bash
tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7
```
Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own decision log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped.
## 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.
@@ -277,14 +348,14 @@ Language model sampling is non-deterministic. Even at a fixed temperature, provi
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.
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 name a non-reasoning model in your config, or in `TRADINGAGENTS_DEEP_THINK_LLM` and `TRADINGAGENTS_QUICK_THINK_LLM`. Any model ID your provider serves is accepted, whether or not the picker lists it.
```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).
# Reasoning models ignore temperature. For tighter reproducibility, name a
# non-reasoning model in deep_think_llm / quick_think_llm.
```
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -1,5 +1,6 @@
import datetime
import os
import sys
import time
from collections import deque
from functools import wraps
@@ -19,6 +20,7 @@ from rich.table import Table
from rich.text import Text
from cli.announcements import display_announcements, fetch_announcements
from cli.prefs import load_last_run, sanitize, save_last_run
from cli.stats_handler import StatsCallbackHandler
from cli.utils import (
ask_anthropic_effort,
@@ -40,6 +42,9 @@ from cli.utils import (
select_research_depth,
select_shallow_thinking_agent,
)
from tradingagents.agents.utils.rating import is_review
from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.dataflows.utils import safe_ticker_component
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.graph.analyst_execution import (
AnalystWallTimeTracker,
@@ -48,10 +53,23 @@ from tradingagents.graph.analyst_execution import (
sync_analyst_tracker_from_chunk,
)
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.portfolio import load_portfolio
from tradingagents.reporting import write_report_tree
console = Console()
# prompt_toolkit's win32 output module is importable only on Windows (it asserts
# the platform at import time), so gate on the platform rather than catching the
# failure — that way a genuinely broken prompt_toolkit on Windows still surfaces
# instead of silently disabling the handler below. Off Windows this stays an
# empty tuple, which `except` accepts and never matches (#1138).
if sys.platform == "win32": # pragma: no cover - platform dependent
from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
_NO_CONSOLE_ERRORS: tuple[type[BaseException], ...] = (NoConsoleScreenBufferError,)
else:
_NO_CONSOLE_ERRORS = ()
app = typer.Typer(
name="TradingAgents",
help="TradingAgents CLI: Multi-Agents LLM Financial Trading Framework",
@@ -480,7 +498,14 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non
def get_user_selections():
"""Get all user selections before starting the analysis display."""
"""Ask for the run's settings, offering the previous run's answers."""
selections = _prompt_selections(load_last_run())
save_last_run(selections)
return selections
def _prompt_selections(prefs):
"""Walk the selection steps. ``prefs`` prefills, the environment skips."""
# Display ASCII art welcome message
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
welcome_ascii = f.read()
@@ -573,7 +598,7 @@ def get_user_selections():
"Select the language for analyst reports and final decision"
)
)
output_language = ask_output_language()
output_language = ask_output_language(prefs.get("output_language"))
# Step 4: Select analysts
console.print(
@@ -581,7 +606,8 @@ def get_user_selections():
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
)
)
selected_analysts = select_analysts(asset_type)
prefs = sanitize(prefs, asset_type.value)
selected_analysts = select_analysts(asset_type, prefs.get("analysts"))
console.print(
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}"
)
@@ -606,7 +632,7 @@ def get_user_selections():
"Step 5: Research Depth", "Select your research depth level"
)
)
selected_research_depth = select_research_depth()
selected_research_depth = select_research_depth(prefs.get("research_depth"))
# Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER).
# The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set,
@@ -628,7 +654,7 @@ def get_user_selections():
"Step 6: LLM Provider", "Select your LLM provider"
)
)
selected_llm_provider, backend_url = select_llm_provider()
selected_llm_provider, backend_url = select_llm_provider(prefs.get("llm_provider"))
# Providers with regional endpoints prompt for the region as a secondary
# step so the main dropdown stays clean (mainland China and international
@@ -649,7 +675,9 @@ def get_user_selections():
# The generic OpenAI-compatible endpoint has no default; ask for it if
# neither the menu nor the environment supplied one.
if selected_llm_provider == "openai_compatible" and not backend_url:
backend_url = prompt_openai_compatible_url()
remembered_url = (prefs.get("backend_url")
if prefs.get("llm_provider") == selected_llm_provider else None)
backend_url = prompt_openai_compatible_url(remembered_url)
# For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default)
# before model selection so it's obvious where we're connecting.
@@ -675,8 +703,13 @@ def get_user_selections():
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
)
)
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
remembered = prefs if prefs.get("llm_provider") == selected_llm_provider else {}
selected_shallow_thinker = select_shallow_thinking_agent(
selected_llm_provider, remembered.get("quick_think_llm")
)
selected_deep_thinker = select_deep_thinking_agent(
selected_llm_provider, remembered.get("deep_think_llm")
)
# Step 8: Provider-specific reasoning/thinking configuration. Each knob is
# settable via its TRADINGAGENTS_* env var; when that var is set (or the
@@ -719,8 +752,8 @@ def get_user_selections():
"research_depth": selected_research_depth,
"llm_provider": selected_llm_provider.lower(),
"backend_url": backend_url,
"shallow_thinker": selected_shallow_thinker,
"deep_thinker": selected_deep_thinker,
"quick_think_llm": selected_shallow_thinker,
"deep_think_llm": selected_deep_thinker,
"google_thinking_level": thinking_level,
"openai_reasoning_effort": reasoning_effort,
"anthropic_effort": anthropic_effort,
@@ -887,21 +920,16 @@ def extract_content_string(content):
"""Extract string content from various message formats.
Returns None if no meaningful text content is found.
"""
import ast
def is_empty(val):
"""Check if value is empty using Python's truthiness."""
if val is None or val == '':
return True
"""Whether a value carries nothing to show.
Text is judged by whether anything was written, not by what it would
mean as Python: a report saying "0" or "None" is a message the run
produced, and reading it as a falsy literal dropped it from the display.
"""
if isinstance(val, str):
s = val.strip()
if not s:
return True
try:
return not bool(ast.literal_eval(s))
except (ValueError, SyntaxError):
return False # Can't parse = real text
return not bool(val)
return not val.strip()
return val is None or not bool(val)
if is_empty(content):
return None
@@ -958,6 +986,29 @@ def format_tool_args(args, max_length=80) -> str:
return result[:max_length - 3] + "..."
return result
def _run_directory(config: dict, ticker: str, trade_date: str) -> Path:
"""Where this run writes, with the ticker validated as a path component.
Every other path that interpolates a ticker checks it first; a value of
".." here would place the run outside the results directory.
"""
return Path(config["results_dir"]) / safe_ticker_component(ticker) / trade_date
def _announce_checkpoint_state(graph, ticker: str, trade_date: str) -> None:
"""Say whether this run resumed a saved one, where the user can see it.
The graph logs this, but nothing in the CLI configures logging and the live
view owns the screen, so a resume was invisible.
"""
if getattr(graph, "_resuming", False):
message_buffer.add_message(
"System", f"Resuming the saved run for {ticker} on {trade_date}"
)
else:
message_buffer.add_message("System", f"Starting fresh for {ticker} on {trade_date}")
def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
"""Assemble the run config from interactive selections, honoring env precedence.
@@ -968,12 +1019,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
# Research depth sets both round counts, but an explicit env override
# (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the
# interactive selection — leave the env-applied value in place (#977).
if not os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS"):
config["max_debate_rounds"] = selections["research_depth"]
if not os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS"):
config["max_risk_discuss_rounds"] = selections["research_depth"]
config["quick_think_llm"] = selections["shallow_thinker"]
config["deep_think_llm"] = selections["deep_thinker"]
for env_var, key in (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"),
("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")):
if os.environ.get(env_var):
# The depth prompt still appeared (it is skipped only when both are
# set), so say which half of the answer the environment overrode.
console.print(
f"[green]✓ {key} from environment:[/green] {config[key]} "
f"(set by {env_var}, so the research depth you chose does not apply to it)"
)
else:
config[key] = selections["research_depth"]
config["quick_think_llm"] = selections["quick_think_llm"]
config["deep_think_llm"] = selections["deep_think_llm"]
config["backend_url"] = selections["backend_url"]
config["llm_provider"] = selections["llm_provider"].lower()
# Provider-specific thinking configuration
@@ -988,7 +1046,7 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
return config
def run_analysis(checkpoint: bool | None = None):
def run_analysis(checkpoint: bool | None = None, portfolio=None):
# First get all user selections
selections = get_user_selections()
@@ -1018,7 +1076,7 @@ def run_analysis(checkpoint: bool | None = None):
start_time = time.time()
# Create result directory
results_dir = Path(config["results_dir"]) / selections["ticker"] / selections["analysis_date"]
results_dir = _run_directory(config, selections["ticker"], selections["analysis_date"])
results_dir.mkdir(parents=True, exist_ok=True)
report_dir = results_dir / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
@@ -1068,7 +1126,9 @@ def run_analysis(checkpoint: bool | None = None):
# Now start the display layout
layout = create_layout()
with Live(layout, refresh_per_second=4):
# The alternate screen keeps a layout taller than the window from redrawing
# by scrolling; the final report prints after this block, on the normal screen.
with Live(layout, refresh_per_second=4, screen=True):
# Initial display
update_display(layout, stats_handler=stats_handler, start_time=start_time)
@@ -1097,132 +1157,147 @@ def run_analysis(checkpoint: bool | None = None):
)
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
# Initialize state and get graph args with callbacks.
# Resolve the instrument identity once here so all agents anchor to
# the real company (#814); the CLI builds state directly rather than
# going through propagate(), so this must happen on the CLI path too.
instrument_context = graph.resolve_instrument_context(
selections["ticker"], selections["asset_type"]
)
init_agent_state = graph.propagator.create_initial_state(
selections["ticker"],
selections["analysis_date"],
asset_type=selections["asset_type"],
instrument_context=instrument_context,
# The same initial state propagate() builds: settled decision log, past
# context and resolved instrument identity.
init_agent_state = graph.create_run_state(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
)
# Pass callbacks to graph config for tool execution tracking
# (LLM tracking is handled separately via LLM constructor)
args = graph.propagator.get_graph_args(callbacks=[stats_handler])
# Stream the analysis
# Recompile with a checkpointer and inject the thread_id so --checkpoint
# actually saves and resumes on the CLI path (#1249); a no-op when
# checkpointing is disabled. Torn down in the finally below.
checkpoint_tid = graph.begin_checkpoint(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
)
if checkpoint_tid is not None:
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid
# Stream the analysis. On resume, feed None so LangGraph continues the
# interrupted run instead of re-appending the initial state (#1249); the
# try/finally tears the checkpointer down even if the stream raises.
trace = []
for chunk in graph.graph.stream(init_agent_state, **args):
# Process all messages in chunk, deduplicating by message ID
for message in chunk.get("messages", []):
msg_id = getattr(message, "id", None)
if msg_id is not None:
if msg_id in message_buffer._processed_message_ids:
continue
message_buffer._processed_message_ids.add(msg_id)
try:
for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args):
# Process all messages in chunk, deduplicating by message ID
for message in chunk.get("messages", []):
msg_id = getattr(message, "id", None)
if msg_id is not None:
if msg_id in message_buffer._processed_message_ids:
continue
message_buffer._processed_message_ids.add(msg_id)
msg_type, content = classify_message_type(message)
if content and content.strip():
message_buffer.add_message(msg_type, content)
msg_type, content = classify_message_type(message)
if content and content.strip():
message_buffer.add_message(msg_type, content)
if hasattr(message, "tool_calls") and message.tool_calls:
for tool_call in message.tool_calls:
if isinstance(tool_call, dict):
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
else:
message_buffer.add_tool_call(tool_call.name, tool_call.args)
if hasattr(message, "tool_calls") and message.tool_calls:
for tool_call in message.tool_calls:
if isinstance(tool_call, dict):
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
else:
message_buffer.add_tool_call(tool_call.name, tool_call.args)
# Update analyst statuses based on report state (runs on every chunk)
update_analyst_statuses(
message_buffer,
chunk,
wall_time_tracker=analyst_wall_time_tracker,
)
# Research Team - Handle Investment Debate State
if chunk.get("investment_debate_state"):
debate_state = chunk["investment_debate_state"]
bull_hist = debate_state.get("bull_history", "").strip()
bear_hist = debate_state.get("bear_history", "").strip()
judge = debate_state.get("judge_decision", "").strip()
# Only update status when there's actual content
if bull_hist or bear_hist:
update_research_team_status("in_progress")
if bull_hist:
message_buffer.update_report_section(
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
)
if bear_hist:
message_buffer.update_report_section(
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
)
if judge:
message_buffer.update_report_section(
"investment_plan", f"### Research Manager Decision\n{judge}"
)
update_research_team_status("completed")
message_buffer.update_agent_status("Trader", "in_progress")
# Trading Team
if chunk.get("trader_investment_plan"):
message_buffer.update_report_section(
"trader_investment_plan", chunk["trader_investment_plan"]
# Update analyst statuses based on report state (runs on every chunk)
update_analyst_statuses(
message_buffer,
chunk,
wall_time_tracker=analyst_wall_time_tracker,
)
if message_buffer.agent_status.get("Trader") != "completed":
message_buffer.update_agent_status("Trader", "completed")
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
# Risk Management Team - Handle Risk Debate State
if chunk.get("risk_debate_state"):
risk_state = chunk["risk_debate_state"]
agg_hist = risk_state.get("aggressive_history", "").strip()
con_hist = risk_state.get("conservative_history", "").strip()
neu_hist = risk_state.get("neutral_history", "").strip()
judge = risk_state.get("judge_decision", "").strip()
# Research Team - Handle Investment Debate State
if chunk.get("investment_debate_state"):
debate_state = chunk["investment_debate_state"]
bull_hist = debate_state.get("bull_history", "").strip()
bear_hist = debate_state.get("bear_history", "").strip()
judge = debate_state.get("judge_decision", "").strip()
if agg_hist:
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
# Only update status when there's actual content
if bull_hist or bear_hist:
update_research_team_status("in_progress")
if bull_hist:
message_buffer.update_report_section(
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
)
if bear_hist:
message_buffer.update_report_section(
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
)
if judge:
message_buffer.update_report_section(
"investment_plan", f"### Research Manager Decision\n{judge}"
)
update_research_team_status("completed")
message_buffer.update_agent_status("Trader", "in_progress")
# Trading Team
if chunk.get("trader_investment_plan"):
message_buffer.update_report_section(
"trader_investment_plan", chunk["trader_investment_plan"]
)
if message_buffer.agent_status.get("Trader") != "completed":
message_buffer.update_agent_status("Trader", "completed")
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
)
if con_hist:
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
)
if neu_hist:
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
)
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
)
message_buffer.update_agent_status("Aggressive Analyst", "completed")
message_buffer.update_agent_status("Conservative Analyst", "completed")
message_buffer.update_agent_status("Neutral Analyst", "completed")
message_buffer.update_agent_status("Portfolio Manager", "completed")
# Update the display
update_display(layout, stats_handler=stats_handler, start_time=start_time)
# Risk Management Team - Handle Risk Debate State
if chunk.get("risk_debate_state"):
risk_state = chunk["risk_debate_state"]
agg_hist = risk_state.get("aggressive_history", "").strip()
con_hist = risk_state.get("conservative_history", "").strip()
neu_hist = risk_state.get("neutral_history", "").strip()
judge = risk_state.get("judge_decision", "").strip()
trace.append(chunk)
if agg_hist:
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
)
if con_hist:
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
)
if neu_hist:
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
)
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
)
message_buffer.update_agent_status("Aggressive Analyst", "completed")
message_buffer.update_agent_status("Conservative Analyst", "completed")
message_buffer.update_agent_status("Neutral Analyst", "completed")
message_buffer.update_agent_status("Portfolio Manager", "completed")
# Streamed chunks are per-node deltas, not full state. Merge them
# so every report field populated across the run is present.
final_state = {}
for chunk in trace:
final_state.update(chunk)
# Update the display
update_display(layout, stats_handler=stats_handler, start_time=start_time)
trace.append(chunk)
# Streamed chunks are per-node deltas, not full state. Merge them
# so every report field populated across the run is present.
final_state = {}
for chunk in trace:
final_state.update(chunk)
# Clean run: log the decision, then drop this run's checkpoint so a
# later run starts fresh. A mid-stream failure skips both, keeping
# the checkpoint for resume.
graph.record_decision(selections["ticker"], selections["analysis_date"], final_state)
graph.clear_checkpoint_on_success(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
)
finally:
# Always restore the plain uncheckpointed graph, even on failure.
graph.end_checkpoint()
# Update all agent statuses to completed
for agent in message_buffer.agent_status:
@@ -1242,13 +1317,26 @@ def run_analysis(checkpoint: bool | None = None):
# Post-analysis prompts (outside Live context for clean interaction)
console.print("\n[bold cyan]Analysis Complete![/bold cyan]\n")
# A decision nobody can read is not a position. Say so here rather than
# leaving the run to look like a normal result.
if is_review(graph.process_signal(final_state.get("final_trade_decision", ""))):
console.print(
"[yellow]No rating could be read from the final decision, so this run "
"is recorded for review rather than as a position. Re-run, or read the "
"decision text below and judge it yourself.[/yellow]\n"
)
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
# Prompt to save report
save_choice = typer.prompt("Save report?", default="Y").strip().upper()
if save_choice in ("Y", "YES", ""):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
default_path = Path.cwd() / "reports" / f"{selections['ticker']}_{timestamp}"
# Under results_dir, not the working directory: in Docker the working
# directory is inside the container and the report goes with it, while
# results_dir is the mounted volume the rest of the run already writes to.
default_path = (Path(config["results_dir"]) / "reports"
/ f"{safe_ticker_component(selections['ticker'])}_{timestamp}")
save_path_str = typer.prompt(
"Save path (press Enter for default)",
default=str(default_path)
@@ -1267,8 +1355,9 @@ def run_analysis(checkpoint: bool | None = None):
display_complete_report(final_state)
@app.command()
@app.callback(invoke_without_command=True)
def analyze(
ctx: typer.Context,
checkpoint: bool | None = typer.Option(
None,
"--checkpoint/--no-checkpoint",
@@ -1280,12 +1369,91 @@ def analyze(
"--clear-checkpoints",
help="Delete all saved checkpoints before running (force fresh start).",
),
portfolio: str = typer.Option(
None,
"--portfolio",
help="JSON file with current holdings and cash, so the trader, risk and "
"portfolio agents size against your actual position.",
),
):
"""Run an analysis. This is what a bare `tradingagents` does."""
if ctx.invoked_subcommand is not None:
return
if clear_checkpoints:
from tradingagents.graph.checkpointer import clear_all_checkpoints
n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"])
console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]")
run_analysis(checkpoint=checkpoint)
portfolio_context = None
if portfolio:
from tradingagents.portfolio import load_portfolio
try:
portfolio_context = load_portfolio(portfolio)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
try:
run_analysis(checkpoint=checkpoint, portfolio=portfolio_context)
except _NO_CONSOLE_ERRORS:
# A terminal with no console buffer cannot host the interactive prompts.
# Emit one actionable line on stderr instead of a prompt_toolkit
# traceback; plain text, since rich may not render here either (#1138).
typer.echo(
"Error: no Windows console available. The interactive CLI needs a real "
"console buffer — run it from Windows Terminal, PowerShell, or cmd.exe "
"rather than a piped or embedded terminal.",
err=True,
)
raise typer.Exit(code=1) from None
@app.command()
def backtest(
tickers: str = typer.Argument(..., help="Comma-separated tickers, e.g. NVDA,AAPL"),
start: str = typer.Option(..., "--start", help="First analysis date, YYYY-MM-DD"),
end: str = typer.Option(..., "--end", help="Last analysis date, YYYY-MM-DD"),
every: int = typer.Option(7, "--every", help="Days between analysis dates"),
analysts: str = typer.Option(
None, "--analysts", help="Comma-separated analysts to run; omit for all four"
),
asset_type: str = typer.Option("stock", "--asset-type", help="stock or crypto"),
portfolio: str = typer.Option(
None, "--portfolio", help="JSON file with holdings and cash, held constant across the grid"
),
run_id: str = typer.Option(
None, "--run-id", help="Continue an earlier sweep: its cells are skipped and its log reused"
),
):
"""Score past decisions over a grid of tickers and dates."""
from tradingagents.agents.utils.memory import TradingMemoryLog
try:
dates = iter_grid(start, end, every)
book = load_portfolio(portfolio) if portfolio else None
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
names = [t.strip() for t in tickers.split(",") if t.strip()]
if not names:
console.print("[red]No ticker to analyze; pass them comma-separated, e.g. NVDA,AAPL[/red]")
raise typer.Exit(code=1)
kwargs = {"asset_type": asset_type, "portfolio": book, "run_id": run_id}
if analysts:
kwargs["selected_analysts"] = [a.strip().lower() for a in analysts.split(",") if a.strip()]
try:
result = run_backtest(names, dates, DEFAULT_CONFIG, **kwargs)
except Exception as exc: # a missing key or an unknown analyst is a setup error
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
console.print(summarize(TradingMemoryLog({"memory_log_path": str(result.log_path)})).render())
console.print(f"\nRan {result.cells_run} cells, skipped {result.skipped}. Log: {result.log_path}")
for ticker, date, reason in result.failures:
console.print(f"[yellow]failed:[/yellow] {ticker} {date}: {reason}")
for ticker, reason in result.settlement_failures:
console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}")
if __name__ == "__main__":

91
cli/prefs.py Normal file
View File

@@ -0,0 +1,91 @@
"""What the last run chose, offered back as the next run's defaults.
The interactive flow asks the same questions every time, and only some of them
have an environment variable to skip them (the analyst set has none). Remembered
answers prefill the prompts so Enter accepts them; they never skip a step, so a
run always starts on choices the user has seen.
Only answers that are stable between runs are kept. The ticker and the analysis
date are not: they change every run, and a remembered date would quietly offer a
stale one.
Every value is checked against the current choices on the way out, because
models and providers are added and retired between versions. A remembered model
that is no longer offered is dropped rather than shown.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from cli.models import AnalystType, AssetType
from cli.utils import _llm_provider_table, filter_analysts_for_asset_type
from tradingagents.llm_clients.model_catalog import get_model_options
_PREFS_PATH = Path(os.path.expanduser("~")) / ".tradingagents" / "cli_prefs.json"
REMEMBERED = (
"output_language", "analysts", "research_depth", "llm_provider",
"quick_think_llm", "deep_think_llm", "backend_url",
)
def load_last_run() -> dict:
"""The previous run's answers, or an empty dict when there is nothing usable.
Convenience state: an unreadable or corrupt file means no defaults, never an
error in the user's way.
"""
try:
data = json.loads(_PREFS_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def save_last_run(selections: dict) -> None:
"""Record the answers worth offering next time; failure is never fatal."""
kept = {k: v for k, v in selections.items() if k in REMEMBERED and v not in (None, "", [])}
kept["analysts"] = [getattr(a, "value", a) for a in kept.get("analysts", [])] or None
kept = {k: v for k, v in kept.items() if v is not None}
try:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
temp = _PREFS_PATH.with_suffix(".tmp")
temp.write_text(json.dumps(kept, indent=2), encoding="utf-8")
os.replace(temp, _PREFS_PATH) # a concurrent run reads one file or the other
except OSError:
return
def sanitize(prefs: dict, asset_type) -> dict:
"""Keep only the remembered answers that are still choosable now."""
kept: dict = {}
if isinstance(prefs.get("output_language"), str):
kept["output_language"] = prefs["output_language"]
if prefs.get("research_depth") in (1, 3, 5):
kept["research_depth"] = prefs["research_depth"]
known = {a.value for a in AnalystType}
analysts = [a for a in prefs.get("analysts") or [] if a in known]
allowed = filter_analysts_for_asset_type([AnalystType(a) for a in analysts], AssetType(asset_type))
if allowed:
kept["analysts"] = [a.value for a in allowed]
provider = prefs.get("llm_provider")
# Region-specific providers (qwen-cn) are picked in a second prompt, so the
# base key is what the provider menu matches.
base = (provider or "").split("-cn")[0]
if base and base in {key for _, key, _ in _llm_provider_table()}:
kept["llm_provider"] = provider
if isinstance(prefs.get("backend_url"), str) and prefs["backend_url"]:
kept["backend_url"] = prefs["backend_url"]
for field, mode in (("quick_think_llm", "quick"), ("deep_think_llm", "deep")):
try:
offered = {model for _, model in get_model_options(base, mode)}
except KeyError:
continue
if prefs.get(field) in offered:
kept[field] = prefs[field]
return kept

View File

@@ -132,8 +132,16 @@ def get_analysis_date() -> str:
return date.strip()
def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType]:
"""Select analysts using an interactive checkbox."""
def _matching_choice(options, default):
"""The option value equal to ``default``, or None to leave the menu as is."""
return next((value for _, value in options if value == default), None)
def select_analysts(asset_type: AssetType = AssetType.STOCK, default=None) -> list[AnalystType]:
"""Select analysts using an interactive checkbox.
``default`` pre-checks the previous run's analysts; the prompt still shows.
"""
available_analysts = filter_analysts_for_asset_type(
[value for _, value in ANALYST_ORDER],
asset_type,
@@ -141,7 +149,7 @@ def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType
choices = questionary.checkbox(
"Select Your [Analysts Team]:",
choices=[
questionary.Choice(display, value=value)
questionary.Choice(display, value=value, checked=value.value in (default or []))
for display, value in ANALYST_ORDER
if value in available_analysts
],
@@ -164,7 +172,7 @@ def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType
return choices
def select_research_depth() -> int:
def select_research_depth(default=None) -> int:
"""Select research depth using an interactive selection."""
# Define research depth options with their corresponding values
@@ -179,6 +187,7 @@ def select_research_depth() -> int:
choices=[
questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS
],
default=_matching_choice(DEPTH_OPTIONS, default),
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
@@ -289,7 +298,7 @@ def _prompt_custom_model_id() -> str:
return _require_text("Enter model ID:", "Please enter a model ID.")
def _select_model(provider: str, mode: str) -> str:
def _select_model(provider: str, mode: str, default=None) -> str:
"""Select a model for the given provider and mode (quick/deep)."""
if provider.lower() == "openrouter":
return select_openrouter_model(mode)
@@ -306,6 +315,7 @@ def _select_model(provider: str, mode: str) -> str:
questionary.Choice(display, value=value)
for display, value in get_model_options(provider, mode)
],
default=_matching_choice(get_model_options(provider, mode), default),
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
@@ -326,14 +336,14 @@ def _select_model(provider: str, mode: str) -> str:
return choice
def select_shallow_thinking_agent(provider) -> str:
def select_shallow_thinking_agent(provider, default=None) -> str:
"""Select shallow thinking llm engine using an interactive selection."""
return _select_model(provider, "quick")
return _select_model(provider, "quick", default)
def select_deep_thinking_agent(provider) -> str:
def select_deep_thinking_agent(provider, default=None) -> str:
"""Select deep thinking llm engine using an interactive selection."""
return _select_model(provider, "deep")
return _select_model(provider, "deep", default)
def _llm_provider_table() -> list[tuple[str, str, str | None]]:
"""(display_name, provider_key, base_url) for every supported provider.
@@ -352,7 +362,9 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]:
("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/"),
# Z.AI international, the platform ZHIPU_API_KEY belongs to; the CN
# platform is the separate glm-cn key, chosen in the region prompt.
("GLM", "glm", "https://api.z.ai/api/paas/v4/"),
("MiniMax", "minimax", "https://api.minimax.io/v1"),
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
("Mistral", "mistral", "https://api.mistral.ai/v1"),
@@ -388,11 +400,12 @@ def resolve_backend_url(
return env_url or menu_url or provider_default_url(provider)
def prompt_openai_compatible_url() -> str:
def prompt_openai_compatible_url(default=None) -> 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):",
default=default or "",
validate=lambda x: x.strip().startswith(("http://", "https://"))
or "Enter a URL starting with http:// or https://",
).ask()
@@ -402,9 +415,15 @@ def prompt_openai_compatible_url() -> str:
return url.strip()
def select_llm_provider() -> tuple[str, str | None]:
def select_llm_provider(default=None) -> tuple[str, str | None]:
"""Select the LLM provider and its API endpoint."""
PROVIDERS = _llm_provider_table()
# A region-specific key (qwen-cn) is chosen in a later prompt; the menu
# lists the base provider.
base = (default or "").split("-cn")[0]
preselected = next(
((key, url) for _, key, url in PROVIDERS if key == base), None
)
choice = questionary.select(
"Select your LLM Provider:",
@@ -412,6 +431,7 @@ def select_llm_provider() -> tuple[str, str | None]:
questionary.Choice(display, value=(provider_key, url))
for display, provider_key, url in PROVIDERS
],
default=preselected,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
@@ -458,9 +478,9 @@ def ask_anthropic_effort() -> str | None:
return questionary.select(
"Select Effort Level:",
choices=[
questionary.Choice("High (recommended)", "high"),
questionary.Choice("Medium (balanced)", "medium"),
questionary.Choice("Low (faster, cheaper)", "low"),
questionary.Choice("High (recommended)", "high"),
questionary.Choice("Medium (balanced)", "medium"),
questionary.Choice("Low (faster, cheaper)", "low"),
],
style=questionary.Style([
("selected", "fg:cyan noinherit"),
@@ -479,8 +499,8 @@ def ask_gemini_thinking_config() -> str | None:
return questionary.select(
"Select Thinking Mode:",
choices=[
questionary.Choice("Enable Thinking (recommended)", "high"),
questionary.Choice("Minimal/Disable Thinking", "minimal"),
questionary.Choice("Enable Thinking (recommended)", "high"),
questionary.Choice("Minimal/Disable Thinking", "minimal"),
],
style=questionary.Style([
("selected", "fg:green noinherit"),
@@ -643,31 +663,41 @@ def ensure_api_key(provider: str) -> str | None:
return None
env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env")
Path(env_path).touch(exist_ok=True)
# The file holds credentials, so make it owner-only before writing: create
# it 0600 when absent, and tighten an existing one (set_key keeps the mode).
if not os.path.exists(env_path):
os.close(os.open(env_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
os.chmod(env_path, 0o600)
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."""
def ask_output_language(default=None) -> str:
"""Ask for report output language.
``default`` is offered only when it is one of the listed languages: a custom
one entered last time is free text, which the menu cannot preselect.
"""
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"),
]
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"),
],
choices=choices,
default=_matching_choice([(c.title, c.value) for c in choices], default),
style=questionary.Style([
("selected", "fg:yellow noinherit"),
("highlighted", "fg:yellow noinherit"),

View File

@@ -12,8 +12,7 @@ config = DEFAULT_CONFIG.copy()
ta = TradingAgentsGraph(debug=True, config=config)
# forward propagate
_, decision = ta.propagate("NVDA", "2024-05-10")
_, decision = ta.propagate("NVDA", "2026-09-01")
print(decision)
# Memorize mistakes and reflect
# ta.reflect_and_remember(1000) # parameter is the position returns

View File

@@ -4,31 +4,25 @@ build-backend = "setuptools.build_meta"
[project]
name = "tradingagents"
version = "0.3.1"
version = "0.5.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",
]
@@ -69,7 +63,7 @@ filterwarnings = [
[tool.ruff]
line-length = 100
target-version = "py310"
extend-exclude = ["results", "worklog"]
extend-exclude = ["results"]
[tool.ruff.lint]
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +

View File

@@ -1,174 +0,0 @@
"""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())

14
test.py
View File

@@ -1,14 +0,0 @@
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)

View File

@@ -3,7 +3,8 @@
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).
string, not a dict), and the date trim that keeps post-end_date bars out of a
historical run.
"""
import json
@@ -11,9 +12,13 @@ import pytest
import tradingagents.dataflows.alpha_vantage_common as av
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
import tradingagents.dataflows.alpha_vantage_stock as avs
import tradingagents.dataflows.utils as utils
class _FakeResponse:
status_code = 200
def __init__(self, text):
self.text = text
@@ -32,7 +37,7 @@ def _patched_get(body, capture=None):
@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))
monkeypatch.setattr(utils.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
@@ -40,7 +45,7 @@ def test_request_passes_timeout(monkeypatch):
@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))
monkeypatch.setattr(utils.requests, "get", _patched_get(body))
with pytest.raises(av.AlphaVantageRateLimitError):
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
@@ -51,11 +56,11 @@ def test_invalid_key_not_mislabeled_as_rate_limit(monkeypatch):
# (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))
monkeypatch.setattr(utils.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."}'))
monkeypatch.setattr(utils.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}'))
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
@@ -94,3 +99,115 @@ def test_fundamentals_no_curr_date_passes_through(monkeypatch):
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"
# ---------------------------------------------------------------------------
# Date trim (see the rationale on the unguarded trim in alpha_vantage_common)
# ---------------------------------------------------------------------------
_DAILY_CSV = (
"timestamp,open,high,low,close,volume\n"
"2024-05-13,1,1,1,1,10\n" # after end_date -> must never be served
"2024-05-10,1,1,1,1,10\n"
"2024-05-09,1,1,1,1,10\n"
)
@pytest.mark.unit
def test_stock_data_is_trimmed_to_the_requested_window(monkeypatch):
monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: _DAILY_CSV)
out = avs.get_stock("IBM", "2024-05-09", "2024-05-10")
assert "2024-05-10" in out and "2024-05-09" in out
assert "2024-05-13" not in out, "bar after end_date leaked into the window"
@pytest.mark.unit
def test_unparseable_body_is_never_served_untrimmed(monkeypatch):
"""The trim used to swallow the failure and return the whole body, putting
bars after end_date into a backtest. It must raise instead."""
monkeypatch.setattr(avs, "_make_api_request",
lambda *a, **k: "timestamp,close\nnot-a-date,1\n")
with pytest.raises(ValueError):
avs.get_stock("IBM", "2024-05-09", "2024-05-10")
@pytest.mark.unit
def test_empty_body_still_passes_through(monkeypatch):
monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: "")
assert avs.get_stock("IBM", "2024-05-09", "2024-05-10") == ""
def test_request_error_message_carries_no_key(monkeypatch):
# Alpha Vantage also sends its key in the URL (#1324).
import requests
key = "AVKEY1234567890XYZ"
monkeypatch.setenv("ALPHA_VANTAGE_API_KEY", key)
def boom(*a, **k):
raise requests.Timeout(f"Read timed out. url: https://www.alphavantage.co/query?apikey={key}")
monkeypatch.setattr(utils.requests, "get", boom)
with pytest.raises(requests.Timeout) as caught:
av._make_api_request("OVERVIEW", {"symbol": "IBM"})
assert key not in str(caught.value)
@pytest.mark.unit
def test_global_news_omitted_optionals_use_the_configured_defaults(monkeypatch):
"""The tool passes None for an omitted look_back_days or limit (#1326)."""
from tradingagents.dataflows import alpha_vantage_news
monkeypatch.setattr(alpha_vantage_news, "get_config",
lambda: {"global_news_lookback_days": 3, "global_news_article_limit": 9})
seen = {}
monkeypatch.setattr(alpha_vantage_news, "_make_api_request", lambda fn, params: seen.update(params) or "{}")
alpha_vantage_news.get_global_news("2026-08-14", None, None)
assert seen["time_from"].startswith("20260811") and seen["limit"] == "9"
@pytest.mark.unit
def test_the_news_window_includes_the_analysis_day(monkeypatch):
"""time_to was midnight at the start of the end date, so everything
published during the analysis day, the most decision-relevant day, was
excluded. The yfinance path includes it."""
from tradingagents.dataflows import alpha_vantage_news
seen = {}
monkeypatch.setattr(alpha_vantage_news, "_make_api_request",
lambda fn, params: seen.update(params) or "{}")
alpha_vantage_news.get_news("AAPL", "2026-03-10", "2026-03-14")
assert seen["time_from"] == "20260310T0000"
assert seen["time_to"] == "20260314T2359"
@pytest.mark.unit
@pytest.mark.parametrize("indicator", ["vwma", "mfi"])
def test_an_indicator_this_vendor_lacks_lets_the_next_one_serve_it(indicator):
"""Returning prose counts as success to the router, so the chain stops at a
vendor that cannot compute the indicator while the next one can."""
from tradingagents.dataflows import alpha_vantage_indicator
from tradingagents.dataflows.errors import VendorError
with pytest.raises(VendorError):
alpha_vantage_indicator.get_indicator("AAPL", indicator, "2026-05-08", 30)
@pytest.mark.unit
def test_ticker_news_asks_for_only_as_many_articles_as_configured(monkeypatch):
"""The endpoint returns 50 articles with per-article sentiment arrays by
default, and the whole payload went into the prompt."""
from tradingagents.dataflows import alpha_vantage_news
monkeypatch.setattr(alpha_vantage_news, "get_config", lambda: {"news_article_limit": 8})
seen = {}
monkeypatch.setattr(alpha_vantage_news, "_make_api_request",
lambda fn, params: seen.update(params) or "{}")
alpha_vantage_news.get_news("AAPL", "2026-03-10", "2026-03-14")
assert seen["limit"] == "8"

View File

@@ -60,7 +60,7 @@ class TestEffortGate:
@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"],
["claude-sonnet-5", "claude-fable-5", "claude-mythos-5", "claude-opus-5", "claude-fable-5-1"],
)
def test_claude_5_family_receives_effort(self, monkeypatch, model):
captured = _capture_kwargs(monkeypatch)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import stat
from unittest.mock import patch
import pytest
@@ -146,3 +147,47 @@ def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_uti
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
def _prompt_key(cli_utils, monkeypatch, tmp_path, key="sk-typed-in"):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(cli_utils, "find_dotenv", lambda **k: "")
with patch.object(cli_utils, "questionary") as mock_q:
mock_q.password.return_value.ask.return_value = key
cli_utils.ensure_api_key("openai")
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
def test_saved_key_file_is_owner_only(monkeypatch, cli_utils, tmp_path):
# The prompt writes a real credential; the file must not be readable by
# other local users whatever the umask is.
old = os.umask(0o002)
try:
_prompt_key(cli_utils, monkeypatch, tmp_path)
finally:
os.umask(old)
env = tmp_path / ".env"
assert "sk-typed-in" in env.read_text()
assert stat.S_IMODE(env.stat().st_mode) == 0o600
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
def test_existing_key_file_is_tightened_before_writing(monkeypatch, cli_utils, tmp_path):
env = tmp_path / ".env"
env.write_text("OTHER=1\n")
os.chmod(env, 0o664)
_prompt_key(cli_utils, monkeypatch, tmp_path)
assert stat.S_IMODE(env.stat().st_mode) == 0o600
assert "OTHER=1" in env.read_text()
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
def test_read_only_key_file_is_still_updated(monkeypatch, cli_utils, tmp_path):
env = tmp_path / ".env"
env.write_text("OTHER=1\n")
os.chmod(env, 0o400)
_prompt_key(cli_utils, monkeypatch, tmp_path)
assert "sk-typed-in" in env.read_text()
assert stat.S_IMODE(env.stat().st_mode) == 0o600

248
tests/test_backtest.py Normal file
View File

@@ -0,0 +1,248 @@
"""Backtesting: many single-shot decisions, scored by the decision log.
A run already records its rating and later settles it with realized and alpha
return against the regional benchmark. A backtest is that machinery over a grid
of tickers and dates, aggregated. It evaluates decision quality; it does not
simulate a portfolio, so there is no execution, no fees and no equity curve.
"""
from __future__ import annotations
import pytest
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.backtest import iter_grid, run_backtest, summarize
DECISION = "Rating: Buy\n\nbuy it"
@pytest.mark.unit
def test_grid_spacing_and_canonical_dates():
assert iter_grid("2026-01-05", "2026-01-20", every_n_days=7) == ["2026-01-05", "2026-01-12", "2026-01-19"]
@pytest.mark.unit
def test_grid_stops_at_today(monkeypatch):
import tradingagents.backtest as bt
monkeypatch.setattr(bt, "get_current_date", lambda: "2026-01-10")
assert iter_grid("2026-01-05", "2026-02-20", every_n_days=5) == ["2026-01-05", "2026-01-10"]
@pytest.mark.unit
def test_grid_rejects_a_non_canonical_date():
with pytest.raises(ValueError, match="YYYY-MM-DD"):
iter_grid("2026-1-5", "2026-01-20")
class _FakeGraph:
"""Stands in for TradingAgentsGraph, writing to the log the harness gave it."""
instances: list = []
fail_on: set = set()
def __init__(self, selected_analysts=None, config=None, **kw):
self.analysts = list(selected_analysts) if selected_analysts else None
self.config = config
self.memory_log = TradingMemoryLog(config)
self.calls = []
self.settled = []
_FakeGraph.instances.append(self)
def propagate(self, ticker, trade_date, asset_type="stock", portfolio=None):
self.calls.append((ticker, trade_date))
if (ticker, trade_date) in _FakeGraph.fail_on:
raise RuntimeError("vendor exploded")
self.memory_log.store_decision(ticker, trade_date, DECISION)
return {"final_trade_decision": DECISION}, "Buy"
def settle_pending(self, ticker):
self.settled.append(ticker)
@pytest.fixture(autouse=True)
def _fake_graph(monkeypatch, tmp_path):
import tradingagents.backtest as bt
_FakeGraph.instances = []
_FakeGraph.fail_on = set()
monkeypatch.setattr(bt, "TradingAgentsGraph", _FakeGraph)
return _FakeGraph
def _config(tmp_path):
return {"results_dir": str(tmp_path / "results"),
"memory_log_path": str(tmp_path / "live_trading_memory.md")}
@pytest.mark.unit
def test_the_live_decision_log_is_never_written(tmp_path):
config = _config(tmp_path)
result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config)
assert not (tmp_path / "live_trading_memory.md").exists()
assert result.log_path.exists() and result.cells_run == 2
@pytest.mark.unit
def test_a_cell_already_in_the_log_is_not_run_again(tmp_path):
config = _config(tmp_path)
first = run_backtest(["NVDA"], ["2026-01-05"], config)
again = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config, run_id=first.run_id)
assert again.cells_run == 1 and again.skipped == 1
assert _FakeGraph.instances[-1].calls == [("NVDA", "2026-01-12")]
@pytest.mark.unit
def test_every_ticker_is_settled_after_the_grid(tmp_path):
"""Settlement runs at the start of the next same-ticker run, so the last
date of each ticker would stay pending without an explicit pass."""
run_backtest(["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path))
assert sorted(_FakeGraph.instances[-1].settled) == ["AAPL", "NVDA"]
@pytest.mark.unit
def test_a_failed_cell_does_not_abort_the_sweep(tmp_path):
_FakeGraph.fail_on = {("NVDA", "2026-01-05")}
result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], _config(tmp_path))
assert result.cells_run == 1
assert result.failures == [("NVDA", "2026-01-05", "vendor exploded")]
# --- reading the result ------------------------------------------------------
def _log_with(tmp_path, rows):
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
for ticker, date, decision, outcome in rows:
log.store_decision(ticker, date, decision)
if outcome is not None:
log.update_with_outcome(ticker, date, outcome[0], outcome[1], 5, "note", "2026-02-01")
return log
@pytest.mark.unit
def test_summary_scores_resolved_cells_and_keeps_pending_out_of_the_average(tmp_path):
log = _log_with(tmp_path, [
("NVDA", "2026-01-05", "Rating: Buy\n\nx", (0.10, 0.04)),
("NVDA", "2026-01-12", "Rating: Buy\n\nx", (-0.02, -0.02)),
("AAPL", "2026-01-05", "Rating: Sell\n\nx", None),
])
summary = summarize(log)
assert summary.resolved == 2 and summary.pending == 1
buys = summary.by_rating["Buy"]
assert buys.count == 2 and buys.hit_rate == 0.5 and round(buys.mean_alpha, 4) == 0.01
assert "Sell" not in summary.by_rating # unsettled: nothing to score yet
@pytest.mark.unit
def test_summary_states_what_it_cannot_prove(tmp_path):
text = summarize(_log_with(tmp_path, [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))])).render()
assert "not archived" in text
assert "one" in text.lower() and "sampl" in text.lower()
@pytest.mark.unit
def test_the_analyst_set_under_test_is_the_one_that_runs(tmp_path):
"""A backtest of a two-analyst setup must not silently run four."""
run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), selected_analysts=["market", "news"])
assert _FakeGraph.instances[-1].analysts == ["market", "news"]
@pytest.mark.unit
def test_a_run_id_cannot_escape_the_results_directory(tmp_path):
"""run_id becomes a path segment, so it is validated like a ticker is."""
with pytest.raises(ValueError):
run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), run_id="../../escaped")
with pytest.raises(ValueError):
run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), run_id="/etc/cron.d/x")
@pytest.mark.unit
def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monkeypatch):
"""Settlement reflects with an LLM, so it can fail; the sweep still returns
its result and every other ticker still gets settled."""
settled = []
def _settle(self, ticker):
if ticker == "NVDA":
raise RuntimeError("reflector timed out")
settled.append(ticker)
monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False)
result = run_backtest(["NVDA", "AAPL"], ["2026-01-05"], _config(tmp_path))
assert result.cells_run == 2
assert settled == ["AAPL"]
assert result.settlement_failures == [("NVDA", "reflector timed out")]
@pytest.mark.unit
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
assert "Pending" not in summarize(_log_with(tmp_path, settled)).render()
assert "Pending" in summarize(_log_with(tmp_path, settled + [("AAPL", "2026-01-05", DECISION, None)])).render()
# --- scoring reads the direction the rating claimed ---------------------------
def _scored(tmp_path, rows):
log = _log_with(tmp_path, rows)
return summarize(log).by_rating
@pytest.mark.unit
def test_a_bearish_call_that_fell_counts_as_right(tmp_path):
"""Alpha below the benchmark is the outcome a Sell predicted; scoring it as
a miss reported the system as wrong exactly when it was right."""
scores = _scored(tmp_path, [
("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (-0.08, -0.05)),
("AAPL", "2026-01-05", "**Rating**: Underweight\n\nx", (-0.03, -0.02)),
])
assert scores["Sell"].hit_rate == 1.0
assert scores["Underweight"].hit_rate == 1.0
@pytest.mark.unit
def test_a_bearish_call_that_rose_counts_as_wrong(tmp_path):
scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (0.08, 0.05))])
assert scores["Sell"].hit_rate == 0.0
@pytest.mark.unit
def test_a_bullish_call_is_scored_the_same_way_as_before(tmp_path):
scores = _scored(tmp_path, [
("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.10, 0.04)),
("AAPL", "2026-01-05", "**Rating**: Buy\n\nx", (-0.02, -0.02)),
])
assert scores["Buy"].hit_rate == 0.5
@pytest.mark.unit
def test_hold_claims_no_direction_so_it_gets_no_hit_rate(tmp_path):
scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Hold\n\nx", (0.01, 0.005))])
assert scores["Hold"].hit_rate is None
assert scores["Hold"].mean_alpha == 0.005
@pytest.mark.unit
def test_the_report_names_the_window_the_scores_cover(tmp_path):
text = summarize(_log_with(tmp_path, [
("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.1, 0.05))])).render()
assert "5" in text and "day" in text.lower()
assert "Hold" not in text or "no direction" in text.lower()
@pytest.mark.unit
def test_the_window_reported_is_the_one_the_outcomes_used(tmp_path):
"""The log records the window each outcome was measured over; the summary
must not claim a different one."""
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 21, "note", "2026-02-01")
assert "21 trading days" in summarize(log).render()

View File

@@ -116,6 +116,37 @@ class TestDefault:
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."""

View File

@@ -0,0 +1,172 @@
"""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
@pytest.mark.unit
def test_clearing_removes_the_database_sidecars(tmp_path):
"""SQLite writes -wal and -shm next to the database; leaving them behind
means a cleared checkpoint still has committed state on disk."""
from tradingagents.graph.checkpointer import clear_all_checkpoints
cp = tmp_path / "checkpoints"
cp.mkdir(parents=True)
for suffix in (".db", ".db-wal", ".db-shm"):
(cp / f"NVDA{suffix}").write_text("x")
cleared = clear_all_checkpoints(str(tmp_path))
assert cleared == 1
assert list(cp.iterdir()) == []

153
tests/test_cli_commands.py Normal file
View File

@@ -0,0 +1,153 @@
"""The CLI keeps running an analysis with no arguments, and gains `backtest`.
Every documented invocation is bare (`tradingagents --checkpoint`), so analysis
has to stay the default action while a second command exists alongside it.
"""
from __future__ import annotations
import pytest
from typer.testing import CliRunner
import cli.main as m
@pytest.fixture
def runner(monkeypatch):
monkeypatch.setattr(m, "run_analysis", lambda **kw: calls.append(("analysis", kw)))
calls.clear()
return CliRunner()
calls: list = []
@pytest.mark.unit
def test_no_arguments_still_runs_an_analysis(runner):
assert runner.invoke(m.app, []).exit_code == 0
assert calls == [("analysis", {"checkpoint": None, "portfolio": None})]
@pytest.mark.unit
def test_options_still_parse_without_a_subcommand(runner):
assert runner.invoke(m.app, ["--checkpoint"]).exit_code == 0
assert calls[0][1]["checkpoint"] is True
@pytest.mark.unit
def test_backtest_does_not_also_run_an_analysis(runner, monkeypatch, tmp_path):
swept = []
monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append((a, kw)) or _Result(tmp_path))
monkeypatch.setattr(m, "summarize", lambda log: _Summary())
result = runner.invoke(m.app, ["backtest", "NVDA,AAPL", "--start", "2026-06-01",
"--end", "2026-06-15", "--every", "7"])
assert result.exit_code == 0, result.output
assert calls == [] # the interactive analysis must not run
(tickers, dates, _config), kwargs = swept[0]
assert tickers == ["NVDA", "AAPL"]
assert dates == ["2026-06-01", "2026-06-08", "2026-06-15"]
assert "scored" in result.output
@pytest.mark.unit
def test_backtest_reports_a_bad_date_instead_of_a_traceback(runner):
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "June", "--end", "2026-06-15"])
assert result.exit_code == 1
assert "YYYY-MM-DD" in result.output
@pytest.mark.unit
def test_help_lists_the_backtest_command(runner):
assert "backtest" in runner.invoke(m.app, ["--help"]).output
class _Result:
def __init__(self, tmp_path):
self.run_id = "20260916_000000"
self.log_path = tmp_path / "trading_memory.md"
self.cells_run = 2
self.skipped = 0
self.failures = []
self.settlement_failures = []
class _Summary:
def render(self):
return "scored 2 cells"
@pytest.mark.unit
def test_every_command_is_registered_when_run_as_a_module():
"""README documents `python -m cli.main`, which executes the file top to
bottom, so a command defined after the __main__ block would not exist."""
import re
import subprocess
import sys
out = subprocess.run([sys.executable, "-m", "cli.main", "backtest", "--help"],
capture_output=True, text=True, timeout=120)
assert out.returncode == 0, out.stderr[-400:]
# Where the terminal takes colour, help styles each option and splits
# "--start" across escape sequences, so read the text without them.
plain = re.sub(r"\x1b\[[0-9;]*m", "", out.stdout)
assert "--start" in plain
@pytest.mark.unit
def test_the_cli_says_whether_a_run_resumed(monkeypatch):
"""The README promises the user can tell a resumed run from a fresh one.
The graph logs it, but nothing configures logging, so it was never shown."""
import cli.main as m
messages = []
monkeypatch.setattr(m.message_buffer, "add_message",
lambda kind, text: messages.append(text), raising=False)
m._announce_checkpoint_state(type("G", (), {"_resuming": True})(), "NVDA", "2026-01-10")
m._announce_checkpoint_state(type("G", (), {"_resuming": False})(), "NVDA", "2026-01-10")
assert any("resum" in text.lower() for text in messages)
assert any("fresh" in text.lower() for text in messages)
@pytest.mark.unit
def test_backtest_can_continue_an_interrupted_sweep(runner, monkeypatch, tmp_path):
"""Resuming is what makes a long sweep practical, and the Python API has it."""
swept = []
monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append(kw) or _Result(tmp_path))
monkeypatch.setattr(m, "summarize", lambda log: _Summary())
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01",
"--end", "2026-06-08", "--run-id", "20260617_120000"])
assert result.exit_code == 0, result.output
assert swept[0]["run_id"] == "20260617_120000"
@pytest.mark.unit
@pytest.mark.parametrize("args, expected", [
(["backtest", "NVDA", "--start", "2026-08-01", "--end", "2026-06-08"], "before"),
(["backtest", ",,", "--start", "2026-06-01", "--end", "2026-06-08"], "ticker"),
])
def test_backtest_rejects_input_that_would_sweep_nothing(runner, args, expected):
"""An inverted range or an empty ticker list reported a clean zero-cell run,
which reads as 'nothing to find' rather than 'you asked for nothing'."""
result = runner.invoke(m.app, args)
assert result.exit_code == 1
assert expected in result.output.lower()
@pytest.mark.unit
def test_backtest_reports_a_setup_failure_in_one_line(runner, monkeypatch):
"""A missing key or a bad analyst name produced a raw traceback."""
def _explode(*a, **kw):
raise ValueError("API key for provider 'openai' is not set")
monkeypatch.setattr(m, "run_backtest", _explode)
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-08"])
assert result.exit_code == 1
assert "API key" in result.output
assert "Traceback" not in result.output

View File

@@ -14,8 +14,8 @@ 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",
"quick_think_llm": "gpt-5.4-mini",
"deep_think_llm": "gpt-5.5",
"backend_url": None,
"llm_provider": "openai",
"google_thinking_level": None,
@@ -67,3 +67,41 @@ def test_checkpoint_flag_overrides_env(flag):
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
cfg = m._build_run_config(SELECTIONS, checkpoint=flag)
assert cfg["checkpoint_enabled"] is flag
@pytest.mark.unit
def test_glm_resolves_to_the_endpoint_its_key_belongs_to():
"""The provider table, the client registry and the key mapping must name the
same platform: glm is Z.AI international (ZHIPU_API_KEY) and glm-cn is
BigModel China. A mismatch sends the key to the other platform and every
call fails auth."""
from cli.utils import resolve_backend_url
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
assert resolve_backend_url("glm", None, None) == OPENAI_COMPATIBLE_PROVIDERS["glm"].base_url
assert get_api_key_env("glm") == "ZHIPU_API_KEY"
assert "z.ai" in OPENAI_COMPATIBLE_PROVIDERS["glm"].base_url
assert "bigmodel.cn" in OPENAI_COMPATIBLE_PROVIDERS["glm-cn"].base_url
@pytest.mark.unit
def test_a_half_set_round_count_says_which_value_won(capsys, monkeypatch):
"""With only one of the two round-count variables set, the depth prompt is
still shown but half the answer is discarded; the user was never told."""
import cli.main as m
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1")
monkeypatch.delenv("TRADINGAGENTS_MAX_RISK_ROUNDS", raising=False)
printed = []
monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(str(a[0]) if a else ""))
config = m._build_run_config({
"ticker": "NVDA", "analysis_date": "2026-09-01", "asset_type": "stock",
"analysts": [], "research_depth": 5, "llm_provider": "openai",
"quick_think_llm": "gpt-5.6-luna", "deep_think_llm": "gpt-5.6",
"backend_url": None, "output_language": "English",
}, None)
assert config["max_risk_discuss_rounds"] == 5
assert any("TRADINGAGENTS_MAX_DEBATE_ROUNDS" in line for line in printed), printed

View File

@@ -0,0 +1,167 @@
"""The CLI must use the decision log the same way propagate() does.
The CLI streams the graph itself instead of calling propagate(), so memory steps
that lived only in propagate() never ran on the primary entry point: pending
decisions were not settled, the Portfolio Manager got no past context, and the
finished decision was not recorded. Both paths now build their initial state and
record their decision through the same graph methods.
"""
from __future__ import annotations
import pytest
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph
def _bare_graph(tmp_path):
"""A graph without __init__ (no LLM clients), wired to a temp log."""
graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "trading_memory.md")}
graph.memory_log = TradingMemoryLog(graph.config)
return graph
@pytest.mark.unit
def test_create_run_state_settles_pending_and_carries_context(tmp_path, monkeypatch):
from tradingagents.graph.propagation import Propagator
graph = _bare_graph(tmp_path)
graph.propagator = Propagator()
settled = []
monkeypatch.setattr(graph, "_resolve_pending_entries", settled.append, raising=False)
monkeypatch.setattr(graph, "resolve_instrument_context", lambda t, a="stock", d=None: f"id:{t}", raising=False)
monkeypatch.setattr(graph, "_memory_as_of", lambda d: d, raising=False)
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nold call")
graph.memory_log.update_with_outcome("NVDA", "2026-01-05", 0.01, 0.005, 5, "great trade", "2026-01-12")
state = graph.create_run_state("NVDA", "2026-02-01")
assert settled == ["NVDA"]
assert "great trade" in state["past_context"]
assert state["instrument_context"] == "id:NVDA"
assert state["company_of_interest"] == "NVDA"
@pytest.mark.unit
def test_record_decision_appends_a_pending_entry(tmp_path):
graph = _bare_graph(tmp_path)
graph.record_decision("NVDA", "2026-01-10", {"final_trade_decision": "Rating: Buy\n\nBuy NVDA."})
entries = graph.memory_log.load_entries()
assert [(e["ticker"], e["pending"], e["rating"]) for e in entries] == [("NVDA", True, "Buy")]
@pytest.mark.unit
def test_record_decision_skips_a_run_without_a_decision(tmp_path):
graph = _bare_graph(tmp_path)
graph.record_decision("NVDA", "2026-01-10", {})
assert graph.memory_log.load_entries() == []
# --- the CLI path ----------------------------------------------------------------
class _FakeGraph:
"""Records the lifecycle calls run_analysis makes."""
def __init__(self):
self.calls = []
self.graph = self
self.propagator = self
def create_run_state(self, ticker, trade_date, asset_type="stock", portfolio=None):
self.calls.append(("create_run_state", ticker, trade_date))
return {"messages": [], "company_of_interest": ticker}
def process_signal(self, text):
from tradingagents.graph.signal_processing import SignalProcessor
return SignalProcessor.process_signal(None, text)
def record_decision(self, ticker, trade_date, final_state):
self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision")))
def get_graph_args(self, callbacks=None):
return {}
def begin_checkpoint(self, *a, **k):
return None
def checkpoint_input(self, state):
return state
def clear_checkpoint_on_success(self, *a, **k):
self.calls.append(("clear_checkpoint",))
def end_checkpoint(self):
pass
def stream(self, graph_input, **kwargs):
yield {"messages": [], "market_report": "M"}
yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA."}
class _NullLive:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
class _FakeBuffer:
def __init__(self):
self.messages = []
self.tool_calls = []
self.report_sections = {}
self.agent_status = {}
self.selected_analysts = []
self._processed_message_ids = set()
def init_for_analysis(self, selected_analysts):
self.selected_analysts = [a.lower() for a in selected_analysts]
def add_message(self, kind, content):
self.messages.append((0.0, kind, content))
def add_tool_call(self, name, args):
self.tool_calls.append((0.0, name, args))
def update_report_section(self, *a):
pass
def update_agent_status(self, agent, status):
self.agent_status[agent] = status
@pytest.mark.unit
def test_cli_run_uses_the_decision_log_like_propagate(tmp_path, monkeypatch):
import cli.main as m
from cli.models import AnalystType
fake = _FakeGraph()
monkeypatch.setattr(m, "TradingAgentsGraph", lambda *a, **k: fake)
monkeypatch.setattr(m, "message_buffer", _FakeBuffer())
monkeypatch.setattr(m, "create_layout", lambda: None)
monkeypatch.setattr(m, "update_display", lambda *a, **k: None)
monkeypatch.setattr(m, "Live", _NullLive)
monkeypatch.setattr(m, "get_user_selections", lambda: {
"ticker": "NVDA", "analysis_date": "2026-01-10",
"analysts": [AnalystType.MARKET], "asset_type": "stock",
})
monkeypatch.setattr(m, "_build_run_config", lambda selections, checkpoint: {
"data_cache_dir": str(tmp_path / "cache"), "results_dir": str(tmp_path / "results"),
})
monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N")
m.run_analysis()
assert fake.calls == [
("create_run_state", "NVDA", "2026-01-10"),
# The decision is recorded from the merged stream, before the checkpoint
# is cleared, matching propagate().
("record_decision", "NVDA", "2026-01-10", "Rating: Buy\n\nBuy NVDA."),
("clear_checkpoint",),
]

77
tests/test_cli_display.py Normal file
View File

@@ -0,0 +1,77 @@
"""What the live display shows, and what the run log keeps.
The display drops a message it judges empty, and the state log is written for a
person to read afterwards. Both got that wrong in ways that hide real content.
"""
from __future__ import annotations
import json
import pytest
from cli.main import extract_content_string
@pytest.mark.unit
@pytest.mark.parametrize("text", ["0", "False", "None", "[]", "{}", "0.0"])
def test_a_message_that_reads_like_a_python_value_is_still_text(text):
"""These were parsed as Python and judged empty, so the message vanished."""
assert extract_content_string(text) == text
@pytest.mark.unit
@pytest.mark.parametrize("value, expected", [
(" Hold ", "Hold"),
("", None),
(" ", None),
(None, None),
([], None),
({}, None),
({"text": "from a dict"}, "from a dict"),
([{"type": "text", "text": "part one"}, {"type": "text", "text": "part two"}], "part one part two"),
])
def test_the_other_shapes_are_unchanged(value, expected):
assert extract_content_string(value) == expected
@pytest.mark.unit
def test_the_state_log_keeps_non_ascii_readable(tmp_path):
"""Reports can be in any language; the log is read by a person."""
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.config = {"results_dir": str(tmp_path)}
graph.ticker = "600519.SS"
graph.log_states_dict = {}
graph._log_state("2026-09-01", {
"company_of_interest": "600519.SS", "trade_date": "2026-09-01",
"market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻",
"fundamentals_report": "基本面", "investment_plan": "计划",
"trader_investment_plan": "交易计划", "final_trade_decision": "评级: 买入",
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "count": 0},
"risk_debate_state": {"aggressive_history": "", "conservative_history": "",
"neutral_history": "", "history": "", "judge_decision": "",
"latest_speaker": "", "current_aggressive_response": "",
"current_conservative_response": "", "current_neutral_response": "",
"count": 0},
})
written = next(tmp_path.rglob("full_states_log*.json")).read_text(encoding="utf-8")
assert "买入" in written
assert "\\u" not in written
assert json.loads(written) # still valid JSON
@pytest.mark.unit
def test_the_live_display_does_not_scroll_the_terminal():
"""A layout taller than the window makes rich redraw by scrolling, which
reads as flicker; the alternate screen holds it in place (#784). The final
report prints after the live view ends, so nothing is lost when it closes."""
import inspect
import cli.main as m
assert "screen=True" in inspect.getsource(m.run_analysis)

View File

@@ -77,8 +77,8 @@ class TestCliSkipsPromptsFromEnv(unittest.TestCase):
# 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["quick_think_llm"], "deepseek-v4-pro")
self.assertEqual(sel["deep_think_llm"], "kimi-k2.5")
self.assertEqual(sel["output_language"], "Japanese")

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)

169
tests/test_cli_prefs.py Normal file
View File

@@ -0,0 +1,169 @@
"""The CLI remembers what you chose last time and offers it back.
Prefill only: every prompt still appears, so a run never starts on a choice the
user did not see. Environment variables keep skipping their step outright and
win over anything remembered. Remembered values are validated against the
current choices each time, since models and providers come and go between
versions and a stale one must not be offered.
"""
from __future__ import annotations
from unittest import mock
import pytest
from cli.models import AnalystType
from cli.prefs import load_last_run, sanitize, save_last_run
SAVED = {
"output_language": "English",
"analysts": ["market", "fundamentals"],
"research_depth": 3,
"llm_provider": "openai",
"quick_think_llm": "gpt-5.6-mini",
"deep_think_llm": "gpt-5.6",
}
@pytest.fixture(autouse=True)
def _home(tmp_path, monkeypatch):
monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json")
return tmp_path
@pytest.mark.unit
def test_round_trip():
save_last_run(SAVED)
assert load_last_run() == SAVED
@pytest.mark.unit
def test_missing_file_is_not_an_error():
assert load_last_run() == {}
@pytest.mark.unit
def test_a_corrupt_file_degrades_to_no_memory(_home):
(_home / "cli_prefs.json").write_text("{not json")
assert load_last_run() == {}
@pytest.mark.unit
def test_a_half_written_file_cannot_be_observed(_home):
"""Two runs finishing together must never leave a torn file behind."""
save_last_run(SAVED)
save_last_run({**SAVED, "research_depth": 5})
assert load_last_run()["research_depth"] == 5
assert list((_home).glob("*.tmp*")) == []
# --- validation against the current choices ---------------------------------
@pytest.mark.unit
def test_a_model_that_no_longer_exists_is_dropped():
# gpt-5.4 is still accepted by config, but is no longer in the picker's list.
kept = sanitize({**SAVED, "quick_think_llm": "gpt-5.4"}, "stock")
assert "quick_think_llm" not in kept
assert kept["deep_think_llm"] == "gpt-5.6" # the valid sibling survives
@pytest.mark.unit
def test_an_unknown_provider_drops_itself_and_its_models():
kept = sanitize({**SAVED, "llm_provider": "no-such-provider"}, "stock")
assert "llm_provider" not in kept
assert "quick_think_llm" not in kept and "deep_think_llm" not in kept
@pytest.mark.unit
def test_analysts_are_narrowed_to_the_asset_type():
kept = sanitize(SAVED, "crypto")
assert AnalystType.FUNDAMENTALS.value not in kept["analysts"]
assert AnalystType.MARKET.value in kept["analysts"]
@pytest.mark.unit
def test_junk_values_are_dropped_rather_than_offered():
kept = sanitize({"research_depth": 99, "analysts": ["astrology"], "output_language": 5}, "stock")
assert kept == {}
@pytest.mark.unit
def test_a_region_specific_provider_survives():
kept = sanitize({**SAVED, "llm_provider": "qwen-cn", "quick_think_llm": None}, "stock")
assert kept["llm_provider"] == "qwen-cn"
# --- wiring ------------------------------------------------------------------
def _answer_every_prompt(monkeypatch):
"""Drive the real selection flow, answering each prompt with a fixed value."""
import cli.main as m
monkeypatch.setattr(m, "fetch_announcements", lambda: [])
monkeypatch.setattr(m, "display_announcements", lambda *a: None)
monkeypatch.setattr(m, "get_ticker", lambda: "NVDA")
monkeypatch.setattr(m, "get_analysis_date", lambda: "2026-09-01")
monkeypatch.setattr(m, "ask_output_language", lambda default=None: "English")
monkeypatch.setattr(m, "select_analysts", lambda asset_type, default=None: [AnalystType.MARKET])
monkeypatch.setattr(m, "select_research_depth", lambda default=None: 3)
monkeypatch.setattr(m, "select_llm_provider", lambda default=None: ("openai", None))
monkeypatch.setattr(m, "select_shallow_thinking_agent", lambda p, default=None: "gpt-5.6-mini")
monkeypatch.setattr(m, "select_deep_thinking_agent", lambda p, default=None: "gpt-5.6")
monkeypatch.setattr(m, "ask_openai_reasoning_effort", lambda: "medium")
return m
@pytest.mark.unit
def test_selections_are_remembered_after_a_run(monkeypatch):
"""Drives the real flow: a stubbed selections dict would hide a key mismatch."""
m = _answer_every_prompt(monkeypatch)
m.get_user_selections()
remembered = load_last_run()
assert remembered["analysts"] == ["market"]
assert remembered["quick_think_llm"] == "gpt-5.6-mini"
assert remembered["deep_think_llm"] == "gpt-5.6"
assert remembered["llm_provider"] == "openai"
assert "ticker" not in remembered # changes every run; never remembered
assert "analysis_date" not in remembered # a stale date must not be offered
@pytest.mark.unit
def test_a_custom_language_is_remembered_without_breaking_the_next_run():
"""A free-text answer is not one of the menu's choices, and questionary
rejects a default it cannot find, so offering it back would crash startup."""
from cli.utils import ask_output_language
save_last_run({"output_language": "Turkish"})
with mock.patch("cli.utils.questionary.select") as select:
select.return_value.ask.return_value = "English"
ask_output_language(load_last_run()["output_language"])
assert select.call_args.kwargs["default"] is None
@pytest.mark.unit
def test_a_remembered_endpoint_is_offered_back(monkeypatch):
"""Users of a local or custom endpoint retyped the URL every run: it was
remembered and validated, then never read."""
import cli.main as m
save_last_run({"llm_provider": "openai_compatible", "backend_url": "http://localhost:1234/v1"})
offered = {}
monkeypatch.setattr(m, "select_llm_provider", lambda default=None: ("openai_compatible", None))
monkeypatch.setattr(m, "prompt_openai_compatible_url",
lambda default=None: offered.setdefault("default", default) or "http://x/v1")
monkeypatch.setattr(m, "fetch_announcements", lambda: [])
monkeypatch.setattr(m, "display_announcements", lambda *a: None)
monkeypatch.setattr(m, "get_ticker", lambda: "NVDA")
monkeypatch.setattr(m, "get_analysis_date", lambda: "2026-09-01")
monkeypatch.setattr(m, "ask_output_language", lambda default=None: "English")
monkeypatch.setattr(m, "select_analysts", lambda asset_type, default=None: [AnalystType.MARKET])
monkeypatch.setattr(m, "select_research_depth", lambda default=None: 1)
monkeypatch.setattr(m, "select_shallow_thinking_agent", lambda p, default=None: "local-model")
monkeypatch.setattr(m, "select_deep_thinking_agent", lambda p, default=None: "local-model")
m.get_user_selections()
assert offered["default"] == "http://localhost:1234/v1"

View File

@@ -60,3 +60,16 @@ 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)
@pytest.mark.unit
def test_the_run_directory_cannot_escape_the_results_directory(tmp_path, monkeypatch):
"""Every other path that interpolates a ticker validates it first; the CLI's
own results tree did not, so a ticker of '..' wrote a level up."""
import cli.main as m
with pytest.raises(ValueError):
m._run_directory({"results_dir": str(tmp_path)}, "..", "2026-09-01")
ok = m._run_directory({"results_dir": str(tmp_path)}, "NVDA", "2026-09-01")
assert str(ok).startswith(str(tmp_path))

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

@@ -21,8 +21,8 @@ def _reload_with_env(monkeypatch, **overrides):
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.5"
assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.4-mini"
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

View File

@@ -8,6 +8,7 @@ import unittest
from unittest import mock
import pytest
import requests
import tradingagents.dataflows.config as config_module
import tradingagents.default_config as default_config
@@ -150,6 +151,47 @@ class FredFormattingTests(unittest.TestCase):
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):
@@ -192,3 +234,53 @@ class FredRoutingTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
_KEY = "abcdef0123456789abcdef0123456789"
@pytest.mark.unit
class TestKeyKeptOutOfErrors:
"""The key travels as a query parameter, and requests quotes the full URL in
its error messages, so any log or traceback would carry it (#1324)."""
def _raises(self, side_effect):
with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \
mock.patch("tradingagents.dataflows.utils.requests.get", side_effect=side_effect), \
pytest.raises(requests.RequestException) as caught:
fred._request("series", {"series_id": "DGS10"})
return caught.value
def test_http_error_message_carries_no_key(self):
response = mock.Mock(status_code=502)
response.raise_for_status.side_effect = requests.HTTPError(
f"502 Server Error for url: https://api.stlouisfed.org/fred/series?api_key={_KEY}",
response=response,
)
with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \
mock.patch("tradingagents.dataflows.utils.requests.get", return_value=response), \
pytest.raises(requests.HTTPError) as caught:
fred._request("series", {"series_id": "DGS10"})
exc = caught.value
assert _KEY not in str(exc) and _KEY not in repr(exc)
# The response and request carry the full URL, so they are not attached.
assert exc.response is None and exc.request is None
assert exc.__cause__ is None and exc.__context__ is None # no chain holds the key
def test_connection_error_before_any_response_carries_no_key(self):
exc = self._raises(requests.ConnectionError(
f"Max retries exceeded with url: /fred/series?series_id=DGS10&api_key={_KEY}"))
assert isinstance(exc, requests.ConnectionError)
assert _KEY not in str(exc) and exc.__context__ is None
@pytest.mark.unit
def test_error_without_the_key_in_its_message_still_drops_the_request():
# Some timeout messages omit the URL, but the attached request still has it.
import requests as rq
req = rq.Request("GET", f"https://api.stlouisfed.org/fred/series?api_key={_KEY}").prepare()
with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \
mock.patch("tradingagents.dataflows.utils.requests.get", side_effect=rq.Timeout("Read timed out.", request=req)), \
pytest.raises(rq.Timeout) as caught:
fred._request("series", {"series_id": "DGS10"})
assert caught.value.request is None

View File

@@ -0,0 +1,130 @@
"""Historical fundamentals must not leak a live company profile (#1300).
Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage
``OVERVIEW``) serve only present-day values: market cap, valuation multiples,
the 52-week range and TTM income all move with today's quote, and even name,
sector and industry shift when a company renames or is reclassified. None of it
carries a historical vintage, so emitting it into a run dated in the past puts
post-decision information into the analyst's context, in the same family as the
FRED (#1275), social (#1220) and memory (#1251) leaks.
Both vendors withhold on one shared rule (``date_window.withhold_live_profile``)
so switching ``fundamental_data`` between them cannot reintroduce the leak. The
statement tools stay point-in-time by filtering on ``curr_date``, and a live run
is unchanged. All API access is mocked.
"""
from __future__ import annotations
from unittest import mock
import pytest
from tradingagents.dataflows import alpha_vantage_fundamentals as av, date_window, y_finance
_TODAY = "2026-09-07"
_PAST = "2024-05-10"
# A profile payload mixing stable-looking identity fields with market-dependent ones.
_INFO = {
"longName": "Apple Inc.",
"sector": "Technology",
"industry": "Consumer Electronics",
"marketCap": 3_500_000_000_000,
"trailingPE": 34.2,
"fiftyTwoWeekHigh": 260.1,
"totalRevenue": 391_000_000_000,
}
# Values that must never reach a historical run.
_LEAKY = ("3500000000000", "34.2", "260.1", "391000000000",
"Apple Inc.", "Technology", "Consumer Electronics")
def _yf(curr_date, info=_INFO, today=_TODAY):
with mock.patch.object(date_window, "get_current_date", return_value=today), \
mock.patch.object(y_finance, "yf_retry", lambda fn: info), \
mock.patch.object(y_finance.yf, "Ticker"):
return y_finance.get_fundamentals("AAPL", curr_date)
def _av(curr_date, today=_TODAY):
"""Alpha Vantage path; the API call is mocked so a leak would be visible."""
with mock.patch.object(date_window, "get_current_date", return_value=today), \
mock.patch.object(av, "_make_api_request",
return_value="MarketCapitalization: 3500000000000") as req:
return av.get_fundamentals("AAPL", curr_date), req
@pytest.mark.unit
class TestYFinanceHistoricalRun:
def test_no_profile_value_survives(self):
out = _yf(_PAST)
for leaked in _LEAKY:
assert leaked not in out, f"leaked live-profile value {leaked!r}"
def test_states_the_as_of_date_and_explains_itself(self):
# The analyst must be told why the figures are absent, so it does not
# read the gap as a real signal or fabricate around it.
out = _yf(_PAST)
assert f"Point-in-time as of: {_PAST}" in out
assert "withheld" in out
assert _PAST in out and _TODAY in out
def test_no_wall_clock_retrieval_stamp(self):
# The old header stamped datetime.now(), which is what surfaced the leak.
assert "Data retrieved on:" not in _yf(_PAST)
def test_the_request_is_not_even_made(self):
# The response would only be discarded; skipping it also avoids burning
# vendor quota on a call whose result cannot be used.
with mock.patch.object(date_window, "get_current_date", return_value=_TODAY), \
mock.patch.object(y_finance.yf, "Ticker") as tk:
y_finance.get_fundamentals("AAPL", _PAST)
tk.assert_not_called()
@pytest.mark.unit
class TestAlphaVantageHistoricalRun:
"""The same rule must hold for the other fundamentals vendor, or switching
data_vendors["fundamental_data"] would silently reintroduce the leak."""
def test_overview_is_withheld(self):
out, _ = _av(_PAST)
assert "3500000000000" not in out
assert "withheld" in out
assert f"Point-in-time as of: {_PAST}" in out
def test_the_api_call_is_not_made(self):
_, req = _av(_PAST)
req.assert_not_called()
def test_live_run_still_calls_the_api(self):
out, req = _av(_TODAY)
req.assert_called_once()
assert "3500000000000" in out
@pytest.mark.unit
class TestLiveRunUnchanged:
def test_yfinance_current_date_returns_the_full_profile(self):
out = _yf(_TODAY)
for value in _LEAKY:
assert value in out
assert "Data retrieved on:" in out
assert "withheld" not in out
def test_yfinance_absent_curr_date_returns_the_full_profile(self):
out = _yf(None)
assert "Market Cap: 3500000000000" in out
assert "withheld" not in out
@pytest.mark.unit
class TestNoUsableFieldsStillRaises:
def test_stub_payload_raises_no_market_data(self):
# yfinance returns {"trailingPegRatio": None} for unknown symbols; on a
# live run that must stay a hard "no data", not a bare header.
from tradingagents.dataflows.symbol_utils import NoMarketDataError
with pytest.raises(NoMarketDataError):
_yf(_TODAY, info={"trailingPegRatio": None})

View File

@@ -1,8 +1,8 @@
"""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".
directly. Pro, Gemini 3.8+ and the -latest aliases reject "minimal" with a 400,
so it is mapped to "low" there; numbered Flash models before 3.8 accept it.
"""
from unittest import mock
@@ -35,6 +35,22 @@ def test_pro_remaps_minimal_to_low():
assert kw["thinking_level"] == "low" # Pro doesn't accept "minimal"
def test_flash_38_remaps_minimal_to_low():
kw = _captured_kwargs("gemini-3.8-flash", thinking_level="minimal")
assert kw["thinking_level"] == "low" # 3.8 Flash 400s on "minimal"
def test_flash_38_keeps_supported_levels():
kw = _captured_kwargs("gemini-3.8-flash", thinking_level="high")
assert kw["thinking_level"] == "high"
@pytest.mark.parametrize("alias", ["gemini-flash-latest", "gemini-pro-latest"])
def test_latest_alias_remaps_minimal_to_low(alias):
# Aliases move between generations; gemini-flash-latest 400s on "minimal".
assert _captured_kwargs(alias, thinking_level="minimal")["thinking_level"] == "low"
def test_pro_keeps_high():
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="high")
assert kw["thinking_level"] == "high"

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

@@ -29,7 +29,7 @@ class TestVerifiedSnapshot:
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)
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: data)
snap = validator.build_verified_market_snapshot("COF", "2026-05-13")
assert "Verified market data snapshot for COF" in snap
@@ -39,24 +39,24 @@ class TestVerifiedSnapshot:
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())
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _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())
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _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())
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: 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())
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _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-")]
@@ -69,7 +69,7 @@ class TestTool:
from tradingagents.agents.utils.market_data_validation_tools import (
get_verified_market_snapshot,
)
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv())
out = get_verified_market_snapshot.invoke(
{"symbol": "COF", "curr_date": "2026-05-20"}
)

View File

@@ -54,9 +54,14 @@ def _resolve_entry(log, ticker, date, decision, reflection="Good call."):
log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection)
def _price_df(prices):
"""Minimal DataFrame matching yfinance .history() output shape."""
return pd.DataFrame({"Close": prices})
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=""):
@@ -131,6 +136,23 @@ class TestTradingMemoryLogCore:
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
assert len(log.load_entries()) == 1
def test_store_decision_idempotent_after_the_entry_resolves(self, tmp_path):
"""A settled entry still blocks a duplicate.
The guard matched only pending entries, so re-running a ticker and date
whose outcome had already been settled appended a second entry: the same
decision counted twice in past context and in any aggregate over the log.
"""
log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
log.update_with_outcome("NVDA", "2026-01-10", 0.05, 0.02, 5, "worked", "2026-01-17")
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
entries = log.load_entries()
assert len(entries) == 1
assert entries[0]["pending"] is False # the settled record is kept, not replaced
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)
@@ -171,10 +193,14 @@ class TestTradingMemoryLogCore:
log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT)
assert log.load_entries()[0]["rating"] == "Overweight"
def test_rating_fallback_hold(self, tmp_path):
def test_an_unreadable_decision_is_tagged_for_review(self, tmp_path):
"""Not a Hold: a fabricated rating is quoted back to the next run as a
call that was never made, and counted in the backtest figures."""
from tradingagents.agents.utils.rating import RATING_REVIEW
log = make_log(tmp_path)
log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING)
assert log.load_entries()[0]["rating"] == "Hold"
assert log.load_entries()[0]["rating"] == RATING_REVIEW
def test_rating_priority_over_prose(self, tmp_path):
"""'Rating: X' label wins even when an opposing rating word appears earlier in prose."""
@@ -496,35 +522,38 @@ class TestDeferredReflection:
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 = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
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 (None, None, None), no crash."""
"""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 = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19")
assert raw is None and alpha is None and days is None
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 (None, None, None), no crash."""
"""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 = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10")
assert raw is None and alpha is None and days is None
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 must not raise IndexError."""
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
spy_prices = [400.0, 402.0, 403.0]
"""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):
@@ -532,9 +561,26 @@ class TestDeferredReflection:
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 = 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 days == 2
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
@@ -564,6 +610,13 @@ class TestDeferredReflection:
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE"
def test_explicit_benchmark_is_resolved_like_any_other_symbol(self):
"""A configured benchmark takes the same alias mapping as the ticker, or
the return lookup finds nothing and the decision never settles."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {"benchmark_ticker": "SPX500", "benchmark_map": {"": "SPY"}}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "^GSPC"
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)."""
@@ -573,6 +626,8 @@ class TestDeferredReflection:
"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"
# .SH is the exchange's own suffix; Yahoo spells Shanghai .SS (#1260)
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SH") == "000001.SS"
def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
"""US tickers (no dotted suffix) take the empty-suffix entry."""
@@ -640,8 +695,9 @@ class TestDeferredReflection:
log = make_log(tmp_path)
log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {}
mock_graph.memory_log = log
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5))
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
@@ -653,9 +709,10 @@ class TestDeferredReflection:
mock_reflector = MagicMock()
mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed."
mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.config = {}
mock_graph.memory_log = log
mock_graph.reflector = mock_reflector
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5))
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()
@@ -665,6 +722,21 @@ class TestDeferredReflection:
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.config = {}
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
@@ -864,8 +936,107 @@ class TestLegacyRemoval:
mock_graph._run_graph = functools.partial(
TradingAgentsGraph._run_graph, mock_graph
)
mock_graph.record_decision = functools.partial(
TradingAgentsGraph.record_decision, 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
@pytest.mark.unit
def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp_path, monkeypatch):
"""Settling past decisions happens on the way into a new run, and reflection
calls an LLM. A transient failure there must not stop the new analysis."""
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "m.md")}
graph.memory_log = TradingMemoryLog(graph.config)
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\n\nx")
graph.memory_log.store_decision("NVDA", "2026-01-12", "Rating: Sell\n\ny")
monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False)
monkeypatch.setattr(graph, "_fetch_returns",
lambda t, d, holding_days=5, benchmark=None: (0.01, 0.005, holding_days, "2026-01-19"), raising=False)
class _Reflector:
calls = 0
def reflect_on_final_decision(self, **kw):
_Reflector.calls += 1
if _Reflector.calls == 1:
raise RuntimeError("provider timed out")
return "second one worked"
graph.reflector = _Reflector()
graph._resolve_pending_entries("NVDA") # must not raise
entries = graph.memory_log.load_entries()
assert [e["pending"] for e in entries] == [True, False] # the failed one waits for next time
@pytest.mark.unit
def test_the_holding_window_is_configurable(tmp_path, monkeypatch):
"""A decision written for months should not be graded at a week without the
operator choosing that window."""
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21}
graph.memory_log = TradingMemoryLog(graph.config)
graph.memory_log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False)
asked = {}
def _returns(ticker, date, holding_days=5, benchmark=None):
asked["holding_days"] = holding_days
return 0.05, 0.02, holding_days, "2026-02-02"
monkeypatch.setattr(graph, "_fetch_returns", _returns, raising=False)
graph.reflector = type("R", (), {"reflect_on_final_decision": lambda self, **kw: "lesson"})()
graph._resolve_pending_entries("NVDA")
assert asked["holding_days"] == 21
assert graph.memory_log.load_entries()[0]["holding"] == "21d"
@pytest.mark.unit
def test_the_reflection_states_the_window_it_judges():
"""Judging a months-long thesis on a week's alpha, without saying so, turns
a scope mismatch into a lesson that the call was wrong."""
from tradingagents.graph.reflection import Reflector
prompt = Reflector(None)._system_prompt(holding_days=5)
assert "5" in prompt and "trading day" in prompt
@pytest.mark.unit
def test_a_longer_window_asks_for_enough_price_history(monkeypatch):
"""Trading days are not calendar days: a 21-day window needs about a month
of bars, and asking for 28 days left every outcome unsettled."""
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
asked = {}
class _Ticker:
def __init__(self, symbol):
self.symbol = symbol
def history(self, start, end):
asked["start"], asked["end"] = start, end
import pandas as pd
days = pd.bdate_range(start, end)
return pd.DataFrame({"Close": range(len(days))}, index=days)
monkeypatch.setattr("tradingagents.graph.trading_graph.yf.Ticker", _Ticker)
raw, alpha, days, resolved = graph._fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY")
assert days == 21 and resolved is not None, (raw, alpha, days, resolved)

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

View File

@@ -53,3 +53,48 @@ class ModelValidationTests(unittest.TestCase):
client.get_llm()
self.assertEqual(caught, [])
def test_legacy_ids_stay_valid_without_being_offered():
from tradingagents.llm_clients.model_catalog import LEGACY_MODELS, MODEL_OPTIONS
from tradingagents.llm_clients.validators import validate_model
for provider, ids in LEGACY_MODELS.items():
offered = {v for opts in MODEL_OPTIONS[provider].values() for _, v in opts}
for model in ids:
assert validate_model(provider, model), model
assert model not in offered, f"{model} is legacy but still in the picker"
@pytest.mark.unit
def test_an_explicit_alias_of_a_listed_model_is_known():
"""gpt-5.6 is served under its own name and as gpt-5.6-sol; naming the
explicit one should not warn that the model is unknown."""
from tradingagents.llm_clients.validators import validate_model
assert validate_model("openai", "gpt-5.6-sol")
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openai", "anthropic", "google", "xai"])
@pytest.mark.parametrize("mode", ["quick", "deep"])
def test_every_provider_lets_you_name_your_own_model(provider, mode):
"""The docs tell users to name any model their provider serves; the picker
has to offer that too, or a new model is unreachable until we ship a list."""
from tradingagents.llm_clients.model_catalog import get_model_options
assert "custom" in [value for _, value in get_model_options(provider, mode)]
@pytest.mark.unit
@pytest.mark.parametrize("provider, model", [
("xai", "grok-4.20-0309-reasoning"),
("deepseek", "deepseek-v4-flash"),
("qwen", "qwen3.7-max"),
])
def test_a_retired_model_id_still_runs_without_a_warning(provider, model):
"""A config written against an earlier release keeps working: the provider
still serves these, they are just no longer offered in the picker."""
from tradingagents.llm_clients.validators import validate_model
assert validate_model(provider, model)

View File

@@ -2,28 +2,33 @@
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).
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).
"""
import time
from datetime import datetime
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):
return int(time.mktime(datetime.strptime(date_str, "%Y-%m-%d").timetuple()))
"""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"].strftime("%Y-%m-%d") == "2025-05-09"
assert data["pub_date"].tzinfo is not None
assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc)
@pytest.mark.unit
@@ -32,17 +37,38 @@ def test_window_excludes_future_and_undated_in_backtest():
end = datetime(2025, 5, 9) # historical window (well in the past)
inside = datetime(2025, 5, 5)
future = datetime(2025, 6, 1)
assert ynews._in_news_window(inside, start, end) is True
assert ynews._in_news_window(future, start, end) is False # look-ahead blocked
assert ynews._in_news_window(None, start, end) is False # undated -> excluded in backtest
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.
start = datetime.now()
end = datetime.now()
assert ynews._in_news_window(None, start, end) is True
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
@@ -75,5 +101,159 @@ def test_global_news_empty_after_filter_is_informative(monkeypatch):
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
# Only a later article came back, so the feed does not reach this window.
assert "unavailable" in out and "not an absence" in out
def _ticker_with(articles, monkeypatch):
class FakeTicker:
def __init__(self, *a, **k):
pass
def get_news(self, count=20):
return articles
monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker)
@pytest.mark.unit
def test_ticker_news_window_before_feed_coverage_is_unavailable(monkeypatch):
# Yahoo serves only recent articles: a historical window gets none of them,
# which must read as "cannot answer", not "no news happened".
recent = [{"title": "RECENT", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2026-09-10")}]
_ticker_with(recent, monkeypatch)
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
assert "RECENT" not in out
assert "unavailable" in out and "not an absence" in out
assert "2026-09-10" in out # says how far back the feed actually reaches
@pytest.mark.unit
def test_ticker_news_covered_but_empty_window_is_a_real_absence(monkeypatch):
articles = [{"title": "RECENT", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2026-09-10")},
{"title": "OLDER", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2026-07-01")}]
_ticker_with(articles, monkeypatch)
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
assert "No news found" in out
assert "unavailable" not in out
@pytest.mark.unit
@pytest.mark.parametrize("dates, expect_gap", [
([], True), # empty feed: covers at most now
([None], True), # undated only: same
([datetime(2026, 5, 20, tzinfo=timezone.utc)], True), # all after the window
([datetime(2026, 5, 4, tzinfo=timezone.utc)], True), # starts mid-window: partial
([datetime(2026, 5, 1, 18, tzinfo=timezone.utc)], False), # reaches the first day
([datetime(2026, 5, 20, tzinfo=timezone.utc),
datetime(2026, 4, 1, tzinfo=timezone.utc)], False), # coverage reaches back
])
def test_coverage_gap_boundaries(dates, expect_gap):
from tradingagents.dataflows.date_window import coverage_gap
out = coverage_gap(dates, "2026-05-01", "2026-05-08", "Feed", "items")
assert (out is not None) is expect_gap
if expect_gap:
assert "unavailable for 2026-05-01..2026-05-08" in out and "not an absence" in out
@pytest.mark.unit
def test_ticker_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch):
_ticker_with([], monkeypatch)
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
assert "unavailable" in out and "not an absence" in out
@pytest.mark.unit
def test_ticker_news_null_feed_is_handled(monkeypatch):
# Yahoo can return None instead of a list; that is unavailability, not an error.
_ticker_with(None, monkeypatch)
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
assert "unavailable" in out and "Error" not in out
@pytest.mark.unit
def test_global_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch):
class FakeSearch:
def __init__(self, *a, **k):
self.news = []
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
assert "unavailable" in out and "not an absence" in out
@pytest.mark.unit
def test_global_news_does_not_infer_coverage_from_a_stale_search_hit(monkeypatch):
# Global news merges fuzzy searches; one old hit before the window says
# nothing about the days in between, so the window stays unavailable.
stale = {"title": "STALE", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-01-01")}
fresh = {"title": "FRESH", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-06-01")}
class FakeSearch:
def __init__(self, *a, **k):
self.news = [fresh, stale]
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
assert "unavailable" in out and "No global news found" not in out
@pytest.mark.unit
def test_coverage_gap_future_window_is_unavailable():
from datetime import timedelta
from tradingagents.dataflows.date_window import coverage_gap
today = datetime.now(timezone.utc).date()
out = coverage_gap([], str(today), str(today + timedelta(days=3)), "Feed", "items")
assert out is not None and "past today" in out
@pytest.mark.unit
def test_out_of_window_articles_do_not_consume_the_article_budget(monkeypatch):
"""The limit counts articles the run may see, not candidates fetched (#1356).
Out-of-window items were counted first, so they filled the budget, stopped
the remaining searches, and the in-window news was reported as absent.
"""
stale = [{"title": f"OLD {i}", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2025-01-01")} for i in range(2)]
wanted = {"title": "IN WINDOW", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2025-05-08")}
pages = [stale, [wanted]]
class FakeSearch:
def __init__(self, *a, **k):
self.news = pages.pop(0) if pages else []
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
monkeypatch.setattr(ynews, "get_config", lambda: {
"global_news_lookback_days": 7, "global_news_article_limit": 2,
"global_news_queries": ["markets", "economy"],
})
out = ynews.get_global_news_yfinance("2025-05-09")
assert "IN WINDOW" in out
assert "OLD 0" not in out
@pytest.mark.unit
def test_the_article_limit_still_caps_what_is_returned(monkeypatch):
articles = [{"title": f"NEWS {i}", "publisher": "P", "link": "l",
"providerPublishTime": _epoch("2025-05-08")} for i in range(5)]
class FakeSearch:
def __init__(self, *a, **k):
self.news = articles
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=3)
assert out.count("### ") == 3

View File

@@ -0,0 +1,105 @@
"""The OHLCV cache: one file per symbol, fresh only on the day it was written.
A current-day request also refetches past a TTL, so a run started before the
day's bar was final is not served that snapshot all day (#1150). Keying the file
by symbol rather than by day keeps the cache from growing a file per symbol per
day (#1330).
"""
from __future__ import annotations
import os
import pandas as pd
import pytest
import tradingagents.dataflows.stockstats_utils as su
NOW = pd.Timestamp("2026-07-18 12:00")
STALE = su.OHLCV_CACHE_TTL_SECONDS + 60
def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026-07-17"):
f = tmp_path / name
pd.DataFrame({"Date": [last_date], "Close": [100.0]}).to_csv(f, index=False)
written = NOW.timestamp() - age_seconds
os.utime(f, (written, written))
return f
def _load(tmp_path, monkeypatch, curr_date, download):
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: NOW))
monkeypatch.setattr(su.yf, "download", download)
return su.load_ohlcv("AAPL", curr_date)
def _fail_download(*a, **k):
raise AssertionError("fresh cache must not refetch")
@pytest.mark.unit
def test_current_day_cache_past_ttl_is_not_fresh(tmp_path):
# Today's bar missing or still in progress: row inspection can't tell, so the TTL governs.
assert su._cache_is_fresh(_write(tmp_path, age_seconds=STALE), NOW.normalize(), NOW) is False
f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18")
assert su._cache_is_fresh(f, NOW.normalize(), NOW) is False
@pytest.mark.unit
def test_recent_cache_is_fresh(tmp_path):
# Written moments ago: don't hammer the vendor (weekend/holiday guard).
assert su._cache_is_fresh(_write(tmp_path), NOW.normalize(), NOW) is True
@pytest.mark.unit
def test_historical_request_uses_todays_cache_past_the_ttl(tmp_path):
f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30")
assert su._cache_is_fresh(f, pd.Timestamp("2026-05-01"), NOW) is True
@pytest.mark.unit
def test_a_download_from_an_earlier_day_is_not_fresh(tmp_path):
f = _write(tmp_path, age_seconds=13 * 3600) # yesterday 23:00
assert su._cache_is_fresh(f, pd.Timestamp("2026-05-01"), NOW) is False
@pytest.mark.unit
def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
"""End-to-end: the freshness check is wired into load_ohlcv's cache branch."""
_write(tmp_path, age_seconds=STALE)
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")
out = _load(tmp_path, monkeypatch, "2026-07-18", _fake_download)
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):
_write(tmp_path, last_date="2026-07-18")
_load(tmp_path, monkeypatch, "2026-07-18", _fail_download)
@pytest.mark.unit
def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch):
"""A later day's download replaces the symbol's file instead of adding one (#1330)."""
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]})
downloads = []
monkeypatch.setattr(su.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date"))
for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"):
now = pd.Timestamp(day)
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda now=now: now))
su.load_ohlcv("AAPL", "2026-07-17")
written = list(tmp_path.glob("AAPL-*.csv"))
os.utime(written[0], (now.timestamp(), now.timestamp()))
assert len(downloads) == 3, "each new day refetches"
assert [p.name for p in tmp_path.iterdir()] == ["AAPL-YFin-data.csv"]

View File

@@ -0,0 +1,200 @@
"""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 before the cutoff, so the frame ends at the last settled bar
instead of carrying a fabricated close.
Refusing the whole frame instead (the first attempt at #1201) reported a
tradable symbol as invalid or delisted (#1289), so only a range with no close
anywhere counts as no data and the staleness check judges the rest.
"""
from __future__ import annotations
import os
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))
cache_file = tmp_path / "AAPL-YFin-data.csv"
cache_file.write_text(frame.to_csv(index=False))
os.utime(cache_file, (today.timestamp(), today.timestamp()))
def _fail_download(*a, **k):
raise AssertionError("should use the seeded cache, not download")
monkeypatch.setattr(su.yf, "download", _fail_download)
return su.load_ohlcv("AAPL", curr_date)
@pytest.mark.unit
def test_unsettled_latest_bar_is_served_as_the_last_settled_bar(monkeypatch, tmp_path):
# Newest bar (the curr_date) has no close: serve the last settled bar rather
# than reporting the whole symbol as unavailable (#1289).
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],
})
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-07")
assert out["Close"].iloc[-1] == 100.5
@pytest.mark.unit
def test_no_settled_bar_at_all_is_still_no_data(monkeypatch, tmp_path):
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": [float("nan"), float("nan")], "Volume": [1_000_000, 1_000_000],
})
with pytest.raises(NoMarketDataError, match="no bar in range has a closing price"):
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
@pytest.mark.unit
def test_serving_the_last_settled_bar_does_not_bypass_the_staleness_check(
monkeypatch, tmp_path
):
# Falling back must not resurrect a long-dead series: once the closeless
# tail is gone, the remaining bar is judged on its age like any other.
frame = pd.DataFrame({
"Date": ["2026-01-05", "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="stale"):
_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")
@pytest.mark.unit
def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, tmp_path):
"""Gap filling exists so indicators compute on a continuous series. The
verification snapshot is the one place a number must be what the vendor
reported, or the module built to stop invented prices supplies them."""
from tradingagents.dataflows import market_data_validator as mdv, stockstats_utils as su
frame = pd.DataFrame({
"Date": ["2026-05-06", "2026-05-07", "2026-05-08"],
"Open": [100.0, 104.5, ""], # the latest bar has not settled
"High": [101.0, 105.5, ""],
"Low": [99.0, 103.5, ""],
"Close": [100.5, 105.0, 106.0],
"Volume": [1000000, 1000000, ""],
})
today = pd.Timestamp("2026-05-08 12:00")
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
cache = tmp_path / "AAPL-YFin-data.csv"
cache.write_text(frame.to_csv(index=False))
os.utime(cache, (today.timestamp(), today.timestamp()))
monkeypatch.setattr(su.yf, "download", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("should read the seeded cache")))
out = mdv.build_verified_market_snapshot("AAPL", "2026-05-08", 3)
row = out.split("Latest verified OHLCV row")[1].split("###")[0]
assert "104.50" not in row and "105.50" not in row # the previous session's numbers
assert "106.00" in row # the close the vendor did report

View File

@@ -3,9 +3,20 @@
from __future__ import annotations
import importlib
import re
import pytest
# Rich colorizes console output and highlights numbers and URLs, which splits
# asserted substrings with escape codes ("port \x1b[1;33m11434"). Whether it
# does so depends on the ambient terminal, so strip the codes to keep these
# assertions independent of where the suite runs.
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _console_out(capsys) -> str:
return _ANSI.sub("", capsys.readouterr().out)
@pytest.fixture(scope="module", autouse=True)
def _resync_reloaded_modules():
@@ -122,7 +133,7 @@ def test_confirm_endpoint_shows_default(monkeypatch, capsys):
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
out = capsys.readouterr().out
out = _console_out(capsys)
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
@@ -133,7 +144,7 @@ def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys):
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
out = capsys.readouterr().out
out = _console_out(capsys)
assert "http://remote-host:11434/v1" in out
assert "OLLAMA_BASE_URL" in out
@@ -144,7 +155,7 @@ def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys):
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("0.0.0.128")
out = capsys.readouterr().out
out = _console_out(capsys)
assert "missing a scheme" in out
assert "http://<host>:11434/v1" in out
@@ -155,7 +166,7 @@ def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys):
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
out = capsys.readouterr().out
out = _console_out(capsys)
assert "port 11434" in out
@@ -165,7 +176,7 @@ def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys):
import cli.utils as cli_utils
importlib.reload(cli_utils)
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
out = capsys.readouterr().out
out = _console_out(capsys)
assert "Note" not in out # localhost is fine without explicit port
@@ -186,3 +197,28 @@ def test_ollama_offers_custom_model_id():
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}"
@pytest.mark.unit
def test_structured_output_suppresses_object_tool_choice(monkeypatch):
"""Ollama rejects the object-form tool_choice like other local servers
(#1062), and a local model ID has no capability entry saying otherwise, so
it takes the same client as the generic local endpoint."""
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
from tradingagents.llm_clients import create_llm_client
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",
)
create_llm_client(provider="ollama", model="qwen3:30b").get_llm().with_structured_output(Schema)
assert captured["tool_choice"] is None

View File

@@ -17,9 +17,10 @@ from tradingagents.llm_clients.openai_client import (
"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-5.5-pro", True), ("gpt-6-astra", True), ("o1", True), ("o3-mini", True),
("gpt-4.1", False), ("gpt-4o", False), ("gpt-4o-mini", False),
("gpt-3.5-turbo", False),
("gpt-3.5-turbo", False), ("gpt-10", True),
("gpt-5foo", False), ("gpt-60x", False), ("o3rd-party", False),
],
)
def test_supports_reasoning_effort(model, expected):

View File

@@ -0,0 +1,240 @@
"""Portfolio context: what the caller holds, threaded into the decision agents.
Decisions were made with no knowledge of the current book, so "add to a full
position" and "open a new one" read alike. The context is optional and carries
three distinct states: a position, a flat book, and no context at all. Nothing
may present the third as the second. The research team stays blind so the bull
and bear cases are not anchored by the caller's position.
"""
from __future__ import annotations
import json
import pytest
from tradingagents.agents.utils.agent_utils import get_portfolio_context_from_state
from tradingagents.portfolio import PortfolioContext, load_portfolio
HOLDING = {
"cash": 25000.0,
"currency": "USD",
"positions": [
{"ticker": "AAPL", "quantity": 120, "average_price": 150.0},
{"ticker": "MSFT", "quantity": 10},
],
}
@pytest.mark.unit
def test_position_in_the_analyzed_instrument_leads_the_render():
text = PortfolioContext.model_validate(HOLDING).render("AAPL")
assert "120" in text and "150" in text
assert "MSFT" in text and "25,000" in text and "USD" in text
@pytest.mark.unit
def test_flat_book_says_no_position_rather_than_omitting_it():
text = PortfolioContext.model_validate({"cash": 1000.0, "positions": []}).render("AAPL")
assert "No current position in AAPL" in text
@pytest.mark.unit
def test_a_ticker_held_under_another_spelling_is_matched():
text = PortfolioContext.model_validate({"positions": [{"ticker": "aapl", "quantity": 5}]}).render("AAPL")
assert "No current position" not in text and "5" in text
@pytest.mark.unit
def test_absent_context_is_reported_as_not_provided():
notice = get_portfolio_context_from_state({"company_of_interest": "AAPL"})
assert "not provided" in notice.lower()
assert "no position" not in notice.lower() # missing must not read as flat
@pytest.mark.unit
def test_rendered_context_reaches_the_agents_from_state():
block = get_portfolio_context_from_state({"portfolio_context": "Portfolio: flat", "company_of_interest": "AAPL"})
assert block == "Portfolio: flat"
@pytest.mark.unit
def test_load_rejects_a_malformed_file_with_a_clear_error(tmp_path):
bad = tmp_path / "p.json"
bad.write_text(json.dumps({"positions": [{"quantity": 5}]}))
with pytest.raises(ValueError, match="portfolio"):
load_portfolio(bad)
@pytest.mark.unit
def test_load_reads_a_valid_file(tmp_path):
good = tmp_path / "p.json"
good.write_text(json.dumps(HOLDING))
assert load_portfolio(good).positions[0].ticker == "AAPL"
# --- threading through the graph --------------------------------------------
def _bare_graph(tmp_path):
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.graph.propagation import Propagator
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1,
"max_risk_discuss_rounds": 1}
graph.memory_log = TradingMemoryLog(graph.config)
graph.propagator = Propagator()
graph.selected_analysts = ["market"]
graph._resolve_pending_entries = lambda t: None
graph.resolve_instrument_context = lambda t, a="stock", d=None: ""
graph._memory_as_of = lambda d: None
return graph
@pytest.mark.unit
def test_create_run_state_renders_the_portfolio_once(tmp_path):
graph = _bare_graph(tmp_path)
state = graph.create_run_state("AAPL", "2026-08-14", portfolio=PortfolioContext.model_validate(HOLDING))
assert "120" in state["portfolio_context"]
assert graph.create_run_state("AAPL", "2026-08-14")["portfolio_context"] == ""
@pytest.mark.unit
def test_checkpoint_signature_changes_with_the_portfolio(tmp_path):
graph = _bare_graph(tmp_path)
none = graph._run_signature("stock")
flat = graph._run_signature("stock", PortfolioContext())
held = graph._run_signature("stock", PortfolioContext.model_validate(HOLDING))
assert len({none, flat, held}) == 3
@pytest.mark.unit
@pytest.mark.parametrize("module, factory", [
("tradingagents.agents.trader.trader", "create_trader"),
("tradingagents.agents.managers.portfolio_manager", "create_portfolio_manager"),
("tradingagents.agents.risk_mgmt.aggressive_debator", "create_aggressive_debator"),
("tradingagents.agents.risk_mgmt.conservative_debator", "create_conservative_debator"),
("tradingagents.agents.risk_mgmt.neutral_debator", "create_neutral_debator"),
])
def test_decision_agents_see_the_portfolio(module, factory, monkeypatch):
"""The prompt each decision agent sends carries the portfolio block."""
import importlib
mod = importlib.import_module(module)
seen = []
class _LLM:
def invoke(self, prompt, *a, **k):
seen.append(prompt if isinstance(prompt, str) else json.dumps(str(prompt)))
from langchain_core.messages import AIMessage
return AIMessage("Rating: Hold\n\nnothing to do")
def with_structured_output(self, *a, **k):
raise NotImplementedError # force the free-text path
state = {
"company_of_interest": "AAPL", "trade_date": "2026-08-14", "asset_type": "stock",
"instrument_context": "", "market_report": "M", "sentiment_report": "S",
"news_report": "N", "fundamentals_report": "F", "investment_plan": "P",
"trader_investment_plan": "T", "past_context": "",
"portfolio_context": "PORTFOLIO_BLOCK_MARKER",
"investment_debate_state": {"history": "", "judge_decision": "", "count": 0},
"risk_debate_state": {"history": "", "latest_speaker": "", "count": 0,
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
"current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "judge_decision": ""},
}
node = getattr(mod, factory)(_LLM())
node(state)
assert any("PORTFOLIO_BLOCK_MARKER" in p for p in seen), f"{factory} prompt lacks the portfolio block"
@pytest.mark.unit
def test_research_team_stays_blind_to_the_portfolio():
import inspect
from tradingagents.agents.researchers import bear_researcher, bull_researcher
for mod in (bull_researcher, bear_researcher):
assert "portfolio_context" not in inspect.getsource(mod)
@pytest.mark.unit
def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch):
"""The clear must key on the same portfolio the run was checkpointed under.
Keyed on a different one it deletes nothing, and the next identical call
resumes the finished thread and returns the old decision without running.
"""
import tradingagents.graph.trading_graph as tg
graph = _bare_graph(tmp_path)
graph.config.update({"checkpoint_enabled": True, "data_cache_dir": str(tmp_path),
"results_dir": str(tmp_path)})
graph.debug = False
graph._resuming = False
graph.propagator.get_graph_args = lambda callbacks=None: {}
graph.process_signal = lambda d: "Hold"
graph._log_state = lambda *a, **k: None
graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx"}})()
book = PortfolioContext.model_validate(HOLDING)
written = graph._run_signature("stock", book) # what begin_checkpoint keys on
cleared = []
monkeypatch.setattr(tg, "clear_checkpoint", lambda d, t, dt, signature: cleared.append(signature))
graph._run_graph("AAPL", "2026-08-14", "stock", checkpoint_thread_id=None, portfolio=book)
assert cleared == [written]
@pytest.mark.unit
def test_research_layer_sizes_against_a_standard_allocation():
"""The research team is blind to the book, so its plan cannot promise
position-relative sizing: it sizes against a standard allocation instead."""
from tradingagents.agents.schemas import ResearchPlan
description = ResearchPlan.model_fields["strategic_actions"].description
assert "standard allocation" in description
assert "does not see the caller's holdings" in description
@pytest.mark.unit
def test_partial_portfolio_states_only_what_it_was_given():
"""Cash omitted is not cash zero; the line is absent rather than invented."""
text = PortfolioContext.model_validate({"positions": [{"ticker": "AAPL", "quantity": 5}]}).render("AAPL")
assert "Cash" not in text
assert "5" in text
@pytest.mark.unit
def test_cli_rejects_an_unusable_portfolio_file_before_running(tmp_path, monkeypatch):
from typer.testing import CliRunner
import cli.main as m
bad = tmp_path / "bad.json"
bad.write_text('{"positions": [{"quantity": 5}]}')
ran = []
monkeypatch.setattr(m, "run_analysis", lambda **k: ran.append(k))
result = CliRunner().invoke(m.app, ["--portfolio", str(bad)])
assert result.exit_code == 1 and ran == []
@pytest.mark.unit
def test_cli_passes_a_valid_portfolio_into_the_run(tmp_path, monkeypatch):
from typer.testing import CliRunner
import cli.main as m
good = tmp_path / "good.json"
good.write_text(json.dumps(HOLDING))
ran = []
monkeypatch.setattr(m, "run_analysis", lambda **k: ran.append(k))
result = CliRunner().invoke(m.app, ["--portfolio", str(good)])
assert result.exit_code == 0
assert ran[0]["portfolio"].position_in("AAPL").quantity == 120

View File

@@ -0,0 +1,87 @@
"""What the agents are actually told.
Three problems the audit found: one analyst's brief reached the model as a Python
tuple, every analyst was asked for a trade call that nothing reads, and a report
that was never produced was presented as an empty labelled section, which invites
the next agent to fill it in from nothing.
"""
from __future__ import annotations
import importlib
import pytest
ANALYSTS = ["market_analyst", "sentiment_analyst", "news_analyst", "fundamentals_analyst"]
@pytest.mark.unit
@pytest.mark.parametrize("name", ANALYSTS)
def test_an_analyst_brief_is_text_not_a_python_object(name):
"""A trailing comma made one brief a tuple, so the model was handed its repr
(quotes, parens and all) instead of the instruction."""
import ast
import inspect
mod = importlib.import_module(f"tradingagents.agents.analysts.{name}")
tree = ast.parse(inspect.getsource(mod))
briefs = [node.value for node in ast.walk(tree)
if isinstance(node, ast.Assign)
and getattr(node.targets[0], "id", "") == "system_message"]
assert briefs, f"{name} has no system_message"
for brief in briefs:
assert not isinstance(brief, ast.Tuple), "the brief is a tuple, not text"
@pytest.mark.unit
@pytest.mark.parametrize("name", ANALYSTS)
def test_an_analyst_is_not_asked_for_a_trade_call_nothing_reads(name):
"""The stop signal is never consumed, and asking for it makes an analyst
open with a direction that then travels as evidence."""
import inspect
mod = importlib.import_module(f"tradingagents.agents.analysts.{name}")
assert "FINAL TRANSACTION PROPOSAL" not in inspect.getsource(mod)
@pytest.mark.unit
@pytest.mark.parametrize("module, factory", [
("tradingagents.agents.researchers.bull_researcher", "create_bull_researcher"),
("tradingagents.agents.researchers.bear_researcher", "create_bear_researcher"),
("tradingagents.agents.risk_mgmt.aggressive_debator", "create_aggressive_debator"),
("tradingagents.agents.risk_mgmt.conservative_debator", "create_conservative_debator"),
("tradingagents.agents.risk_mgmt.neutral_debator", "create_neutral_debator"),
])
def test_a_report_that_was_never_produced_says_so(module, factory):
"""`--analysts market` leaves three reports empty; presenting them as blank
sections invites the model to invent the contents."""
from langchain_core.messages import AIMessage
mod = importlib.import_module(module)
seen = []
class _LLM:
def invoke(self, prompt, *a, **k):
seen.append(prompt if isinstance(prompt, str) else str(prompt))
return AIMessage("argument")
def with_structured_output(self, *a, **k):
raise NotImplementedError
state = {
"company_of_interest": "NVDA", "trade_date": "2026-08-14", "asset_type": "stock",
"instrument_context": "", "portfolio_context": "", "past_context": "",
"market_report": "RSI 61, price 178.", "sentiment_report": "", "news_report": "",
"fundamentals_report": "", "investment_plan": "P", "trader_investment_plan": "T",
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "count": 0},
"risk_debate_state": {"history": "", "latest_speaker": "", "count": 0,
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
"current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "judge_decision": ""},
}
getattr(mod, factory)(_LLM())(state)
prompt = " ".join(seen)
assert "not part of this run" in prompt or "not available" in prompt, prompt[:400]
assert "RSI 61" in prompt # the report that does exist is still passed through

View File

@@ -7,6 +7,7 @@ import pytest
from tradingagents.llm_clients.openai_client import (
OPENAI_COMPATIBLE_PROVIDERS,
DeepSeekChatOpenAI,
LocalCompatibleChatOpenAI,
MinimaxChatOpenAI,
NormalizedChatOpenAI,
is_openai_compatible,
@@ -39,7 +40,7 @@ def test_registry_membership():
("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),
("ollama", "http://localhost:11434/v1", LocalCompatibleChatOpenAI, False),
])
def test_registry_spec(provider, base_url, chat_class, responses):
spec = OPENAI_COMPATIBLE_PROVIDERS[provider]

View File

@@ -0,0 +1,211 @@
"""A decision is recorded as the call that was made, or as needing review.
Two readers used to disagree about the same text: the signal said REVIEW while
the memory log wrote a fabricated Hold. Worse, prose that argued against a Buy
before concluding Underweight was read as Buy, because the parser took the first
rating word anywhere in the document. A wrong direction is worse than no
direction, so an unclear decision is REVIEW everywhere.
"""
from __future__ import annotations
import pytest
from tradingagents.agents.utils.rating import RATING_REVIEW, extract_rating, parse_rating
INVERTED = ("The aggressive analyst pushed hard for a Buy on the AI backlog, but the "
"conservative case on margin compression carried the debate. "
"Final rating — Underweight. Trim to half weight over the next two weeks.")
REFUSAL = "I'm sorry, I can't provide a rating for this security."
@pytest.mark.unit
@pytest.mark.parametrize("separator", [":", "-", "", "", "", ": **"])
def test_the_labelled_rating_wins_whatever_separates_it(separator):
text = f"Buy arguments were raised and rejected.\n\nRating{separator}Underweight\n\nTrim."
assert extract_rating(text) == "Underweight"
@pytest.mark.unit
def test_a_rating_argued_against_is_not_read_as_the_decision():
assert extract_rating(INVERTED) == "Underweight"
@pytest.mark.unit
def test_prose_naming_several_ratings_without_a_label_needs_review():
"""Nothing in the text says which one is the call, so guessing risks
reporting the opposite of the decision."""
text = "The bull wants Buy, the bear wants Sell, and the committee was split."
assert extract_rating(text) is None
@pytest.mark.unit
def test_prose_naming_one_rating_is_taken_as_the_call():
assert extract_rating("On balance we stay Underweight until margins recover.") == "Underweight"
@pytest.mark.unit
def test_a_refusal_has_no_rating_and_is_not_defaulted():
assert extract_rating(REFUSAL) is None
assert parse_rating(REFUSAL) == RATING_REVIEW
@pytest.mark.unit
def test_the_scale_quoted_in_a_prompt_does_not_become_the_rating():
"""A free-text answer that echoes the rating scale was read as the first
tier listed in it."""
text = ("**Rating Scale**: Buy, Overweight, Hold, Underweight, Sell.\n\n"
"**Rating**: Sell\n\nExit the position.")
assert extract_rating(text) == "Sell"
# --- the readers agree ------------------------------------------------------
@pytest.mark.unit
def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path):
from tradingagents.agents.utils.memory import TradingMemoryLog
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
log.store_decision("NVDA", "2026-01-05", REFUSAL)
entry = log.load_entries()[0]
assert entry["rating"] == RATING_REVIEW
@pytest.mark.unit
def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path):
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.graph.signal_processing import SignalProcessor
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."):
log.store_decision("NVDA", f"2026-01-0{len(log.load_entries()) + 1}", text)
signals = [SignalProcessor.process_signal(None, text)
for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate.")]
assert [e["rating"] for e in log.load_entries()] == signals
@pytest.mark.unit
def test_an_unscored_decision_is_left_out_of_the_backtest_figures(tmp_path):
"""REVIEW has no direction, so it cannot count for or against the system."""
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.backtest import summarize
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 5, "note", "2026-02-01")
log.store_decision("AAPL", "2026-01-05", REFUSAL)
log.update_with_outcome("AAPL", "2026-01-05", 0.1, 0.04, 5, "note", "2026-02-01")
summary = summarize(log)
assert set(summary.by_rating) == {"Buy"}
@pytest.mark.unit
def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path, capsys):
"""The CLI is the primary entry point; an unreadable decision must be
visible there, not only in the log."""
import cli.main as m
from cli.models import AnalystType
printed = []
class _Graph:
graph = propagator = None
def create_run_state(self, *a, **k):
return {"messages": []}
def record_decision(self, *a, **k):
pass
def process_signal(self, text):
from tradingagents.graph.signal_processing import SignalProcessor
return SignalProcessor.process_signal(None, text)
def get_graph_args(self, callbacks=None):
return {}
def begin_checkpoint(self, *a, **k):
return None
def checkpoint_input(self, state):
return state
def clear_checkpoint_on_success(self, *a, **k):
pass
def end_checkpoint(self):
pass
def stream(self, *a, **k):
yield {"messages": [], "final_trade_decision": REFUSAL}
fake = _Graph()
fake.graph = fake
fake.propagator = fake
monkeypatch.setattr(m, "TradingAgentsGraph", lambda *a, **k: fake)
monkeypatch.setattr(m, "create_layout", lambda: None)
monkeypatch.setattr(m, "update_display", lambda *a, **k: None)
monkeypatch.setattr(m, "Live", type("L", (), {"__init__": lambda s, *a, **k: None,
"__enter__": lambda s: s,
"__exit__": lambda s, *a: False}))
monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a)))
monkeypatch.setattr(m, "display_complete_report", lambda *a, **k: None)
monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N")
monkeypatch.setattr(m, "get_user_selections", lambda: {
"ticker": "NVDA", "analysis_date": "2026-01-10",
"analysts": [AnalystType.MARKET], "asset_type": "stock",
})
monkeypatch.setattr(m, "_build_run_config", lambda s, c: {
"data_cache_dir": str(tmp_path / "c"), "results_dir": str(tmp_path / "r")})
m.run_analysis()
assert any("review" in line.lower() for line in printed), printed[-5:]
@pytest.mark.unit
@pytest.mark.parametrize("module, factory, must_name", [
("tradingagents.agents.managers.portfolio_manager", "create_portfolio_manager", "Rating"),
("tradingagents.agents.managers.research_manager", "create_research_manager", "Recommendation"),
("tradingagents.agents.trader.trader", "create_trader", "Action"),
])
def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_name):
"""The field descriptions live in the schema, which a provider without
structured output never sees. Without the format in the prompt body, the
fallback answer is prose nobody can read a rating from."""
import importlib
from langchain_core.messages import AIMessage
mod = importlib.import_module(module)
seen = []
class _LLM:
def invoke(self, prompt, *a, **k):
seen.append(prompt if isinstance(prompt, str) else str(prompt))
return AIMessage("**Rating**: Hold\n\nnothing to do")
def with_structured_output(self, *a, **k):
raise NotImplementedError # force the free-text path
state = {
"company_of_interest": "NVDA", "trade_date": "2026-08-14", "asset_type": "stock",
"instrument_context": "", "market_report": "M", "sentiment_report": "S",
"news_report": "N", "fundamentals_report": "F", "investment_plan": "P",
"trader_investment_plan": "T", "past_context": "", "portfolio_context": "",
"investment_debate_state": {"bull_history": "b", "bear_history": "r", "history": "h",
"current_response": "", "judge_decision": "", "count": 2},
"risk_debate_state": {"history": "h", "latest_speaker": "", "count": 3,
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
"current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "judge_decision": ""},
}
getattr(mod, factory)(_LLM())(state)
prompt = " ".join(seen)
assert "## Output" in prompt, "no output-format section in the prompt"
section = prompt.split("## Output", 1)[1]
assert f"**{must_name}**" in section, section[:300]

View File

@@ -1,5 +1,5 @@
"""Tests for the RSS-first Reddit fetcher, its 429 backoff, the opt-in JSON
path's degradation (#862), and chunked-transfer error handling (#1024)."""
"""Tests for the Reddit RSS fetcher: one combined request, its 429 backoff, and
chunked-transfer error handling (#1024)."""
from __future__ import annotations
@@ -36,8 +36,9 @@ def _resp(read_fn):
def __exit__(self_inner, *a):
return False
def read(self_inner):
return read_fn()
def read(self_inner, size=-1):
data = read_fn()
return data if size is None or size < 0 else data[:size]
return _Resp()
@@ -79,46 +80,13 @@ class TestRssParsing:
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"]
assert posts[0]["subreddit"] == "stocks"
def test_malformed_xml_fails_open(self):
def test_malformed_xml_reports_unavailable(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"
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
@pytest.mark.unit
@@ -138,7 +106,7 @@ class TestRss429Backoff:
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 == []
assert posts is None
def test_retry_after_header_is_honoured(self):
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, None)
@@ -147,21 +115,43 @@ class TestRss429Backoff:
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 48.0 <= wait <= 72.0 # 60s +/-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):
def test_rss_incomplete_read_reports_unavailable(self):
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
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) is None
@pytest.mark.unit
@@ -172,25 +162,12 @@ class TestFormatterHandlesRssPosts:
"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
with patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",))
assert "" not in out # RSS has no scores; none are invented
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:
@@ -199,12 +176,12 @@ class TestCryptoSearchTerm:
def _captured_ticker(self, ticker):
seen = {}
def fake_fetch(t, sub, limit, timeout):
def fake_fetch(t, subs, limit, timeout, **kwargs):
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)
with patch.object(reddit, "_fetch_subreddit_rss", side_effect=fake_fetch):
reddit.fetch_reddit_posts(ticker, subreddits=("stocks",))
return seen["ticker"]
def test_crypto_pair_searches_base(self):
@@ -212,3 +189,86 @@ class TestCryptoSearchTerm:
def test_equity_passes_through(self):
assert self._captured_ticker("NVDA") == "NVDA"
@pytest.mark.unit
class TestOneRequestForAllSubreddits:
"""Reddit's anonymous RSS allows about one request per minute per IP, so a
request per subreddit spent a back-off on nearly every run. One combined
feed (``r/a+b+c``) carries each entry's subreddit, so nothing is lost."""
def _post(self, sub, title="NVDA pops"):
return {"title": title, "score": None, "num_comments": None,
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
"selftext": "", "source": "rss", "subreddit": sub}
def test_all_subreddits_share_one_request(self):
calls = []
def record(t, subs, limit, timeout):
calls.append((subs, limit))
return []
with patch.object(reddit, "_fetch_subreddit_rss", side_effect=record):
reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b", "c"), limit_per_sub=5)
# One full page, so a busy subreddit cannot crowd the others out.
assert calls == [("a+b+c", reddit._FEED_PAGE)]
def test_posts_are_grouped_back_by_subreddit(self):
posts = [self._post("b", "FROM B"), self._post("a", "FROM A")]
with patch.object(reddit, "_fetch_subreddit_rss", return_value=posts):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
assert out.index("r/a") < out.index("FROM A") < out.index("r/b") < out.index("FROM B")
def test_failed_request_is_unavailable_not_silence(self):
# #1295: a throttled fetch must not read as "no posts found".
with patch.object(reddit, "_fetch_subreddit_rss", return_value=None):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
assert "Reddit unavailable" in out
assert "no Reddit posts found" not in out
def test_genuine_empty_still_reports_no_posts(self):
with patch.object(reddit, "_fetch_subreddit_rss", return_value=[]):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
assert "no Reddit posts found" in out
assert "unavailable" not in out
def test_subreddit_with_no_posts_is_listed_when_others_have_some(self):
with patch.object(reddit, "_fetch_subreddit_rss", return_value=[self._post("a")]):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
assert "r/b: <no posts found" in out
@pytest.mark.unit
def test_posts_from_an_unrequested_or_unnamed_subreddit_are_not_dropped():
posts = [
{"title": "ELSEWHERE", "created_utc": None, "selftext": "", "subreddit": "options"},
{"title": "NO LABEL", "created_utc": None, "selftext": "", "subreddit": ""},
]
with patch.object(reddit, "_fetch_subreddit_rss", return_value=posts):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
assert "ELSEWHERE" in out and "r/options" in out
assert "NO LABEL" in out
@pytest.mark.unit
def test_each_subreddit_keeps_its_own_quota():
busy = [{"title": f"A{i}", "created_utc": None, "selftext": "", "subreddit": "a"} for i in range(9)]
quiet = [{"title": "B0", "created_utc": None, "selftext": "", "subreddit": "b"}]
with patch.object(reddit, "_fetch_subreddit_rss", return_value=busy + quiet):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"), limit_per_sub=3)
assert "A0" in out and "A2" in out and "A3" not in out # capped per subreddit
assert "B0" in out # not crowded out
@pytest.mark.unit
def test_empty_subreddit_on_a_full_page_is_not_called_empty():
# A full page may have cut a quieter subreddit's posts off, so its absence
# from the page is not evidence of no posts.
full = [{"title": f"A{i}", "created_utc": None, "selftext": "", "subreddit": "a"}
for i in range(reddit._FEED_PAGE)]
with patch.object(reddit, "_fetch_subreddit_rss", return_value=full):
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
assert "r/b: <no posts found" not in out
assert f"newest {reddit._FEED_PAGE}" in out

239
tests/test_sec_edgar.py Normal file
View File

@@ -0,0 +1,239 @@
"""SEC EDGAR fundamentals: statements as they were filed, not as they read today.
Every other fundamentals vendor serves the current value of a past period and
cuts on the fiscal period end, so a run sees figures the company had not yet
filed, and later restatements replace what was actually published. EDGAR carries
the filing date of every fact, so a run can be limited to what was on file by its
own date.
"""
from __future__ import annotations
from unittest import mock
import pytest
from tradingagents.dataflows import sec_edgar
from tradingagents.dataflows.errors import NoMarketDataError
_REAL_FETCH = sec_edgar._fetch_json
TICKER_MAP = {"0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."}}
def _fact(end, val, filed, form="10-K", fp="FY", start=None):
fact = {"end": end, "val": val, "filed": filed, "form": form, "fy": int(end[:4]), "fp": fp}
if start:
fact["start"] = start
return fact
FACTS = {
"cik": 320193,
"entityName": "Apple Inc.",
"facts": {"us-gaap": {
"Assets": {"units": {"USD": [
_fact("2008-09-27", 39_572_000_000, "2008-11-05"),
_fact("2008-09-27", 36_171_000_000, "2010-01-25", form="10-K/A"),
_fact("2022-03-26", 350_662_000_000, "2022-04-29", form="10-Q", fp="Q2"),
_fact("2024-09-28", 364_980_000_000, "2024-11-01"),
]}},
"Liabilities": {"units": {"USD": [_fact("2024-09-28", 308_030_000_000, "2024-11-01")]}},
"EarningsPerShareDiluted": {"units": {"USD/shares": [
_fact("2024-09-28", 6.08, "2024-11-01", start="2023-09-30"),
]}},
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
# One filing reports the quarter and the year to date under one end date.
_fact("2025-12-31", 81_300_000_000, "2026-01-29", form="10-Q", fp="Q2", start="2025-10-01"),
_fact("2025-12-31", 158_900_000_000, "2026-01-29", form="10-Q", fp="Q2", start="2025-07-01"),
_fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30"),
]}},
}},
}
@pytest.fixture(autouse=True)
def _no_network_or_cache(tmp_path, monkeypatch):
monkeypatch.setattr(sec_edgar, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(sec_edgar, "_fetch_json", lambda url: TICKER_MAP if "company_tickers" in url else FACTS)
@pytest.mark.unit
def test_a_us_filer_resolves_to_its_cik():
assert sec_edgar.cik_for("AAPL") == "0000320193"
assert sec_edgar.cik_for("aapl") == "0000320193"
@pytest.mark.unit
def test_a_non_filer_is_reported_as_such_not_as_missing_data():
with pytest.raises(NoMarketDataError, match="not a US SEC filer"):
sec_edgar.get_balance_sheet("0700.HK", "annual", "2026-01-01")
@pytest.mark.unit
def test_a_restated_figure_reads_as_it_did_at_the_time():
"""The value published then, not the correction filed later."""
as_filed = sec_edgar.get_balance_sheet("AAPL", "annual", "2009-06-30")
restated = sec_edgar.get_balance_sheet("AAPL", "annual", "2011-01-01")
assert "39572" in as_filed and "36171" not in as_filed
assert "36171" in restated
@pytest.mark.unit
def test_a_period_that_ended_but_was_not_filed_yet_is_not_served():
"""The fiscal year ended 2024-09-28; it reached the public on 2024-11-01."""
before = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-10-15")
after = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert "2024-09-28" not in before
assert "2024-09-28" in after and "364980" in after
@pytest.mark.unit
def test_the_quarter_is_not_confused_with_the_year_to_date():
"""One filing carries both spans under the same end date (#MSFT-shaped)."""
out = sec_edgar.get_income_statement("AAPL", "quarterly", "2026-06-01")
assert "81300" in out
assert "158900" not in out
@pytest.mark.unit
def test_a_line_the_filer_does_not_tag_is_named_unavailable():
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert "Stockholders Equity" in out and "unavailable" in out
assert "364980" in out # the rest of the statement still returns
@pytest.mark.unit
def test_the_report_states_the_vintage_rule():
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert "filed on or before 2024-11-15" in out
@pytest.mark.unit
def test_a_filer_with_no_usable_facts_reads_differently_from_a_non_filer(monkeypatch):
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else {"facts": {}})
with pytest.raises(NoMarketDataError, match="no us-gaap facts"):
sec_edgar.get_balance_sheet("AAPL", "annual", "2026-01-01")
@pytest.mark.unit
def test_company_facts_are_fetched_once_per_company_not_once_per_date(tmp_path, monkeypatch):
"""A sweep asks for many dates; the filing history is the same file."""
calls = []
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: calls.append(url) or (TICKER_MAP if "company_tickers" in url else FACTS))
for date in ("2024-11-15", "2025-01-15", "2025-06-15"):
sec_edgar.get_balance_sheet("AAPL", "annual", date)
assert len([u for u in calls if "companyfacts" in u]) == 1
@pytest.mark.unit
def test_it_works_unconfigured_and_takes_the_caller_s_own_contact(monkeypatch):
"""SEC returns 403 for a User-Agent with no contact address, so the default
carries one; a caller who sets their own replaces it."""
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
assert "@" in sec_edgar._user_agent()
monkeypatch.setenv("SEC_EDGAR_USER_AGENT", "MyDesk research@example.com")
assert sec_edgar._user_agent() == "MyDesk research@example.com"
@pytest.mark.unit
def test_a_throttle_lets_the_next_vendor_try(monkeypatch):
"""SEC throttles by refusing the request; the router then tries yfinance."""
import requests
from tradingagents.dataflows.errors import VendorRateLimitError
def _throttled(*a, **k):
raise requests.HTTPError(response=mock.Mock(status_code=429))
monkeypatch.setattr(sec_edgar.requests, "get", _throttled)
with pytest.raises(VendorRateLimitError):
_REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json")
@pytest.mark.unit
def test_values_do_not_break_the_columns():
"""Figures run to the billions; a thousands separator would split the field."""
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
body = [row for row in out.splitlines() if row.startswith("Total Assets")][0]
assert body.count(",") == out.splitlines()[3].count(",")
@pytest.mark.unit
def test_a_per_share_figure_keeps_its_own_unit():
"""Statements are reported in millions, but EPS is dollars per share: scaling
it the same way prints a real figure as zero."""
out = sec_edgar.get_income_statement("AAPL", "annual", "2024-11-15")
row = [r for r in out.splitlines() if r.startswith("Diluted EPS")][0]
assert "6.08" in row
assert "USD/shares" in row or "per share" in row
@pytest.mark.unit
def test_every_row_has_one_cell_per_period():
"""An untagged line still has to line up with the columns, or the table is
misread by position."""
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
table = [r for r in out.splitlines() if r and not r.startswith("#")]
widths = {row.count(",") for row in table}
assert len(widths) == 1, table
@pytest.mark.unit
def test_a_server_error_lets_the_next_vendor_try(monkeypatch):
import requests
from tradingagents.dataflows.errors import VendorError
def _server_error(*a, **k):
raise requests.HTTPError(response=mock.Mock(status_code=503))
monkeypatch.setattr(sec_edgar.requests, "get", _server_error)
with pytest.raises(VendorError): # not a bare HTTPError
_REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json")
@pytest.mark.unit
def test_older_periods_fall_back_to_the_tag_the_filer_used_then():
"""Filers renamed lines when the revenue standard changed, so one tag covers
only recent years. Each period takes one tag, never a sum of two."""
facts = {"Revenues": {"units": {"USD": [_fact("2015-09-26", 233_715_000_000, "2015-10-28",
start="2014-09-28")]}},
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
_fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30")]}}}
values, unit = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues"), "2026-01-01", (300, 400))
assert values == {"2015-09-26": 233_715_000_000, "2024-09-28": 391_035_000_000}
assert unit == "USD"
@pytest.mark.unit
def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both():
facts = {"Revenues": {"units": {"USD": [_fact("2024-09-28", 111, "2024-11-01", start="2023-09-30")]}},
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
_fact("2024-09-28", 999, "2024-11-01", start="2023-09-30")]}}}
values, _ = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues"), "2026-01-01", (300, 400))
assert values == {"2024-09-28": 999}
@pytest.mark.unit
def test_the_default_identification_tracks_the_installed_version(monkeypatch):
"""A release should identify itself, not a version frozen in the source."""
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
monkeypatch.setattr(sec_edgar.metadata, "version", lambda name: "9.9.9")
assert sec_edgar._user_agent() == "TradingAgents/9.9.9 (contact@example.com)"
@pytest.mark.unit
def test_an_uninstalled_checkout_still_identifies_itself(monkeypatch):
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
def _missing(name):
raise sec_edgar.metadata.PackageNotFoundError(name)
monkeypatch.setattr(sec_edgar.metadata, "version", _missing)
assert "@" in sec_edgar._user_agent()

View File

@@ -10,7 +10,13 @@ to it.
import pytest
from tradingagents.agents.utils.rating import RATINGS_5_TIER, parse_rating
from tradingagents.agents.utils.rating import (
RATING_REVIEW,
RATINGS_5_TIER,
extract_rating,
is_review,
parse_rating,
)
from tradingagents.graph.signal_processing import SignalProcessor
# ---------------------------------------------------------------------------
@@ -50,8 +56,9 @@ class TestParseRating:
)
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_is_flagged_for_review_not_defaulted(self):
# A decision nobody can read is not a Hold; recording one invents a call.
assert parse_rating("No clear directional signal at this time.") == RATING_REVIEW
def test_no_rating_custom_default(self):
assert parse_rating("Plain prose.", default="Underweight") == "Underweight"
@@ -84,6 +91,51 @@ class TestSignalProcessor:
llm.invoke.assert_not_called()
llm.with_structured_output.assert_not_called()
def test_default_when_no_rating_present(self):
def test_unparseable_signal_is_review_not_silent_hold(self):
# #1170: an unrecognizable decision must surface REVIEW, not a fabricated
# tradeable Hold.
sp = SignalProcessor()
assert sp.process_signal("Plain prose without a recommendation.") == "Hold"
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_defaults_to_review(self):
# The memory log tags an unreadable decision REVIEW, never a tradeable rating.
assert parse_rating("No rating here.") == RATING_REVIEW
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,225 @@
"""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 "2026-05-01..2026-05-08" in out
assert "Bullish: 1" not in out # the recent bullish message did not leak
# Coverage starts after the window: unavailable, never a claim of silence.
assert "unavailable" in out and "not an absence" in out
@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_rss", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date="2026-05-01", end_date="2026-05-08",
)
assert "NOW" not in out
assert "unavailable" in out and "not an absence" in out
@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_rss", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date="2026-05-01", end_date="2026-05-08",
)
assert "INRANGE" in out
# --- coverage vs absence --------------------------------------------------------
# The public feeds only serve recent items. When everything fetched postdates the
# window the source cannot answer for that date; reporting "no posts" there is a
# claim about the market that was never observed.
@pytest.mark.unit
def test_stocktwits_covered_but_empty_window_is_a_real_absence(monkeypatch):
# The stream reaches back before the window (an older message exists) yet
# nothing falls inside it: that is genuine silence.
msgs = [_msg("2026-08-30T12:00:00Z"), _msg("2026-04-20T12:00:00Z")]
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 "no StockTwits messages" in out
assert "unavailable" not in out
@pytest.mark.unit
def test_reddit_covered_but_empty_window_is_a_real_absence(monkeypatch):
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"},
{"title": "OLD", "created_utc": _epoch("2026-04-20"), "source": "rss"}]
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date="2026-05-01", end_date="2026-05-08",
)
assert "no reddit posts" in out.lower()
assert "unavailable" not in out
@pytest.mark.unit
def test_reddit_empty_feed_for_an_old_window_is_unavailable(monkeypatch):
# Search is limited to the last week, so an empty response says nothing
# about a window from months ago: there are no timestamps to go on, and the
# lookback bound alone must decide.
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date="2024-05-01", end_date="2024-05-08",
)
assert "unavailable" in out and "not an absence" in out
@pytest.mark.unit
def test_reddit_live_empty_feed_is_a_real_absence(monkeypatch):
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
out = reddit.fetch_reddit_posts("AAPL", subreddits=("stocks",))
assert "no reddit posts" in out.lower() and "past 7 days" in out
assert "unavailable" not in out
@pytest.mark.unit
def test_stocktwits_empty_stream_for_a_past_window_is_unavailable(monkeypatch):
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": []}))
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
assert "unavailable" in out and "not an absence" in out
@pytest.mark.unit
def test_reddit_window_straddling_the_lookback_is_unavailable(monkeypatch):
# Ten days ago through five days ago: the week-long search never reaches the
# first three days, so an empty result cannot stand for the whole window.
from datetime import timedelta
today = datetime.now(timezone.utc).date()
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date=str(today - timedelta(days=10)), end_date=str(today - timedelta(days=5)),
)
assert "unavailable" in out
@pytest.mark.unit
def test_reddit_standard_week_window_empty_is_a_real_absence(monkeypatch):
# The graph's window is [trade_date - 7, trade_date]; the week-long search
# covers it, so an empty result is genuine silence.
from datetime import timedelta
today = datetime.now(timezone.utc).date()
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date=str(today - timedelta(days=7)), end_date=str(today),
)
assert "no reddit posts" in out.lower() and "unavailable" not in out
@pytest.mark.unit
def test_reddit_full_page_does_not_vouch_for_older_days(monkeypatch):
# 100 posts from today say nothing about five days ago: the page may have
# cut older matches off, so the window stays unavailable.
from datetime import timedelta
today = datetime.now(timezone.utc).date()
ts = _epoch(str(today))
page = [{"title": f"T{i}", "created_utc": ts, "subreddit": "stocks"} for i in range(reddit._FEED_PAGE)]
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: page)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",),
start_date=str(today - timedelta(days=6)), end_date=str(today - timedelta(days=5)),
)
assert "unavailable" in out and "no reddit posts" not in out.lower()

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

@@ -7,12 +7,14 @@ behavior we added for the Trader, Research Manager, and Sentiment Analyst
so they share the same deterministic output shape.
"""
import inspect
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.portfolio_manager import create_portfolio_manager
from tradingagents.agents.managers.research_manager import create_research_manager
from tradingagents.agents.schemas import (
PortfolioDecision,
@@ -59,12 +61,13 @@ class TestRenderTraderProposal:
assert "**Position Sizing**: 6% of portfolio" in md
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in md
def test_optional_fields_omitted_when_absent(self):
def test_optional_fields_are_named_as_not_provided(self):
"""An omitted line reads as a field nobody asked for; the reader cannot
tell it from a level the trader declined to set."""
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
for field in ("Entry Price", "Stop Loss", "Position Sizing"):
assert f"**{field}**: not provided" in md
assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md
@@ -97,6 +100,43 @@ class TestNullishFloatCoercion:
)
assert d.price_target is None
def test_percentage_answer_to_a_price_field_becomes_none(self):
# The Trader is asked for concrete levels and may answer a price field
# with a distance ("15%"), which failed the whole proposal (#1288).
# A percentage cannot be salvaged: 15% must not become a $15 stop.
for pct in ("15%", " 7.5% ", "-10%"):
p = TraderProposal(
action=TraderAction.BUY,
reasoning="x",
entry_price=pct,
stop_loss=pct,
)
assert p.entry_price is None
assert p.stop_loss is None
def test_human_formatted_price_is_reduced_to_its_number(self):
p = TraderProposal(
action=TraderAction.BUY,
reasoning="x",
entry_price="$1,234.50",
stop_loss="1,180",
)
assert p.entry_price == 1234.50
assert p.stop_loss == 1180.0
def test_one_bad_field_no_longer_fails_the_whole_proposal(self):
# Previously a single '15%' raised, forcing a free-text retry that lost
# the action and reasoning; now the rest of the proposal survives.
p = TraderProposal(
action=TraderAction.SELL,
reasoning="downgrade on margin compression",
entry_price="612.40",
stop_loss="15%",
)
assert p.action is TraderAction.SELL
assert p.entry_price == 612.40
assert p.stop_loss is None
@pytest.mark.unit
class TestRenderResearchPlan:
@@ -131,6 +171,7 @@ 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.",
}
@@ -200,6 +241,31 @@ class TestTraderAgent:
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"
@@ -367,6 +433,21 @@ def _structured_sentiment_llm(captured: dict, report: SentimentReport | None = N
@pytest.mark.unit
class TestSentimentAnalystAgent:
@pytest.fixture(autouse=True)
def _stub_prefetched_sources(self, monkeypatch):
"""Stub the sources the analyst pre-fetches before prompting.
create_sentiment_analyst fetches news, StockTwits and Reddit itself, so
without this these tests hit the live network. A real Reddit 429 then
backs the fetcher off for a minute per subreddit, which is what turned
this file into a multi-minute hang.
"""
from tradingagents.agents.analysts import sentiment_analyst as sentiment
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)
def test_structured_path_produces_rendered_markdown(self):
captured = {}
report = SentimentReport(
@@ -406,3 +487,62 @@ class TestSentimentAnalystAgent:
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
@pytest.mark.unit
@pytest.mark.parametrize("source", [
pytest.param(lambda: ResearchPlan.model_fields["recommendation"].description, id="ResearchPlan.recommendation"),
pytest.param(lambda: PortfolioDecision.model_fields["rating"].description, id="PortfolioDecision.rating"),
pytest.param(lambda: inspect.getsource(create_research_manager), id="research_manager prompt"),
pytest.param(lambda: inspect.getsource(create_portfolio_manager), id="portfolio_manager prompt"),
])
def test_conflict_alone_is_not_a_hold_trigger(source):
# The debate always contains conflicting arguments, so a Hold condition that
# conflict satisfies fires on every run and swallows directional calls
# (#1321). All four decision sites must state the same rule.
text = " ".join(source().split())
assert "conflict alone is not a reason to Hold" in text or \
"Conflicting arguments alone are not a reason to Hold" in text
assert "materially conflicting" not in text
@pytest.mark.unit
@pytest.mark.parametrize("written", ["150-160", "150 to 160", "around 150", "150/160", "~150"])
def test_a_price_written_as_a_range_drops_only_that_field(written):
"""Anything that is not a single number becomes None. Letting it through
fails the whole decision's validation, and the run falls back to free text,
losing every other field the model got right."""
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
decision = PortfolioDecision(rating=PortfolioRating.BUY, executive_summary="s",
investment_thesis="t", price_target=written)
assert decision.price_target is None
@pytest.mark.unit
def test_a_price_that_is_a_number_survives():
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
decision = PortfolioDecision(rating=PortfolioRating.BUY, executive_summary="s",
investment_thesis="t", price_target="$1,150.25")
assert decision.price_target == 1150.25
@pytest.mark.unit
def test_a_field_the_model_did_not_give_says_so():
"""An omitted line and a line never asked for read the same to an analyst."""
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating, render_pm_decision
rendered = render_pm_decision(PortfolioDecision(
rating=PortfolioRating.HOLD, executive_summary="s", investment_thesis="t"))
assert "Price Target" in rendered and "not provided" in rendered.lower()
@pytest.mark.unit
def test_the_trader_names_the_levels_it_did_not_give():
from tradingagents.agents.schemas import TraderAction, TraderProposal, render_trader_proposal
rendered = render_trader_proposal(TraderProposal(action=TraderAction.HOLD, reasoning="r"))
for field in ("Entry Price", "Stop Loss", "Position Sizing"):
assert field in rendered
assert rendered.lower().count("not provided") == 3

View File

@@ -41,18 +41,21 @@ def test_fetch_returns_normalizes_symbol(monkeypatch):
queried.append(symbol)
def history(self, *args, **kwargs):
return pd.DataFrame({"Close": [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0]})
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 = TradingAgentsGraph._fetch_returns(
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):

View File

@@ -52,6 +52,27 @@ class TestNormalizeSymbol(unittest.TestCase):
def test_empty_input_passthrough(self):
self.assertEqual(normalize_symbol(""), "")
def test_hk_five_digit_code_repadded_to_four(self):
# HKEX lists up to 5-digit codes; Yahoo only accepts 4 (#957).
self.assertEqual(normalize_symbol("09992.HK"), "9992.HK")
self.assertEqual(normalize_symbol("00700.HK"), "0700.HK")
self.assertEqual(normalize_symbol("00001.HK"), "0001.HK")
def test_hk_four_digit_code_unchanged(self):
self.assertEqual(normalize_symbol("0700.HK"), "0700.HK")
self.assertEqual(normalize_symbol("9992.HK"), "9992.HK")
self.assertEqual(normalize_symbol("80737.HK"), "80737.HK")
def test_hk_short_code_padded_to_four(self):
self.assertEqual(normalize_symbol("700.HK"), "0700.HK")
def test_hk_code_case_insensitive_suffix(self):
self.assertEqual(normalize_symbol("09992.hk"), "9992.HK")
def test_shanghai_sh_suffix_maps_to_yahoo_ss(self):
self.assertEqual(normalize_symbol("600519.sh"), "600519.SS")
self.assertEqual(normalize_symbol("600519.SS"), "600519.SS")
@pytest.mark.unit
class TestNoMarketDataError(unittest.TestCase):

View File

@@ -0,0 +1,140 @@
"""Dated tools take the analysis date from graph state, not from the model.
Every point-in-time guard behind a tool trusts the date it is given. A model that
omits the date, or passes today's instead of the analysis date, would otherwise
walk past them. The run's trade_date is injected from state and hidden from the
model-visible schema.
"""
from __future__ import annotations
from unittest import mock
import pytest
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from tradingagents.agents.utils import (
core_stock_tools,
fundamental_data_tools,
macro_data_tools,
market_data_validation_tools,
news_data_tools,
technical_indicators_tools,
)
from tradingagents.dataflows.date_window import as_of, as_of_window
TRADE_DATE = "2026-08-14"
@pytest.mark.unit
@pytest.mark.parametrize("requested, expected", [
("2026-09-14", TRADE_DATE), # later than the run: clamped
("2026-08-01", "2026-08-01"), # earlier: narrows, allowed
(None, TRADE_DATE), # omitted
("Sept 1", TRADE_DATE), # unparseable
("", TRADE_DATE),
])
def test_as_of_takes_the_earlier_date(requested, expected):
assert as_of(requested, TRADE_DATE) == expected
@pytest.mark.unit
def test_as_of_without_a_trade_date_passes_the_request_through():
assert as_of("2026-09-14", "") == "2026-09-14"
@pytest.mark.unit
@pytest.mark.parametrize("start, end, expected", [
("2026-08-01", "2026-09-14", ("2026-08-01", TRADE_DATE)), # end clamped
("2026-08-01", "2026-08-10", ("2026-08-01", "2026-08-10")), # inside: unchanged
("2026-09-01", "2026-09-08", ("2026-08-07", TRADE_DATE)), # wholly later: span kept, moved back
])
def test_as_of_window(start, end, expected):
assert as_of_window(start, end, TRADE_DATE) == expected
DATED_TOOLS = [
core_stock_tools.get_stock_data,
fundamental_data_tools.get_fundamentals,
fundamental_data_tools.get_balance_sheet,
fundamental_data_tools.get_cashflow,
fundamental_data_tools.get_income_statement,
news_data_tools.get_news,
news_data_tools.get_global_news,
technical_indicators_tools.get_indicators,
macro_data_tools.get_macro_indicators,
market_data_validation_tools.get_verified_market_snapshot,
]
@pytest.mark.unit
@pytest.mark.parametrize("tool", DATED_TOOLS, ids=lambda t: t.name)
def test_trade_date_is_hidden_from_the_model(tool):
assert "trade_date" not in tool.tool_call_schema.model_json_schema()["properties"]
class _State(MessagesState):
trade_date: str
def _run(tool, args, module):
"""Call the tool through a ToolNode in a graph carrying the run's trade_date."""
graph = StateGraph(_State)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
with mock.patch.object(module, "route_to_vendor", return_value="ok") as routed:
graph.compile().invoke({
"messages": [AIMessage("", tool_calls=[{"name": tool.name, "args": args, "id": "1"}])],
"trade_date": TRADE_DATE,
})
return routed.call_args.args
@pytest.mark.unit
def test_statement_tool_with_omitted_date_uses_the_run_date():
args = _run(fundamental_data_tools.get_balance_sheet, {"ticker": "AAPL"}, fundamental_data_tools)
assert args[-1] == TRADE_DATE # #1331: an omitted date no longer means unfiltered
@pytest.mark.unit
def test_future_curr_date_from_the_model_is_clamped():
args = _run(fundamental_data_tools.get_fundamentals,
{"ticker": "AAPL", "curr_date": "2026-09-14"}, fundamental_data_tools)
assert args == ("get_fundamentals", "AAPL", TRADE_DATE)
@pytest.mark.unit
def test_future_window_from_the_model_is_clamped():
args = _run(core_stock_tools.get_stock_data,
{"symbol": "AAPL", "start_date": "2026-08-01", "end_date": "2026-09-14"}, core_stock_tools)
assert args == ("get_stock_data", "AAPL", "2026-08-01", TRADE_DATE)
@pytest.mark.unit
def test_direct_call_without_state_is_unchanged():
with mock.patch.object(news_data_tools, "route_to_vendor", return_value="ok") as routed:
news_data_tools.get_news.func("AAPL", "2026-09-01", "2026-09-08")
assert routed.call_args.args == ("get_news", "AAPL", "2026-09-01", "2026-09-08")
# --- the run date itself (#1319) -------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize("bad", ["2026-9-10", "2026-09-10 00:00", "Sept 10", None])
def test_propagate_rejects_a_non_canonical_date(bad):
from tradingagents.graph.trading_graph import TradingAgentsGraph
with pytest.raises(ValueError, match="YYYY-MM-DD"):
object.__new__(TradingAgentsGraph).propagate("AAPL", bad)
@pytest.mark.unit
def test_propagate_rejects_a_future_date(monkeypatch):
import tradingagents.graph.trading_graph as tg
monkeypatch.setattr(tg, "get_current_date", lambda: "2026-09-10")
with pytest.raises(ValueError, match="future"):
object.__new__(tg.TradingAgentsGraph).propagate("AAPL", "2026-09-11")

View File

@@ -0,0 +1,248 @@
"""Insider filings and prediction-market odds are bounded by the run's trade date.
Neither tool takes a date from the model, so the run's trade_date is injected from
graph state. Insider filings carry dates and are filtered to it; Polymarket serves
only live odds, so a historical run withholds them.
"""
from __future__ import annotations
import json
from unittest import mock
import pandas as pd
import pytest
from tradingagents.agents.utils import news_data_tools, prediction_markets_tools
from tradingagents.dataflows import alpha_vantage_news, polymarket, y_finance
def _insider_frame(*dates):
return pd.DataFrame({
"Shares": [100] * len(dates),
"Text": [f"Sale at price {100 + i} per share." for i in range(len(dates))],
"Start Date": pd.to_datetime(list(dates)),
})
def _yf_insider(frame, curr_date):
ticker = mock.Mock(insider_transactions=frame)
with mock.patch.object(y_finance.yf, "Ticker", return_value=ticker):
return y_finance.get_insider_transactions("AAPL", curr_date)
@pytest.mark.unit
def test_yfinance_insider_filings_after_the_date_are_dropped():
out = _yf_insider(_insider_frame("2026-09-08", "2025-06-02", "2025-05-30", "2025-01-10"), "2025-06-01")
assert "2026-09-08" not in out and "2025-06-02" not in out
assert "2025-05-30" in out and "2025-01-10" in out
@pytest.mark.unit
def test_yfinance_insider_date_before_coverage_is_unavailable_not_absent():
out = _yf_insider(_insider_frame("2026-09-08", "2025-06-02"), "2024-01-01")
assert "unavailable" in out and "No insider transactions reported" not in out
assert "2025-06-02" in out # where coverage starts
@pytest.mark.unit
def test_yfinance_insider_without_a_date_is_unfiltered():
out = _yf_insider(_insider_frame("2026-09-08", "2025-01-10"), None)
assert "2026-09-08" in out and "2025-01-10" in out
@pytest.mark.unit
def test_alpha_vantage_insider_filings_after_the_date_are_dropped():
body = json.dumps({"data": [
{"transaction_date": "2026-09-08", "executive": "A"},
{"transaction_date": "2025-05-30", "executive": "B"},
]})
with mock.patch.object(alpha_vantage_news, "_make_api_request", return_value=body):
out = json.loads(alpha_vantage_news.get_insider_transactions("AAPL", "2025-06-01"))
assert [t["executive"] for t in out["data"]] == ["B"]
@pytest.mark.unit
def test_polymarket_withholds_live_odds_from_a_historical_run():
with mock.patch.object(polymarket, "_request", side_effect=AssertionError("must not fetch")):
out = polymarket.get_prediction_markets("Fed rate cut", curr_date="2025-06-01")
assert "withheld" in out
@pytest.mark.unit
def test_polymarket_serves_a_current_run():
with mock.patch.object(polymarket, "_request", return_value={"events": []}) as req:
polymarket.get_prediction_markets("Fed rate cut", curr_date=polymarket.get_current_date())
req.assert_called_once()
@pytest.mark.unit
@pytest.mark.parametrize("tool", [news_data_tools.get_insider_transactions,
prediction_markets_tools.get_prediction_markets], ids=lambda t: t.name)
def test_trade_date_is_injected_not_model_visible(tool):
assert "trade_date" in tool.func.__code__.co_varnames
props = tool.tool_call_schema.model_json_schema()["properties"]
assert "trade_date" not in props and "curr_date" not in props
# --- the instrument's identity -------------------------------------------------
@pytest.mark.unit
def test_a_historical_run_is_told_the_identity_is_current(monkeypatch):
"""The company name, sector and industry come from today's vendor profile.
They are usually right for a past date, but a company that renamed or was
reclassified since would read wrong, and every agent is told to anchor to
this identity, so the run has to know which date it describes."""
from tradingagents.agents.utils.agent_utils import build_instrument_context
identity = {"company_name": "Example Corp", "sector": "Technology",
"industry": "Software", "exchange": "NMS"}
historical = build_instrument_context("EXMP", "stock", identity, curr_date="2024-03-14")
assert "Example Corp" in historical
assert "2024-03-14" in historical and "today" in historical.lower()
@pytest.mark.unit
def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch):
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.dataflows.utils import get_current_date
today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"},
curr_date=get_current_date())
assert "Example Corp" in today
assert "resolved today" not in today.lower()
@pytest.mark.unit
def test_insider_rows_are_dated_by_the_trade_not_the_filing():
"""yfinance reports the transaction date and carries no filing date. A trade
becomes public when the Form 4 is filed, up to two business days later, so a
run must not be told these rows were public on their transaction date."""
import pandas as pd
from tradingagents.dataflows import y_finance
frame = pd.DataFrame({
"Shares": [100, 200],
"Text": ["Sale at price 10.00 per share.", "Sale at price 11.00 per share."],
"Start Date": pd.to_datetime(["2026-05-01", "2026-05-20"]),
})
ticker = mock.Mock(insider_transactions=frame)
with mock.patch.object(y_finance.yf, "Ticker", return_value=ticker):
out = y_finance.get_insider_transactions("AAPL", "2026-05-10")
assert "2026-05-01" in out and "2026-05-20" not in out # still bounded by the date
assert "transaction date" in out.lower() # and says what the date means
assert "filed" in out.lower() # and that filing comes later
@pytest.mark.unit
def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value():
"""The per-day fallback returned an empty string for a failed read, so the
table rendered a row per day with nothing after the colon: an analyst reads
that as "no value on that day" rather than "could not be obtained"."""
from tradingagents.dataflows import y_finance
from tradingagents.dataflows.errors import VendorError
with mock.patch.object(y_finance.StockstatsUtils, "get_stock_stats",
side_effect=RuntimeError("cache parse failed")), \
pytest.raises(VendorError):
y_finance.get_stockstats_indicator("AAPL", "rsi", "2026-05-08")
@pytest.mark.unit
@pytest.mark.parametrize("func, args", [
# A past date withholds the live profile before any request, so the
# fundamentals case is exercised on the date it does fetch.
("get_fundamentals", ("AAPL", None)),
("get_balance_sheet", ("AAPL", "annual", "2026-09-01")),
("get_cashflow", ("AAPL", "annual", "2026-09-01")),
("get_income_statement", ("AAPL", "annual", "2026-09-01")),
("get_insider_transactions", ("AAPL", "2026-09-01")),
])
def test_a_yfinance_failure_is_a_vendor_error_not_a_report(func, args):
"""Returning the failure as text makes the router count it as an answer, so
the chain stops and the analyst reads the error message as if it were data.
yfinance serves the default path, so this is the one that matters most."""
from tradingagents.dataflows import y_finance
from tradingagents.dataflows.errors import VendorError
with mock.patch.object(y_finance.yf, "Ticker", side_effect=RuntimeError("yahoo hiccup")), \
pytest.raises(VendorError):
getattr(y_finance, func)(*args)
@pytest.mark.unit
@pytest.mark.parametrize("func, args", [
("get_news_yfinance", ("AAPL", "2026-08-25", "2026-09-01")),
("get_global_news_yfinance", ("2026-09-01", 7, 5)),
])
def test_a_yfinance_news_failure_is_a_vendor_error_not_a_report(func, args):
from tradingagents.dataflows import yfinance_news
from tradingagents.dataflows.errors import VendorError
target = "Ticker" if "global" not in func else "Search"
with mock.patch.object(yfinance_news.yf, target, side_effect=RuntimeError("yahoo hiccup")), \
pytest.raises(VendorError):
getattr(yfinance_news, func)(*args)
@pytest.mark.unit
def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch):
"""yfinance returns an empty frame when it cannot reach Yahoo, with no
exception. Reporting that as "no data for AAPL" tells the analyst the
company has no balance sheet, when the truth is we could not ask."""
import pandas as pd
from tradingagents.dataflows import stockstats_utils, y_finance
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame())
monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: empty)
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"):
y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01")
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01")
@pytest.mark.unit
def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch):
"""A throttled or unreachable chain used to raise RuntimeError('No available
vendor'), which ends the run, and never said the vendor was the problem."""
from tradingagents.dataflows import interface
from tradingagents.dataflows.errors import VendorRateLimitError
def _down(*a, **k):
raise VendorRateLimitError("Yahoo Finance is unreachable")
monkeypatch.setitem(interface.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down)
out = interface.route_to_vendor("get_balance_sheet", "AAPL", "annual", "2026-09-01")
assert "unavailable" in out.lower() and "unreachable" in out.lower()
assert "delisted" not in out.lower() # not a claim about the symbol
@pytest.mark.unit
def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch):
"""Prices are the most-used path, so an outage there must not read as a
delisted symbol either."""
import pandas as pd
from tradingagents.dataflows import stockstats_utils, y_finance
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame()))
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"):
y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")

View File

@@ -26,7 +26,7 @@ def create_fundamentals_analyst(llm):
"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 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(),
+ get_language_instruction()
)
prompt = ChatPromptTemplate.from_messages(
@@ -37,8 +37,7 @@ def create_fundamentals_analyst(llm):
" 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."
" Report what your tools support; another agent decides the trade."
" You have access to the following tools: {tool_names}."
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
"{system_message}",

View File

@@ -63,8 +63,7 @@ Write a very detailed and nuanced report of the trends you observe. Provide spec
" 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."
" Report what your tools support; another agent decides the trade."
" You have access to the following tools: {tool_names}."
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
"{system_message}",

View File

@@ -38,8 +38,7 @@ def create_news_analyst(llm):
" 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."
" Report what your tools support; another agent decides the trade."
" You have access to the following tools: {tool_names}."
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
"{system_message}",

View File

@@ -13,6 +13,10 @@ the LLM is invoked and injects them into the prompt as structured blocks:
user-labeled Bullish/Bearish sentiment tags
3. Reddit posts — r/wallstreetbets, r/stocks, r/investing
Each source is trimmed to the analysis window. These text feeds serve recent
items and are not archived as of a past date, so sentiment inputs for a
historical run are not guaranteed to be point-in-time.
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
@@ -36,6 +40,7 @@ from tradingagents.agents.utils.agent_utils import (
get_news,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
@@ -67,8 +72,12 @@ def create_sentiment_analyst(llm):
# 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)
stocktwits_block = fetch_stocktwits_messages(ticker, limit=30)
reddit_block = fetch_reddit_posts(ticker)
# 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,
@@ -84,9 +93,12 @@ def create_sentiment_analyst(llm):
(
"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."
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}"
" Report what your tools support; another agent decides the trade."
# 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"),
@@ -147,7 +159,7 @@ Fast-moving signal. Each message carries a user-labeled sentiment tag (Bullish /
<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).
Community discussion, without vote or comment counts. Subreddit character matters (r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term).
<start_of_reddit>
{reddit_block}
@@ -159,7 +171,7 @@ Community discussion. Engagement signal via upvote score and comment count. Subr
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.
3. **Read Reddit posts for substance.** The feed carries no vote or comment counts, so judge a post by its body excerpt, not its title alone, and do not infer engagement.
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.

View File

@@ -14,8 +14,10 @@ from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
get_portfolio_context_from_state,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
@@ -26,6 +28,7 @@ def create_portfolio_manager(llm):
def portfolio_manager_node(state) -> dict:
instrument_context = get_instrument_context_from_state(state)
portfolio_context = get_portfolio_context_from_state(state)
history = state["risk_debate_state"]["history"]
risk_debate_state = state["risk_debate_state"]
@@ -43,6 +46,8 @@ def create_portfolio_manager(llm):
{instrument_context}
{portfolio_context}
---
**Rating Scale** (use exactly one):
@@ -61,7 +66,17 @@ def create_portfolio_manager(llm):
---
Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}"""
Ground every conclusion in specific evidence from the analysts. The risk debate always contains conflicting stances; deciding which is stronger is the job, so conflict alone is not a reason to Hold. Commit to the stronger case, sized by how decisively it wins. Choose Hold only when the evidence is still balanced after that weighing, or too thin to support a call; do not force a direction to appear decisive. Weigh the analysts on their merits, independent of speaking order.
## Output
Write these sections, in this order, starting with the rating on its own line:
- **Rating**: exactly one of Buy / Overweight / Hold / Underweight / Sell
- **Executive Summary**: the call and how to act on it
- **Investment Thesis**: the evidence that decided it, and what would change it
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
final_trade_decision = invoke_structured_or_freetext(
structured_llm,

View File

@@ -8,6 +8,7 @@ from tradingagents.agents.utils.agent_utils import (
get_language_instruction,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
@@ -35,12 +36,22 @@ def create_research_manager(llm):
- **Underweight**: Cautious view; recommend trimming exposure
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
Commit to a clear stance whenever the debate's strongest arguments warrant one; reserve Hold for situations where the evidence on both sides is genuinely balanced.
The debate always contains conflicting arguments; deciding which side is stronger is the job, so conflict alone is not a reason to Hold. Commit to the side with the stronger case, sized by how decisively it wins. Choose Hold only when the evidence is still balanced after that weighing, or too thin to support a call; do not manufacture a direction to appear decisive. Weigh the bull and bear cases on their merits, independent of which side spoke first or last.
---
**Debate History:**
{history}""" + get_language_instruction()
{history}
## Output
Write these sections, in this order, starting with the recommendation on its own line:
- **Recommendation**: exactly one of Buy / Overweight / Hold / Underweight / Sell
- **Rationale**: which arguments decided it
- **Strategic Actions**: concrete steps for the trader, sized against a standard allocation
{NO_EXTERNAL_TOOLS}""" + get_language_instruction()
investment_plan = invoke_structured_or_freetext(
structured_llm,

View File

@@ -1,6 +1,8 @@
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
opponent_argument_or_opening,
report_or_absent,
)
@@ -10,11 +12,13 @@ def create_bear_researcher(llm):
history = investment_debate_state.get("history", "")
bear_history = investment_debate_state.get("bear_history", "")
current_response = investment_debate_state.get("current_response", "")
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
current_response = opponent_argument_or_opening(
investment_debate_state.get("current_response", ""), "bull analyst"
)
market_research_report = report_or_absent(state["market_report"], "market")
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
news_report = report_or_absent(state["news_report"], "news")
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
instrument_context = get_instrument_context_from_state(state)
asset_type = state.get("asset_type", "stock")
target_label = "stock" if asset_type == "stock" else "asset"

View File

@@ -1,6 +1,8 @@
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
opponent_argument_or_opening,
report_or_absent,
)
@@ -10,11 +12,13 @@ def create_bull_researcher(llm):
history = investment_debate_state.get("history", "")
bull_history = investment_debate_state.get("bull_history", "")
current_response = investment_debate_state.get("current_response", "")
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
current_response = opponent_argument_or_opening(
investment_debate_state.get("current_response", ""), "bear analyst"
)
market_research_report = report_or_absent(state["market_report"], "market")
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
news_report = report_or_absent(state["news_report"], "news")
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
instrument_context = get_instrument_context_from_state(state)
asset_type = state.get("asset_type", "stock")
target_label = "stock" if asset_type == "stock" else "asset"

View File

@@ -1,6 +1,9 @@
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
get_portfolio_context_from_state,
opponent_argument_or_opening,
report_or_absent,
)
@@ -10,14 +13,19 @@ def create_aggressive_debator(llm):
history = risk_debate_state.get("history", "")
aggressive_history = risk_debate_state.get("aggressive_history", "")
current_conservative_response = risk_debate_state.get("current_conservative_response", "")
current_neutral_response = risk_debate_state.get("current_neutral_response", "")
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"]
market_research_report = report_or_absent(state["market_report"], "market")
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
news_report = report_or_absent(state["news_report"], "news")
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
instrument_context = get_instrument_context_from_state(state)
portfolio_context = get_portfolio_context_from_state(state)
trader_decision = state["trader_investment_plan"]
@@ -28,6 +36,7 @@ def create_aggressive_debator(llm):
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}
{portfolio_context}
Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report}

View File

@@ -1,6 +1,9 @@
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
get_portfolio_context_from_state,
opponent_argument_or_opening,
report_or_absent,
)
@@ -10,14 +13,19 @@ def create_conservative_debator(llm):
history = risk_debate_state.get("history", "")
conservative_history = risk_debate_state.get("conservative_history", "")
current_aggressive_response = risk_debate_state.get("current_aggressive_response", "")
current_neutral_response = risk_debate_state.get("current_neutral_response", "")
current_aggressive_response = opponent_argument_or_opening(
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"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
market_research_report = report_or_absent(state["market_report"], "market")
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
news_report = report_or_absent(state["news_report"], "news")
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
instrument_context = get_instrument_context_from_state(state)
portfolio_context = get_portfolio_context_from_state(state)
trader_decision = state["trader_investment_plan"]
@@ -28,6 +36,7 @@ def create_conservative_debator(llm):
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}
{portfolio_context}
Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report}

View File

@@ -1,6 +1,9 @@
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
get_portfolio_context_from_state,
opponent_argument_or_opening,
report_or_absent,
)
@@ -10,14 +13,19 @@ def create_neutral_debator(llm):
history = risk_debate_state.get("history", "")
neutral_history = risk_debate_state.get("neutral_history", "")
current_aggressive_response = risk_debate_state.get("current_aggressive_response", "")
current_conservative_response = risk_debate_state.get("current_conservative_response", "")
current_aggressive_response = opponent_argument_or_opening(
risk_debate_state.get("current_aggressive_response", ""), "aggressive analyst"
)
current_conservative_response = opponent_argument_or_opening(
risk_debate_state.get("current_conservative_response", ""), "conservative analyst"
)
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
market_research_report = report_or_absent(state["market_report"], "market")
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
news_report = report_or_absent(state["news_report"], "news")
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
instrument_context = get_instrument_context_from_state(state)
portfolio_context = get_portfolio_context_from_state(state)
trader_decision = state["trader_investment_plan"]
@@ -28,6 +36,7 @@ def create_neutral_debator(llm):
Your task is to challenge both the Aggressive and Conservative Analysts, pointing out where each perspective may be overly optimistic or overly cautious. Use insights from the following data sources to support a moderate, sustainable strategy to adjust the trader's decision:
{instrument_context}
{portfolio_context}
Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report}

View File

@@ -31,9 +31,31 @@ _NULLISH_FLOAT = {"", "none", "n/a", "na", "null", "nil", "-", "tbd", "unknown"}
def _coerce_optional_float(value):
if isinstance(value, str) and value.strip().lower() in _NULLISH_FLOAT:
"""Normalise an LLM-written optional numeric field before validation.
Three shapes show up in practice: a placeholder string ("None", "N/A") in
place of an omitted value (#1058); a percentage where a price was asked for
("15%", #1288); and a human-formatted price ("$1,234.50"). A percentage
cannot be salvaged into an absolute level -- reading "15%" as 15 would put a
stop at $15 on a $600 stock -- so it is dropped like a placeholder, leaving
one bad field to null out instead of failing the whole proposal. A formatted
price is reduced to its number.
Anything that is not a single number is dropped the same way. A range
("150-160") or a hedge ("around 150") would otherwise reach pydantic, fail
validation, and discard the whole decision, losing every field the model got
right along with the price.
"""
if not isinstance(value, str):
return value
text = value.strip()
if text.lower() in _NULLISH_FLOAT or text.endswith("%"):
return None
cleaned = text.replace(",", "").lstrip("$€£¥").strip()
try:
return float(cleaned)
except ValueError:
return None
return value
# ---------------------------------------------------------------------------
@@ -82,9 +104,10 @@ class ResearchPlan(BaseModel):
recommendation: PortfolioRating = Field(
description=(
"The investment recommendation. Exactly one of Buy / Overweight / "
"Hold / Underweight / Sell. Reserve Hold for situations where the "
"evidence on both sides is genuinely balanced; otherwise commit to "
"the side with the stronger arguments."
"Hold / Underweight / Sell. Conflicting arguments alone are not a "
"reason to Hold: commit to the stronger side, sized by how "
"decisively it wins. Choose Hold only when the evidence is still "
"balanced after weighing, or too thin to support a call."
),
)
rationale: str = Field(
@@ -97,7 +120,9 @@ class ResearchPlan(BaseModel):
strategic_actions: str = Field(
description=(
"Concrete steps for the trader to implement the recommendation, "
"including position sizing guidance consistent with the rating."
"including sizing guidance relative to a standard allocation. The "
"research team does not see the caller's holdings; the trader and "
"portfolio manager apply the actual position."
),
)
@@ -138,11 +163,19 @@ class TraderProposal(BaseModel):
)
entry_price: float | None = Field(
default=None,
description="Optional entry price target in the instrument's quote currency.",
description=(
"Optional entry price target as an absolute number in the instrument's "
"quote currency (e.g. 189.5), never a percentage or a range. Omit it "
"if you cannot state a specific level."
),
)
stop_loss: float | None = Field(
default=None,
description="Optional stop-loss price in the instrument's quote currency.",
description=(
"Optional stop-loss as an absolute price in the instrument's quote "
"currency (e.g. 172.0), never a percentage. Convert a percentage "
"distance to the price level it implies, or omit it."
),
)
position_sizing: str | None = Field(
default=None,
@@ -167,12 +200,12 @@ def render_trader_proposal(proposal: TraderProposal) -> str:
"",
f"**Reasoning**: {proposal.reasoning}",
]
if proposal.entry_price is not None:
parts.extend(["", f"**Entry Price**: {proposal.entry_price}"])
if proposal.stop_loss is not None:
parts.extend(["", f"**Stop Loss**: {proposal.stop_loss}"])
if proposal.position_sizing:
parts.extend(["", f"**Position Sizing**: {proposal.position_sizing}"])
# Named even when absent, so a reader can tell a level the trader chose not
# to give from one the schema never asked for.
for label, value in (("Entry Price", proposal.entry_price),
("Stop Loss", proposal.stop_loss),
("Position Sizing", proposal.position_sizing)):
parts.extend(["", f"**{label}**: {value if value is not None and value != '' else 'not provided'}"])
parts.extend([
"",
f"FINAL TRANSACTION PROPOSAL: **{proposal.action.value.upper()}**",
@@ -197,7 +230,11 @@ class PortfolioDecision(BaseModel):
rating: PortfolioRating = Field(
description=(
"The final position rating. Exactly one of Buy / Overweight / Hold / "
"Underweight / Sell, picked based on the analysts' debate."
"Underweight / Sell, picked based on the analysts' debate. "
"Conflicting arguments alone are not a reason to Hold: commit to the "
"stronger side, sized by how decisively it wins. Choose Hold only "
"when the evidence is still balanced after weighing, or too thin to "
"support a call."
),
)
executive_summary: str = Field(
@@ -243,10 +280,11 @@ def render_pm_decision(decision: PortfolioDecision) -> str:
"",
f"**Investment Thesis**: {decision.investment_thesis}",
]
if decision.price_target is not None:
parts.extend(["", f"**Price Target**: {decision.price_target}"])
if decision.time_horizon:
parts.extend(["", f"**Time Horizon**: {decision.time_horizon}"])
# Named even when absent: a missing line reads as a field nobody asked for,
# so a reader cannot tell "no target" from "target not reported".
target = decision.price_target if decision.price_target is not None else "not provided"
parts.extend(["", f"**Price Target**: {target}"])
parts.extend(["", f"**Time Horizon**: {decision.time_horizon or 'not provided'}"])
return "\n".join(parts)

View File

@@ -10,8 +10,10 @@ from tradingagents.agents.schemas import TraderProposal, render_trader_proposal
from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state,
get_language_instruction,
get_portfolio_context_from_state,
)
from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
@@ -24,6 +26,24 @@ def create_trader(llm):
company_name = state["company_of_interest"]
instrument_context = get_instrument_context_from_state(state)
investment_plan = state["investment_plan"]
# The research plan digests the debate but loses exact price structure;
# give the Trader the technical market report so entry/stop levels are
# grounded in real ATR / support-resistance / current price (#1167). The
# report is empty when the user did not select the market analyst, so
# only offer it (and the grounding instruction) when it has content.
market_report = (state["market_report"] or "").strip()
portfolio_context = get_portfolio_context_from_state(state)
if market_report:
grounding = (
"Ground concrete price levels (entry, stop-loss, position sizing) in the technical "
"market report's price structure -- current price, support/resistance, ATR, and "
"volatility -- and use the research plan for direction and strategy. "
)
report_section = f"Technical Market Report:\n{market_report}\n\n"
else:
grounding = ""
report_section = ""
messages = [
{
@@ -31,19 +51,35 @@ def create_trader(llm):
"content": (
"You are a trading agent analyzing market data to make investment decisions. "
"Based on your analysis, provide a specific recommendation to buy, sell, or hold. "
"Anchor your reasoning in the analysts' reports and the research plan."
+ grounding
# Entry/stop are numeric price fields. Asking for concrete
# levels invites a percentage ("15%"), which is not a price
# and fails the structured parse (#1288).
+ "State entry price and stop-loss as absolute price levels in the "
"instrument's quote currency (for example 189.5), never a percentage "
"or a range; convert a percentage distance to the price level it "
"implies, or omit the field if you cannot state a number. "
+ NO_EXTERNAL_TOOLS
+ get_language_instruction()
),
},
{
"role": "user",
"content": (
f"Based on a comprehensive analysis by a team of analysts, here is an investment "
f"plan tailored for {company_name}. {instrument_context} This plan incorporates "
f"insights from current technical market trends, macroeconomic indicators, and "
f"social media sentiment. Use this plan as a foundation for evaluating your next "
f"trading decision.\n\nProposed Investment Plan: {investment_plan}\n\n"
f"Leverage these insights to make an informed and strategic decision."
f"Here is the research team's investment plan for {company_name}. "
f"{instrument_context}\n\n"
f"{report_section}"
f"{portfolio_context}\n\n"
f"Proposed Investment Plan:\n{investment_plan}\n\n"
"Make an informed, strategic trading decision.\n\n"
"## Output\n\n"
"Write these sections, in this order, starting with the action "
"on its own line:\n\n"
"- **Action**: exactly one of Buy / Hold / Sell. A research "
"recommendation of Overweight is a Buy and Underweight is a Sell, "
"sized by how strong the case is; conflict alone is not a Hold.\n"
"- **Reasoning**: why, against the plan and the price structure\n"
"- **Entry Price**, **Stop Loss**, **Position Sizing**: when you can state them"
),
},
]

View File

@@ -74,3 +74,4 @@ class AgentState(MessagesState):
]
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"]
past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"]
portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"]

View File

@@ -48,6 +48,8 @@ __all__ = [
logger = logging.getLogger(__name__)
from tradingagents.dataflows.utils import get_current_date # noqa: E402
def get_language_instruction() -> str:
"""Return a prompt instruction for the configured output language.
@@ -65,6 +67,20 @@ def get_language_instruction() -> str:
return f" Write your entire response in {lang}."
def opponent_argument_or_opening(text: str, opponent: str) -> str:
"""Opponent's latest argument, or an explicit opening marker when empty.
The first speaker in each debate round receives an empty opponent response;
interpolating it into a "refute the opponent" prompt makes the model
fabricate the other side's position. Returning a clear "has not spoken yet"
marker instead lets it open with its own case (#1176).
"""
text = (text or "").strip()
if text:
return text
return f"(The {opponent} has not spoken yet — open the debate with your own case.)"
def _clean_identity_value(value: Any) -> str | None:
"""Return a trimmed string, or None for empty / placeholder-ish values."""
if not isinstance(value, str):
@@ -123,6 +139,7 @@ def build_instrument_context(
ticker: str,
asset_type: str = "stock",
identity: Mapping[str, str] | None = None,
curr_date: str | None = None,
) -> str:
"""Describe the exact instrument so agents preserve identity and ticker.
@@ -130,6 +147,11 @@ def build_instrument_context(
:func:`resolve_instrument_identity`), the company name and business
classification are injected so agents anchor to the real company rather
than pattern-matching the price chart to a wrong one (#814).
That profile carries no historical vintage: it describes the company today.
For a run dated earlier, the context says so, since a company that has since
renamed or been reclassified would otherwise anchor the whole graph to an
identity it did not have on the analysis date.
"""
is_crypto = asset_type == "crypto"
instrument_label = "asset" if is_crypto else "instrument"
@@ -160,6 +182,13 @@ def build_instrument_context(
"Do not substitute a different company or ticker unless a tool "
"result explicitly disproves this resolved identity."
)
today = get_current_date()
if curr_date and str(curr_date) < today:
context += (
f" This identity is how the vendor describes the instrument today "
f"({today}), not necessarily on {curr_date}: a name or "
f"classification changed since then would read as the current one."
)
if is_crypto:
context += (
@@ -187,6 +216,37 @@ def get_instrument_context_from_state(state: Mapping[str, Any]) -> str:
)
def report_or_absent(text: str, source: str) -> str:
"""An analyst's report, or a marker saying it was never produced.
A report is empty when its analyst was not selected, refused, or returned
nothing. Interpolating that into a labelled section presents an absence as a
blank finding, and the reading agent fills it in from nothing, the same way
an empty opponent argument used to invite an invented rebuttal (#1176).
"""
text = (text or "").strip()
if text:
return text
return f"(No {source} report in this run: it is not available, not an empty finding.)"
def get_portfolio_context_from_state(state: Mapping[str, Any]) -> str:
"""Return the caller's portfolio block, or a notice that none was given.
A run without portfolio context must not read as a flat book: the agents
would otherwise size as if the caller held nothing, which is a claim about
an account we were never told about.
"""
context = state.get("portfolio_context")
if isinstance(context, str) and context.strip():
return context
return (
"Portfolio context: not provided. You do not know the caller's current "
"holdings or cash, so do not assume a flat book; give direction and "
"sizing guidance in terms the caller can apply to their own position."
)
def create_msg_delete():
def delete_messages(state):
"""Clear messages and add a context-anchored placeholder.

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of_window
from tradingagents.dataflows.interface import route_to_vendor
@@ -10,6 +12,7 @@ def get_stock_data(
symbol: Annotated[str, "ticker symbol of the company"],
start_date: Annotated[str, "Start date in yyyy-mm-dd format"],
end_date: Annotated[str, "End date in yyyy-mm-dd format"],
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve stock price data (OHLCV) for a given ticker symbol.
@@ -21,4 +24,5 @@ def get_stock_data(
Returns:
str: A formatted dataframe containing the stock price data for the specified ticker symbol in the specified date range.
"""
start_date, end_date = as_of_window(start_date, end_date, trade_date)
return route_to_vendor("get_stock_data", symbol, start_date, end_date)

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of
from tradingagents.dataflows.interface import route_to_vendor
@@ -9,6 +11,7 @@ from tradingagents.dataflows.interface import route_to_vendor
def get_fundamentals(
ticker: Annotated[str, "ticker symbol"],
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"],
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve comprehensive fundamental data for a given ticker symbol.
@@ -19,7 +22,7 @@ def get_fundamentals(
Returns:
str: A formatted report containing comprehensive fundamental data
"""
return route_to_vendor("get_fundamentals", ticker, curr_date)
return route_to_vendor("get_fundamentals", ticker, as_of(curr_date, trade_date))
@tool
@@ -27,6 +30,7 @@ def get_balance_sheet(
ticker: Annotated[str, "ticker symbol"],
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve balance sheet data for a given ticker symbol.
@@ -38,7 +42,7 @@ def get_balance_sheet(
Returns:
str: A formatted report containing balance sheet data
"""
return route_to_vendor("get_balance_sheet", ticker, freq, curr_date)
return route_to_vendor("get_balance_sheet", ticker, freq, as_of(curr_date, trade_date))
@tool
@@ -46,6 +50,7 @@ def get_cashflow(
ticker: Annotated[str, "ticker symbol"],
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve cash flow statement data for a given ticker symbol.
@@ -57,7 +62,7 @@ def get_cashflow(
Returns:
str: A formatted report containing cash flow statement data
"""
return route_to_vendor("get_cashflow", ticker, freq, curr_date)
return route_to_vendor("get_cashflow", ticker, freq, as_of(curr_date, trade_date))
@tool
@@ -65,6 +70,7 @@ def get_income_statement(
ticker: Annotated[str, "ticker symbol"],
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve income statement data for a given ticker symbol.
@@ -76,4 +82,4 @@ def get_income_statement(
Returns:
str: A formatted report containing income statement data
"""
return route_to_vendor("get_income_statement", ticker, freq, curr_date)
return route_to_vendor("get_income_statement", ticker, freq, as_of(curr_date, trade_date))

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of
from tradingagents.dataflows.interface import route_to_vendor
@@ -17,6 +19,7 @@ def get_macro_indicators(
look_back_days: Annotated[
int | None, "Trailing window length in days; omit for a 1-year window"
] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve a macroeconomic indicator time series from FRED (Federal Reserve
@@ -33,4 +36,4 @@ def get_macro_indicators(
Returns:
str: A formatted markdown report of the macro series
"""
return route_to_vendor("get_macro_indicators", indicator, curr_date, look_back_days)
return route_to_vendor("get_macro_indicators", indicator, as_of(curr_date, trade_date), look_back_days)

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of
from tradingagents.dataflows.market_data_validator import build_verified_market_snapshot
@@ -12,6 +14,7 @@ def get_verified_market_snapshot(
look_back_days: Annotated[
int, "number of recent trading rows to include for sanity-checking"
] = 30,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""Deterministic verification snapshot for exact market-data claims.
@@ -20,4 +23,4 @@ def get_verified_market_snapshot(
price levels, Bollinger bands, RSI, MACD, moving averages, support /
resistance, or historical comparisons, and treat it as the source of truth.
"""
return build_verified_market_snapshot(symbol, curr_date, look_back_days)
return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days)

View File

@@ -36,11 +36,14 @@ class TradingMemoryLog:
"""Append pending entry at end of propagate(). No LLM call."""
if not self._log_path:
return
# Idempotency guard: fast raw-text scan instead of full parse
# Idempotency guard: fast raw-text scan instead of full parse. Any entry
# for this ticker and date blocks another, pending or settled: a re-run
# after the outcome landed would otherwise count the same decision twice
# in past context and in every aggregate over the log.
if self._log_path.exists():
raw = self._log_path.read_text(encoding="utf-8")
for line in raw.splitlines():
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("| pending]"):
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"):
return
rating = parse_rating(final_trade_decision)
tag = f"[{trade_date} | {ticker} | {rating} | pending]"
@@ -67,9 +70,21 @@ class TradingMemoryLog:
"""Return entries with outcome:pending (for Phase B)."""
return [e for e in self.load_entries() if e.get("pending")]
def get_past_context(self, ticker: str, n_same: int = 5, n_cross: int = 3) -> str:
"""Return formatted past context string for agent prompt injection."""
def get_past_context(
self, ticker: str, n_same: int = 5, n_cross: int = 3, as_of: str | None = None
) -> str:
"""Return formatted past context string for agent prompt injection.
When ``as_of`` (yyyy-mm-dd) is given, only lessons whose outcome was
already known by that date are included — an entry is kept only if it
stores a resolution date (``resolved:...``) that is on or before
``as_of``. This keeps a historical/backtest run from learning from
outcomes that had not happened yet (#1251). ``as_of=None`` disables the
filter, so live runs and pre-migration entries are unaffected.
"""
entries = [e for e in self.load_entries() if not e.get("pending")]
if as_of is not None:
entries = [e for e in entries if e.get("resolved") and e["resolved"] <= as_of]
if not entries:
return ""
@@ -104,12 +119,14 @@ class TradingMemoryLog:
alpha_return: float,
holding_days: int,
reflection: str,
resolution_date: str | None = None,
) -> None:
"""Replace pending tag and append REFLECTION section using atomic write.
Finds the first pending entry matching (trade_date, ticker), updates
its tag with return figures, and appends a REFLECTION section. Uses
a temp-file + os.replace() so a crash mid-write never corrupts the log.
its tag with return figures (and the ``resolution_date`` the outcome
became known), and appends a REFLECTION section. Uses a temp-file +
os.replace() so a crash mid-write never corrupts the log.
"""
if not self._log_path or not self._log_path.exists():
return
@@ -140,9 +157,8 @@ class TradingMemoryLog:
# Parse rating from the existing pending tag
fields = [f.strip() for f in tag_line[1:-1].split("|")]
rating = fields[2]
new_tag = (
f"[{trade_date} | {ticker} | {rating}"
f" | {raw_pct} | {alpha_pct} | {holding_days}d]"
new_tag = self._resolved_tag(
trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date
)
rest = "\n".join(lines[1:])
new_blocks.append(
@@ -194,9 +210,9 @@ class TradingMemoryLog:
rating = fields[2]
raw_pct = f"{upd['raw_return']:+.1%}"
alpha_pct = f"{upd['alpha_return']:+.1%}"
new_tag = (
f"[{trade_date} | {ticker} | {rating}"
f" | {raw_pct} | {alpha_pct} | {upd['holding_days']}d]"
new_tag = self._resolved_tag(
trade_date, ticker, rating, raw_pct, alpha_pct,
upd["holding_days"], upd.get("resolution_date"),
)
rest = "\n".join(lines[1:])
new_blocks.append(
@@ -217,6 +233,21 @@ class TradingMemoryLog:
# --- Helpers ---
@staticmethod
def _resolved_tag(
trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date
) -> str:
"""Build a resolved entry tag, recording the outcome's known-by date.
``resolution_date`` (the date of the last price bar used for the return)
is the point-in-time cutoff a later run filters on (#1251). Omitted when
unavailable, keeping the legacy 6-field tag.
"""
tag = f"[{trade_date} | {ticker} | {rating} | {raw_pct} | {alpha_pct} | {holding_days}d"
if resolution_date:
tag += f" | resolved:{resolution_date}"
return tag + "]"
def _apply_rotation(self, blocks: list[str]) -> list[str]:
"""Drop oldest resolved blocks when their count exceeds max_entries.
@@ -264,6 +295,12 @@ class TradingMemoryLog:
fields = [f.strip() for f in tag_line[1:-1].split("|")]
if len(fields) < 4:
return None
# Optional trailing "resolved:YYYY-MM-DD" field records when the outcome
# became known, for point-in-time filtering (#1251).
resolved = None
for f in fields[6:]:
if f.startswith("resolved:"):
resolved = f[len("resolved:"):].strip()
entry = {
"date": fields[0],
"ticker": fields[1],
@@ -272,6 +309,7 @@ class TradingMemoryLog:
"raw": fields[3] if fields[3] != "pending" else None,
"alpha": fields[4] if len(fields) > 4 else None,
"holding": fields[5] if len(fields) > 5 else None,
"resolved": resolved,
}
body = "\n".join(lines[1:]).strip()
decision_match = self._DECISION_RE.search(body)

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of, as_of_window
from tradingagents.dataflows.interface import route_to_vendor
@@ -10,6 +12,7 @@ def get_news(
ticker: Annotated[str, "Ticker symbol"],
start_date: Annotated[str, "Start date in yyyy-mm-dd format"],
end_date: Annotated[str, "End date in yyyy-mm-dd format"],
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve news data for a given ticker symbol.
@@ -21,6 +24,7 @@ def get_news(
Returns:
str: A formatted string containing news data
"""
start_date, end_date = as_of_window(start_date, end_date, trade_date)
return route_to_vendor("get_news", ticker, start_date, end_date)
@tool
@@ -28,6 +32,7 @@ def get_global_news(
curr_date: Annotated[str, "Current date in yyyy-mm-dd format"],
look_back_days: Annotated[int | None, "Days to look back; omit to use the configured default"] = None,
limit: Annotated[int | None, "Max articles to return; omit to use the configured default"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve global news data.
@@ -43,11 +48,12 @@ def get_global_news(
Returns:
str: A formatted string containing global news data
"""
return route_to_vendor("get_global_news", curr_date, look_back_days, limit)
return route_to_vendor("get_global_news", as_of(curr_date, trade_date), look_back_days, limit)
@tool
def get_insider_transactions(
ticker: Annotated[str, "ticker symbol"],
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve insider transaction information about a company.
@@ -57,4 +63,4 @@ def get_insider_transactions(
Returns:
str: A report of insider transaction data
"""
return route_to_vendor("get_insider_transactions", ticker)
return route_to_vendor("get_insider_transactions", ticker, trade_date or None)

View File

@@ -1,6 +1,7 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.interface import route_to_vendor
@@ -13,6 +14,7 @@ def get_prediction_markets(
"'US election', or a sector/company event.",
],
limit: Annotated[int | None, "Max markets to return; omit for a default of 6"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve live, market-implied probabilities for forward-looking events from
@@ -28,4 +30,4 @@ def get_prediction_markets(
Returns:
str: A formatted markdown report of matching prediction markets
"""
return route_to_vendor("get_prediction_markets", topic, limit)
return route_to_vendor("get_prediction_markets", topic, limit, trade_date or None)

View File

@@ -7,42 +7,87 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
- The memory log (rating tag stored alongside each decision entry)
Centralising it here avoids drift between those call sites.
``extract_rating`` returns ``None`` when no rating can be found, and every
caller turns that into ``REVIEW`` rather than a tradeable position: a decision
nobody can read is not a Hold, and a Hold recorded in its place is quoted back to
the next run as a call that was never made (#1170).
"""
from __future__ import annotations
import re
import unicodedata
# Canonical, ordered 5-tier scale (most bullish to most bearish).
RATINGS_5_TIER: tuple[str, ...] = (
"Buy", "Overweight", "Hold", "Underweight", "Sell",
)
# Signal emitted when the model's decision has no recognizable rating. It is not
# a tradeable position: it flags output that needs a human/re-run rather than
# silently degrading to Hold. Callers that map the signal onto the 5-tier enum
# (e.g. ``PortfolioRating(signal)``) should guard with ``is_review`` first.
RATING_REVIEW = "REVIEW"
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
# Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown
# bold wrappers and either a colon or hyphen separator.
_RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE)
# Matches "Rating: X" / "rating - X" / "Rating **X**" — tolerates markdown
# bold wrappers and any dash or colon a model writes as the separator.
_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
re.IGNORECASE)
# A line presenting the scale rather than a decision ("Rating Scale: Buy, ...").
_RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE)
# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match).
_RATING_WORD_RE = re.compile(
r"\b(" + "|".join(RATINGS_5_TIER) + r")\b", re.IGNORECASE
)
def parse_rating(text: str, default: str = "Hold") -> str:
"""Heuristically extract a 5-tier rating from prose text.
def extract_rating(text: str) -> str | None:
"""Extract a 5-tier rating from prose, or ``None`` if none is present.
Two-pass strategy:
1. Look for an explicit "Rating: X" label (tolerant of markdown bold).
2. Fall back to the first 5-tier rating word found anywhere in the text.
Returns a Title-cased rating string, or ``default`` if no rating word appears.
Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
``RatingOverweight`` is matched the same as ASCII):
1. An explicit "Rating: X" label (tolerant of markdown bold).
2. The first standalone 5-tier rating word found anywhere.
"""
for line in text.splitlines():
if not text:
return None
norm = unicodedata.normalize("NFKC", text)
# The labelled rating, taking the last one written: a decision states its
# rating after discussing the alternatives. Lines presenting the scale
# itself are a legend the model echoed, not a call.
labelled = None
for line in norm.splitlines():
if _RATING_SCALE_RE.search(line):
continue
m = _RATING_LABEL_RE.search(line)
if m and m.group(1).lower() in _RATING_SET:
return m.group(1).capitalize()
labelled = m.group(1).capitalize()
if labelled:
return labelled
for line in text.splitlines():
for word in line.lower().split():
clean = word.strip("*:.,")
if clean in _RATING_SET:
return clean.capitalize()
# No label. A single rating word in the text is the call; several are an
# argument, and picking one of them reports a direction nobody decided --
# prose that rejects a Buy before concluding Underweight read as Buy.
named = {m.group(1).capitalize() for m in _RATING_WORD_RE.finditer(norm)}
return named.pop() if len(named) == 1 else None
return default
def parse_rating(text: str, default: str = RATING_REVIEW) -> str:
"""Extract a 5-tier rating, or ``REVIEW`` when the decision has none.
For callers that need a string for every decision, such as the memory log's
entry tag. The default is the review sentinel, never a tradeable rating.
"""
rating = extract_rating(text)
return rating if rating is not None else default
def is_review(signal: str) -> bool:
"""Whether a signal is the non-tradeable REVIEW sentinel (#1170)."""
return signal == RATING_REVIEW

View File

@@ -28,6 +28,16 @@ logger = logging.getLogger(__name__)
T = TypeVar("T", bound=BaseModel)
# Schema-only structured output binds exactly one tool (the schema itself), so a
# model that reaches for a search tool emits an unknown tool call and the whole
# structured attempt is discarded for a free-text retry. Agents on this path
# state the constraint explicitly rather than relying on the binding alone
# (#1130).
NO_EXTERNAL_TOOLS = (
"Use only the evidence provided in this prompt. Do not call external tools "
"or search the web; if something is missing, say so explicitly."
)
def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None:
"""Return ``llm.with_structured_output(schema)`` or ``None`` if unsupported.

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of
from tradingagents.dataflows.interface import route_to_vendor
@@ -11,6 +13,7 @@ def get_indicators(
indicator: Annotated[str, "technical indicator to get the analysis and report of"],
curr_date: Annotated[str, "The current trading date you are trading on, YYYY-mm-dd"],
look_back_days: Annotated[int, "how many days to look back"] = 30,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
"""
Retrieve a single technical indicator for a given ticker symbol.
@@ -25,6 +28,7 @@ def get_indicators(
"""
# LLMs sometimes pass multiple indicators as a comma-separated string;
# split and process each individually.
curr_date = as_of(curr_date, trade_date)
indicators = [i.strip().lower() for i in indicator.split(",") if i.strip()]
results = []
for ind in indicators:

201
tradingagents/backtest.py Normal file
View File

@@ -0,0 +1,201 @@
"""Run the graph over a grid of tickers and dates, and score what came back.
One run yields one decision, so it cannot say whether the system decides well.
This runs the same machinery over many (ticker, date) cells and reads the
aggregate. The decision log is the results table: every run already records its
rating and later settles it with realized and alpha return against the
instrument's regional benchmark, so there is nothing to record separately.
Scope: this evaluates decision quality. It is not a portfolio simulator, and
must not grow one. Turning a rating into a filled order needs a quantity, a fill
price and a cash ledger, none of which the system has; inventing them here would
put an execution model behind an evaluation tool. Cells are therefore
independent, and a portfolio, when given, is the same standing book for every
cell rather than a position carried forward.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from tradingagents.agents.utils.memory import TradingMemoryLog
from tradingagents.agents.utils.rating import RATING_REVIEW
from tradingagents.dataflows.utils import get_current_date, safe_ticker_component
from tradingagents.graph.trading_graph import TradingAgentsGraph
logger = logging.getLogger(__name__)
def iter_grid(start_date: str, end_date: str, every_n_days: int = 1) -> list[str]:
"""Analysis dates from ``start_date``, never past today.
A future date has no outcome to settle against, and the graph rejects one, so
the grid stops at the present rather than producing cells that cannot score.
"""
start, end = _canonical(start_date), _canonical(end_date)
if every_n_days < 1:
raise ValueError("every_n_days must be at least 1")
if end < start:
raise ValueError(f"the grid ends before it starts: {end_date} is before {start_date}")
last = min(end, datetime.strptime(get_current_date(), "%Y-%m-%d"))
dates, cursor = [], start
while cursor <= last:
dates.append(cursor.strftime("%Y-%m-%d"))
cursor += timedelta(days=every_n_days)
return dates
def _canonical(date: str) -> datetime:
"""Parse a grid bound, rejecting anything the run date would also reject."""
try:
parsed = datetime.strptime(str(date), "%Y-%m-%d")
except (TypeError, ValueError) as exc:
raise ValueError(f"grid dates must be in YYYY-MM-DD format, got {date!r}") from exc
if parsed.strftime("%Y-%m-%d") != str(date):
raise ValueError(f"grid dates must be in YYYY-MM-DD format, got {date!r}")
return parsed
def _alpha(entry: dict) -> float | None:
"""Alpha return of a settled entry, or None when it has not settled.
The log stores it as a percentage rounded to one decimal, so aggregates here
are accurate to 0.1 of a percentage point, not to the raw quote.
"""
text = (entry.get("alpha") or "").strip().rstrip("%")
try:
return float(text) / 100
except ValueError:
return None
@dataclass
class BacktestResult:
run_id: str
log_path: Path
cells_run: int = 0
skipped: int = 0
failures: list[tuple[str, str, str]] = field(default_factory=list)
settlement_failures: list[tuple[str, str]] = field(default_factory=list)
# What each rating claims will happen, so an outcome can be scored against it.
# Hold claims no direction, so nothing about alpha proves it right or wrong.
_DIRECTION = {"Buy": 1, "Overweight": 1, "Hold": 0, "Underweight": -1, "Sell": -1}
@dataclass
class RatingScore:
count: int
hit_rate: float | None
mean_alpha: float
@dataclass
class BacktestSummary:
resolved: int
pending: int
by_rating: dict[str, RatingScore]
unscored: int = 0
holding: str = ""
def render(self) -> str:
lines = [f"Resolved cells: {self.resolved} · pending: {self.pending}"
+ (f" · unscored: {self.unscored}" if self.unscored else "")]
for rating, score in self.by_rating.items():
called = (f"called the direction {score.hit_rate:.0%}"
if score.hit_rate is not None else "no direction claimed")
lines.append(
f"- {rating}: n={score.count}, {called}, "
f"mean alpha {score.mean_alpha:+.2%} vs the benchmark"
)
lines.append("")
if self.pending:
lines.append("Pending cells are not scored above; re-run to settle them.")
lines.append(
f"Alpha is measured over {self.holding} after each analysis date. "
"One model sampling per cell, and text feeds are not archived, so "
"these figures are indicative rather than repeatable."
)
return "\n".join(lines)
def run_backtest(
tickers: list[str],
dates: list[str],
config: dict,
asset_type: str = "stock",
portfolio=None,
selected_analysts=("market", "social", "news", "fundamentals"),
run_id: str | None = None,
) -> BacktestResult:
"""Analyze every ticker on every date, into a decision log of this run's own.
The live log stays untouched: a sweep would otherwise flood the context that
real runs read back. Cells already in this run's log are skipped, so an
interrupted sweep resumes by being run again.
"""
# run_id becomes a path segment, so it is validated like a ticker: an
# absolute or dotted value would otherwise place the run outside results_dir.
run_id = safe_ticker_component(run_id or datetime.now().strftime("%Y%m%d_%H%M%S"))
run_dir = Path(config["results_dir"]) / "backtest" / run_id
run_dir.mkdir(parents=True, exist_ok=True)
run_config = {**config, "results_dir": str(run_dir),
"memory_log_path": str(run_dir / "trading_memory.md")}
graph = TradingAgentsGraph(selected_analysts, config=run_config)
result = BacktestResult(run_id=run_id, log_path=Path(run_config["memory_log_path"]))
done = {(e["ticker"], e["date"]) for e in graph.memory_log.load_entries()}
for ticker in tickers:
for date in dates:
if (ticker, date) in done:
result.skipped += 1
continue
try:
graph.propagate(ticker, date, asset_type, portfolio=portfolio)
result.cells_run += 1
except Exception as exc: # one unreachable vendor must not end the sweep
logger.warning("Backtest cell %s %s failed: %s", ticker, date, exc)
result.failures.append((ticker, date, str(exc)))
# Settlement runs at the start of the next run for a ticker, so each ticker's
# last cell would stay pending without this pass.
for ticker in tickers:
try:
graph.settle_pending(ticker)
except Exception as exc: # reflection calls an LLM; one failure is not the sweep's
logger.warning("Settling %s failed: %s", ticker, exc)
result.settlement_failures.append((ticker, str(exc)))
return result
def summarize(memory_log: TradingMemoryLog) -> BacktestSummary:
"""Score the settled decisions in a log, by rating."""
entries = memory_log.load_entries()
# A decision with no readable rating has no direction, so it can neither
# count for nor against the system; it is reported as unscored instead.
resolved = [(e, _alpha(e)) for e in entries
if not e["pending"] and e["rating"] != RATING_REVIEW]
resolved = [(e, a) for e, a in resolved if a is not None]
by_rating: dict[str, RatingScore] = {}
for rating in dict.fromkeys(e["rating"] for e, _ in resolved):
alphas = [a for e, a in resolved if e["rating"] == rating]
direction = _DIRECTION.get(rating, 0)
by_rating[rating] = RatingScore(
count=len(alphas),
hit_rate=(sum(a * direction > 0 for a in alphas) / len(alphas)) if direction else None,
mean_alpha=sum(alphas) / len(alphas),
)
unscored = sum(1 for e in entries if e["rating"] == RATING_REVIEW)
# Report the window the outcomes were actually measured over, from the log.
windows = {f"{e['holding'][:-1]} trading days" for e, _ in resolved
if (e.get("holding") or "").endswith("d")}
return BacktestSummary(resolved=len(resolved),
pending=len(entries) - len(resolved) - unscored,
by_rating=by_rating, unscored=unscored,
holding=", ".join(sorted(windows)) or "the configured window")

View File

@@ -4,9 +4,9 @@ from datetime import datetime
from io import StringIO
import pandas as pd
import requests
from .errors import VendorNotConfiguredError, VendorRateLimitError
from .utils import get_scrubbed
API_BASE_URL = "https://www.alphavantage.co/query"
@@ -34,8 +34,13 @@ def get_api_key() -> str:
)
return api_key
def format_datetime_for_api(date_input) -> str:
"""Convert various date formats to YYYYMMDDTHHMM format required by Alpha Vantage API."""
def format_datetime_for_api(date_input, end_of_day: bool = False) -> str:
"""Convert various date formats to the YYYYMMDDTHHMM Alpha Vantage expects.
A plain date means midnight, which is the start of that day. For the end of
a window pass ``end_of_day`` so the day itself is inside it, rather than
dropping everything published on the analysis date.
"""
if isinstance(date_input, str):
# If already in correct format, return as-is
if len(date_input) == 13 and 'T' in date_input:
@@ -43,7 +48,7 @@ def format_datetime_for_api(date_input) -> str:
# Try to parse common date formats
try:
dt = datetime.strptime(date_input, "%Y-%m-%d")
return dt.strftime("%Y%m%dT0000")
return dt.strftime("%Y%m%dT2359" if end_of_day else "%Y%m%dT0000")
except ValueError:
try:
dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M")
@@ -66,10 +71,11 @@ def _make_api_request(function_name: str, params: dict) -> dict | str:
AlphaVantageRateLimitError: When API rate limit is exceeded
"""
# Create a copy of params to avoid modifying the original
api_key = get_api_key()
api_params = params.copy()
api_params.update({
"function": function_name,
"apikey": get_api_key(),
"apikey": api_key,
"source": "trading_agents",
})
@@ -83,8 +89,9 @@ def _make_api_request(function_name: str, params: dict) -> dict | str:
# Remove entitlement if it's None or empty
api_params.pop("entitlement", None)
response = requests.get(API_BASE_URL, params=api_params, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
response = get_scrubbed(
API_BASE_URL, params=api_params, timeout=REQUEST_TIMEOUT, secret=api_key
)
response_text = response.text
@@ -128,24 +135,18 @@ def _filter_csv_by_date_range(csv_data: str, start_date: str, end_date: str) ->
if not csv_data or csv_data.strip() == "":
return csv_data
try:
# Parse CSV data
df = pd.read_csv(StringIO(csv_data))
# Deliberately unguarded: TIME_SERIES_DAILY_ADJUSTED returns the full series
# up to today, so this trim is the only thing keeping bars after end_date out
# of a historical run. Swallowing a parse failure would serve the untrimmed
# body, and with it future prices.
df = pd.read_csv(StringIO(csv_data))
# Assume the first column is the date column (timestamp)
date_col = df.columns[0]
df[date_col] = pd.to_datetime(df[date_col])
# Assume the first column is the date column (timestamp)
date_col = df.columns[0]
df[date_col] = pd.to_datetime(df[date_col])
# Filter by date range
start_dt = pd.to_datetime(start_date)
end_dt = pd.to_datetime(end_date)
start_dt = pd.to_datetime(start_date)
end_dt = pd.to_datetime(end_date)
filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)]
filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)]
# Convert back to CSV string
return filtered_df.to_csv(index=False)
except Exception as e:
# If filtering fails, return original data with a warning
print(f"Warning: Failed to filter CSV data by date range: {e}")
return csv_data
return filtered_df.to_csv(index=False)

View File

@@ -1,6 +1,7 @@
import json
from .alpha_vantage_common import _make_api_request
from .date_window import withhold_live_profile
def _filter_reports_by_date(result, curr_date: str):
@@ -31,13 +32,22 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str:
"""
Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage.
OVERVIEW serves only present-day values and carries no historical vintage, so
a past ``curr_date`` withholds it rather than leaking post-decision figures
into a backtest (#1300); the statement endpoints below stay point-in-time via
``_filter_reports_by_date``.
Args:
ticker (str): Ticker symbol of the company
curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage)
curr_date (str): Analysis date, yyyy-mm-dd
Returns:
str: Company overview data including financial ratios and key metrics
"""
withheld = withhold_live_profile(curr_date, ticker)
if withheld:
return withheld
params = {
"symbol": ticker,
}

View File

@@ -1,4 +1,9 @@
from .alpha_vantage_common import AlphaVantageNotConfiguredError, _make_api_request
import logging
from .alpha_vantage_common import _make_api_request
from .errors import NoMarketDataError, VendorError
logger = logging.getLogger(__name__)
def get_indicator(
@@ -60,8 +65,11 @@ def get_indicator(
}
if indicator not in supported_indicators:
raise ValueError(
f"Indicator {indicator} is not supported. Please choose from: {list(supported_indicators.keys())}"
# A vendor error, not a caller error: another vendor may compute it, and
# the router decides. yfinance rejects a name nobody serves.
raise NoMarketDataError(
symbol, symbol,
f"Alpha Vantage does not serve {indicator}; it serves {list(supported_indicators)}"
)
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
@@ -130,12 +138,13 @@ def get_indicator(
"time_period": str(time_period),
"datatype": "csv"
})
elif indicator == "vwma":
# Alpha Vantage doesn't have direct VWMA, so we'll return an informative message
# In a real implementation, this would need to be calculated from OHLCV data
return f"## VWMA (Volume Weighted Moving Average) for {symbol}:\n\nVWMA calculation requires OHLCV data and is not directly available from Alpha Vantage API.\nThis indicator would need to be calculated from the raw stock data using volume-weighted price averaging.\n\n{indicator_descriptions.get('vwma', 'No description available.')}"
else:
return f"Error: Indicator {indicator} not implemented yet."
# This vendor has no endpoint for the indicator. Raising lets the
# router try the next vendor, which computes it; returning prose
# counted as a successful answer and ended the chain here.
raise NoMarketDataError(
symbol, symbol, f"Alpha Vantage does not serve the {indicator} indicator"
)
# Parse CSV data and extract values for the date range
lines = data.strip().split('\n')
@@ -205,11 +214,11 @@ def get_indicator(
return result_str
except AlphaVantageNotConfiguredError:
# Vendor unavailable (no API key). Let it propagate so the router can
# fall back / emit the no-data sentinel instead of returning this as a
# successful-looking error string.
except VendorError:
# Unavailable vendor, throttle, or an indicator this vendor does not
# serve. Let it propagate so the router falls back to a vendor that can,
# instead of returning the failure as a successful-looking string.
raise
except Exception as e:
print(f"Error getting Alpha Vantage indicator data for {indicator}: {e}")
return f"Error retrieving {indicator} data: {str(e)}"
logger.warning("Alpha Vantage indicator %s failed: %s", indicator, e)
raise NoMarketDataError(symbol, symbol, f"{indicator} unavailable: {e}") from e

View File

@@ -1,4 +1,7 @@
import json
from .alpha_vantage_common import _make_api_request, format_datetime_for_api
from .config import get_config
def get_news(ticker, start_date, end_date) -> dict[str, str] | str:
@@ -15,30 +18,40 @@ def get_news(ticker, start_date, end_date) -> dict[str, str] | str:
Dictionary containing news sentiment data or JSON string.
"""
# Without a limit the endpoint returns 50 articles, each with per-ticker
# sentiment arrays, and all of it reaches the prompt.
params = {
"tickers": ticker,
"time_from": format_datetime_for_api(start_date),
"time_to": format_datetime_for_api(end_date),
"time_to": format_datetime_for_api(end_date, end_of_day=True),
"limit": str(get_config()["news_article_limit"]),
}
return _make_api_request("NEWS_SENTIMENT", params)
def get_global_news(curr_date, look_back_days: int = 7, limit: int = 50) -> dict[str, str] | str:
def get_global_news(curr_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str:
"""Returns global market news & sentiment data without ticker-specific filtering.
Covers broad market topics like financial markets, economy, and more.
Args:
curr_date: Current date in yyyy-mm-dd format.
look_back_days: Number of days to look back (default 7).
limit: Maximum number of articles (default 50).
look_back_days: Number of days to look back; ``None`` uses
``global_news_lookback_days`` from the active config.
limit: Maximum number of articles; ``None`` uses
``global_news_article_limit`` from the active config.
Returns:
Dictionary containing global news sentiment data or JSON string.
"""
from datetime import datetime, timedelta
# Calculate start date
config = get_config()
if look_back_days is None:
look_back_days = config["global_news_lookback_days"]
if limit is None:
limit = config["global_news_article_limit"]
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
start_dt = curr_dt - timedelta(days=look_back_days)
start_date = start_dt.strftime("%Y-%m-%d")
@@ -46,20 +59,21 @@ def get_global_news(curr_date, look_back_days: int = 7, limit: int = 50) -> dict
params = {
"topics": "financial_markets,economy_macro,economy_monetary",
"time_from": format_datetime_for_api(start_date),
"time_to": format_datetime_for_api(curr_date),
"time_to": format_datetime_for_api(curr_date, end_of_day=True),
"limit": str(limit),
}
return _make_api_request("NEWS_SENTIMENT", params)
def get_insider_transactions(symbol: str) -> dict[str, str] | str:
def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[str, str] | str:
"""Returns latest and historical insider transactions by key stakeholders.
Covers transactions by founders, executives, board members, etc.
Args:
symbol: Ticker symbol. Example: "IBM".
curr_date: When given, only transactions on or before it (yyyy-mm-dd).
Returns:
Dictionary containing insider transaction data or JSON string.
@@ -69,4 +83,9 @@ def get_insider_transactions(symbol: str) -> dict[str, str] | str:
"symbol": symbol,
}
return _make_api_request("INSIDER_TRANSACTIONS", params)
response = _make_api_request("INSIDER_TRANSACTIONS", params)
if not curr_date:
return response
payload = json.loads(response)
payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= curr_date]
return json.dumps(payload)

View File

@@ -0,0 +1,121 @@
"""Shared look-ahead-safe date-window filtering for dated content.
News, StockTwits, and Reddit all pull recent items that must be trimmed to the
analysis window so a historical/backtest run never sees content published after
its as-of date. Centralizing the rule keeps every source consistent (#1126,
#1220): every timestamp is normalized to UTC, the upper bound is exclusive at
midnight after ``end`` (so an item stamped exactly then can't leak), and an
undated item is kept only when the window reaches the present (a live run), since
in a backtest we can't prove it isn't future.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from .utils import get_current_date
def to_utc(dt: datetime) -> datetime:
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool:
"""Whether an item belongs in the half-open window ``[start, end + 1 day)``.
``pub_dt`` None means undated: kept only when the window reaches the present.
"""
end = to_utc(end_dt)
if pub_dt is not None:
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
return end >= datetime.now(timezone.utc) - timedelta(days=1)
def coverage_gap(
dates, start_date: str, end_date: str, source: str, subject: str
) -> str | None:
"""Placeholder for a window a feed did not fully observe, else None.
Yahoo news and the Reddit and StockTwits feeds return their latest items
whatever window is asked for, so "none found" over a window they never
observed would claim an absence nobody saw. A window is observed when
coverage reaches its first day and it ends by today; an empty result is then
a real absence and this returns None.
``dates`` are the returned items' timestamps, plus the lookback start for a
feed with a fixed lookback. The oldest one bounds coverage only for a feed
returned newest-first and unbroken in time; a merged or relevance-ranked
result passes no dates, leaving only the present as the bound.
"""
now = datetime.now(timezone.utc)
oldest = min((to_utc(d) for d in dates if d is not None), default=now)
if datetime.strptime(end_date, "%Y-%m-%d").date() > now.date():
reason = "the window extends past today"
elif oldest.date() > datetime.strptime(start_date, "%Y-%m-%d").date():
reason = f"it only serves recent items (coverage starts {oldest:%Y-%m-%d})"
else:
return None
return f"<{source} unavailable for {start_date}..{end_date}: {reason}, so this is not an absence of {subject}>"
def _parse(date: str | None) -> datetime | None:
try:
return datetime.strptime(date, "%Y-%m-%d")
except (TypeError, ValueError):
return None
def as_of(requested: str | None, trade_date: str) -> str | None:
"""The date a tool serves: the model's date, but never later than the run's.
A model can omit the date or pass today's instead of the analysis date, which
would walk past every point-in-time guard behind the tool. An empty
``trade_date`` (a direct call outside a graph run) passes the request through.
"""
if not trade_date:
return requested
parsed = _parse(requested)
return requested if parsed is not None and parsed <= _parse(trade_date) else trade_date
def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, str]:
"""``[start, end]`` with its end clamped to the run date.
A window wholly after the run date keeps its length and moves back to end there.
"""
end = as_of(end_date, trade_date)
start, old_end = _parse(start_date), _parse(end_date)
if end == end_date or start is None or start <= _parse(end):
return start_date, end
span = (old_end - start) if old_end is not None and old_end >= start else timedelta(0)
return f"{_parse(end) - span:%Y-%m-%d}", end
def withhold_live_profile(curr_date: str | None, label: str) -> str | None:
"""Notice to serve instead of a live-only company profile, or None to serve it.
Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage
``OVERVIEW``) carry no historical vintage — not even name, sector and
industry, which move when a company renames or is reclassified — so serving
one into a run dated in the past leaks post-decision information (#1300).
Every fundamentals vendor withholds on this rule, so switching between them
cannot reintroduce the leak.
"""
if not curr_date:
return None
today = get_current_date()
if curr_date >= today:
return None
return (
f"# Company Fundamentals for {label}\n"
f"# Point-in-time as of: {curr_date}\n\n"
f"Profile fundamentals are withheld for this date. This vendor serves "
f"only present-day values ({today}) with no historical vintage: market "
f"cap, valuation multiples, the 52-week range and TTM income move with "
f"today's quote, and even the name, sector and industry reflect today "
f"rather than {curr_date} (companies rename and get reclassified). "
f"Serving them would put post-decision information into a {curr_date} "
f"analysis. Point-in-time fundamentals for {curr_date} are available "
f"from the balance sheet, income statement, and cash flow tools."
)

View File

@@ -12,14 +12,21 @@ import logging
import os
from datetime import datetime, timedelta
import requests
import pytz
from .errors import VendorNotConfiguredError
from .utils import get_scrubbed
logger = logging.getLogger(__name__)
FRED_API_BASE = "https://api.stlouisfed.org/fred"
# FRED's realtime clock runs on US Central (St. Louis Fed). It rejects a
# realtime date in its own future with a 400, so the vintage pin is clamped to
# this rather than the caller's local date (#1275). pytz (already a dependency)
# bundles its own tz database, so this works where system tzdata is absent.
FRED_TZ = pytz.timezone("America/Chicago")
# Network timeout (seconds) so a stalled request can't hang the agents,
# mirroring the Alpha Vantage client.
REQUEST_TIMEOUT = 30
@@ -115,11 +122,25 @@ def _resolve_series_id(indicator: str) -> str:
return candidate
def _fred_today() -> str:
"""FRED's current calendar date (US Central) as ``yyyy-mm-dd``.
The vintage pin is clamped to this: FRED rejects a ``realtime_start`` after
its own today with a 400, and ``curr_date`` on a live run comes from the
caller's local clock, which can already be tomorrow in Chicago.
"""
return datetime.now(FRED_TZ).strftime("%Y-%m-%d")
def _request(path: str, params: dict) -> dict:
"""GET a FRED endpoint, surfacing FRED's JSON error body on a bad request."""
api_params = {**params, "api_key": get_api_key(), "file_type": "json"}
response = requests.get(
f"{FRED_API_BASE}/{path}", params=api_params, timeout=REQUEST_TIMEOUT
api_key = get_api_key()
response = get_scrubbed(
f"{FRED_API_BASE}/{path}",
params={**params, "api_key": api_key, "file_type": "json"},
timeout=REQUEST_TIMEOUT,
secret=api_key,
passthrough=(400,),
)
# FRED returns 400 with a JSON {"error_message": ...} for unknown series IDs
# or malformed params; turn that into a clear, actionable error.
@@ -129,7 +150,6 @@ def _request(path: str, params: dict) -> dict:
except ValueError:
message = response.text
raise ValueError(f"FRED request failed: {message}")
response.raise_for_status()
return response.json()
@@ -143,8 +163,12 @@ def get_macro_data(
Args:
indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury")
or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10").
curr_date: End of the window (yyyy-mm-dd); no later observations are
returned, so a past date never leaks future data.
curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window
AND pins the data vintage: FRED is queried with the realtime bounds
set to ``curr_date`` (clamped to FRED's own today) so a historical
run sees the values that were actually published by that date, not
later revisions. Without this, revision-prone series (CPI, GDP, ...)
would leak future information into a backtest (#1275).
look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS.
Returns:
@@ -157,6 +181,17 @@ def get_macro_data(
end_dt = datetime.strptime(curr_date, "%Y-%m-%d")
start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d")
# Pin the data vintage. FRED defaults both realtime bounds to today, serving
# the LATEST revision of every observation; a single-day realtime interval
# asks for the values known as of the pin instead, on both the metadata and
# observations requests (#1275). Clamp to FRED's today: on a live run
# curr_date is the caller's local date, which can be a day ahead of Chicago,
# and a realtime date in FRED's future 400s -> the routing layer would then
# drop macro data silently. A past curr_date is unaffected, so historical
# point-in-time behaviour is preserved.
pit = min(curr_date, _fred_today())
realtime = {"realtime_start": pit, "realtime_end": pit}
# Invalid LLM-supplied indicator: return guidance rather than raising, so a
# bad argument doesn't abort the run (the routing layer also degrades macro
# data, but a specific message is more useful to the analyst).
@@ -165,7 +200,7 @@ def get_macro_data(
except ValueError as e:
return f"FRED: {e}"
meta = _request("series", {"series_id": series_id}).get("seriess") or []
meta = _request("series", {"series_id": series_id, **realtime}).get("seriess") or []
if not meta:
return (
f"FRED series '{series_id}' not found. Pass a known alias "
@@ -184,6 +219,7 @@ def get_macro_data(
"observation_start": start_date,
"observation_end": curr_date,
"sort_order": "asc",
**realtime,
},
).get("observations", [])
@@ -204,8 +240,10 @@ def get_macro_data(
if not points:
return header + (
f"\nNo observations for {series_id} in this window. The series may "
f"report less frequently than the window length; widen look_back_days."
f"\nNo observations for {series_id} in this window at the {pit} "
f"vintage. The series may report less frequently than the window "
f"(try a longer look_back_days), or have no vintage published by "
f"then (unpublished as of {pit}, or before ALFRED coverage begins)."
)
first_date, first_val = points[0]

View File

@@ -19,6 +19,11 @@ from .errors import (
)
from .fred import get_macro_data as get_fred_macro_data
from .polymarket import get_prediction_markets as get_polymarket_prediction_markets
from .sec_edgar import (
get_balance_sheet as get_sec_edgar_balance_sheet,
get_cashflow as get_sec_edgar_cashflow,
get_income_statement as get_sec_edgar_income_statement,
)
from .y_finance import (
get_balance_sheet as get_yfinance_balance_sheet,
get_cashflow as get_yfinance_cashflow,
@@ -79,6 +84,7 @@ TOOLS_CATEGORIES = {
VENDOR_LIST = [
"yfinance",
"sec_edgar",
"fred",
"polymarket",
"alpha_vantage",
@@ -110,14 +116,17 @@ VENDOR_METHODS = {
},
"get_balance_sheet": {
"alpha_vantage": get_alpha_vantage_balance_sheet,
"sec_edgar": get_sec_edgar_balance_sheet,
"yfinance": get_yfinance_balance_sheet,
},
"get_cashflow": {
"alpha_vantage": get_alpha_vantage_cashflow,
"sec_edgar": get_sec_edgar_cashflow,
"yfinance": get_yfinance_cashflow,
},
"get_income_statement": {
"alpha_vantage": get_alpha_vantage_income_statement,
"sec_edgar": get_sec_edgar_income_statement,
"yfinance": get_yfinance_income_statement,
},
# news_data
@@ -193,6 +202,7 @@ def route_to_vendor(method: str, *args, **kwargs):
vendor_chain = all_available_vendors
last_no_data: NoMarketDataError | None = None
last_unavailable: VendorRateLimitError | None = None
first_error: Exception | None = None
for vendor in vendor_chain:
vendor_impl = VENDOR_METHODS[method][vendor]
@@ -200,8 +210,11 @@ def route_to_vendor(method: str, *args, **kwargs):
try:
return impl_func(*args, **kwargs)
except VendorRateLimitError:
logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method)
except VendorRateLimitError as e:
logger.warning("Vendor %r unavailable for %s: %s; trying next vendor.", vendor, method, e)
# Kept so an all-unavailable chain can say the vendor was the
# problem, rather than reporting nothing about the symbol.
last_unavailable = e
continue
except VendorNotConfiguredError as e:
logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method)
@@ -250,6 +263,15 @@ def route_to_vendor(method: str, *args, **kwargs):
# first real error (e.g. the primary vendor's network failure). Optional
# enrichment categories degrade to a sentinel instead, so flavour data can't
# abort the run.
# Every vendor was throttled or unreachable: that is a fact about the
# vendors, not about the instrument, and it must not end the run.
if last_unavailable is not None:
return (
f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now "
f"({last_unavailable}). This says nothing about the instrument; report the "
f"data as unavailable and do not estimate or fabricate values."
)
if first_error is not None:
if category in OPTIONAL_CATEGORIES:
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)

View File

@@ -32,7 +32,9 @@ def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame:
look-ahead rows, but we re-apply the cutoff defensively — this is a
verification path, so it must not trust its input to be pre-filtered.
"""
data = load_ohlcv(symbol, curr_date)
# As reported: this snapshot is quoted by the agents as exact prices, so a
# gap-filled cell would put the previous session's number under this date.
data = load_ohlcv(symbol, curr_date, fill_gaps=False)
if data is None or data.empty:
raise ValueError(f"No OHLCV data available for {symbol}.")

View File

@@ -15,6 +15,8 @@ from datetime import datetime, timezone
import requests
from .utils import get_current_date
logger = logging.getLogger(__name__)
GAMMA_BASE = "https://gamma-api.polymarket.com"
@@ -65,7 +67,7 @@ def _is_forward_looking(market: dict, now: datetime) -> bool:
)
def get_prediction_markets(topic: str, limit: int | None = None) -> str:
def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str | None = None) -> str:
"""Return live prediction-market probabilities for an event topic.
Args:
@@ -73,12 +75,20 @@ def get_prediction_markets(topic: str, limit: int | None = None) -> str:
"US election", or a sector/company event.
limit: Max markets to return (ranked by traded volume); ``None`` uses
DEFAULT_LIMIT.
curr_date: The analysis date. Polymarket serves only live odds, so a
date before today withholds them.
Returns:
A markdown report of the most-traded open markets matching the topic,
each with its implied probability, traded volume, resolution date, and
recent (1-week) move.
"""
if curr_date and curr_date < get_current_date():
return (
f"Prediction-market odds are withheld for {curr_date}. Polymarket serves "
f"only live odds on open markets, with no historical vintage, so serving "
f"them would put post-decision information into a {curr_date} analysis."
)
if limit is None:
limit = DEFAULT_LIMIT

View File

@@ -1,14 +1,13 @@
"""Reddit search fetcher for ticker-specific discussion posts.
Default path is Reddit's public Atom/RSS search feed
(``reddit.com/r/{sub}/search.rss``). The richer JSON search endpoint
(``/search.json``) is reliably WAF-blocked (``HTTP 403``) for public clients
(issue #862), and probing it on every call only doubled our request volume
against Reddit's per-IP rate limit — tripping ``429`` on the RSS fallback — so
it is kept (``_fetch_subreddit_json``) but not used by default. On a 429 we back
off once (honouring ``Retry-After``). RSS lacks score / comment counts, so those
posts are marked and the formatter omits the metrics rather than printing fake
zeros.
Reads Reddit's public Atom/RSS search feed, searching all subreddits in one
combined request. The JSON search endpoint is WAF-blocked (``HTTP 403``) for
anonymous clients (#862), so RSS is the only path; it carries no score or comment
counts. On a 429 we back off once, honouring ``Retry-After``.
A fetch that fails is reported as ``<unavailable>``, never as "no posts found":
the two are different claims, and passing a rate-limited fetch off as silence
hands the sentiment analyst a signal that was never observed (#1295).
No API key required. Returns formatted plaintext blocks ready for prompt
injection and degrades gracefully — returns a placeholder string rather than
@@ -19,22 +18,52 @@ from __future__ import annotations
import html
import http.client
import json
import logging
import random
import re
import time
import xml.etree.ElementTree as ET
from collections.abc import Iterable
from datetime import datetime
from datetime import datetime, timedelta, timezone
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from .date_window import coverage_gap, in_window
from .symbol_utils import crypto_base
logger = logging.getLogger(__name__)
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
def _within_window(posts, start_date, end_date):
"""Keep only posts published in [start_date, end_date] (look-ahead safe).
No window (both None) leaves the list untouched for live callers. A post with
no ``created_utc`` epoch is dropped in a historical window (#1220).
"""
if not (start_date and end_date):
return posts
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
return [p for p in posts if in_window(_posted_at(p), start_dt, end_dt)]
def _posted_at(post) -> datetime | None:
"""A post's ``created_utc`` epoch as a UTC datetime, or None when missing."""
ts = post.get("created_utc")
return datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
def _coverage_dates(posts) -> list:
"""Dates that bound the feed's coverage. The search is limited to the last
week (``t=week``), so the lookback start bounds it even when nothing came
back; a full page may have cut older matches off, so then only the posts
themselves do."""
dates = [_posted_at(p) for p in posts]
if len(posts) < _FEED_PAGE:
dates.append(datetime.now(timezone.utc) - _SEARCH_LOOKBACK)
return dates
_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}"
# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit
# blocks generic/anonymous tokens like bare "Mozilla/5.0" or "curl/…" but
@@ -48,6 +77,14 @@ _ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
# investing trend more measured. Caller can override.
DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing")
# Reddit's maximum page size. A week of posts for a ticker across the default
# subreddits fits well inside one page, which keeps a high-volume subreddit from
# crowding the others out of a combined search.
_FEED_PAGE = 100
_SEARCH_LOOKBACK = timedelta(days=7) # matches t=week below
def _search_qs(ticker: str, limit: int) -> str:
return urlencode({
@@ -81,37 +118,83 @@ def _strip_html(content: str) -> str:
return " ".join(html.unescape(text).split())
# Headerless-429 backoff when Reddit gives no Retry-After. Measured against
# /r/{sub}/search.rss, a retry still 429s at 8s, 10s and 30s of spacing and
# succeeds at 60s, so a shorter wait spends the one retry on a request that
# cannot succeed (#1295). Jittered so several analyses sharing an IP don't
# retry in lockstep and re-collide on the limit.
_RETRY_FALLBACK_SECONDS = 60.0
def _jitter(seconds: float, frac: float = 0.2) -> float:
"""Return ``seconds`` with +/-``frac`` random jitter, to desynchronize
concurrent runs pacing against the same per-IP limit."""
return seconds * (1.0 + random.uniform(-frac, frac))
def _retry_after_seconds(exc: HTTPError) -> float | None:
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 30s."""
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 60s.
The cap matches ``_RETRY_FALLBACK_SECONDS``: honouring less than we would
wait on our own would spend the one retry on a request we already know is
too early.
Returns ``None`` only when the header is absent or unparseable; a valid
``Retry-After: 0`` returns ``0.0`` (retry at once), not ``None``.
"""
try:
val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
return min(float(val), 30.0) if val else None
return min(float(val), 60.0) if val is not None else None
except (ValueError, TypeError, AttributeError):
return None
# Reddit search feeds are small (a page of results); cap the read so a
# compromised or misbehaving endpoint can't stream an unbounded body into
# memory before we parse it. Overflow raises http.client.HTTPException, which
# both fetch paths already treat as a failed fetch (degrade to empty / RSS).
_MAX_FEED_BYTES = 5 * 1024 * 1024
def _read_capped(resp) -> bytes:
"""Read a response body bounded to ``_MAX_FEED_BYTES``, raising on overflow."""
data = resp.read(_MAX_FEED_BYTES + 1)
if len(data) > _MAX_FEED_BYTES:
raise http.client.HTTPException(
f"Reddit feed exceeded {_MAX_FEED_BYTES} bytes; refusing to parse"
)
return data
def _fetch_subreddit_rss(
ticker: str,
sub: str,
limit: int,
timeout: float,
_retry: bool = True,
) -> list[dict]:
) -> list[dict] | None:
"""Default path: parse the public Atom search feed for a subreddit.
Carries no score / comment counts, so those fields are left None and the
post is tagged ``source="rss"`` for honest display. On a 429 (Reddit's
``sub`` may be one subreddit or several joined with ``+``. On a 429 (Reddit's
per-IP rate limit) we back off once — honouring ``Retry-After`` when
present — before giving up, so a transient burst doesn't blank the feed.
Returns ``[]`` when the search ran and matched nothing, and ``None`` when
the fetch itself failed. The caller must keep these apart: rendering a
failed fetch as "no posts found" hands the sentiment analyst an absence of
discussion that was never observed (#1295).
"""
url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit))
req = Request(url, headers={"User-Agent": _UA})
try:
with urlopen(req, timeout=timeout) as resp:
root = ET.fromstring(resp.read())
root = ET.fromstring(_read_capped(resp))
except HTTPError as exc:
if exc.code == 429 and _retry:
wait = _retry_after_seconds(exc) or 5.0
# Honour a server-supplied Retry-After exactly (including 0); jitter
# only our own fallback so concurrent runs don't retry in lockstep.
retry_after = _retry_after_seconds(exc)
wait = retry_after if retry_after is not None else _jitter(_RETRY_FALLBACK_SECONDS)
logger.warning(
"Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once",
sub, ticker, wait,
@@ -119,132 +202,102 @@ def _fetch_subreddit_rss(
time.sleep(wait)
return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=False)
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
return []
return None
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
# OSError covers URLError/TimeoutError/connection resets; HTTPException
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
return []
return None
posts = []
for entry in root.findall("atom:entry", _ATOM_NS)[:limit]:
title_el = entry.find("atom:title", _ATOM_NS)
published_el = entry.find("atom:published", _ATOM_NS)
content_el = entry.find("atom:content", _ATOM_NS)
category_el = entry.find("atom:category", _ATOM_NS)
posts.append({
"title": (title_el.text if title_el is not None else "") or "",
"score": None,
"num_comments": None,
"created_utc": _iso_to_timestamp(
published_el.text if published_el is not None else None
),
"selftext": _strip_html(content_el.text if content_el is not None else ""),
"source": "rss",
# A combined feed names each entry's subreddit; a single-subreddit
# feed may omit it, and then it can only be that one.
"subreddit": category_el.get("term") if category_el is not None
else (sub if "+" not in sub else ""),
})
return posts
def _fetch_subreddit_json(
ticker: str,
sub: str,
limit: int,
timeout: float,
) -> list[dict]:
"""Richer JSON search path (carries score / comment counts).
Reddit's WAF currently returns ``403 Blocked`` on this endpoint for
non-OAuth clients (issue #862), so it is NOT used by default — calling it on
every request only doubled our volume against the per-IP rate limit and
triggered 429s on the RSS fallback. Kept for the day the WAF relaxes or an
OAuth token is wired in; degrades to RSS on failure.
"""
url = _API.format(sub=sub, qs=_search_qs(ticker, limit))
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
try:
with urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read())
children = (payload.get("data") or {}).get("children") or []
return [c.get("data", {}) for c in children if isinstance(c, dict)]
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
logger.warning(
"Reddit JSON fetch failed for r/%s · %s: %s — falling back to RSS feed.",
sub, ticker, exc,
)
return _fetch_subreddit_rss(ticker, sub, limit, timeout)
def _fetch_subreddit(
ticker: str,
sub: str,
limit: int,
timeout: float,
) -> list[dict]:
"""Fetch one subreddit, RSS-first.
The JSON search endpoint is reliably WAF-blocked (403) for public clients,
so we go straight to the RSS feed — which serves our identified User-Agent
reliably — halving our request volume against Reddit's per-IP rate limit.
"""
return _fetch_subreddit_rss(ticker, sub, limit, timeout)
def fetch_reddit_posts(
ticker: str,
subreddits: Iterable[str] = DEFAULT_SUBREDDITS,
*,
limit_per_sub: int = 5,
timeout: float = 10.0,
inter_request_delay: float = 1.0,
start_date: str | None = None,
end_date: str | None = None,
) -> str:
"""Fetch recent Reddit posts mentioning ``ticker`` across finance
subreddits and return them as a formatted plaintext block.
``inter_request_delay`` paces the (now RSS-only) per-subreddit requests to
stay under Reddit's public per-IP rate limit; combined with the RSS-first
path it makes 429s rare even when several analyses run back-to-back.
All subreddits are searched in one combined feed (``r/a+b+c``): anonymous
RSS allows about one request per minute per IP, so a request per subreddit
spent a back-off on almost every run. Each entry names its subreddit, and
posts are grouped back by it.
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, posts are trimmed to
that window so a historical run does not leak current discussion into a
backtest (#1220).
"""
# Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base
# ("BTC") so the query actually matches discussion instead of near-nothing.
ticker = crypto_base(ticker) or ticker
blocks = []
total_posts = 0
for i, sub in enumerate(subreddits):
if i > 0:
time.sleep(inter_request_delay)
posts = _fetch_subreddit(ticker, sub, limit_per_sub, timeout)
total_posts += len(posts)
if not posts:
blocks.append(f"r/{sub}: <no posts found mentioning {ticker.upper()} in the past 7 days>")
continue
subreddits = list(subreddits)
label = ", ".join(f"r/{s}" for s in subreddits)
fetched = _fetch_subreddit_rss(ticker, "+".join(subreddits), _FEED_PAGE, timeout)
if fetched is None:
return f"<Reddit unavailable: fetch failed ({label}); this is not an absence of discussion>"
via_rss = any(p.get("source") == "rss" for p in posts)
header = f"r/{sub}{len(posts)} recent posts mentioning {ticker.upper()}"
header += " (via RSS feed; scores/comments unavailable):" if via_rss else ":"
lines = [header]
for p in posts:
title = (p.get("title") or "").replace("\n", " ").strip()
score = p.get("score")
comments = p.get("num_comments")
created = p.get("created_utc")
created_str = (
time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?"
window = bool(start_date and end_date)
posts = _within_window(fetched, start_date, end_date)
if not posts:
gap = window and coverage_gap(
_coverage_dates(fetched), start_date, end_date,
"Reddit search", f"discussion of {ticker.upper()}",
)
period = f"within {start_date}..{end_date}" if window else "in the past 7 days"
return gap or f"<no Reddit posts found mentioning {ticker.upper()} across {label} {period}>"
# Group by the subreddit each entry names, in the requested order. Nothing
# is dropped: an unlabelled post from a one-subreddit request belongs to it,
# and any other name gets its own block.
by_sub = {s.lower(): (s, []) for s in subreddits}
for p in posts:
name = p.get("subreddit") or (subreddits[0] if len(subreddits) == 1 else "unknown")
by_sub.setdefault(name.lower(), (name, []))[1].append(p)
page_full = len(fetched) >= _FEED_PAGE
blocks = []
for sub, sub_posts in by_sub.values():
if not sub_posts:
blocks.append(
f"r/{sub}: <not among the newest {_FEED_PAGE} matches across {label}>"
if page_full else f"r/{sub}: <no posts found mentioning {ticker.upper()}>"
)
# Score / comment counts are absent on the RSS fallback path —
# show them only when present rather than printing fake zeros.
meta = created_str
if score is not None and comments is not None:
meta += f" · {score:>4}↑ · {comments:>3}c"
continue
sub_posts = sub_posts[:limit_per_sub] # the feed is newest-first
lines = [f"r/{sub}{len(sub_posts)} recent posts mentioning {ticker.upper()}:"]
for p in sub_posts:
title = (p.get("title") or "").replace("\n", " ").strip()
created = p.get("created_utc")
created_str = time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?"
selftext = (p.get("selftext") or "").replace("\n", " ").strip()
if len(selftext) > 240:
selftext = selftext[:240] + ""
lines.append(
f" [{meta}] {title}"
f" [{created_str}] {title}"
+ (f"\n body excerpt: {selftext}" if selftext else "")
)
blocks.append("\n".join(lines))
if total_posts == 0:
return (
f"<no Reddit posts found mentioning {ticker.upper()} across "
f"{', '.join(f'r/{s}' for s in subreddits)} in the past 7 days>"
)
return "\n\n".join(blocks)

View File

@@ -0,0 +1,229 @@
"""Company statements as they were filed, from SEC EDGAR.
Every other fundamentals vendor serves a period's current value and cuts the
statement at the fiscal period end. That is two claims a run should not make: a
period that has ended is not public until the company files, weeks later, and a
figure that was later restated is not what investors saw at the time.
EDGAR reports every fact with the date it was filed, so a run dated ``curr_date``
serves exactly what was on file by then, restatements included at the vintage
that was current: Apple's 2008 total assets read 39.6B until the 2010 amendment
restated them to 36.2B.
Access needs no key or account, only a User-Agent identifying the caller, which
SEC requires and refuses requests without. US filers only: anything absent from
EDGAR's ticker map falls through to the next configured vendor.
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import date, datetime
from importlib import metadata
from pathlib import Path
import requests
from .config import get_config
from .errors import NoMarketDataError, VendorRateLimitError
logger = logging.getLogger(__name__)
_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
# A filing history only changes when something new is filed, so one fetch per
# company per day serves every date a run asks about.
_CACHE_TTL_SECONDS = 24 * 60 * 60
# Line items, each with the tags filers use for it, best first. First match wins
# and values are never summed across tags: a company reporting revenue under two
# tags would otherwise be counted twice.
_STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = {
"balance_sheet": [
("Total Assets", ("Assets",)),
("Current Assets", ("AssetsCurrent",)),
("Cash and Equivalents", ("CashAndCashEquivalentsAtCarryingValue",)),
("Total Liabilities", ("Liabilities",)),
("Current Liabilities", ("LiabilitiesCurrent",)),
("Stockholders Equity", ("StockholdersEquity",
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest")),
],
"income_statement": [
("Revenue", ("RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues",
"SalesRevenueNet")),
("Cost of Revenue", ("CostOfRevenue", "CostOfGoodsAndServicesSold")),
("Gross Profit", ("GrossProfit",)),
("Operating Income", ("OperatingIncomeLoss",)),
("Net Income", ("NetIncomeLoss",)),
("Diluted EPS", ("EarningsPerShareDiluted",)),
],
"cashflow": [
("Operating Cash Flow", ("NetCashProvidedByUsedInOperatingActivities",
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations")),
("Investing Cash Flow", ("NetCashProvidedByUsedInInvestingActivities",)),
("Financing Cash Flow", ("NetCashProvidedByUsedInFinancingActivities",)),
("Capital Expenditure", ("PaymentsToAcquirePropertyPlantAndEquipment",)),
],
}
# A statement's figures cover a span: a quarter is about 90 days, a year about
# 365. One filing reports both the quarter and the year to date under the same
# end date, so a match on the end date alone can report half a year as a quarter.
_SPANS = {"quarterly": (60, 115), "annual": (300, 400)}
def _user_agent() -> str:
"""Who SEC sees. No account or key exists; callers identify themselves.
www.sec.gov, which serves the ticker map, refuses a User-Agent carrying no
contact address: a client name alone or with a project URL gets 403, one
with an address gets 200. So the default carries a placeholder address and
the package version. Set SEC_EDGAR_USER_AGENT to your own name and address
so SEC can reach you about your traffic rather than the project.
"""
configured = os.getenv("SEC_EDGAR_USER_AGENT", "").strip()
return configured or f"TradingAgents/{_version()} (contact@example.com)"
def _version() -> str:
"""The installed package version, so a release identifies itself correctly."""
try:
return metadata.version("tradingagents")
except metadata.PackageNotFoundError:
return "dev"
def _fetch_json(url: str) -> dict:
"""Read a public EDGAR document, respecting SEC's identification rule."""
try:
response = requests.get(url, headers={"User-Agent": _user_agent()}, timeout=30)
response.raise_for_status()
return response.json()
except requests.RequestException as exc:
status = getattr(getattr(exc, "response", None), "status_code", None)
# Every failure here is "this vendor cannot serve it now", so the router
# moves on instead of seeing a transport exception it has no rule for.
raise VendorRateLimitError(f"SEC EDGAR request failed ({status or type(exc).__name__})") from exc
except ValueError as exc:
raise VendorRateLimitError("SEC EDGAR returned an unreadable response") from exc
def _cached_json(url: str, name: str) -> dict:
path = Path(get_config()["data_cache_dir"]) / "sec_edgar" / name
if path.exists() and time.time() - path.stat().st_mtime < _CACHE_TTL_SECONDS:
try:
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
pass # a truncated file is a miss, not a failure
data = _fetch_json(url)
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(data), encoding="utf-8")
os.replace(temp, path)
return data
def cik_for(ticker: str) -> str | None:
"""The filer's CIK, or None when the ticker is not a US filer."""
table = _cached_json(_TICKERS_URL, "company_tickers.json")
wanted = ticker.strip().upper()
for entry in table.values():
if entry.get("ticker", "").upper() == wanted:
return f"{int(entry['cik_str']):010d}"
return None
def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int]) -> tuple[dict, str]:
"""({period end: value}, unit) for the first tag the filer reports, as known then.
A period reported more than once takes its latest filing on or before the
date, so an amendment counts from the day it was filed and not before. The
unit comes from the filing: most lines are USD, earnings per share are
USD/shares, and scaling those alike would print a real figure as zero.
"""
low, high = span
values: dict[str, float] = {}
chosen_unit = "USD"
# Tags are tried in order and a period keeps the first one that reports it:
# filers renamed lines over the years, so one tag covers only part of the
# history. Values are never added across tags, which would double count.
for tag in tags:
for unit, unit_values in ((facts.get(tag) or {}).get("units", {})).items():
latest: dict[str, dict] = {}
for fact in unit_values:
if fact["filed"] > curr_date or fact["end"] in values:
continue
# A duration fact (revenue, cash flow) must cover the span asked
# for. An instant fact (a balance) has no span and serves both.
if "start" in fact:
days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days
if not low <= days <= high:
continue
seen = latest.get(fact["end"])
if seen is None or fact["filed"] >= seen["filed"]:
latest[fact["end"]] = fact
if latest:
chosen_unit = unit
values.update({end: fact["val"] for end, fact in latest.items()})
return dict(sorted(values.items())), chosen_unit
def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> str:
curr_date = curr_date or datetime.now().strftime("%Y-%m-%d")
cik = cik_for(ticker)
if cik is None:
raise NoMarketDataError(ticker, ticker, "not a US SEC filer")
facts = _cached_json(_FACTS_URL.format(cik=cik), f"CIK{cik}.json")
us_gaap = (facts.get("facts") or {}).get("us-gaap")
if not us_gaap:
raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts")
span = _SPANS["quarterly" if freq.lower() == "quarterly" else "annual"]
lines = {label: _as_of(us_gaap, tags, curr_date, span) for label, tags in _STATEMENTS[kind]}
periods = sorted({end for values, _ in lines.values() for end in values})
if not periods:
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {curr_date}")
header = (
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n"
f"# SEC EDGAR facts filed on or before {curr_date}, at the values filed then\n\n"
)
rows = [",".join([""] + periods)]
for label, (values, unit) in lines.items():
# Every row spans the same columns, or a reader lines the table up wrong.
if not values:
rows.append(",".join([label] + ["unavailable (not tagged by this filer)"] * len(periods)))
continue
name = label if unit == "USD" else f"{label} ({unit})"
# Plain numbers: a thousands separator would split the CSV field.
cells = [
(f"{values[p] / 1e6:.0f}" if unit == "USD" else f"{values[p]:.2f}")
if p in values else "" for p in periods
]
rows.append(",".join([name] + cells))
return header + "\n".join(rows) + "\n"
def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str:
"""Balance sheet as filed on or before ``curr_date``."""
return _statement("balance_sheet", ticker, freq, curr_date, "Balance Sheet")
def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str:
"""Income statement as filed on or before ``curr_date``.
A fourth quarter is never derived: filers report it only inside the annual
figure, and subtracting three separately filed quarters would invent a number
with no filing date behind it.
"""
return _statement("income_statement", ticker, freq, curr_date, "Income Statement")
def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str:
"""Cash flow statement as filed on or before ``curr_date``."""
return _statement("cashflow", ticker, freq, curr_date, "Cash Flow Statement")

View File

@@ -9,16 +9,36 @@ from stockstats import wrap
from yfinance.exceptions import YFRateLimitError
from .config import get_config
from .errors import VendorRateLimitError
from .symbol_utils import NoMarketDataError, normalize_symbol
from .utils import safe_ticker_component
from .utils import safe_ticker_component, vendor_reachable
logger = logging.getLogger(__name__)
_YAHOO_HOST = "https://query2.finance.yahoo.com"
# A vendor's latest OHLCV row this many calendar days before the requested date
# is treated as stale. Generous enough to span long holiday weekends, tight
# enough to catch the year-old frames yfinance occasionally returns (#1021).
MAX_OHLCV_STALE_DAYS = 10
# How long a same-day cache that does not yet reach the requested day may be
# reused before it is refetched (#1150). Short enough that an intraday run picks
# up today's close soon after it publishes, long enough that a day with no bar
# at all (weekend, holiday) cannot trigger a download on every call.
OHLCV_CACHE_TTL_SECONDS = 900
def raise_for_empty(symbol: str, canonical: str, what: str) -> None:
"""Report an empty Yahoo result as an absence, or as an outage if it is one.
yfinance returns an empty frame for a failed request rather than raising, so
without this a Yahoo outage reads as "this symbol has no {what}".
"""
if not vendor_reachable(_YAHOO_HOST):
raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved")
raise NoMarketDataError(symbol, canonical, f"no {what}")
def yf_retry(func, max_retries=3, base_delay=2.0):
"""Execute a yfinance call with exponential backoff on rate limits.
@@ -54,17 +74,53 @@ def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
return data
def _local_midnight(value) -> pd.Timestamp:
"""A single timestamp as its naive, midnight-normalized local date (or NaT)."""
if pd.isna(value):
return pd.NaT
try:
ts = pd.Timestamp(value)
except (ValueError, TypeError):
return pd.NaT
if ts.tzinfo is not None:
ts = ts.tz_localize(None) # drop tz, keep the local wall-clock date
return ts.normalize()
def _normalize_dates(dates) -> pd.Series:
"""Parse to naive, midnight-normalized dates so tz-aware or intraday
timestamps compare correctly against the naive ``curr_date`` cutoff (#1201).
Normalized per element: 5 years of yfinance bars span daylight-saving
changes (and cache CSVs round-trip the offsets as strings), so the series can
carry mixed UTC offsets that ``pd.to_datetime`` cannot unify without
``utc=True`` — which would shift non-US (positive-offset) markets to the
previous day. Keeping each bar's own local date avoids both.
"""
return pd.to_datetime(pd.Series(dates).map(_local_midnight))
def _clean_dataframe(data: pd.DataFrame) -> pd.DataFrame:
"""Normalize a stock DataFrame for stockstats: parse dates, drop invalid rows, fill price gaps."""
"""Normalize a stock DataFrame for stockstats: parse/normalize dates and
coerce prices to numeric (NaN where invalid). Dropping incomplete rows and
filling gaps is left to ``_fill_price_gaps`` so the caller can first inspect
the latest in-range bar (#1201)."""
data = _ensure_date_column(data)
data["Date"] = pd.to_datetime(data["Date"], errors="coerce")
data["Date"] = _normalize_dates(data["Date"])
data = data.dropna(subset=["Date"])
price_cols = [c for c in ["Open", "High", "Low", "Close", "Volume"] if c in data.columns]
data[price_cols] = data[price_cols].apply(pd.to_numeric, errors="coerce")
data = data.dropna(subset=["Close"])
data[price_cols] = data[price_cols].ffill().bfill()
return data
def _fill_price_gaps(data: pd.DataFrame) -> pd.DataFrame:
"""Drop rows with no close and forward/back-fill remaining price gaps so
indicators compute on a continuous series."""
price_cols = [c for c in ["Open", "High", "Low", "Close", "Volume"] if c in data.columns]
# copy() so a filtered (sliced) input is written to safely, not via a view.
data = data.dropna(subset=["Close"]).copy()
data[price_cols] = data[price_cols].ffill().bfill()
return data
@@ -122,12 +178,31 @@ def _assert_ohlcv_not_stale(
)
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
def _cache_is_fresh(data_file, curr_date_dt, now) -> bool:
"""Whether the symbol's cached download can serve this request.
The file holds the download made on the day it was written, so it serves
only that day. A current-day request also refetches once the file is older
than the TTL: Yahoo publishes a partial daily candle during market hours,
whose ``Close`` is not the closing price, and row inspection cannot tell it
from a final one (#1150).
"""
written = pd.Timestamp.fromtimestamp(os.path.getmtime(data_file))
if written.date() != now.date():
return False
return curr_date_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS
def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFrame:
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
Downloads 5 years of data up to today and caches per symbol. On
subsequent calls the cache is reused. Rows after curr_date are
filtered out so backtests never see future prices.
``fill_gaps`` carries prices forward over gaps so indicators compute on a
continuous series. Pass ``False`` to read the values as the vendor reported
them, leaving a cell that was never reported empty.
"""
# Resolve broker/forex symbols (XAUUSD+ -> GC=F) to Yahoo's convention,
# then reject values that would escape the cache directory when
@@ -136,21 +211,21 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
safe_symbol = safe_ticker_component(canonical)
config = get_config()
curr_date_dt = pd.to_datetime(curr_date)
curr_date_dt = pd.to_datetime(curr_date).normalize()
# Cache uses a fixed window (5y to today) so one file per symbol.
today_date = pd.Timestamp.today()
start_date = today_date - pd.DateOffset(years=5)
# One cache file per symbol, holding the latest 5y-to-today download.
now = pd.Timestamp.today()
start_date = now - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d")
# yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
# when curr_date is the current day (#986). Look-ahead is still prevented by
# the curr_date filter below.
end_str = (today_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
end_str = (now + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
os.makedirs(config["data_cache_dir"], exist_ok=True)
data_file = os.path.join(
config["data_cache_dir"],
f"{safe_symbol}-YFin-data-{start_str}-{end_str}.csv",
f"{safe_symbol}-YFin-data.csv",
)
# A cached file may be empty if a prior fetch failed (unknown symbol,
@@ -159,7 +234,11 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
data = None
if os.path.exists(data_file):
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
if not cached.empty and "Close" in cached.columns:
if (
not cached.empty
and "Close" in cached.columns
and _cache_is_fresh(data_file, curr_date_dt, now)
):
data = cached
if data is None:
@@ -174,17 +253,36 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
downloaded = _ensure_date_column(downloaded.reset_index())
# Only cache real data — never persist an empty frame.
if downloaded.empty or "Close" not in downloaded.columns:
raise NoMarketDataError(
symbol, canonical, "Yahoo Finance returned no rows"
)
raise_for_empty(symbol, canonical, "price rows")
downloaded.to_csv(data_file, index=False, encoding="utf-8")
data = downloaded
data = _clean_dataframe(data)
# Filter to curr_date to prevent look-ahead bias in backtesting
# Filter to curr_date to prevent look-ahead bias in backtesting.
data = data[data["Date"] <= curr_date_dt]
# A closeless newest bar is an unsettled session, not a symbol without data.
# _fill_price_gaps below drops it, here and mid-series alike, so the frame
# ends at the last settled bar; only a range with no close anywhere is no
# data (#1201, #1289).
if not data.empty and pd.isna(data["Close"].iloc[-1]):
settled = data["Close"].notna().to_numpy().nonzero()[0]
if settled.size == 0:
raise NoMarketDataError(
symbol, canonical, "no bar in range has a closing price"
)
logger.warning(
"%s: %d trailing bar(s) through %s have no closing price; using %s "
"as the latest close.", canonical, len(data) - settled[-1] - 1,
data["Date"].iloc[-1].date(), data["Date"].iloc[settled[-1]].date(),
)
# Indicators need a continuous series, so gaps are carried forward. A caller
# that reports the numbers themselves asks for the frame as it was reported:
# a filled cell is the previous session's price under this session's date.
data = _fill_price_gaps(data) if fill_gaps else data.dropna(subset=["Close"]).copy()
# Reject a stale frame (latest row far older than curr_date) rather than
# feeding year-old prices into indicators (#1021).
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical)

View File

@@ -14,11 +14,14 @@ network call succeeded.
from __future__ import annotations
import contextlib
import http.client
import json
import logging
from datetime import datetime
from urllib.request import Request, urlopen
from .date_window import coverage_gap, in_window
from .symbol_utils import crypto_base
logger = logging.getLogger(__name__)
@@ -27,6 +30,30 @@ _API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
def _created_at(message) -> datetime | None:
"""Parse a message's ISO 8601 ``created_at``; None when missing or malformed."""
raw = message.get("created_at")
if not raw:
return None
with contextlib.suppress(ValueError, TypeError):
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
return None
def _within_window(messages, start_date, end_date):
"""Keep only messages published in [start_date, end_date] (look-ahead safe).
No window (both None) leaves the list untouched for live callers. A message
whose ``created_at`` (ISO 8601) is unparseable is dropped in a historical
window, since we can't prove it isn't from after the as-of date (#1220).
"""
if not (start_date and end_date):
return messages
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
return [m for m in messages if in_window(_created_at(m), start_dt, end_dt)]
def _stocktwits_symbol(ticker: str) -> str:
"""Map a crypto pair to StockTwits' ``<BASE>.X`` convention.
@@ -38,10 +65,21 @@ def _stocktwits_symbol(ticker: str) -> str:
return f"{base}.X" if base else ticker.strip().upper()
def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str:
def fetch_stocktwits_messages(
ticker: str,
limit: int = 30,
timeout: float = 10.0,
start_date: str | None = None,
end_date: str | None = None,
) -> str:
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
formatted plaintext block ready for prompt injection.
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, messages are trimmed
to that window, so a historical run never sees today's chatter (#1220). The
public stream only serves recent messages, so a window it cannot reach is
reported as unavailable rather than as silence.
Returns a placeholder string when the endpoint is unreachable, the
symbol has no messages, or the response shape is unexpected — the
caller never has to special-case None or exceptions.
@@ -57,8 +95,18 @@ def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.
logger.warning("StockTwits fetch failed for %s: %s", ticker, exc)
return f"<stocktwits unavailable: {type(exc).__name__}>"
messages = data.get("messages", []) if isinstance(data, dict) else []
fetched = data.get("messages", []) if isinstance(data, dict) else []
messages = _within_window(fetched, start_date, end_date)
if not messages:
if start_date and end_date:
gap = coverage_gap(
(_created_at(m) for m in fetched), start_date, end_date,
"StockTwits", f"messages about ${ticker.upper()}",
)
return gap or (
f"<no StockTwits messages for ${ticker.upper()} within "
f"{start_date}..{end_date}>"
)
return f"<no StockTwits messages found for ${ticker.upper()}>"
lines = []

View File

@@ -10,6 +10,8 @@ differ from the broker / TradingView / MT5 style symbols users often type:
EURUSD EURUSD=X spot forex pairs take a ``=X`` suffix
BTCUSD BTC-USD crypto pairs use a ``-`` separator
SPX500, US500 ^GSPC index CFDs map to Yahoo index symbols
09992.HK, 700.HK 9992.HK, 0700.HK HK codes are zero-padded to 4 digits
600519.SH 600519.SS Yahoo spells Shanghai ``.SS``
Passing the raw broker symbol to Yahoo returns an empty result, which the
agents previously received as free text and could hallucinate a price
@@ -72,6 +74,10 @@ _ALIASES = {
# Yahoo symbols may contain letters, digits, and these structural characters.
_YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$")
# HKEX codes as Yahoo spells them: the number zero-padded to 4 digits (#957).
_HK_CODE = re.compile(r"^(\d{1,5})\.HK$")
_SHANGHAI_SH = re.compile(r"^(\d{6})\.SH$")
# Crypto quote currencies that all map to Yahoo's USD pair. Yahoo lists only
# ``<BASE>-USD`` (not the USDT/USDC stablecoin pairs), so a broker symbol quoted
@@ -109,7 +115,10 @@ def normalize_symbol(raw: str) -> str:
2. Crypto rule: a known crypto base quoted in USD/USDT/USDC (dashed or
not) -> ``BASE-USD``.
3. Forex rule: six letters that are two ISO currency codes -> ``PAIR=X``.
4. Otherwise the upper-cased symbol is returned unchanged (plain
4. HK rule: a numeric ``.HK`` code -> Yahoo's 4-digit padding
(``09992.HK`` -> ``9992.HK``, ``700.HK`` -> ``0700.HK``).
5. Shanghai rule: ``600519.SH`` -> ``600519.SS``.
6. Otherwise the upper-cased symbol is returned unchanged (plain
equities, ETFs, Yahoo-native symbols like ``GC=F`` or ``^GSPC``).
A trailing ``+`` (broker CFD marker, e.g. ``XAUUSD+``) is stripped before
@@ -130,6 +139,10 @@ def normalize_symbol(raw: str) -> str:
canonical = crypto
elif len(s) == 6 and s[:3] in _FOREX_CURRENCIES and s[3:] in _FOREX_CURRENCIES:
canonical = f"{s}=X"
elif hk := _HK_CODE.match(s):
canonical = f"{int(hk.group(1)):04d}.HK"
elif sh := _SHANGHAI_SH.match(s):
canonical = f"{sh.group(1)}.SS"
else:
canonical = s

View File

@@ -1,10 +1,7 @@
import re
from datetime import date, datetime, timedelta
from typing import Annotated
from datetime import date
import pandas as pd
SavePathType = Annotated[str, "File path to save data. If None, data is not saved."]
import requests
# Tickers can contain letters, digits, dot, dash, underscore, caret
# (index symbols like ^GSPC), equals (futures like GC=F), and plus
@@ -42,34 +39,39 @@ def safe_ticker_component(value: str, *, max_len: int = 32) -> str:
return value
def save_output(data: pd.DataFrame, tag: str, save_path: SavePathType = None) -> None:
if save_path:
data.to_csv(save_path, encoding="utf-8")
print(f"{tag} saved to {save_path}")
def get_current_date():
return date.today().strftime("%Y-%m-%d")
def decorate_all_methods(decorator):
def class_decorator(cls):
for attr_name, attr_value in cls.__dict__.items():
if callable(attr_value):
setattr(cls, attr_name, decorator(attr_value))
return cls
def get_scrubbed(url: str, *, params: dict, timeout: float, secret: str, passthrough=()):
"""``requests.get`` plus ``raise_for_status``, with ``secret`` kept out of errors.
return class_decorator
Vendors that authenticate with a query parameter put the key in the URL, and
requests quotes the full URL in HTTP, connection and timeout errors, so any
log or traceback that records one would carry the key (#1324). A requests
error is re-raised as the same class with the key replaced and nothing
attached: no request or response (both hold the URL) and no exception chain,
which is why this raises after the ``except`` block rather than inside it.
Statuses in ``passthrough`` are returned for the caller to handle.
"""
try:
response = requests.get(url, params=params, timeout=timeout)
if response.status_code not in passthrough:
response.raise_for_status()
return response
except requests.RequestException as exc:
error = type(exc)(str(exc).replace(secret, "***")) if secret else exc
raise error
def get_next_weekday(date):
def vendor_reachable(url: str, timeout: float = 5.0) -> bool:
"""Whether the vendor answers at all, for telling silence from an outage.
if not isinstance(date, datetime):
date = datetime.strptime(date, "%Y-%m-%d")
if date.weekday() >= 5:
days_to_add = 7 - date.weekday()
next_weekday = date + timedelta(days=days_to_add)
return next_weekday
else:
return date
A client that returns an empty result instead of raising leaves those two
cases indistinguishable. Called only when a result is empty.
"""
try:
requests.head(url, timeout=timeout, allow_redirects=True)
return True
except requests.RequestException:
return False

View File

@@ -1,3 +1,4 @@
import logging
from datetime import datetime
from typing import Annotated
@@ -5,14 +6,22 @@ import pandas as pd
import yfinance as yf
from dateutil.relativedelta import relativedelta
from .date_window import withhold_live_profile
from .errors import VendorError, VendorRateLimitError
from .stockstats_utils import (
StockstatsUtils,
_assert_ohlcv_not_stale,
filter_financials_by_date,
load_ohlcv,
raise_for_empty,
yf_retry,
)
from .symbol_utils import NoMarketDataError, normalize_symbol
from .utils import vendor_reachable
_YAHOO_HOST = "https://query2.finance.yahoo.com"
logger = logging.getLogger(__name__)
def get_YFin_data_online(
@@ -38,9 +47,7 @@ def get_YFin_data_online(
# instead of returning prose: the routing layer turns it into a single
# unambiguous "no data" signal so the agent never fabricates a price.
if data.empty:
raise NoMarketDataError(
symbol, canonical, f"no rows between {start_date} and {end_date}"
)
raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}")
# Remove timezone info from index for cleaner output
if data.index.tz is not None:
@@ -185,10 +192,10 @@ def get_stock_stats_indicators_window(
for date_str, value in date_values:
ind_string += f"{date_str}: {value}\n"
except NoMarketDataError:
except VendorError:
raise # Unknown/delisted symbol — let the router emit the sentinel
except Exception as e:
print(f"Error getting bulk stockstats data: {e}")
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
# Fallback to original implementation if bulk method fails
ind_string = ""
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
@@ -260,29 +267,43 @@ def get_stockstats_indicator(
indicator,
curr_date,
)
except NoMarketDataError:
except VendorError:
raise # Unknown/delisted symbol — let the router emit the sentinel
except Exception as e:
print(
f"Error getting stockstats indicator data for indicator {indicator} on {curr_date}: {e}"
)
return ""
# An empty string renders as "2026-05-08: " in the indicator table, which
# reads as no value that day rather than a read that failed. Raise so the
# router can try the next vendor or report the series unavailable.
raise NoMarketDataError(
symbol, symbol, f"{indicator} could not be read for {curr_date}: {e}"
) from e
return str(indicator_value)
def get_fundamentals(
ticker: Annotated[str, "ticker symbol of the company"],
curr_date: Annotated[str, "current date (not used for yfinance)"] = None
curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None
):
"""Get company fundamentals overview from yfinance."""
"""Get company fundamentals overview from yfinance.
``Ticker.info`` is a present-day snapshot with no historical vintage, so a
past ``curr_date`` withholds it through the shared point-in-time guard
(``date_window.withhold_live_profile``, #1300).
"""
canonical = normalize_symbol(ticker)
# Guard before the request: the response would only be discarded, and the
# answer does not depend on it.
withheld = withhold_live_profile(curr_date, canonical)
if withheld:
return withheld
try:
ticker_obj = yf.Ticker(canonical)
info = yf_retry(lambda: ticker_obj.info)
if not info:
raise NoMarketDataError(ticker, canonical, "no fundamentals returned")
raise_for_empty(ticker, canonical, "fundamentals")
fields = [
("Name", info.get("longName")),
@@ -315,10 +336,7 @@ def get_fundamentals(
("Free Cash Flow", info.get("freeCashflow")),
]
lines = []
for label, value in fields:
if value is not None:
lines.append(f"{label}: {value}")
lines = [f"{label}: {v}" for label, v in fields if v is not None]
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
# unknown symbols, so `info` is truthy but every field is empty. Treat
@@ -332,10 +350,10 @@ def get_fundamentals(
return header + "\n".join(lines)
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving fundamentals for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"fundamentals unavailable: {e}") from e
def get_balance_sheet(
@@ -356,21 +374,22 @@ def get_balance_sheet(
data = filter_financials_by_date(data, curr_date)
if data.empty:
raise NoMarketDataError(ticker, canonical, "no balance sheet data")
raise_for_empty(ticker, canonical, "balance sheet data")
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Balance Sheet data for {canonical} ({freq})\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
header += _PERIOD_END_VINTAGE
return header + csv_string
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving balance sheet for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"balance sheet unavailable: {e}") from e
def get_cashflow(
@@ -391,21 +410,22 @@ def get_cashflow(
data = filter_financials_by_date(data, curr_date)
if data.empty:
raise NoMarketDataError(ticker, canonical, "no cash flow data")
raise_for_empty(ticker, canonical, "cash flow data")
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Cash Flow data for {canonical} ({freq})\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
header += _PERIOD_END_VINTAGE
return header + csv_string
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving cash flow for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"cash flow unavailable: {e}") from e
def get_income_statement(
@@ -426,25 +446,49 @@ def get_income_statement(
data = filter_financials_by_date(data, curr_date)
if data.empty:
raise NoMarketDataError(ticker, canonical, "no income statement data")
raise_for_empty(ticker, canonical, "income statement data")
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Income Statement data for {canonical} ({freq})\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
header += _PERIOD_END_VINTAGE
return header + csv_string
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving income statement for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"income statement unavailable: {e}") from e
# Rows are dated by the transaction, which is when the insider traded, not when
# the market learned of it: a Form 4 is filed up to two business days later and
# this vendor reports no filing date, so the most recent rows may not have been
# public on the analysis date.
_TRANSACTION_DATE_VINTAGE = (
"# Rows are dated by transaction date. A trade becomes public when its Form 4 "
"is filed, up to two business days later, so the newest rows may not have been "
"known on this date.\n\n"
)
# This vendor dates a statement by the period it covers, not by the day it was
# filed, and carries no filing date to do better. A company files weeks after its
# period ends, so a run dated in that gap can be served figures that were not yet
# public. Say so rather than implying the stricter guarantee (SEC EDGAR, which
# does carry filing dates, serves US filers as filed).
_PERIOD_END_VINTAGE = (
"# Periods are cut at the fiscal period end; this vendor does not report "
"filing dates, so the most recent period may not have been published yet.\n\n"
)
def get_insider_transactions(
ticker: Annotated[str, "ticker symbol of the company"]
ticker: Annotated[str, "ticker symbol of the company"],
curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None,
):
"""Get insider transactions data from yfinance."""
canonical = normalize_symbol(ticker)
@@ -455,16 +499,29 @@ def get_insider_transactions(
# Empty is normal here (many valid symbols have no insider filings),
# so report it plainly rather than treating the symbol as invalid.
if data is None or data.empty:
if not vendor_reachable(_YAHOO_HOST):
raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved")
return f"No insider transactions reported for symbol '{canonical}'"
if curr_date:
traded = data["Start Date"]
kept = data[traded <= pd.Timestamp(curr_date)]
if kept.empty:
return (
f"<insider transactions unavailable for {canonical} as of {curr_date}: "
f"Yahoo serves recent transactions only (coverage starts {traded.min():%Y-%m-%d})>"
)
data = kept
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Insider Transactions data for {canonical}\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
header += _TRANSACTION_DATE_VINTAGE
return header + csv_string
except Exception as e:
return f"Error retrieving insider transactions for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e

View File

@@ -1,12 +1,14 @@
"""yfinance-based news data fetching functions."""
import contextlib
from datetime import datetime
from datetime import datetime, timezone
import yfinance as yf
from dateutil.relativedelta import relativedelta
from .config import get_config
from .date_window import coverage_gap, in_window
from .errors import NoMarketDataError
from .stockstats_utils import yf_retry
from .symbol_utils import normalize_symbol
@@ -46,8 +48,10 @@ def _extract_article_data(article: dict) -> dict:
pub_date = None
ts = article.get("providerPublishTime")
if ts:
# Epoch seconds are UTC; parse them as UTC-aware so filtering does
# not shift with the host timezone (#1126).
with contextlib.suppress(ValueError, OSError, TypeError):
pub_date = datetime.fromtimestamp(ts)
pub_date = datetime.fromtimestamp(ts, tz=timezone.utc)
return {
"title": article.get("title", "No title"),
"summary": article.get("summary", ""),
@@ -57,20 +61,6 @@ def _extract_article_data(article: dict) -> dict:
}
def _in_news_window(pub_date, start_dt, end_dt) -> bool:
"""Whether an article belongs in the [start_dt, end_dt] window.
Dated articles are kept only if they fall in the window. An undated article
is kept only when the window reaches the present (live run) — in a
historical/backtest window it's excluded, since we can't prove it isn't
future news (look-ahead safety, #992/#1007).
"""
if pub_date is not None:
naive = pub_date.replace(tzinfo=None) if hasattr(pub_date, "replace") else pub_date
return start_dt <= naive <= end_dt + relativedelta(days=1)
return end_dt >= datetime.now() - relativedelta(days=1)
def get_news_yfinance(
ticker: str,
start_date: str,
@@ -95,10 +85,7 @@ def get_news_yfinance(
resolved = "" if canonical == ticker else f" (resolved to {canonical})"
try:
stock = yf.Ticker(canonical)
news = yf_retry(lambda: stock.get_news(count=article_limit))
if not news:
return f"No news found for {ticker}{resolved}"
news = yf_retry(lambda: stock.get_news(count=article_limit)) or []
# Parse date range for filtering
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
@@ -111,7 +98,7 @@ def get_news_yfinance(
data = _extract_article_data(article)
# Keep only articles within the requested window (look-ahead safe).
if not _in_news_window(data["pub_date"], start_dt, end_dt):
if not in_window(data["pub_date"], start_dt, end_dt):
continue
news_str += f"### {data['title']} (source: {data['publisher']})\n"
@@ -123,12 +110,16 @@ def get_news_yfinance(
filtered_count += 1
if filtered_count == 0:
return f"No news found for {ticker}{resolved} between {start_date} and {end_date}"
gap = coverage_gap(
(_extract_article_data(a)["pub_date"] for a in news),
start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}",
)
return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}"
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
except Exception as e:
return f"Error fetching news for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e
def get_global_news_yfinance(
@@ -156,7 +147,11 @@ def get_global_news_yfinance(
limit = config["global_news_article_limit"]
search_queries = config["global_news_queries"]
all_news = []
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
start_dt = curr_dt - relativedelta(days=look_back_days)
start_date = start_dt.strftime("%Y-%m-%d")
in_window_news = []
seen_titles = set()
try:
@@ -167,53 +162,39 @@ def get_global_news_yfinance(
enable_fuzzy_query=True,
))
if search.news:
for article in search.news:
# Handle both flat and nested structures
if "content" in article:
data = _extract_article_data(article)
title = data["title"]
else:
title = article.get("title", "")
for article in search.news or []:
# Window first: the limit counts what the run may read, so an
# out-of-window article must not spend the budget or cut the
# remaining searches short (#1356). Flat articles are filtered
# on the same rule, so none can leak future news (#1007).
data = _extract_article_data(article)
if not in_window(data["pub_date"], start_dt, curr_dt):
continue
if data["title"] and data["title"] not in seen_titles:
seen_titles.add(data["title"])
in_window_news.append(data)
# Deduplicate by title
if title and title not in seen_titles:
seen_titles.add(title)
all_news.append(article)
if len(all_news) >= limit:
if len(in_window_news) >= limit:
break
if not all_news:
return f"No global news found for {curr_date}"
# Calculate date range
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
start_dt = curr_dt - relativedelta(days=look_back_days)
start_date = start_dt.strftime("%Y-%m-%d")
news_str = ""
kept = 0
for article in all_news[:limit]:
# Extract uniformly (flat + nested) and apply the same look-ahead-safe
# window filter, so flat articles can't leak future news (#1007).
data = _extract_article_data(article)
if not _in_news_window(data["pub_date"], start_dt, curr_dt):
continue
for data in in_window_news[:limit]:
news_str += f"### {data['title']} (source: {data['publisher']})\n"
if data["summary"]:
news_str += f"{data['summary']}\n"
if data["link"]:
news_str += f"Link: {data['link']}\n"
news_str += "\n"
kept += 1
# All candidates fell outside the window -> say so rather than return an
# Nothing fell inside the window -> say so rather than return an
# empty-bodied report (#993).
if kept == 0:
return f"No global news found between {start_date} and {curr_date}"
if not news_str:
# Results merge several fuzzy searches, so their timestamps prove no
# continuous coverage; judge the window against the present only.
gap = coverage_gap((), start_date, curr_date, "Yahoo Finance global news", "market news")
return gap or f"No global news found between {start_date} and {curr_date}"
return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}"
except Exception as e:
return f"Error fetching global news: {str(e)}"
raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e

View File

@@ -19,6 +19,7 @@ _ENV_OVERRIDES = {
"TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker",
"TRADINGAGENTS_TEMPERATURE": "temperature",
"TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries",
"TRADINGAGENTS_MAX_TOKENS": "max_tokens",
# Provider-specific reasoning/thinking knobs (None = each provider's own
# default). Settable here for non-interactive runs; the CLI also offers an
# interactive choice, which is skipped when the matching var is set.
@@ -79,8 +80,8 @@ DEFAULT_CONFIG = _apply_env_overrides({
"memory_log_max_entries": None,
# LLM settings
"llm_provider": "openai",
"deep_think_llm": "gpt-5.5",
"quick_think_llm": "gpt-5.4-mini",
"deep_think_llm": "gpt-5.6",
"quick_think_llm": "gpt-5.6-luna",
# When None, each provider's client falls back to its own default endpoint
# (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...).
# The CLI overrides this per provider when the user picks one. Keeping a
@@ -100,6 +101,11 @@ DEFAULT_CONFIG = _apply_env_overrides({
# provider/SDK at its own default (usually 2). Raise it to ride out bursty
# 429 throttling on rate-limited deployments instead of aborting a run (#1091).
"llm_max_retries": None,
# Cap on output tokens forwarded to every provider chat client. None leaves
# each provider at its own default. Set it to bound a model that emits
# unbounded reasoning/output and hangs or trips a gateway idle timeout
# (e.g. some deepseek-v4-flash deployments, #1204).
"max_tokens": None,
# Checkpoint/resume: when True, LangGraph saves state after each node
# so a crashed run can resume from the last successful step.
"checkpoint_enabled": False,
@@ -148,6 +154,9 @@ DEFAULT_CONFIG = _apply_env_overrides({
# based on the ticker's exchange suffix. SPY remains the US default
# so the reflection label keeps reading "Alpha vs SPY" for US tickers
# while non-US tickers get their regional index automatically.
# Trading days after the analysis date over which a decision's outcome is
# measured, for reflection and for the backtest figures.
"holding_period_days": 5,
"benchmark_ticker": None,
"benchmark_map": {
".NS": "^NSEI", # NSE India (Nifty 50)

View File

@@ -71,13 +71,19 @@ def checkpoint_step(data_dir: str | Path, ticker: str, date: str, signature: str
def clear_all_checkpoints(data_dir: str | Path) -> int:
"""Remove all checkpoint DBs. Returns number of files deleted."""
"""Remove all checkpoint databases. Returns the number of databases deleted.
SQLite keeps committed state in ``-wal`` and ``-shm`` files beside the
database, so deleting only the ``.db`` leaves a cleared checkpoint with data
still on disk.
"""
cp_dir = Path(data_dir) / "checkpoints"
if not cp_dir.exists():
return 0
dbs = list(cp_dir.glob("*.db"))
for db in dbs:
db.unlink()
for path in (db, *cp_dir.glob(f"{db.name}-*")):
path.unlink(missing_ok=True)
return len(dbs)

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