CiscsoPonce commited on
Commit
52fef82
ยท
1 Parent(s): 28012a0

Fix: whale_hunter_v2

Browse files
Files changed (1) hide show
  1. src/whale_hunter.py +95 -126
src/whale_hunter.py CHANGED
@@ -1,259 +1,228 @@
1
  import os
2
- import operator
3
  from typing import TypedDict, Annotated, List, Union
4
  from langgraph.graph import StateGraph, END
5
  from langchain_core.messages import HumanMessage, SystemMessage
6
 
7
- # Import our existing tools
8
  import yfinance as yf
9
  from src.llm import get_llm
10
  from src.finance_tools import check_financial_health
11
  from src.email_utils import send_email_report
12
- from src.agent import brave_market_search # Re-using the search tool from agent.py
13
 
14
  # --- 1. CONFIGURATION ---
15
- # ๐Ÿ“‰ "Small Cap" Definition: Companies under $500 Million.
16
- # We ignore anything larger to focus on "Deep Value."
17
- MAX_MARKET_CAP = 500_000_000
18
 
19
  # --- 2. THE MEMORY (State) ---
20
  class AgentState(TypedDict):
21
- ticker: str # The symbol (e.g., LMFA)
22
- company_name: str # Official Name
23
- market_cap: float # The size (e.g., $10M)
24
- is_small_cap: bool # True/False Flag
25
- financial_data: dict # Graham Numbers
26
- final_verdict: str # Buy/Avoid text
27
- retry_count: int # Loop Counter (to avoid infinite loops)
28
 
29
- # Initialize LLM
30
  llm = get_llm()
31
 
32
  # --- 3. THE WORKERS (Nodes) ---
33
 
34
  def scout_node(state):
35
  """
36
- ๐Ÿ•ต๏ธโ€โ™‚๏ธ THE REAL SCOUT (Live Mode)
37
- Searches for fresh small-cap opportunities and extracts the best ticker.
 
38
  """
39
- print(f"๐Ÿ”ญ Scouting Global Markets...")
40
-
41
- # 1. Define the Hunt (We rotate regions or pick one)
42
- # You can randomize this or hardcode a specific region per run
43
  queries = [
44
- "undervalued small cap stocks USA today news",
45
- "top undervalued UK small cap stocks February 2026",
46
- "ASX small cap value stocks winners today",
47
- "TSX Venture undervalued mining stocks news"
 
 
48
  ]
49
 
50
- # Pick a random query or cycle them (for now, let's grab USA/Global)
51
- import random
52
  query = random.choice(queries)
53
  print(f" โ†ณ Query: '{query}'")
54
 
55
- # 2. Perform Real Search
56
  search_results = brave_market_search(query)
57
 
58
- if not search_results or "Error" in search_results:
59
- print(" โš ๏ธ Search failed. Falling back to Safety.")
60
- return {"ticker": "LMFA"} # Only use fallback on strict failure
61
-
62
- # 3. Use LLM to Extract the Best Ticker
63
- # The search gives us text; we need the LLM to pick the specific symbol.
64
  extraction_prompt = f"""
65
- ROLE: You are a Financial Data Extractor.
66
-
67
- INPUT DATA (Search Results):
68
- {search_results}
69
 
70
- TASK:
71
- Identify the single most interesting "Small Cap" or "Undervalued" stock ticker mentioned in the text.
72
- Ignore large giants (like NVDA, TSLA).
73
 
74
- OUTPUT FORMAT:
75
- Return ONLY the ticker symbol (e.g., LMFA, ABF.L, ALK.AX).
76
- Do not add markdown, explanation, or punctuation. just the symbol.
77
  """
78
 
79
  try:
80
  if llm:
81
  ticker = llm.invoke(extraction_prompt).content.strip().upper()
82
- # Clean up ticker (remove $ or extra spaces)
83
  ticker = ticker.replace("$", "").replace("Ticker:", "").strip()
 
 
 
84
  print(f" ๐ŸŽฏ Target Acquired: {ticker}")
85
- return {"ticker": ticker}
86
  else:
87
- return {"ticker": "LMFA"} # No LLM available
88
 
89
  except Exception as e:
90
  print(f" โŒ Extraction Error: {e}")
91
- return {"ticker": "LMFA"}
92
-
93
  def gatekeeper_node(state):
