emmanuelakbi commited on
Commit
7c0742a
·
1 Parent(s): 2ac24b1

Re-ground parsed signals when entry drifts from live price; back-fill HOLD N/A

Browse files

Two remaining failure modes from the live Space:

1. NVDA returned BUY at $10 (with stop $9.70 / target $10.50) because
Qwen3-14B hallucinated small fake numbers that satisfied the BUY
inequality but had nothing to do with the real $215 price. The
parser happily accepted them.

2. BTC-USD HOLD card rendered with 'N/A' for stop and target because
the LLM wrote 'Stop Loss: N/A' literally.

Fix: after the primary parser succeeds, call get_price_change directly
and compare the parsed entry to the live price. If the drift exceeds
30 %, swap the whole signal with the deterministic synthesis. Also
back-fill any missing stop/target (particularly on HOLD) with the
standard ±3–5 % bands so every card shows three concrete prices.

Files changed (1) hide show
  1. crew/crew.py +120 -0
crew/crew.py CHANGED
@@ -98,6 +98,15 @@ class FinAgentCrew:
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:
@@ -214,6 +223,117 @@ class FinAgentCrew:
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]:
 
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
+ else:
102
+ # Cross-check the parsed entry against the live tool
103
+ # price. Small LLMs occasionally emit completely
104
+ # fabricated prices (e.g. $10.00 for a $215 stock) that
105
+ # happen to pass the relative-ordering sanity check
106
+ # because stop/target are on the right side of entry.
107
+ # Re-synthesize from tools whenever the parsed entry is
108
+ # wildly off from reality.
109
+ signal = self._reground_if_drifted(ticker, signal, raw_output)
110
 
111
  if signal is not None:
112
  if self._callback:
 
223
  """
224
  return self._parser.parse(raw_output, ticker)
225
 
226
+ def _reground_if_drifted(
227
+ self,
228
+ ticker: str,
229
+ signal: TradingSignal,
230
+ raw_output: str,
231
+ ) -> TradingSignal:
232
+ """Swap out a parsed signal whose entry is wildly off the live price.
233
+
234
+ Qwen3-14B occasionally invents prices at synthesis time that happen
235
+ to satisfy the BUY/SELL inequalities — e.g. returning a \$10.00
236
+ entry for NVDA on a day it traded at \$215. Those pass the parser
237
+ but mislead the user. We fetch the live price via
238
+ :func:`get_price_change` and, if the parsed entry differs by more
239
+ than 30 %, replace the whole signal with the deterministic
240
+ synthesis from :meth:`_synthesize_from_tools`. If the entry is in
241
+ range we also back-fill any missing stop/target so HOLD cards
242
+ render with real numbers instead of N/A.
243
+ """
244
+ parsed_entry = signal.entry_price
245
+
246
+ # Get the live price (best-effort; don't block on tool errors).
247
+ live_entry: Optional[float] = None
248
+ try:
249
+ market_tools = self._tools.get("market_scanner", [])
250
+ price_tool = next(
251
+ (t for t in market_tools
252
+ if getattr(t, "name", "") == "Get Price Change"),
253
+ None,
254
+ )
255
+ if price_tool is not None:
256
+ price_fn = getattr(price_tool, "func", price_tool)
257
+ result = str(price_fn(ticker))
258
+ m = re.search(
259
+ r"Current Price:\s*\$\s*([\d,]+\.?\d*)", result
260
+ )
261
+ if m:
262
+ live_entry = float(m.group(1).replace(",", ""))
263
+ except Exception:
264
+ live_entry = None
265
+
266
+ # If we couldn't fetch a live price, leave the signal as-is —
267
+ # the sanity-fix already clamped relative ordering.
268
+ if live_entry is None or live_entry <= 0:
269
+ return self._backfill_missing_prices(signal)
270
+
271
+ # If the parsed entry is missing or drifts by more than 30 %,
272
+ # replace with the deterministic synthesis.
273
+ drifted = (
274
+ parsed_entry is None
275
+ or parsed_entry <= 0
276
+ or abs(parsed_entry - live_entry) / live_entry > 0.30
277
+ )
278
+ if drifted:
279
+ synthesized = self._synthesize_from_tools(ticker, raw_output)
280
+ if synthesized is not None:
281
+ return synthesized
282
+ # If synthesis failed too, at least swap the entry.
283
+ return TradingSignal(
284
+ ticker=signal.ticker,
285
+ action=signal.action,
286
+ confidence=signal.confidence,
287
+ entry_price=live_entry,
288
+ stop_loss=signal.stop_loss,
289
+ target_price=signal.target_price,
290
+ reasoning=signal.reasoning,
291
+ )
292
+
293
+ # Parsed entry is close to live price — keep the LLM signal and
294
+ # just back-fill any missing stop/target fields.
295
+ return self._backfill_missing_prices(signal)
296
+
297
+ @staticmethod
298
+ def _backfill_missing_prices(signal: TradingSignal) -> TradingSignal:
299
+ """Back-fill stop-loss and target when the LLM emitted N/A or omitted them.
300
+
301
+ This ensures the UI card always shows three numeric prices rather
302
+ than a mix of numbers and N/A placeholders.
303
+ """
304
+ entry = signal.entry_price
305
+ if entry is None or entry <= 0:
306
+ return signal
307
+
308
+ stop = signal.stop_loss
309
+ target = signal.target_price
310
+
311
+ if stop is None or stop <= 0:
312
+ if signal.action == Action.BUY:
313
+ stop = round(entry * 0.97, 2)
314
+ elif signal.action == Action.SELL:
315
+ stop = round(entry * 1.03, 2)
316
+ else: # HOLD
317
+ stop = round(entry * 0.97, 2)
318
+
319
+ if target is None or target <= 0:
320
+ if signal.action == Action.BUY:
321
+ target = round(entry * 1.05, 2)
322
+ elif signal.action == Action.SELL:
323
+ target = round(entry * 0.95, 2)
324
+ else: # HOLD
325
+ target = round(entry * 1.03, 2)
326
+
327
+ return TradingSignal(
328
+ ticker=signal.ticker,
329
+ action=signal.action,
330
+ confidence=signal.confidence,
331
+ entry_price=entry,
332
+ stop_loss=stop,
333
+ target_price=target,
334
+ reasoning=signal.reasoning,
335
+ )
336
+
337
  def _synthesize_from_tools(
338
  self, ticker: str, raw_output: str
339
  ) -> Optional[TradingSignal]: