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 "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

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
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)