94
  """
95
- ๐Ÿ›ก๏ธ THE FILTER
96
- Checks if the company is actually small enough (< $500M).
97
  """
98
  ticker = state['ticker']
99
  print(f"โš–๏ธ Weighing {ticker}...")
100
 
101
  try:
102
  stock = yf.Ticker(ticker)
103
- # Fast fetch of market cap
104
  mkt_cap = stock.info.get('marketCap', 0)
105
  name = stock.info.get('shortName', ticker)
106
 
107
- # ๐ŸŸข THE LOGIC
108
- if 0 < mkt_cap < MAX_MARKET_CAP:
109
- print(f"โœ… {ticker} is a Small Cap (${mkt_cap:,.0f}). Proceeding.")
110
  return {"market_cap": mkt_cap, "is_small_cap": True, "company_name": name}
111
 
112
- elif mkt_cap >= MAX_MARKET_CAP:
113
- print(f"๐Ÿšซ {ticker} is too big (${mkt_cap:,.0f}). Stopping.")
114
- return {"market_cap": mkt_cap, "is_small_cap": False, "company_name": name}
115
-
116
  else:
117
- print(f"โš ๏ธ Could not verify Cap for {ticker}. Assuming Small.")
118
- return {"market_cap": 0, "is_small_cap": True, "company_name": name}
 
119
 
120
  except Exception as e:
121
  print(f"โŒ Gatekeeper Error: {e}")
122
- return {"is_small_cap": False} # Fail safe
123
 
124
  def analyst_node(state):
125
  """
126
  ๐Ÿง  THE ANALYST
127
- Runs the Graham Number logic and writes the specific thesis.
128
  """
129
  ticker = state['ticker']
130
  print(f"๐Ÿงฎ Analyzing {ticker}...")
131
 
132
- # 1. Run Math
133
  fin_data = check_financial_health(ticker)
 
134
 
135
- # 2. Run Qualitative Search
136
- news = brave_market_search(f"{ticker} stock news analysis")
137
-
138
- # 3. Ask LLM for Verdict
139
  prompt = f"""
140
  Analyze {state['company_name']} ({ticker}).
141
  Market Cap: ${state.get('market_cap', 'N/A')}
 
 
 
142
 
143
- Financial Health: {fin_data.get('reason')}
144
- Graham Data: {fin_data.get('metrics')}
145
-
146
- Recent News:
147
- {news}
148
-
149
- Task: Write a strict Value Investing Thesis.
150
- Focus on: Downside Protection (Margin of Safety) vs Upside Potential.
151
  Verdict: BUY / WATCH / AVOID.
 
152
  """
153
 
154
  if llm:
155
- response = llm.invoke([SystemMessage(content="You are a skeptical Value Investor."), HumanMessage(content=prompt)])
156
  verdict = response.content
157
  else:
158
- verdict = f"Simulated Verdict: {fin_data.get('reason')}"
159
 
160
  return {"financial_data": fin_data, "final_verdict": verdict}
161
 
162
  def email_node(state):
163
  """
164
- ๐Ÿ“ง THE REPORTER (Robust Version)
165
- Sends an email even if the hunt failed, so we know the agent is alive.
166
  """
167
  ticker = state.get('ticker', 'Unknown')
168
- verdict = state.get('final_verdict', 'No Verdict Generated.')
169
 
170
- # ๐Ÿšจ DEBUG: Print to logs
171
- print(f"๐Ÿ“จ Email Node Triggered for {ticker}")
172
- print(f" Verdict Length: {len(verdict)} chars")
173
-
174
- print(f"๐Ÿ“จ Preparing email dispatch for {ticker}...")
 
 
 
 
 
 
 
 
 
 
 
175
 
176
- # 1. Define Team
177
  team = [
178
  {"name": "Cisco", "email": os.getenv("EMAIL_CISCO"), "key": os.getenv("RESEND_API_KEY_CISCO")},
179
  {"name": "Raul", "email": os.getenv("EMAIL_RAUL"), "key": os.getenv("RESEND_API_KEY_RAUL")},
180
  {"name": "David", "email": os.getenv("EMAIL_DAVID"), "key": os.getenv("RESEND_API_KEY_DAVID")}
181
  ]
182
 
