emmanuelakbi commited on
Commit
1e840aa
·
1 Parent(s): 9d444de

feat(prefs): risk tolerance + trading style now shape the signal

Browse files
Files changed (6) hide show
  1. app.py +12 -4
  2. crew/__init__.py +2 -1
  3. crew/config.py +51 -0
  4. crew/crew.py +50 -25
  5. crew/runner.py +4 -1
  6. crew/tasks.py +49 -8
app.py CHANGED
@@ -196,6 +196,7 @@ def run_analysis(
196
  from crew import (
197
  LLMConfig,
198
  OrchestratorConfig,
 
199
  WatchlistRunner,
200
  )
201
  from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType
@@ -205,6 +206,12 @@ def run_analysis(
205
  llm=LLMConfig(base_url=VLLM_ENDPOINT_URL),
206
  )
207
 
 
 
 
 
 
 
208
  # Buffer events emitted by the runner/crew during ``_run_single``.
209
  # The callback handler runs synchronously on the same thread, so
210
  # a plain list plus a closure is sufficient — we drain it into
@@ -219,9 +226,7 @@ def run_analysis(
219
  # Build the tools dict that maps each agent's name to its tool
220
  # function list. Without this, every agent runs without tools
221
  # and the LLM just hallucinates plausible-looking numbers
222
- # instead of calling yfinance / ddgs / pandas-ta. (This was the
223
- # cause of the "AAPL entry $192" hallucination from the first
224
- # deploy — real AAPL was ~$293 at the time.)
225
  from tools import (
226
  search_news,
227
  get_price_change,
@@ -243,7 +248,10 @@ def run_analysis(
243
  }
244
 
245
  runner = WatchlistRunner(
246
- config=config, tools=crew_tools, callback=callback
 
 
 
247
  )
248
 
249
  for i, ticker in enumerate(tickers, 1):
 
196
  from crew import (
197
  LLMConfig,
198
  OrchestratorConfig,
199
+ TradePreferences,
200
  WatchlistRunner,
201
  )
202
  from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType
 
206
  llm=LLMConfig(base_url=VLLM_ENDPOINT_URL),
207
  )
208
 
209
+ preferences = TradePreferences(
210
+ risk_tolerance=risk_tolerance,
211
+ trading_style=trading_style,
212
+ portfolio_value=float(portfolio_value),
213
+ )
214
+
215
  # Buffer events emitted by the runner/crew during ``_run_single``.
216
  # The callback handler runs synchronously on the same thread, so
217
  # a plain list plus a closure is sufficient — we drain it into
 
226
  # Build the tools dict that maps each agent's name to its tool
227
  # function list. Without this, every agent runs without tools
228
  # and the LLM just hallucinates plausible-looking numbers
229
+ # instead of calling yfinance / ddgs / pandas-ta.
 
 
230
  from tools import (
231
  search_news,
232
  get_price_change,
 
248
  }
249
 
250
  runner = WatchlistRunner(
251
+ config=config,
252
+ tools=crew_tools,
253
+ callback=callback,
254
+ preferences=preferences,
255
  )
256
 
257
  for i, ticker in enumerate(tickers, 1):
crew/__init__.py CHANGED
@@ -5,7 +5,7 @@ tickers and produce structured trading signals (BUY/SELL/HOLD).
5
  """
6
 
7
  from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType
8
- from crew.config import CrewConfig, LLMConfig, OrchestratorConfig
9
  from crew.crew import CrewResult, FinAgentCrew
10
  from crew.runner import WatchlistResult, WatchlistRunner
11
  from crew.signals import Action, TradingSignal
@@ -20,6 +20,7 @@ __all__ = [
20
  "FinAgentCrew",
21
  "LLMConfig",
22
  "OrchestratorConfig",
 
23
  "TradingSignal",
24
  "WatchlistResult",
25
  "WatchlistRunner",
 
5
  """
6
 
7
  from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType
8
+ from crew.config import CrewConfig, LLMConfig, OrchestratorConfig, TradePreferences
9
  from crew.crew import CrewResult, FinAgentCrew
10
  from crew.runner import WatchlistResult, WatchlistRunner
11
  from crew.signals import Action, TradingSignal
 
20
  "FinAgentCrew",
21
  "LLMConfig",
22
  "OrchestratorConfig",
23
+ "TradePreferences",
24
  "TradingSignal",
25
  "WatchlistResult",
26
  "WatchlistRunner",
crew/config.py CHANGED
@@ -1,6 +1,57 @@
1
  """LLM configuration, timeouts, and constants for the orchestration layer."""
2
 
3
  from dataclasses import dataclass, field
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
 
6
  @dataclass
 
1
  """LLM configuration, timeouts, and constants for the orchestration layer."""
2
 
3
  from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+
7
+ RiskTolerance = Literal["Conservative", "Moderate", "Aggressive"]
8
+ TradingStyle = Literal["Day Trading", "Swing Trading", "Position Trading"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TradePreferences:
13
+ """User-selected risk / style / portfolio preferences.
14
+
15
+ These come from the Gradio UI dropdowns and are threaded end-to-end
16
+ into the Chief Strategist's task description so the final signal
17
+ reflects the user's stated profile. They also tune the re-ground
18
+ clamps in :class:`FinAgentCrew` — Conservative + Day Trading pushes
19
+ stop / target toward tight, short-horizon bands; Aggressive +
20
+ Position Trading opens them up.
21
+
22
+ Numbers here are percentages of entry (0.03 == 3 %).
23
+ """
24
+
25
+ risk_tolerance: RiskTolerance = "Moderate"
26
+ trading_style: TradingStyle = "Swing Trading"
27
+ portfolio_value: float = 10000.0
28
+
29
+ @property
30
+ def stop_pct(self) -> float:
31
+ return {
32
+ "Conservative": 0.015,
33
+ "Moderate": 0.03,
34
+ "Aggressive": 0.05,
35
+ }[self.risk_tolerance]
36
+
37
+ @property
38
+ def target_pct(self) -> float:
39
+ return {
40
+ "Day Trading": 0.02,
41
+ "Swing Trading": 0.05,
42
+ "Position Trading": 0.10,
43
+ }[self.trading_style]
44
+
45
+ @property
46
+ def stop_clamp(self) -> float:
47
+ """Upper bound used by _reground_if_drifted. Slightly looser than
48
+ ``stop_pct`` so the LLM has headroom, but tight enough to catch
49
+ the wide ATR-style stops Qwen sometimes emits."""
50
+ return self.stop_pct * 2.5
51
+
52
+ @property
53
+ def target_clamp(self) -> float:
54
+ return self.target_pct * 3.0
55
 
56
 
57
  @dataclass
crew/crew.py CHANGED
@@ -8,7 +8,7 @@ from typing import Optional
8
 
9
  from crewai import Crew, Process
10
 
11
- from crew.config import OrchestratorConfig
12
  from crew.agents import (
13
  create_llm,
14
  create_market_scanner,
@@ -47,6 +47,7 @@ class FinAgentCrew:
47
  config: OrchestratorConfig,
48
  tools: dict[str, list],
49
  callback: Optional[ActivityFeedCallback] = None,
 
50
  ):
51
  """Initialize the crew orchestrator.
52
 
@@ -60,10 +61,14 @@ class FinAgentCrew:
60
  "risk_manager": [calculate_position_size, set_stop_loss],
61
  }
62
  callback: Optional activity feed callback for real-time UI updates
 
 
 
63
  """
64
  self._config = config
65
  self._tools = tools
66
  self._callback = callback
 
67
  self._parser = TradingSignalParser()
68
 
69
  def run(self, ticker: str) -> CrewResult:
@@ -180,11 +185,14 @@ class FinAgentCrew:
180
  market_task = create_market_scan_task(market_scanner, ticker)
181
  fundamental_task = create_fundamental_task(fundamental_analyst, ticker)
182
  technical_task = create_technical_task(technical_analyst, ticker)
183
- risk_task = create_risk_task(risk_manager, ticker, [technical_task])
 
 
184
  strategy_task = create_strategy_task(
185
  chief_strategist,
186
  ticker,
187
  [market_task, fundamental_task, technical_task, risk_task],
 
188
  )
189
 
190
  return Crew(
@@ -324,20 +332,23 @@ class FinAgentCrew:
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. Stops must stay
328
- # tight (<= 8 %) so the R:R remains sane for a retail card;
329
- # targets can be more ambitious (<= 15 %). If the LLM emitted
330
- # something implausibly wide we let the back-fill replace it
331
- # with the default 3 % / 5 % band.
 
 
 
332
  def _stop_unreasonable(v: Optional[float]) -> bool:
333
  if v is None:
334
  return True
335
- return abs(v - live_entry) / live_entry > 0.08
336
 
337
  def _target_unreasonable(v: Optional[float]) -> bool:
338
  if v is None:
339
  return True
340
- return abs(v - live_entry) / live_entry > 0.15
341
 
342
  if _stop_unreasonable(new_stop):
343
  new_stop = None
@@ -355,12 +366,14 @@ class FinAgentCrew:
355
  )
356
  return self._backfill_missing_prices(rescaled)
357
 
358
- @staticmethod
359
- def _backfill_missing_prices(signal: TradingSignal) -> TradingSignal:
360
  """Back-fill stop-loss and target when the LLM emitted N/A or omitted them.
361
 
362
  This ensures the UI card always shows three numeric prices rather
363
- than a mix of numbers and N/A placeholders.
 
 
 
364
  """
365
  entry = signal.entry_price
366
  if entry is None or entry <= 0:
@@ -368,22 +381,29 @@ class FinAgentCrew:
368
 
369
  stop = signal.stop_loss
370
  target = signal.target_price
 
 
 
 
 
 
 
371
 
372
  if stop is None or stop <= 0:
373
  if signal.action == Action.BUY:
374
- stop = round(entry * 0.97, 2)
375
  elif signal.action == Action.SELL:
376
- stop = round(entry * 1.03, 2)
377
  else: # HOLD
378
- stop = round(entry * 0.97, 2)
379
 
380
  if target is None or target <= 0:
381
  if signal.action == Action.BUY:
382
- target = round(entry * 1.05, 2)
383
  elif signal.action == Action.SELL:
384
- target = round(entry * 0.95, 2)
385
  else: # HOLD
386
- target = round(entry * 1.03, 2)
387
 
388
  return TradingSignal(
389
  ticker=signal.ticker,
@@ -487,16 +507,21 @@ class FinAgentCrew:
487
  else:
488
  action = Action.HOLD
489
 
490
- # Price bands: tight stop, a touch more space on target.
 
 
 
 
 
491
  if action == Action.BUY:
492
- stop = round(entry * 0.97, 2)
493
- target = round(entry * 1.05, 2)
494
  elif action == Action.SELL:
495
- stop = round(entry * 1.03, 2)
496
- target = round(entry * 0.95, 2)
497
  else: # HOLD
498
- stop = round(entry * 0.97, 2)
499
- target = round(entry * 1.03, 2)
500
 
501
  # Confidence = 50 baseline + up to 25 more for stronger moves.
502
  confidence = int(
 
8
 
9
  from crewai import Crew, Process
10
 
11
+ from crew.config import OrchestratorConfig, TradePreferences
12
  from crew.agents import (
13
  create_llm,
14
  create_market_scanner,
 
47
  config: OrchestratorConfig,
48
  tools: dict[str, list],
49
  callback: Optional[ActivityFeedCallback] = None,
50
+ preferences: Optional[TradePreferences] = None,
51
  ):
52
  """Initialize the crew orchestrator.
53
 
 
61
  "risk_manager": [calculate_position_size, set_stop_loss],
62
  }
63
  callback: Optional activity feed callback for real-time UI updates
64
+ preferences: User trading preferences that shape the final
65
+ signal's stop / target bands. Defaults to Moderate /
66
+ Swing Trading / $10k when not supplied.
67
  """
68
  self._config = config
69
  self._tools = tools
70
  self._callback = callback
71
+ self._preferences = preferences or TradePreferences()
72
  self._parser = TradingSignalParser()
73
 
74
  def run(self, ticker: str) -> CrewResult:
 
185
  market_task = create_market_scan_task(market_scanner, ticker)
186
  fundamental_task = create_fundamental_task(fundamental_analyst, ticker)
187
  technical_task = create_technical_task(technical_analyst, ticker)
188
+ risk_task = create_risk_task(
189
+ risk_manager, ticker, [technical_task], self._preferences
190
+ )
191
  strategy_task = create_strategy_task(
192
  chief_strategist,
193
  ticker,
194
  [market_task, fundamental_task, technical_task, risk_task],
195
+ self._preferences,
196
  )
197
 
198
  return Crew(
 
332
  new_target = None if _bad(target) else round(target * scale, 2)
333
 
334
  # Extra guard: after rescaling, stop / target should still be
335
+ # within a reasonable band of the new entry. Bounds come from
336
+ # the user's preferences Conservative / Day Trading locks
337
+ # everything down tight, Aggressive / Position Trading opens
338
+ # it up. If the LLM emitted something implausibly wide we let
339
+ # the back-fill replace it with the profile default band.
340
+ stop_clamp = self._preferences.stop_clamp
341
+ target_clamp = self._preferences.target_clamp
342
+
343
  def _stop_unreasonable(v: Optional[float]) -> bool:
344
  if v is None:
345
  return True
346
+ return abs(v - live_entry) / live_entry > stop_clamp
347
 
348
  def _target_unreasonable(v: Optional[float]) -> bool:
349
  if v is None:
350
  return True
351
+ return abs(v - live_entry) / live_entry > target_clamp
352
 
353
  if _stop_unreasonable(new_stop):
354
  new_stop = None
 
366
  )
367
  return self._backfill_missing_prices(rescaled)
368
 
369
+ def _backfill_missing_prices(self, signal: TradingSignal) -> TradingSignal:
 
370
  """Back-fill stop-loss and target when the LLM emitted N/A or omitted them.
371
 
372
  This ensures the UI card always shows three numeric prices rather
373
+ than a mix of numbers and N/A placeholders. The band widths come
374
+ from the user's :class:`TradePreferences` so Conservative / Day
375
+ Trading produces tight short-horizon numbers and Aggressive /
376
+ Position Trading produces wider longer-horizon numbers.
377
  """
378
  entry = signal.entry_price
379
  if entry is None or entry <= 0:
 
381
 
382
  stop = signal.stop_loss
383
  target = signal.target_price
384
+ stop_pct = self._preferences.stop_pct
385
+ target_pct = self._preferences.target_pct
386
+ # HOLD cards use a half-width band so the card still renders
387
+ # with numeric stop / target without implying a directional
388
+ # trade the Strategist wasn't making.
389
+ hold_stop_pct = max(0.015, stop_pct * 0.5)
390
+ hold_target_pct = max(0.02, target_pct * 0.5)
391
 
392
  if stop is None or stop <= 0:
393
  if signal.action == Action.BUY:
394
+ stop = round(entry * (1 - stop_pct), 2)
395
  elif signal.action == Action.SELL:
396
+ stop = round(entry * (1 + stop_pct), 2)
397
  else: # HOLD
398
+ stop = round(entry * (1 - hold_stop_pct), 2)
399
 
400
  if target is None or target <= 0:
401
  if signal.action == Action.BUY:
402
+ target = round(entry * (1 + target_pct), 2)
403
  elif signal.action == Action.SELL:
404
+ target = round(entry * (1 - target_pct), 2)
405
  else: # HOLD
406
+ target = round(entry * (1 + hold_target_pct), 2)
407
 
408
  return TradingSignal(
409
  ticker=signal.ticker,
 
507
  else:
508
  action = Action.HOLD
509
 
510
+ # Price bands derived from user preferences.
511
+ stop_pct = self._preferences.stop_pct
512
+ target_pct = self._preferences.target_pct
513
+ hold_stop_pct = max(0.015, stop_pct * 0.5)
514
+ hold_target_pct = max(0.02, target_pct * 0.5)
515
+
516
  if action == Action.BUY:
517
+ stop = round(entry * (1 - stop_pct), 2)
518
+ target = round(entry * (1 + target_pct), 2)
519
  elif action == Action.SELL:
520
+ stop = round(entry * (1 + stop_pct), 2)
521
+ target = round(entry * (1 - target_pct), 2)
522
  else: # HOLD
523
+ stop = round(entry * (1 - hold_stop_pct), 2)
524
+ target = round(entry * (1 + hold_target_pct), 2)
525
 
526
  # Confidence = 50 baseline + up to 25 more for stronger moves.
527
  confidence = int(
crew/runner.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
  from dataclasses import dataclass, field
6
  from typing import Optional
7
 
8
- from crew.config import OrchestratorConfig
9
  from crew.crew import CrewResult, FinAgentCrew
10
  from crew.callbacks import ActivityFeedCallback
11
 
@@ -28,10 +28,12 @@ class WatchlistRunner:
28
  config: OrchestratorConfig,
29
  tools: dict[str, list],
30
  callback: Optional[ActivityFeedCallback] = None,
 
31
  ):
32
  self._config = config
33
  self._tools = tools
34
  self._callback = callback
 
35
 
36
  def run(self, watchlist: str) -> WatchlistResult:
37
  """Parse the watchlist and run the analysis pipeline for each ticker.
@@ -100,6 +102,7 @@ class WatchlistRunner:
100
  config=self._config,
101
  tools=self._tools,
102
  callback=self._callback,
 
103
  )
104
  return crew.run(ticker)
105
  except Exception as e:
 
5
  from dataclasses import dataclass, field
6
  from typing import Optional
7
 
8
+ from crew.config import OrchestratorConfig, TradePreferences
9
  from crew.crew import CrewResult, FinAgentCrew
10
  from crew.callbacks import ActivityFeedCallback
11
 
 
28
  config: OrchestratorConfig,
29
  tools: dict[str, list],
30
  callback: Optional[ActivityFeedCallback] = None,
31
+ preferences: Optional[TradePreferences] = None,
32
  ):
33
  self._config = config
34
  self._tools = tools
35
  self._callback = callback
36
+ self._preferences = preferences or TradePreferences()
37
 
38
  def run(self, watchlist: str) -> WatchlistResult:
39
  """Parse the watchlist and run the analysis pipeline for each ticker.
 
102
  config=self._config,
103
  tools=self._tools,
104
  callback=self._callback,
105
+ preferences=self._preferences,
106
  )
107
  return crew.run(ticker)
108
  except Exception as e:
crew/tasks.py CHANGED
@@ -1,7 +1,11 @@
1
  """Task factory functions with dependencies for CrewAI Task instances."""
2
 
 
 
3
  from crewai import Task, Agent
4
 
 
 
5
 
6
  def create_market_scan_task(agent: Agent, ticker: str) -> Task:
7
  """Create the market scanning task."""
@@ -56,19 +60,36 @@ def create_technical_task(agent: Agent, ticker: str) -> Task:
56
  )
57
 
58
 
59
- def create_risk_task(agent: Agent, ticker: str, context: list) -> Task:
 
 
 
 
 
60
  """Create the risk assessment task.
61
 
62
  Args:
63
  agent: Risk Manager agent
64
  ticker: Stock symbol
65
  context: [technical_task] — depends on Technical Analyst output
 
 
66
  """
 
 
 
 
67
  return Task(
68
  description=(
69
  f"Calculate position sizing and stop-loss levels for {ticker}. "
70
  f"Use the entry price from the Technical Analyst's recommendation "
71
- f"to determine optimal position size and ATR-based stop-loss."
 
 
 
 
 
 
72
  ),
73
  expected_output=(
74
  f"Risk parameters for {ticker} including: "
@@ -80,20 +101,40 @@ def create_risk_task(agent: Agent, ticker: str, context: list) -> Task:
80
  )
81
 
82
 
83
- def create_strategy_task(agent: Agent, ticker: str, context: list) -> Task:
84
- """Create the strategy synthesis task.
 
 
 
 
 
85
 
86
  Args:
87
  agent: Chief Strategist agent
88
  ticker: Stock symbol
89
  context: [market_task, fundamental_task, technical_task, risk_task]
 
 
 
90
  """
 
 
 
 
91
  return Task(
92
  description=(
93
- f"Synthesize all analysis for {ticker} into a final trading signal. "
94
- f"Use the current price reported by get_price_change as Entry. "
95
- f"For BUY: Stop Loss below Entry, Target above Entry (within 5%). "
96
- f"For SELL: Stop Loss above Entry, Target below Entry (within 5%). "
 
 
 
 
 
 
 
 
97
  f"Keep the response concise — do not deliberate at length.\n\n"
98
  f"Output EXACTLY this format on its own lines, with NO extra prose:\n"
99
  f"{ticker} — BUY (Confidence: 75%)\n"
 
1
  """Task factory functions with dependencies for CrewAI Task instances."""
2
 
3
+ from typing import Optional
4
+
5
  from crewai import Task, Agent
6
 
7
+ from crew.config import TradePreferences
8
+
9
 
10
  def create_market_scan_task(agent: Agent, ticker: str) -> Task:
11
  """Create the market scanning task."""
 
60
  )
61
 
62
 
63
+ def create_risk_task(
64
+ agent: Agent,
65
+ ticker: str,
66
+ context: list,
67
+ preferences: Optional[TradePreferences] = None,
68
+ ) -> Task:
69
  """Create the risk assessment task.
70
 
71
  Args:
72
  agent: Risk Manager agent
73
  ticker: Stock symbol
74
  context: [technical_task] — depends on Technical Analyst output
75
+ preferences: User preferences so the risk assessment matches the
76
+ investor's profile (portfolio size, risk appetite).
77
  """
78
+ prefs = preferences or TradePreferences()
79
+ stop_target_pct = int(round(prefs.stop_pct * 100))
80
+ target_target_pct = int(round(prefs.target_pct * 100))
81
+
82
  return Task(
83
  description=(
84
  f"Calculate position sizing and stop-loss levels for {ticker}. "
85
  f"Use the entry price from the Technical Analyst's recommendation "
86
+ f"to determine optimal position size and ATR-based stop-loss.\n\n"
87
+ f"The user's profile:\n"
88
+ f"- Risk tolerance: {prefs.risk_tolerance} "
89
+ f"(target stop-loss distance ~{stop_target_pct}% from entry)\n"
90
+ f"- Trading style: {prefs.trading_style} "
91
+ f"(target profit distance ~{target_target_pct}% from entry)\n"
92
+ f"- Portfolio value: ${prefs.portfolio_value:,.0f}\n"
93
  ),
94
  expected_output=(
95
  f"Risk parameters for {ticker} including: "
 
101
  )
102
 
103
 
104
+ def create_strategy_task(
105
+ agent: Agent,
106
+ ticker: str,
107
+ context: list,
108
+ preferences: Optional[TradePreferences] = None,
109
+ ) -> Task:
110
+ r"""Create the strategy synthesis task.
111
 
112
  Args:
113
  agent: Chief Strategist agent
114
  ticker: Stock symbol
115
  context: [market_task, fundamental_task, technical_task, risk_task]
116
+ preferences: User preferences that shape the final signal's
117
+ stop / target distances and horizon. When ``None``, falls
118
+ back to a Moderate / Swing Trading / \$10k profile.
119
  """
120
+ prefs = preferences or TradePreferences()
121
+ stop_pct = int(round(prefs.stop_pct * 100))
122
+ target_pct = int(round(prefs.target_pct * 100))
123
+
124
  return Task(
125
  description=(
126
+ f"Synthesize all analysis for {ticker} into a final trading "
127
+ f"signal for a **{prefs.risk_tolerance} "
128
+ f"{prefs.trading_style}** investor "
129
+ f"with a ${prefs.portfolio_value:,.0f} portfolio.\n\n"
130
+ f"Use the current price reported by get_price_change as Entry.\n"
131
+ f"Profile-specific stop / target distances:\n"
132
+ f"- Stop Loss: approximately {stop_pct}% from Entry "
133
+ f"(tighter for Conservative, wider for Aggressive)\n"
134
+ f"- Target: approximately {target_pct}% from Entry "
135
+ f"(smaller for Day Trading, larger for Position Trading)\n\n"
136
+ f"For BUY: Stop Loss below Entry, Target above Entry.\n"
137
+ f"For SELL: Stop Loss above Entry, Target below Entry.\n"
138
  f"Keep the response concise — do not deliberate at length.\n\n"
139
  f"Output EXACTLY this format on its own lines, with NO extra prose:\n"
140
  f"{ticker} — BUY (Confidence: 75%)\n"