From f8042efddea48f4b29a4fef92967713bbc223887 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 18 Sep 2026 00:04:53 +0000 Subject: [PATCH] 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 --- tests/test_structured_agents.py | 51 ++++++++++++++++++++++++++++++--- tradingagents/agents/schemas.py | 33 +++++++++++++-------- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/tests/test_structured_agents.py b/tests/test_structured_agents.py index f411beb35..efc104aeb 100644 --- a/tests/test_structured_agents.py +++ b/tests/test_structured_agents.py @@ -61,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 @@ -503,3 +504,45 @@ def test_conflict_alone_is_not_a_hold_trigger(source): 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 diff --git a/tradingagents/agents/schemas.py b/tradingagents/agents/schemas.py index 52f56d0d6..fb89aa42b 100644 --- a/tradingagents/agents/schemas.py +++ b/tradingagents/agents/schemas.py @@ -39,7 +39,12 @@ def _coerce_optional_float(value): 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 else passes through to pydantic. + 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 @@ -47,7 +52,10 @@ def _coerce_optional_float(value): if text.lower() in _NULLISH_FLOAT or text.endswith("%"): return None cleaned = text.replace(",", "").lstrip("$€£¥").strip() - return cleaned or None + try: + return float(cleaned) + except ValueError: + return None # --------------------------------------------------------------------------- @@ -192,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()}**", @@ -272,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)