183
- # 2. Format HTML
184
- subject = f"๐Ÿณ Whale Hunter: {ticker} Analysis"
185
- html_body = f"""
186
- <h1>๐ŸŒŠ Whale Hunter Report: {ticker}</h1>
187
- <h3>Market Cap: ${state.get('market_cap', 0):,.0f}</h3>
188
- <hr>
189
- <p>{verdict.replace(chr(10), '<br>')}</p>
190
- <hr>
191
- <small>Generated by LangGraph Agent (Sprint 7)</small>
192
- """
193
-
194
- # 3. Send Loop
195
- results = []
196
  for member in team:
197
  if member["email"] and member["key"]:
198
  try:
199
  send_email_report(subject, html_body, member["email"], member["key"])
200
- results.append(f"Sent to {member['name']}")
201
- except Exception as e:
202
- print(f"Failed to send to {member['name']}: {e}")
203
-
204
- return {"final_verdict": verdict} # Pass through
205
 
206
  # --- 4. THE GRAPH (Manager) ---
207
 
208
  workflow = StateGraph(AgentState)
209
 
210
- # Add Nodes
211
  workflow.add_node("scout", scout_node)
212
  workflow.add_node("gatekeeper", gatekeeper_node)
213
  workflow.add_node("analyst", analyst_node)
214
  workflow.add_node("email", email_node)
215
 
216
- # Set Entry Point
217
  workflow.set_entry_point("scout")
218
 
219
- # --- 5. THE ROUTING LOGIC ---
220
- def check_size(state):
221
  if state['is_small_cap']:
222
- return "analyst" # ๐ŸŸข Small enough? Analyze it.
223
- else:
224
- return END # ๐Ÿ”ด Too big? Stop immediately.
 
 
 
 
 
225
 
226
  workflow.add_edge("scout", "gatekeeper")
227
 
228
  workflow.add_conditional_edges(
229
  "gatekeeper",
230
- check_size,
231
  {
232
  "analyst": "analyst",
233
- END: END
 
234
  }
235
  )
236
 
237
  workflow.add_edge("analyst", "email")
238
  workflow.add_edge("email", END)
239
 
240
- # Compile
241
  app = workflow.compile()
242
 
243
-
244
-
245
- # ๐ŸŸข THE EXECUTION BLOCK )
246
  if __name__ == "__main__":
247
  print("๐Ÿš€ Starting Whale Hunter Agent (Sprint 7)...")
248
-
249
  try:
250
- # Run the graph
251
- # We pass an empty ticker to trigger the 'Scout Node' logic
252
- result = app.invoke({"ticker": ""})
253
-
254
  print("โœ… Mission Complete.")
255
- print(f"Final Verdict: {result.get('final_verdict')}")
256
-
257
  except Exception as e:
258
- print(f"โŒ CRITICAL FAILURE: {str(e)}")
259
-
 
1
  import os
2
+ import random
3
  from typing import TypedDict, Annotated, List, Union
4
  from langgraph.graph import StateGraph, END
5
  from langchain_core.messages import HumanMessage, SystemMessage
6
 
7
+ # Import tools
8
  import yfinance as yf
9
  from src.llm import get_llm
10
  from src.finance_tools import check_financial_health
11
  from src.email_utils import send_email_report
12
+ from src.agent import brave_market_search
13
 
14
  # --- 1. CONFIGURATION ---
15
+ MAX_MARKET_CAP = 500_000_000 # < $500M
16
+ MIN_MARKET_CAP = 1_000_000 # > $1M (Avoid Penny Stocks/Ghosts)
17
+ MAX_RETRIES = 3 # Try 3 different stocks before giving up
18
 
19
  # --- 2. THE MEMORY (State) ---
20
  class AgentState(TypedDict):
21
+ ticker: str
22
+ company_name: str
23
+ market_cap: float
24
+ is_small_cap: bool
25
+ financial_data: dict
26
+ final_verdict: str
27
+ retry_count: int # ๐Ÿ”„ Track how many times we tried
28
 
 
29
  llm = get_llm()
30
 
31
  # --- 3. THE WORKERS (Nodes) ---
32
 
33
  def scout_node(state):
34
  """
35
+ ๐Ÿ•ต๏ธโ€โ™‚๏ธ THE SCOUT
36
+ Searches for a new target.
37
+ If this is a retry, it changes the search query to find something fresh.
38
  """
