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
This commit is contained in:
Yijia-Xiao
2026-09-18 00:04:53 +00:00
parent d0881f081e
commit f8042efdde
2 changed files with 68 additions and 16 deletions

View File

@@ -61,12 +61,13 @@ class TestRenderTraderProposal:
assert "**Position Sizing**: 6% of portfolio" in md assert "**Position Sizing**: 6% of portfolio" in md
assert "FINAL TRANSACTION PROPOSAL: **BUY**" 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.") p = TraderProposal(action=TraderAction.SELL, reasoning="Guidance cut.")
md = render_trader_proposal(p) md = render_trader_proposal(p)
assert "Entry Price" not in md for field in ("Entry Price", "Stop Loss", "Position Sizing"):
assert "Stop Loss" not in md assert f"**{field}**: not provided" in md
assert "Position Sizing" not in md
assert "FINAL TRANSACTION PROPOSAL: **SELL**" 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 \ assert "conflict alone is not a reason to Hold" in text or \
"Conflicting arguments alone are not a reason to Hold" in text "Conflicting arguments alone are not a reason to Hold" in text
assert "materially conflicting" not 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

@@ -39,7 +39,12 @@ def _coerce_optional_float(value):
cannot be salvaged into an absolute level -- reading "15%" as 15 would put a 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 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 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): if not isinstance(value, str):
return value return value
@@ -47,7 +52,10 @@ def _coerce_optional_float(value):
if text.lower() in _NULLISH_FLOAT or text.endswith("%"): if text.lower() in _NULLISH_FLOAT or text.endswith("%"):
return None return None
cleaned = text.replace(",", "").lstrip("$€£¥").strip() 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}", f"**Reasoning**: {proposal.reasoning}",
] ]
if proposal.entry_price is not None: # Named even when absent, so a reader can tell a level the trader chose not
parts.extend(["", f"**Entry Price**: {proposal.entry_price}"]) # to give from one the schema never asked for.
if proposal.stop_loss is not None: for label, value in (("Entry Price", proposal.entry_price),
parts.extend(["", f"**Stop Loss**: {proposal.stop_loss}"]) ("Stop Loss", proposal.stop_loss),
if proposal.position_sizing: ("Position Sizing", proposal.position_sizing)):
parts.extend(["", f"**Position Sizing**: {proposal.position_sizing}"]) parts.extend(["", f"**{label}**: {value if value is not None and value != '' else 'not provided'}"])
parts.extend([ parts.extend([
"", "",
f"FINAL TRANSACTION PROPOSAL: **{proposal.action.value.upper()}**", 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}", f"**Investment Thesis**: {decision.investment_thesis}",
] ]
if decision.price_target is not None: # Named even when absent: a missing line reads as a field nobody asked for,
parts.extend(["", f"**Price Target**: {decision.price_target}"]) # so a reader cannot tell "no target" from "target not reported".
if decision.time_horizon: target = decision.price_target if decision.price_target is not None else "not provided"
parts.extend(["", f"**Time Horizon**: {decision.time_horizon}"]) parts.extend(["", f"**Price Target**: {target}"])
parts.extend(["", f"**Time Horizon**: {decision.time_horizon or 'not provided'}"])
return "\n".join(parts) return "\n".join(parts)