Commit ·
b64ba5a
1
Parent(s): 99a1635
fix(signals): anchor entry to live price, rescale stop/target
Browse files- 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 |
-
"""
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 272 |
-
#
|
| 273 |
-
|
|
|
|
|
|
|
| 274 |
parsed_entry is None
|
| 275 |
or parsed_entry <= 0
|
| 276 |
-
or abs(parsed_entry - live_entry) / live_entry > 0.
|
| 277 |
)
|
| 278 |
-
if
|
| 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
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
| 291 |
)
|
| 292 |
|
| 293 |
-
#
|
| 294 |
-
#
|
| 295 |
-
#
|
| 296 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 304 |
if v is None or v <= 0:
|
| 305 |
return True
|
| 306 |
-
return abs(v -
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 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 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
-
if
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|