Commit ·
98060b8
1
Parent(s): 2c5d02a
Deterministic fallback: synthesize TradingSignal from tool outputs
Browse filesQwen occasionally loses the output format mid-deliberation even with
the simplest prompt, so crew.run now calls _synthesize_from_tools
whenever the parser returns None. That method scans the raw crew
transcript for the Market Scanner's 'Current Price: $X' and
'Change: +/-$Y (+/-Z%)' lines — which come from real yfinance calls
— and derives action (BUY if +1% today, SELL if -1%, else HOLD),
entry = live price, stop and target at conservative fixed percents
of entry, and confidence scaled by |% change|. Reasoning preserves
the LLM narrative.
- crew/crew.py +97 -1
crew/crew.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from typing import Optional
|
| 7 |
|
|
@@ -23,7 +24,7 @@ from crew.tasks import (
|
|
| 23 |
create_risk_task,
|
| 24 |
create_strategy_task,
|
| 25 |
)
|
| 26 |
-
from crew.signals import TradingSignal, TradingSignalParser
|
| 27 |
from crew.callbacks import ActivityFeedCallback
|
| 28 |
|
| 29 |
|
|
@@ -89,6 +90,15 @@ class FinAgentCrew:
|
|
| 89 |
|
| 90 |
signal = self._parse_output(raw_output, ticker)
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
if signal is not None:
|
| 93 |
if self._callback:
|
| 94 |
self._callback.on_task_complete(
|
|
@@ -203,3 +213,89 @@ class FinAgentCrew:
|
|
| 203 |
TradingSignal if parsing succeeds, None if output is unparseable
|
| 204 |
"""
|
| 205 |
return self._parser.parse(raw_output, ticker)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import re
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from typing import Optional
|
| 8 |
|
|
|
|
| 24 |
create_risk_task,
|
| 25 |
create_strategy_task,
|
| 26 |
)
|
| 27 |
+
from crew.signals import Action, TradingSignal, TradingSignalParser
|
| 28 |
from crew.callbacks import ActivityFeedCallback
|
| 29 |
|
| 30 |
|
|
|
|
| 90 |
|
| 91 |
signal = self._parse_output(raw_output, ticker)
|
| 92 |
|
| 93 |
+
# If the parser couldn't extract a structured signal from the
|
| 94 |
+
# Strategist's prose, fall back to a deterministic synthesis
|
| 95 |
+
# that grounds the signal in the upstream tools (live price
|
| 96 |
+
# from yfinance + a heuristic BUY/SELL/HOLD from the recent
|
| 97 |
+
# trend). This keeps the pipeline producing sensible output
|
| 98 |
+
# even when Qwen loses the output format mid-deliberation.
|
| 99 |
+
if signal is None:
|
| 100 |
+
signal = self._synthesize_from_tools(ticker, raw_output)
|
| 101 |
+
|
| 102 |
if signal is not None:
|
| 103 |
if self._callback:
|
| 104 |
self._callback.on_task_complete(
|
|
|
|
| 213 |
TradingSignal if parsing succeeds, None if output is unparseable
|
| 214 |
"""
|
| 215 |
return self._parser.parse(raw_output, ticker)
|
| 216 |
+
|
| 217 |
+
def _synthesize_from_tools(
|
| 218 |
+
self, ticker: str, raw_output: str
|
| 219 |
+
) -> Optional[TradingSignal]:
|
| 220 |
+
"""Build a TradingSignal directly from tool outputs when LLM parsing fails.
|
| 221 |
+
|
| 222 |
+
The agents have already called ``get_price_change``, returning a
|
| 223 |
+
formatted block that contains the live price and the recent percent
|
| 224 |
+
change. Those lines also leak into the crew's full raw_output, so we
|
| 225 |
+
scan there for the canonical ``Current Price: $XXX.XX`` and
|
| 226 |
+
``Change: +/-$X.XX (+/-YY.YY%)`` rows emitted by ``tools.market_scanner``.
|
| 227 |
+
|
| 228 |
+
From those we derive:
|
| 229 |
+
* **entry** = live price
|
| 230 |
+
* **action** = BUY if today's change is ≥ +1 %, SELL if ≤ −1 %,
|
| 231 |
+
otherwise HOLD
|
| 232 |
+
* **stop / target** = ± 3 % / ± 5 % of entry (per the signal parser's
|
| 233 |
+
sanity fix)
|
| 234 |
+
* **confidence** = 50 baseline, + up to 25 scaled by |% change|
|
| 235 |
+
* **reasoning** = preserve the LLM's narrative (first ~800 chars)
|
| 236 |
+
|
| 237 |
+
Returns ``None`` only if no live price was ever retrieved.
|
| 238 |
+
"""
|
| 239 |
+
price_match = re.search(
|
| 240 |
+
r"Current Price:\s*\$\s*([\d,]+\.?\d*)", raw_output
|
| 241 |
+
)
|
| 242 |
+
if not price_match:
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
entry = float(price_match.group(1).replace(",", ""))
|
| 247 |
+
except ValueError:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
# Percent-change row, e.g. "Change: +$5.90 (+2.05%)"
|
| 251 |
+
pct_match = re.search(
|
| 252 |
+
r"Change:[^()]*\(([+-]?)([\d.]+)%\)", raw_output
|
| 253 |
+
)
|
| 254 |
+
pct_change = 0.0
|
| 255 |
+
if pct_match:
|
| 256 |
+
sign = -1.0 if pct_match.group(1) == "-" else 1.0
|
| 257 |
+
try:
|
| 258 |
+
pct_change = sign * float(pct_match.group(2))
|
| 259 |
+
except ValueError:
|
| 260 |
+
pct_change = 0.0
|
| 261 |
+
|
| 262 |
+
# Choose action from today's trend.
|
| 263 |
+
if pct_change >= 1.0:
|
| 264 |
+
action = Action.BUY
|
| 265 |
+
elif pct_change <= -1.0:
|
| 266 |
+
action = Action.SELL
|
| 267 |
+
else:
|
| 268 |
+
action = Action.HOLD
|
| 269 |
+
|
| 270 |
+
# Price bands: tight stop, a touch more space on target.
|
| 271 |
+
if action == Action.BUY:
|
| 272 |
+
stop = round(entry * 0.97, 2)
|
| 273 |
+
target = round(entry * 1.05, 2)
|
| 274 |
+
elif action == Action.SELL:
|
| 275 |
+
stop = round(entry * 1.03, 2)
|
| 276 |
+
target = round(entry * 0.95, 2)
|
| 277 |
+
else: # HOLD
|
| 278 |
+
stop = round(entry * 0.97, 2)
|
| 279 |
+
target = round(entry * 1.03, 2)
|
| 280 |
+
|
| 281 |
+
# Confidence = 50 baseline + up to 25 more for stronger moves.
|
| 282 |
+
confidence = int(
|
| 283 |
+
50 + min(25, round(abs(pct_change) * 5))
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# Preserve the agents' narrative so the user still sees the
|
| 287 |
+
# reasoning — just trimmed so the UI card stays readable.
|
| 288 |
+
narrative = raw_output.strip()
|
| 289 |
+
if len(narrative) > 800:
|
| 290 |
+
narrative = narrative[:800] + "..."
|
| 291 |
+
reasoning = {"Synthesized": narrative} if narrative else None
|
| 292 |
+
|
| 293 |
+
return TradingSignal(
|
| 294 |
+
ticker=ticker,
|
| 295 |
+
action=action,
|
| 296 |
+
confidence=confidence,
|
| 297 |
+
entry_price=entry,
|
| 298 |
+
stop_loss=stop,
|
| 299 |
+
target_price=target,
|
| 300 |
+
reasoning=reasoning,
|
| 301 |
+
)
|