Commit ·
eb4ef03
1
Parent(s): 1d4dd5c
Add deterministic sanity-fix for Strategist's runaway prices
Browse filesThe 14B Strategist occasionally emits wild numbers at synthesis time
(e.g. a $50 000 target on a $290 stock, or stop on the wrong side of
entry). Rather than surface those to the user, the parser now runs
_sanity_fix_prices after extracting the fields: if a stop/target is
more than 20 percent away from the entry or sits on the wrong side of
entry for the BUY/SELL direction, replace it with a conservative
default derived arithmetically from the entry (±3 percent for stop,
±5 percent for target). Valid prices pass through untouched.
- crew/signals.py +82 -2
crew/signals.py
CHANGED
|
@@ -50,6 +50,9 @@ class TradingSignalParser:
|
|
| 50 |
"""Parse raw output into a TradingSignal.
|
| 51 |
|
| 52 |
Attempts primary pattern first, falls back to heuristic extraction.
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
Args:
|
| 55 |
raw_output: Raw text from Chief Strategist agent
|
|
@@ -59,9 +62,86 @@ class TradingSignalParser:
|
|
| 59 |
TradingSignal if parsing succeeds, None if output is unparseable
|
| 60 |
"""
|
| 61 |
signal = self._parse_primary(raw_output, ticker)
|
| 62 |
-
if signal is
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
return signal
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
def _parse_primary(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
|
| 67 |
"""Attempt to parse using the primary structured format."""
|
|
|
|
| 50 |
"""Parse raw output into a TradingSignal.
|
| 51 |
|
| 52 |
Attempts primary pattern first, falls back to heuristic extraction.
|
| 53 |
+
After parsing, prices are sanity-checked so the Strategist can't
|
| 54 |
+
ship obviously-wrong numbers (e.g. $50 000 target on a $290 stock,
|
| 55 |
+
or stop-loss on the wrong side of entry for the given action).
|
| 56 |
|
| 57 |
Args:
|
| 58 |
raw_output: Raw text from Chief Strategist agent
|
|
|
|
| 62 |
TradingSignal if parsing succeeds, None if output is unparseable
|
| 63 |
"""
|
| 64 |
signal = self._parse_primary(raw_output, ticker)
|
| 65 |
+
if signal is None:
|
| 66 |
+
signal = self._parse_fallback(raw_output, ticker)
|
| 67 |
+
if signal is None:
|
| 68 |
+
return None
|
| 69 |
+
return self._sanity_fix_prices(signal)
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _sanity_fix_prices(signal: TradingSignal) -> TradingSignal:
|
| 73 |
+
"""Clamp runaway prices and enforce BUY/SELL inequality semantics.
|
| 74 |
+
|
| 75 |
+
Small LLMs occasionally emit obvious absurdities at synthesis time
|
| 76 |
+
(e.g. target that is 100× entry, or stop_loss on the wrong side
|
| 77 |
+
of entry for a BUY). Rather than surface nonsense to the user,
|
| 78 |
+
detect these and replace with conservative defaults derived from
|
| 79 |
+
the entry price:
|
| 80 |
+
|
| 81 |
+
* For BUY: stop = entry × 0.97, target = entry × 1.05
|
| 82 |
+
* For SELL: stop = entry × 1.03, target = entry × 0.95
|
| 83 |
+
* For HOLD: defaults left as-is.
|
| 84 |
+
|
| 85 |
+
We only override a field when it fails a plausibility test
|
| 86 |
+
(>20 % away from entry, or on the wrong side of entry for the
|
| 87 |
+
current action). Valid prices from the model are preserved.
|
| 88 |
+
"""
|
| 89 |
+
entry = signal.entry_price
|
| 90 |
+
# No entry price → nothing to clamp against.
|
| 91 |
+
if entry is None or entry <= 0:
|
| 92 |
return signal
|
| 93 |
+
|
| 94 |
+
stop = signal.stop_loss
|
| 95 |
+
target = signal.target_price
|
| 96 |
+
action = signal.action
|
| 97 |
+
|
| 98 |
+
def _out_of_band(value: Optional[float]) -> bool:
|
| 99 |
+
"""True if ``value`` is missing, non-positive, or >20 % from entry."""
|
| 100 |
+
if value is None or value <= 0:
|
| 101 |
+
return True
|
| 102 |
+
return abs(value - entry) / entry > 0.20
|
| 103 |
+
|
| 104 |
+
def _wrong_side_stop(value: Optional[float]) -> bool:
|
| 105 |
+
"""Stop on the wrong side of entry for the current action."""
|
| 106 |
+
if value is None:
|
| 107 |
+
return False
|
| 108 |
+
if action == Action.BUY and value >= entry:
|
| 109 |
+
return True
|
| 110 |
+
if action == Action.SELL and value <= entry:
|
| 111 |
+
return True
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
def _wrong_side_target(value: Optional[float]) -> bool:
|
| 115 |
+
"""Target on the wrong side of entry for the current action."""
|
| 116 |
+
if value is None:
|
| 117 |
+
return False
|
| 118 |
+
if action == Action.BUY and value <= entry:
|
| 119 |
+
return True
|
| 120 |
+
if action == Action.SELL and value >= entry:
|
| 121 |
+
return True
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
if action == Action.BUY:
|
| 125 |
+
if _out_of_band(stop) or _wrong_side_stop(stop):
|
| 126 |
+
stop = round(entry * 0.97, 2)
|
| 127 |
+
if _out_of_band(target) or _wrong_side_target(target):
|
| 128 |
+
target = round(entry * 1.05, 2)
|
| 129 |
+
elif action == Action.SELL:
|
| 130 |
+
if _out_of_band(stop) or _wrong_side_stop(stop):
|
| 131 |
+
stop = round(entry * 1.03, 2)
|
| 132 |
+
if _out_of_band(target) or _wrong_side_target(target):
|
| 133 |
+
target = round(entry * 0.95, 2)
|
| 134 |
+
# HOLD: leave as-is; the Strategist can describe a range.
|
| 135 |
+
|
| 136 |
+
return TradingSignal(
|
| 137 |
+
ticker=signal.ticker,
|
| 138 |
+
action=signal.action,
|
| 139 |
+
confidence=signal.confidence,
|
| 140 |
+
entry_price=entry,
|
| 141 |
+
stop_loss=stop,
|
| 142 |
+
target_price=target,
|
| 143 |
+
reasoning=signal.reasoning,
|
| 144 |
+
)
|
| 145 |
|
| 146 |
def _parse_primary(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
|
| 147 |
"""Attempt to parse using the primary structured format."""
|