39
+ retries = state.get('retry_count', 0)
40
+ print(f"๐Ÿ”ญ Scouting... (Attempt {retries + 1}/{MAX_RETRIES + 1})")
41
+
42
+ # 1. diverse queries to ensure we don't find the same stock twice
43
  queries = [
44
+ "undervalued small cap stocks USA today",
45
+ "top rated microcap stocks 2026",
46
+ "insider buying small cap stocks this week",
47
+ "deep value small cap stocks UK",
48
+ "turnaround stocks under $500 million market cap",
49
+ "net net stocks list 2026"
50
  ]
51
 
52
+ # Pick a random query
 
53
  query = random.choice(queries)
54
  print(f" โ†ณ Query: '{query}'")
55
 
56
+ # 2. Search
57
  search_results = brave_market_search(query)
58
 
59
+ # 3. LLM Extraction
 
 
 
 
 
60
  extraction_prompt = f"""
61
+ ROLE: Financial Data Extractor.
62
+ INPUT: {search_results}
 
 
63
 
64
+ TASK: Extract the single most interesting stock ticker.
65
+ CONSTRAINT: Do NOT pick '{state.get('ticker', 'None')}'. Pick a DIFFERENT one if possible.
 
66
 
67
+ OUTPUT: Just the ticker symbol (e.g., LMFA). No text.
 
 
68
  """
69
 
70
  try:
71
  if llm:
72
  ticker = llm.invoke(extraction_prompt).content.strip().upper()
 
73
  ticker = ticker.replace("$", "").replace("Ticker:", "").strip()
74
+ # Remove junk length
75
+ if len(ticker) > 6 or " " in ticker: ticker = "LMFA"
76
+
77
  print(f" ๐ŸŽฏ Target Acquired: {ticker}")
78
+ return {"ticker": ticker, "retry_count": retries}
79
  else:
80
+ return {"ticker": "LMFA", "retry_count": retries}
81
 
82
  except Exception as e:
83
  print(f" โŒ Extraction Error: {e}")
84
+ return {"ticker": "LMFA", "retry_count": retries}
85
+
86
  def gatekeeper_node(state):
87
  """
88
+ ๐Ÿ›ก๏ธ THE STRICT GATEKEEPER
89
+ Now rejects $0 Market Caps and forces a Retry.
90
  """
91
  ticker = state['ticker']
92
  print(f"โš–๏ธ Weighing {ticker}...")
93
 
94
  try:
95
  stock = yf.Ticker(ticker)
 
96
  mkt_cap = stock.info.get('marketCap', 0)
97
  name = stock.info.get('shortName', ticker)
98
 
99
+ # ๐ŸŸข STRICT LOGIC: Must be between $1M and $500M
100
+ if MIN_MARKET_CAP < mkt_cap < MAX_MARKET_CAP:
101
+ print(f"โœ… {ticker} is a Valid Gem (${mkt_cap:,.0f}). Proceeding.")
102
  return {"market_cap": mkt_cap, "is_small_cap": True, "company_name": name}
103
 
 
 
 
 
104
  else:
105
+ print(f"๐Ÿšซ {ticker} Rejected. (Cap: ${mkt_cap:,.0f}). Requesting Retry.")
106
+ # We return False so the graph loops back
107
+ return {"market_cap": mkt_cap, "is_small_cap": False, "company_name": name}
108
 
109
  except Exception as e:
110
  print(f"โŒ Gatekeeper Error: {e}")
111
+ return {"is_small_cap": False, "market_cap": 0}
112
 
113
  def analyst_node(state):
114
  """
115
  ๐Ÿง  THE ANALYST
 
116
  """
117
  ticker = state['ticker']
118
  print(f"๐Ÿงฎ Analyzing {ticker}...")
119
 
 
120
  fin_data = check_financial_health(ticker)
121
+ news = brave_market_search(f"{ticker} stock analysis")
122
 
 
 
 
 
123
  prompt = f"""
124
  Analyze {state['company_name']} ({ticker}).
125
  Market Cap: ${state.get('market_cap', 'N/A')}
126
+ Financials: {fin_data.get('reason')}
127
+ Metrics: {fin_data.get('metrics')}
128
+ News: {news}
129
 
 
 
 
 
 
 
 
 
130
  Verdict: BUY / WATCH / AVOID.
131
+ Thesis: 3 sentences max.
132
  """
