CiscsoPonce commited on
Commit
ccd5d7c
Β·
1 Parent(s): 6e687a1

feat: upgrade cron intelligence with seen-ticker caching and multi-candidate LLM extraction loops

Browse files
Files changed (3) hide show
  1. src/agent.py +7 -3
  2. src/seen_tickers.json +1 -0
  3. src/whale_hunter.py +80 -20
src/agent.py CHANGED
@@ -36,18 +36,22 @@ def generate_chart(ticker: str) -> bytes:
36
  return None
37
 
38
  # --- INLINE SEARCH TOOL ---
39
- def brave_market_search(query: str) -> str:
40
  """Uses the Brave Search API to find financial news."""
41
  api_key = os.getenv("BRAVE_API_KEY")
42
  if not api_key:
43
  return "No Brave API key found."
44
 
45
  headers = {"Accept": "application/json", "X-Subscription-Token": api_key}
 
 
 
 
46
  try:
47
- response = requests.get(f"https://api.search.brave.com/res/v1/web/search?q={query}", headers=headers)
48
  if response.status_code == 200:
49
  results = response.json().get("web", {}).get("results", [])
50
- return "\n".join([f"- {r.get('title')}: {r.get('description')}" for r in results[:5]])
51
  return "Search failed."
52
  except Exception as e:
53
  return f"Search error: {str(e)}"
 
36
  return None
37
 
38
  # --- INLINE SEARCH TOOL ---
39
+ def brave_market_search(query: str, count: int = 5, freshness: str = "") -> str:
40
  """Uses the Brave Search API to find financial news."""
41
  api_key = os.getenv("BRAVE_API_KEY")
42
  if not api_key:
43
  return "No Brave API key found."
44
 
45
  headers = {"Accept": "application/json", "X-Subscription-Token": api_key}
46
+ params = {"q": query, "count": count}
47
+ if freshness:
48
+ params["freshness"] = freshness
49
+
50
  try:
51
+ response = requests.get("https://api.search.brave.com/res/v1/web/search", headers=headers, params=params)
52
  if response.status_code == 200:
53
  results = response.json().get("web", {}).get("results", [])
54
+ return "\n".join([f"- {r.get('title')}: {r.get('description')}" for r in results])
55
  return "Search failed."
56
  except Exception as e:
57
  return f"Search error: {str(e)}"
src/seen_tickers.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"ALML": 1772362801.8056116, "ENS": 1772362863.997372, "BPH.AX": 1772362868.4942782, "CNI": 1772362922.6636171, "CNXC": 1772362924.1084235, "CNQ.TO": 1772362924.6461613, "PFE": 1772362982.242142, "QORIA": 1772363043.4104543, "AMLM": 1772363044.635996}
src/whale_hunter.py CHANGED
@@ -16,12 +16,15 @@ from src.portfolio_tracker import record_paper_trade
16
  from src.email_utils import send_email_report
17
  from src.agent import brave_market_search
18
 
 
 
19
  # --- 1. CONFIGURATION ---
20
  MAX_MARKET_CAP = 300_000_000 # Max: $300 Million
21
  MIN_MARKET_CAP = 10_000_000 # Min: $10 Million
22
  MAX_PRICE_PER_SHARE = 30.00 # NEW: Must be under $30
23
- MAX_RETRIES = 1 # 1 Retry per region (Total 2 attempts)
24
  HARD_TIMEOUT_SECONDS = 3000 # 50 minutes to match GitHub Actions
 
25
 
26
  # 🌍 EXCHANGE SUFFIX MAP
