emmanuelakbi commited on
Commit
98060b8
·
1 Parent(s): 2c5d02a

Deterministic fallback: synthesize TradingSignal from tool outputs

Browse files

Qwen 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.

Files changed (1) hide show
  1. 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
+ )