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