emmanuelakbi commited on
Commit
b64ba5a
·
1 Parent(s): 99a1635

fix(signals): anchor entry to live price, rescale stop/target

Browse files
Files changed (1) hide show
  1. crew/crew.py +72 -57
crew/crew.py CHANGED
@@ -229,17 +229,28 @@ class FinAgentCrew:
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
 
@@ -268,70 +279,74 @@ class FinAgentCrew:
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
- # back-fill any missing stop/target fields, plus swap out any
295
- # stop/target that are wildly off (more than 20 % from entry).
296
- # Small LLMs occasionally emit numbers that are credible-looking
297
- # on their own but nonsensical for the ticker (e.g. BTC-USD
298
- # at \$80 782 with a \$79 stop-loss — the model dropped the "k").
299
- entry = signal.entry_price
300
  stop = signal.stop_loss
301
  target = signal.target_price
302
 
303
- def _far_from_entry(v: Optional[float]) -> bool:
 
 
 
304
  if v is None or v <= 0:
305
  return True
306
- return abs(v - entry) / entry > 0.20
307
 
308
- def _too_close_to_entry(v: Optional[float]) -> bool:
309
- """True if a stop/target is within 0.5 % of entry — effectively a
310
- degenerate zero-risk / zero-reward number that the LLM sometimes
311
- emits when it conflates HOLD with 'no action'."""
312
- if v is None or v <= 0:
313
- return True
314
- return abs(v - entry) / entry < 0.005
315
 
316
- if _far_from_entry(stop) or _too_close_to_entry(stop):
317
- stop = None # trigger back-fill below
318
- if _far_from_entry(target) or _too_close_to_entry(target):
319
- target = None
 
 
 
 
320
 
321
- if stop is None or target is None:
322
- return self._backfill_missing_prices(
323
- TradingSignal(
324
- ticker=signal.ticker,
325
- action=signal.action,
326
- confidence=signal.confidence,
327
- entry_price=entry,
328
- stop_loss=stop,
329
- target_price=target,
330
- reasoning=signal.reasoning,
331
- )
332
- )
333
 
334
- return signal
 
 
 
 
 
 
 
 
 
335
 
336
  @staticmethod
337
  def _backfill_missing_prices(signal: TradingSignal) -> TradingSignal:
 
229
  signal: TradingSignal,
230
  raw_output: str,
231
  ) -> TradingSignal:
232
+ r"""Anchor every signal's entry price to the live quote.
233
+
234
+ Small LLMs frequently emit training-era prices (Qwen3-14B once
235
+ handed back \$203 for NVDA when it was trading at \$215) that
236
+ pass the parser because the BUY/SELL inequalities still hold
237
+ relative to the invented entry. The card then shows a stale
238
+ price useless for a trading signal.
239
+
240
+ The fix is absolute: we fetch the current quote via
241
+ :func:`get_price_change`, force ``entry_price`` to that value,
242
+ and **rescale** stop / target by the same ratio so the LLM's
243
+ risk / reward geometry is preserved. Example: LLM emits
244
+ entry=\$200, stop=\$194, target=\$210 for an NVDA card while
245
+ the live price is \$215 — we scale 1.075× so the card renders
246
+ entry=\$215, stop=\$208.52, target=\$225.75. The narrative
247
+ reasoning is kept as-is.
248
+
249
+ When the parsed entry is missing, zero, or more than 50 % off
250
+ the live price we fall back to the deterministic tool-grounded
251
+ synthesis in :meth:`_synthesize_from_tools`, which picks a
252
+ BUY / SELL / HOLD from the day's % change and derives bands
253
+ from scratch.
254
  """
255
  parsed_entry = signal.entry_price
256
 
 
279
  if live_entry is None or live_entry <= 0:
280
  return self._backfill_missing_prices(signal)
281
 
282
+ # Parsed entry is missing or grossly off (>50 %): the model
283
+ # probably fabricated the entire signal, so re-synthesise from
284
+ # tools. Below 50 % drift we keep the LLM's narrative but
285
+ # rescale the numbers.
286
+ badly_drifted = (
287
  parsed_entry is None
288
  or parsed_entry <= 0
289
+ or abs(parsed_entry - live_entry) / live_entry > 0.50
290
  )
291
+ if badly_drifted:
292
  synthesized = self._synthesize_from_tools(ticker, raw_output)
293
  if synthesized is not None:
294
  return synthesized
295
  # If synthesis failed too, at least swap the entry.
296
+ return self._backfill_missing_prices(
297
+ TradingSignal(
298
+ ticker=signal.ticker,
299
+ action=signal.action,
300
+ confidence=signal.confidence,
301
+ entry_price=live_entry,
302
+ stop_loss=None,
303
+ target_price=None,
304
+ reasoning=signal.reasoning,
305
+ )
306
  )
307
 
308
+ # Anchor to live price and rescale stop / target to preserve
309
+ # the LLM's risk-reward geometry. If either side is missing or
310
+ # degenerate, let the back-fill derive a fresh band.
311
+ scale = live_entry / parsed_entry
 
 
 
312
  stop = signal.stop_loss
313
  target = signal.target_price
314
 
315
+ def _bad(v: Optional[float]) -> bool:
316
+ """Treat very-close-to-entry (< 0.5 %) stops / targets as
317
+ degenerate zero-risk numbers the model sometimes emits on
318
+ HOLD calls."""
319
  if v is None or v <= 0:
320
  return True
321
+ return abs(v - parsed_entry) / parsed_entry < 0.005
322
 
323
+ new_stop = None if _bad(stop) else round(stop * scale, 2)
324
+ new_target = None if _bad(target) else round(target * scale, 2)
 
 
 
 
 
325
 
326
+ # Extra guard: after rescaling, stop / target should still be
327
+ # within a reasonable band of the new entry (tight stop < 10 %,
328
+ # target < 15 %). If the LLM emitted something implausibly wide
329
+ # we let the back-fill replace it.
330
+ def _unreasonable(v: Optional[float]) -> bool:
331
+ if v is None:
332
+ return True
333
+ return abs(v - live_entry) / live_entry > 0.15
334
 
335
+ if _unreasonable(new_stop):
336
+ new_stop = None
337
+ if _unreasonable(new_target):
338
+ new_target = None
 
 
 
 
 
 
 
 
339
 
340
+ rescaled = TradingSignal(
341
+ ticker=signal.ticker,
342
+ action=signal.action,
343
+ confidence=signal.confidence,
344
+ entry_price=round(live_entry, 2),
345
+ stop_loss=new_stop,
346
+ target_price=new_target,
347
+ reasoning=signal.reasoning,
348
+ )
349
+ return self._backfill_missing_prices(rescaled)
350
 
351
  @staticmethod
352
  def _backfill_missing_prices(signal: TradingSignal) -> TradingSignal: