"""TradingSignal dataclass and parser for structured output handling."""
import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class Action(str, Enum):
"""Trading signal actions."""
BUY = "BUY"
SELL = "SELL"
HOLD = "HOLD"
@dataclass
class TradingSignal:
"""Structured trading signal output."""
ticker: str
action: Action
confidence: int # 0-100
entry_price: Optional[float] = None
stop_loss: Optional[float] = None
target_price: Optional[float] = None
reasoning: Optional[dict[str, str]] = None # {agent_name: summary}
@staticmethod
def validate_confidence(value: int) -> int:
"""Clamp confidence to 0-100 range."""
return max(0, min(100, value))
class TradingSignalParser:
"""Parses raw LLM output into structured TradingSignal objects."""
# Primary format: "AAPL — BUY (Confidence: 75%)"
PRIMARY_PATTERN = re.compile(
r"([A-Z\-\.]+)\s*[—–-]\s*(BUY|SELL|HOLD)\s*\(Confidence:\s*(\d{1,3})%\)",
re.IGNORECASE,
)
# Fallback patterns for less structured output
ACTION_PATTERN = re.compile(r"\b(BUY|SELL|HOLD)\b", re.IGNORECASE)
CONFIDENCE_PATTERN = re.compile(r"(\d{1,3})\s*%")
PRICE_PATTERN = re.compile(r"\$\s*([\d,]+\.?\d*)")
# Reasoning-block markers Qwen3 wraps its deliberation in. We strip
# these at the entry point so the downstream regex patterns can
# match against the clean final answer rather than the verbose
# chain-of-thought that surrounds it.
THINK_BLOCK = re.compile(r".*?", re.DOTALL | re.IGNORECASE)
def parse(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
"""Parse raw output into a TradingSignal.
Attempts primary pattern first, falls back to heuristic extraction.
After parsing, prices are sanity-checked so the Strategist can't
ship obviously-wrong numbers (e.g. $50 000 target on a $290 stock,
or stop-loss on the wrong side of entry for the given action).
Args:
raw_output: Raw text from Chief Strategist agent
ticker: Expected ticker symbol
Returns:
TradingSignal if parsing succeeds, None if output is unparseable
"""
# Strip Qwen3's ... reasoning blocks before
# matching. Qwen will usually wrap its internal deliberation in
# these tags and place the structured final answer *after* the
# closing , but a stray token or unclosed tag used to
# push the whole output into the fallback path. Removing them
# lets the primary pattern match reliably.
cleaned = self.THINK_BLOCK.sub("", raw_output)
# Also drop any leftover lone or tag so the
# next regex doesn't accidentally anchor on them.
cleaned = re.sub(r"?think>", "", cleaned, flags=re.IGNORECASE)
signal = self._parse_primary(cleaned, ticker)
if signal is None:
signal = self._parse_fallback(cleaned, ticker)
if signal is None:
return None
return self._sanity_fix_prices(signal)
@staticmethod
def _sanity_fix_prices(signal: TradingSignal) -> TradingSignal:
"""Clamp runaway prices and enforce BUY/SELL inequality semantics.
Small LLMs occasionally emit obvious absurdities at synthesis time
(e.g. target that is 100× entry, or stop_loss on the wrong side
of entry for a BUY). Rather than surface nonsense to the user,
detect these and replace with conservative defaults derived from
the entry price:
* For BUY: stop = entry × 0.97, target = entry × 1.05
* For SELL: stop = entry × 1.03, target = entry × 0.95
* For HOLD: defaults left as-is.
We only override a field when it fails a plausibility test
(>20 % away from entry, or on the wrong side of entry for the
current action). Valid prices from the model are preserved.
"""
entry = signal.entry_price
# No entry price → nothing to clamp against.
if entry is None or entry <= 0:
return signal
stop = signal.stop_loss
target = signal.target_price
action = signal.action
def _out_of_band(value: Optional[float]) -> bool:
"""True if ``value`` is missing, non-positive, or >20 % from entry."""
if value is None or value <= 0:
return True
return abs(value - entry) / entry > 0.20
def _wrong_side_stop(value: Optional[float]) -> bool:
"""Stop on the wrong side of entry for the current action."""
if value is None:
return False
if action == Action.BUY and value >= entry:
return True
if action == Action.SELL and value <= entry:
return True
return False
def _wrong_side_target(value: Optional[float]) -> bool:
"""Target on the wrong side of entry for the current action."""
if value is None:
return False
if action == Action.BUY and value <= entry:
return True
if action == Action.SELL and value >= entry:
return True
return False
if action == Action.BUY:
if _out_of_band(stop) or _wrong_side_stop(stop):
stop = round(entry * 0.97, 2)
if _out_of_band(target) or _wrong_side_target(target):
target = round(entry * 1.05, 2)
elif action == Action.SELL:
if _out_of_band(stop) or _wrong_side_stop(stop):
stop = round(entry * 1.03, 2)
if _out_of_band(target) or _wrong_side_target(target):
target = round(entry * 0.95, 2)
# HOLD: leave as-is; the Strategist can describe a range.
return TradingSignal(
ticker=signal.ticker,
action=signal.action,
confidence=signal.confidence,
entry_price=entry,
stop_loss=stop,
target_price=target,
reasoning=signal.reasoning,
)
def _parse_primary(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
"""Attempt to parse using the primary structured format."""
match = self.PRIMARY_PATTERN.search(raw_output)
if not match:
return None
parsed_ticker = match.group(1).upper()
action_str = match.group(2).upper()
confidence_raw = int(match.group(3))
action = Action(action_str)
confidence = TradingSignal.validate_confidence(confidence_raw)
prices = self._extract_prices(raw_output)
reasoning = self._extract_reasoning(raw_output)
return TradingSignal(
ticker=parsed_ticker,
action=action,
confidence=confidence,
entry_price=prices.get("entry"),
stop_loss=prices.get("stop_loss"),
target_price=prices.get("target"),
reasoning=reasoning,
)
def _parse_fallback(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
"""Attempt heuristic extraction from unstructured output."""
action_match = self.ACTION_PATTERN.search(raw_output)
if not action_match:
return None
action = Action(action_match.group(1).upper())
confidence_match = self.CONFIDENCE_PATTERN.search(raw_output)
if confidence_match:
confidence = TradingSignal.validate_confidence(int(confidence_match.group(1)))
else:
confidence = 50 # Default confidence when not specified
prices = self._extract_prices(raw_output)
return TradingSignal(
ticker=ticker.upper(),
action=action,
confidence=confidence,
entry_price=prices.get("entry"),
stop_loss=prices.get("stop_loss"),
target_price=prices.get("target"),
)
def _extract_prices(self, raw_output: str) -> dict[str, Optional[float]]:
"""Extract entry, stop-loss, and target prices from text.
Finds all $XX.XX patterns and assigns:
- First as entry price
- Second as stop_loss
- Third as target price
"""
matches = self.PRICE_PATTERN.findall(raw_output)
prices: dict[str, Optional[float]] = {
"entry": None,
"stop_loss": None,
"target": None,
}
# Parse matched price strings, removing commas
parsed = []
for m in matches:
cleaned = m.replace(",", "")
try:
parsed.append(float(cleaned))
except ValueError:
continue
if len(parsed) >= 1:
prices["entry"] = parsed[0]
if len(parsed) >= 2:
prices["stop_loss"] = parsed[1]
if len(parsed) >= 3:
prices["target"] = parsed[2]
return prices
def _extract_reasoning(self, raw_output: str) -> Optional[dict[str, str]]:
"""Extract per-agent reasoning summaries from text.
Looks for lines like:
- Market: ...
- Fundamental: ...
- Technical: ...
- Risk: ...
"""
reasoning_pattern = re.compile(
r"^-\s*(Market|Fundamental|Technical|Risk)\s*:\s*(.+)$",
re.MULTILINE | re.IGNORECASE,
)
matches = reasoning_pattern.findall(raw_output)
if not matches:
return None
reasoning = {}
for key, value in matches:
reasoning[key.strip()] = value.strip()
return reasoning if reasoning else None