27
  REGION_SUFFIXES = {
@@ -35,10 +38,33 @@ REGION_SUFFIXES = {
35
  def _timeout_handler(signum, frame):
36
  raise TimeoutError("⏰ Hard timeout reached (50 minutes). Aborting.")
37
 
38
- # --- 2. THE MEMORY ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  class AgentState(TypedDict):
40
  region: str
41
- ticker: str
 
42
  company_name: str
43
  market_cap: float
44
  is_small_cap: bool
@@ -49,13 +75,18 @@ class AgentState(TypedDict):
49
  llm = get_llm()
50
 
51
  # --- HELPER FUNCTIONS ---
52
- def extract_ticker_from_text(text: str) -> str:
53
- cleaned = text.strip().upper()
54
- if re.fullmatch(r'[A-Z]{1,5}(\.[A-Z]{1,2})?', cleaned): return cleaned
55
- candidates = re.findall(r'\b([A-Z]{1,5}(?:\.[A-Z]{1,2})?)\b', cleaned)
56
  noise_words = {"THE", "AND", "FOR", "ARE", "NOT", "YOU", "ALL", "CAN", "ONE", "OUT", "HAS", "NEW", "NOW", "SEE", "WHO", "GET", "SHE", "TOO", "USE", "NONE", "THIS", "THAT", "WITH", "HAVE", "FROM", "THEY", "BEEN", "SAID", "MAKE", "LIKE", "JUST", "OVER", "SUCH", "TAKE", "YEAR", "SOME", "MOST", "VERY", "WHEN", "WHAT", "YOUR", "ALSO", "INTO", "ROLE", "TASK", "INPUT", "STOCK", "TICKER", "CAP", "MICRO", "NANO"}
57
- candidates = [c for c in candidates if c not in noise_words and len(c) >= 2]
58
- return candidates[0] if candidates else "NONE"
 
 
 
 
 
59
 
60
  def resolve_ticker_suffix(raw_ticker: str, region: str) -> str:
61
  if "." in raw_ticker: return raw_ticker
@@ -77,7 +108,23 @@ def resolve_ticker_suffix(raw_ticker: str, region: str) -> str:
77
  def scout_node(state):
78
  region = state.get('region', 'USA')
79
  retries = state.get('retry_count', 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
 
81
  if retries > 0:
82
  print(f" πŸ”„ Retry pause (2s)...")
83
  time.sleep(2)
@@ -93,28 +140,41 @@ def scout_node(state):
93
  ]
94
 
95
  try:
96
- search_results = brave_market_search(random.choice(base_queries))
97
  except Exception as e:
98
  print(f" ❌ Search Error: {e}")
99
- return {"ticker": "NONE"}
100
 
101
  extraction_prompt = f"""
102
  ROLE: Financial Data Extractor.
103
  INPUT: {search_results}
104
- TASK: Extract the single most interesting MICRO-CAP stock ticker.
105
- CONSTRAINT: Must be listed in {region}. Ignore companies larger than $300M.
106
- OUTPUT: Just the ticker symbol. Nothing else.
107
  """
108
 
109
  try:
110
  if llm:
111
  raw = llm.invoke(extraction_prompt).content.strip()
112
- ticker = extract_ticker_from_text(raw)
113
- print(f" 🎯 Target: {ticker}")
114
- if ticker != "NONE": ticker = resolve_ticker_suffix(ticker, region)
115
- return {"ticker": ticker}
116
- else: return {"ticker": "NONE"}
117
- except: return {"ticker": "NONE"}
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  def gatekeeper_node(state):
120
  ticker = state['ticker']
 
16
  from src.email_utils import send_email_report
17
  from src.agent import brave_market_search
18
 
19
+ import json
20
+
21
  # --- 1. CONFIGURATION ---
22
  MAX_MARKET_CAP = 300_000_000 # Max: $300 Million
23
  MIN_MARKET_CAP = 10_000_000 # Min: $10 Million
24
  MAX_PRICE_PER_SHARE = 30.00 # NEW: Must be under $30
25
+ MAX_RETRIES = 3 # 3 Retries per region (Total 4 attempts)
26
  HARD_TIMEOUT_SECONDS = 3000 # 50 minutes to match GitHub Actions
27
+ SEEN_TICKERS_FILE = "src/seen_tickers.json"
28
 
29
  # 🌍 EXCHANGE SUFFIX MAP
30
  REGION_SUFFIXES = {
 
38
  def _timeout_handler(signum, frame):
39
  raise TimeoutError("⏰ Hard timeout reached (50 minutes). Aborting.")
40
 
41
+ # --- MEMORY HANDLERS ---
42
+ def load_seen_tickers() -> dict:
43
+ if not os.path.exists(SEEN_TICKERS_FILE):
44
+ return {}
45
+ try:
46
+ with open(SEEN_TICKERS_FILE, "r") as f:
47
+ data = json.load(f)
48
+ # Basic cleanup: Remove items older than 7 days (7*24*60*60 = 604800s)
49
+ now = time.time()
50
+ clean_data = {k: v for k, v in data.items() if now - v < 604800}
51
+ return clean_data
52
+ except:
53
+ return {}
54
+
55
+ def mark_ticker_seen(ticker: str):
56
+ data = load_seen_tickers()
57
+ data[ticker] = time.time()
58
+ try:
59
+ with open(SEEN_TICKERS_FILE, "w") as f:
60
+ json.dump(data, f)
61
+ except Exception as e:
62
+ print(f"Memory Save Error: {e}")
63
+
64
  class AgentState(TypedDict):
65
  region: str
66
+ ticker: str # The single ticker actively being evaluated by the Gatekeeper
67
+ candidates: list # A list of backup tickers from the LLM extraction
68
  company_name: str
69
  market_cap: float
70
  is_small_cap: bool
 
75
  llm = get_llm()
76
 
77
  # --- HELPER FUNCTIONS ---
78
+ def extract_tickers_from_text(text: str) -> list:
79
+ """Extracts a comma-separated list of tickers from LLM output."""
80
+ raw_tickers = [t.strip().upper() for t in text.split(',')]
81
+ valid_tickers = []
82
  noise_words = {"THE", "AND", "FOR", "ARE", "NOT", "YOU", "ALL", "CAN", "ONE", "OUT", "HAS", "NEW", "NOW", "SEE", "WHO", "GET", "SHE", "TOO", "USE", "NONE", "THIS", "THAT", "WITH", "HAVE", "FROM", "THEY", "BEEN", "SAID", "MAKE", "LIKE", "JUST", "OVER", "SUCH", "TAKE", "YEAR", "SOME", "MOST", "VERY", "WHEN", "WHAT", "YOUR", "ALSO", "INTO", "ROLE", "TASK", "INPUT", "STOCK", "TICKER", "CAP", "MICRO", "NANO"}
83
+
84
+ for t in raw_tickers:
85
+ cleaned = re.sub(r'[^A-Z\.]', '', t)
86
+ if len(cleaned) >= 2 and cleaned not in noise_words and cleaned not in valid_tickers:
87
+ valid_tickers.append(cleaned)
88
+
89
+ return valid_tickers
90
 
91
  def resolve_ticker_suffix(raw_ticker: str, region: str) -> str:
92
  if "." in raw_ticker: return raw_ticker
 
108
  def scout_node(state):
109
  region = state.get('region', 'USA')
110
  retries = state.get('retry_count', 0)
111
+ candidates_in_memory = state.get('candidates', [])
112
+ seen_tickers = load_seen_tickers()
113
+
114
+ # NEW LOGIC: If we already have candidates in memory from the LLM, pop the next one and test it!
115
+ # No need to hit Brave Search or the LLM again until this list is empty.
116
+ while candidates_in_memory:
117
+ next_ticker = candidates_in_memory.pop(0)
118
+ next_ticker = resolve_ticker_suffix(next_ticker, region)
119
+
120
+ if next_ticker not in seen_tickers:
121
+ print(f" 🎯 Popping next candidate from LLM memory: {next_ticker} ({len(candidates_in_memory)} left)")
122
+ mark_ticker_seen(next_ticker)
123
+ return {"ticker": next_ticker, "candidates": candidates_in_memory}
124
+ else:
125
+ print(f" ⏭️ Skipping backup {next_ticker} (Seen recently)")
126
 
127
+ # If we are here, our candidate list is empty and we need to fetch new ones.
128
  if retries > 0:
129
  print(f" πŸ”„ Retry pause (2s)...")
130
  time.sleep(2)
 
140
  ]
141
 
142
  try:
143
+ search_results = brave_market_search(random.choice(base_queries), count=15, freshness="pw")
144
  except Exception as e:
145
  print(f" ❌ Search Error: {e}")
146
+ return {"ticker": "NONE", "candidates": []}
147
 
148
  extraction_prompt = f"""
149
  ROLE: Financial Data Extractor.
150
  INPUT: {search_results}
151
+ TASK: Find ANY stock tickers mentioned in the text. You can return up to 10 candidates.
152
+ CONSTRAINT: Must be listed in {region}. Ignore companies larger than $300M. If unsure, guess the ticker.
153
+ OUTPUT FORMAT: ONLY a comma-separated list of ticker symbols (e.g. AAPL, TSLA, PLTR). No other text.
154
  """
155
 
156
  try:
157
  if llm:
158
  raw = llm.invoke(extraction_prompt).content.strip()
159
+ fresh_candidates = extract_tickers_from_text(raw)
160
+ print(f" πŸ€– Found {len(fresh_candidates)} new candidates: {fresh_candidates}")
161
+
162
+ # Immediately pull the first valid one
163
+ while fresh_candidates:
164
+ first_ticker = fresh_candidates.pop(0)
165
+ first_ticker = resolve_ticker_suffix(first_ticker, region)
166
+
167
+ if first_ticker not in seen_tickers:
168
+ print(f" 🎯 Target Acquired: {first_ticker}")
169
+ mark_ticker_seen(first_ticker)
170
+ return {"ticker": first_ticker, "candidates": fresh_candidates}
171
+ else:
172
+ print(f" ⏭️ Skipping {first_ticker} (Seen recently)")
173
+
174
+ print(" 🚫 All new candidates were already seen.")
175
+ return {"ticker": "NONE", "candidates": []}
176
+ else: return {"ticker": "NONE", "candidates": []}
177
+ except: return {"ticker": "NONE", "candidates": []}
178
 
179
  def gatekeeper_node(state):
180
  ticker = state['ticker']