133
 
134
  if llm:
135
+ response = llm.invoke([SystemMessage(content="You are a value investor."), HumanMessage(content=prompt)])
136
  verdict = response.content
137
  else:
138
+ verdict = f"Data: {fin_data.get('reason')}"
139
 
140
  return {"financial_data": fin_data, "final_verdict": verdict}
141
 
142
  def email_node(state):
143
  """
144
+ ๐Ÿ“ง THE REPORTER
 
145
  """
146
  ticker = state.get('ticker', 'Unknown')
147
+ verdict = state.get('final_verdict', 'No Verdict')
148
 
149
+ # If we failed after 3 tries, send a failure report so we know.
150
+ if not state.get('is_small_cap'):
151
+ subject = "โš ๏ธ Whale Hunter: Search Failed (3 Attempts)"
152
+ html_body = f"<h1>Search Failed</h1><p>Tried 3 times. Last attempt: {ticker} (Cap: ${state.get('market_cap')})</p>"
153
+ else:
154
+ subject = f"๐Ÿณ Whale Hunter: {ticker} Analysis"
155
+ html_body = f"""
156
+ <h1>๐ŸŒŠ Whale Hunter Report: {ticker}</h1>
157
+ <h3>Market Cap: ${state.get('market_cap', 0):,.0f}</h3>
158
+ <hr>
159
+ <p>{verdict.replace(chr(10), '<br>')}</p>
160
+ <hr>
161
+ <small>Generated by LangGraph Agent</small>
162
+ """
163
+
164
+ print(f"๐Ÿ“จ Sending Email: {subject}")
165
 
 
166
  team = [
167
  {"name": "Cisco", "email": os.getenv("EMAIL_CISCO"), "key": os.getenv("RESEND_API_KEY_CISCO")},
168
  {"name": "Raul", "email": os.getenv("EMAIL_RAUL"), "key": os.getenv("RESEND_API_KEY_RAUL")},
169
  {"name": "David", "email": os.getenv("EMAIL_DAVID"), "key": os.getenv("RESEND_API_KEY_DAVID")}
170
  ]
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  for member in team:
173
  if member["email"] and member["key"]:
174
  try:
175
  send_email_report(subject, html_body, member["email"], member["key"])
176
+ except: pass
177
+
178
+ return {}
 
 
179
 
180
  # --- 4. THE GRAPH (Manager) ---
181
 
182
  workflow = StateGraph(AgentState)
183
 
 
184
  workflow.add_node("scout", scout_node)
185
  workflow.add_node("gatekeeper", gatekeeper_node)
186
  workflow.add_node("analyst", analyst_node)
187
  workflow.add_node("email", email_node)
188
 
 
189
  workflow.set_entry_point("scout")
190
 
191
+ # ๐ŸŸข THE LOOP LOGIC
192
+ def check_status(state):
193
  if state['is_small_cap']:
194
+ return "analyst" # โœ… Found one! Analyze it.
195
+
196
+ if state['retry_count'] < MAX_RETRIES:
197
+ # ๐Ÿ”„ Increment retry and LOOP BACK to scout
198
+ state['retry_count'] += 1
199
+ return "scout"
200
+
201
+ return "email" # โŒ Give up and email failure report
202
 
203
  workflow.add_edge("scout", "gatekeeper")
204
 
205
  workflow.add_conditional_edges(
206
  "gatekeeper",
207
+ check_status,
208
  {
209
  "analyst": "analyst",
210
+ "scout": "scout", # ๐Ÿ‘ˆ The Loop
211
+ "email": "email"
212
  }
213
  )
214
 
215
  workflow.add_edge("analyst", "email")
216
  workflow.add_edge("email", END)
217
 
 
218
  app = workflow.compile()
219
 
220
+ # ๐ŸŸข EXECUTION BLOCK
 
 
221
  if __name__ == "__main__":
222
  print("๐Ÿš€ Starting Whale Hunter Agent (Sprint 7)...")
 
223
  try:
224
+ # Initialize retry_count to 0
225
+ result = app.invoke({"ticker": "", "retry_count": 0})
 
 
226
  print("โœ… Mission Complete.")
 
 
227
  except Exception as e:
228
+ print(f"โŒ CRITICAL FAILURE: {str(e)}")