CiscsoPonce commited on
Commit
d5ba3a3
·
1 Parent(s): c51ac99

feat: LangGraph best-practices refactor (Sprint 8 Epics 2-3-4)

Browse files

- Fix state mutation anti-pattern: all 4 workflow agent nodes + debug
wrappers now return partial dicts instead of mutating state
- Add Annotated reducers (operator.add) to candidates/candidate_scores
list fields in AgentState
- Replace deprecated set_entry_point / set_conditional_entry_point with
START edges across agent.py, whale_hunter.py, workflow.py
- Refactor gatekeeper routing to Command pattern, removing separate
check_status functions in agent.py and whale_hunter.py
- Add InMemorySaver checkpointer to all 3 graph compile() calls with
thread_id config on every invoke/ainvoke
- Add RetryPolicy(max_attempts=3) to all graph nodes and
recursion_limit=30 to all invoke calls
- Add InvestmentVerdict Pydantic model (src/models/verdict.py) with
structured output via with_structured_output, graceful fallback to
plain LLM
- Simplify portfolio_tracker verdict parsing when structured_verdict is
provided
- Clean up requirements.txt: LangChain 1.0 LTS range pinning, remove
duplicates and deprecated langchain-classic
- Refactor whale_hunter to parallel region fan-out via Send API with
GlobalHunterState orchestrator graph
- Include VPS API layer (vps/) and memory/portfolio VPS integration

Made-with: Cursor

.github/workflows/hunter.yml CHANGED
@@ -45,6 +45,8 @@ jobs:
45
  LANGCHAIN_API_KEY: ${{ secrets.LANGCHAIN_API_KEY }}
46
  LANGCHAIN_PROJECT: primogreedy
47
  LANGSMITH_WORKSPACE_ID: ${{ secrets.LANGSMITH_WORKSPACE_ID }}
 
 
48
  run: PYTHONPATH=. python src/whale_hunter.py
49
 
50
  # 🚨 CRITICAL NEW STEP: Save the memory file safely without crashing
 
45
  LANGCHAIN_API_KEY: ${{ secrets.LANGCHAIN_API_KEY }}
46
  LANGCHAIN_PROJECT: primogreedy
47
  LANGSMITH_WORKSPACE_ID: ${{ secrets.LANGSMITH_WORKSPACE_ID }}
48
+ VPS_API_URL: ${{ secrets.VPS_API_URL }}
49
+ VPS_API_KEY: ${{ secrets.VPS_API_KEY }}
50
  run: PYTHONPATH=. python src/whale_hunter.py
51
 
52
  # 🚨 CRITICAL NEW STEP: Save the memory file safely without crashing
app.py CHANGED
@@ -90,7 +90,7 @@ async def main(message: cl.Message):
90
  elif " " in user_input:
91
  await cl.Message(content="Consulting Senior Broker...").send()
92
  try:
93
- config = {"configurable": {"thread_id": "ui_session"}}
94
  result = await app.ainvoke(
95
  {"ticker": user_input, "retry_count": 0, "manual_search": False},
96
  config=config,
@@ -112,7 +112,7 @@ async def main(message: cl.Message):
112
  for ticker in tickers:
113
  await cl.Message(content=f"--- **Processing:** {ticker} ---").send()
114
  try:
115
- config = {"configurable": {"thread_id": "ui_session"}}
116
  result = await app.ainvoke(
117
  {"ticker": ticker, "retry_count": 0, "manual_search": True},
118
  config=config,
 
90
  elif " " in user_input:
91
  await cl.Message(content="Consulting Senior Broker...").send()
92
  try:
93
+ config = {"configurable": {"thread_id": "ui_session"}, "recursion_limit": 30}
94
  result = await app.ainvoke(
95
  {"ticker": user_input, "retry_count": 0, "manual_search": False},
96
  config=config,
 
112
  for ticker in tickers:
113
  await cl.Message(content=f"--- **Processing:** {ticker} ---").send()
114
  try:
115
+ config = {"configurable": {"thread_id": "ui_session"}, "recursion_limit": 30}
116
  result = await app.ainvoke(
117
  {"ticker": ticker, "retry_count": 0, "manual_search": True},
118
  config=config,
requirements.txt CHANGED
@@ -17,10 +17,10 @@ httpx-sse==0.4.3
17
  idna==3.11
18
  jsonpatch==1.33
19
  jsonpointer==3.0.0
20
- langchain-classic==1.0.1
21
- langchain-community==0.4.1
22
- langchain-core==1.2.16
23
- langchain-text-splitters==1.1.1
24
  langsmith==0.7.9
25
  marshmallow==3.26.2
26
  multidict==6.7.1
@@ -46,11 +46,9 @@ uuid_utils==0.14.1
46
  xxhash==3.6.0
47
  yarl==1.22.0
48
  zstandard==0.25.0
49
- finnhub-python
50
- langchain-community
51
- langchain-openai
52
  chainlit
53
- langgraph
54
  resend
55
  yfinance
56
  matplotlib
 
17
  idna==3.11
18
  jsonpatch==1.33
19
  jsonpointer==3.0.0
20
+ langchain>=1.0,<2.0
21
+ langchain-community>=0.4.0,<0.5.0
22
+ langchain-core>=1.0,<2.0
23
+ langchain-text-splitters>=1.0,<2.0
24
  langsmith==0.7.9
25
  marshmallow==3.26.2
26
  multidict==6.7.1
 
46
  xxhash==3.6.0
47
  yarl==1.22.0
48
  zstandard==0.25.0
49
+ langchain-openai>=0.3
50
+ langgraph>=1.0,<2.0
 
51
  chainlit
 
52
  resend
53
  yfinance
54
  matplotlib
src/agent.py CHANGED
@@ -10,7 +10,10 @@ import random
10
  import time
11
  import yfinance as yf
12
  import matplotlib.pyplot as plt
13
- from langgraph.graph import StateGraph, END
 
 
 
14
 
15
  from src.llm import get_llm, invoke_with_fallback
16
  from src.finance_tools import (
@@ -128,18 +131,31 @@ def scout_node(state):
128
  return {"ticker": ticker, "manual_search": False}
129
 
130
 
131
- def gatekeeper_node(state):
132
- """Validate candidate with financial health checks."""
 
 
 
 
 
 
 
 
 
 
 
 
133
  ticker = state.get("ticker", "NONE")
134
  retries = state.get("retry_count", 0)
135
 
136
  if ticker == "NONE":
137
- return {
138
  "is_small_cap": False,
139
  "status": "FAIL",
140
  "retry_count": retries + 1,
141
  "financial_data": {"reason": "Scout found no readable ticker."},
142
  }
 
143
 
144
  mark_ticker_seen(ticker)
145
 
@@ -168,7 +184,7 @@ def gatekeeper_node(state):
168
  chart_bytes = generate_chart(ticker)
169
 
170
  if price > MAX_PRICE_PER_SHARE:
171
- return {
172
  "market_cap": mkt_cap,
173
  "is_small_cap": False,
174
  "status": "FAIL",
@@ -178,9 +194,10 @@ def gatekeeper_node(state):
178
  "final_report": f"Price ${price:.2f} exceeds ${MAX_PRICE_PER_SHARE} limit.",
179
  "chart_data": chart_bytes,
180
  }
 
181
 
182
  if not (MIN_MARKET_CAP < mkt_cap < MAX_MARKET_CAP):
183
- return {
184
  "market_cap": mkt_cap,
185
  "is_small_cap": False,
186
  "status": "FAIL",
@@ -190,10 +207,11 @@ def gatekeeper_node(state):
190
  "final_report": f"Market Cap ${mkt_cap:,.0f} is outside the $10M-$300M range.",
191
  "chart_data": chart_bytes,
192
  }
 
193
 
194
  health = check_financial_health(ticker, lean_info)
195
  if health["status"] == "FAIL":
196
- return {
197
  "market_cap": mkt_cap,
198
  "is_small_cap": False,
199
  "status": "FAIL",
@@ -203,8 +221,9 @@ def gatekeeper_node(state):
203
  "final_report": f"**GATEKEEPER REJECT:** {health['reason']}",
204
  "chart_data": chart_bytes,
205
  }
 
206
 
207
- return {
208
  "market_cap": mkt_cap,
209
  "is_small_cap": True,
210
  "status": "PASS",
@@ -212,15 +231,17 @@ def gatekeeper_node(state):
212
  "financial_data": lean_info,
213
  "chart_data": chart_bytes,
214
  }
 
215
 
216
  except Exception as exc:
217
  logger.error("Gatekeeper error for %s: %s", ticker, exc)
218
- return {
219
  "is_small_cap": False,
220
  "status": "FAIL",
221
  "retry_count": retries + 1,
222
  "financial_data": {"reason": f"API Error: {exc}"},
223
  }
 
224
 
225
 
226
  def analyst_node(state):
@@ -299,22 +320,33 @@ def analyst_node(state):
299
  )
300
 
301
  try:
302
- verdict = invoke_with_fallback(prompt, run_name="analyst_node")
303
- record_paper_trade(ticker, price, verdict, source="Chainlit UI")
 
 
 
 
304
  except Exception as exc:
305
- logger.error("LLM analysis failed for %s: %s", ticker, exc)
306
- verdict = f"Strategy: {strategy}\nLLM analysis unavailable: {exc}"
 
 
 
 
 
307
 
308
  return {"final_verdict": verdict, "final_report": verdict, "chart_data": chart_bytes}
309
 
310
 
311
  # --- GRAPH ---
312
 
 
 
313
  workflow = StateGraph(AgentState)
314
- workflow.add_node("chat", chat_node)
315
- workflow.add_node("scout", scout_node)
316
- workflow.add_node("gatekeeper", gatekeeper_node)
317
- workflow.add_node("analyst", analyst_node)
318
 
319
 
320
  def initial_routing(state):
@@ -325,29 +357,9 @@ def initial_routing(state):
325
  return "scout"
326
 
327
 
328
- workflow.set_conditional_entry_point(
329
- initial_routing,
330
- {"chat": "chat", "scout": "scout"},
331
- )
332
-
333
-
334
- def check_status(state):
335
- if state.get("manual_search"):
336
- return "analyst"
337
- if state.get("status") == "PASS":
338
- return "analyst"
339
- if state.get("retry_count", 0) >= MAX_RETRIES:
340
- return "analyst"
341
- return "scout"
342
-
343
-
344
  workflow.add_edge("chat", END)
345
  workflow.add_edge("scout", "gatekeeper")
346
- workflow.add_conditional_edges(
347
- "gatekeeper",
348
- check_status,
349
- {"analyst": "analyst", "scout": "scout"},
350
- )
351
  workflow.add_edge("analyst", END)
352
 
353
- app = workflow.compile()
 
10
  import time
11
  import yfinance as yf
12
  import matplotlib.pyplot as plt
13
+ from typing import Literal
14
+ from langgraph.graph import StateGraph, START, END
15
+ from langgraph.checkpoint.memory import InMemorySaver
16
+ from langgraph.types import Command, RetryPolicy
17
 
18
  from src.llm import get_llm, invoke_with_fallback
19
  from src.finance_tools import (
 
131
  return {"ticker": ticker, "manual_search": False}
132
 
133
 
134
+ def _gatekeeper_route(state, update) -> str:
135
+ """Decide where gatekeeper should route based on state + update."""
136
+ if state.get("manual_search"):
137
+ return "analyst"
138
+ if update.get("status") == "PASS":
139
+ return "analyst"
140
+ new_retries = update.get("retry_count", state.get("retry_count", 0))
141
+ if new_retries >= MAX_RETRIES:
142
+ return "analyst"
143
+ return "scout"
144
+
145
+
146
+ def gatekeeper_node(state) -> Command[Literal["analyst", "scout"]]:
147
+ """Validate candidate with financial health checks. Routes via Command."""
148
  ticker = state.get("ticker", "NONE")
149
  retries = state.get("retry_count", 0)
150
 
151
  if ticker == "NONE":
152
+ update = {
153
  "is_small_cap": False,
154
  "status": "FAIL",
155
  "retry_count": retries + 1,
156
  "financial_data": {"reason": "Scout found no readable ticker."},
157
  }
158
+ return Command(update=update, goto=_gatekeeper_route(state, update))
159
 
160
  mark_ticker_seen(ticker)
161
 
 
184
  chart_bytes = generate_chart(ticker)
185
 
186
  if price > MAX_PRICE_PER_SHARE:
187
+ update = {
188
  "market_cap": mkt_cap,
189
  "is_small_cap": False,
190
  "status": "FAIL",
 
194
  "final_report": f"Price ${price:.2f} exceeds ${MAX_PRICE_PER_SHARE} limit.",
195
  "chart_data": chart_bytes,
196
  }
197
+ return Command(update=update, goto=_gatekeeper_route(state, update))
198
 
199
  if not (MIN_MARKET_CAP < mkt_cap < MAX_MARKET_CAP):
200
+ update = {
201
  "market_cap": mkt_cap,
202
  "is_small_cap": False,
203
  "status": "FAIL",
 
207
  "final_report": f"Market Cap ${mkt_cap:,.0f} is outside the $10M-$300M range.",
208
  "chart_data": chart_bytes,
209
  }
210
+ return Command(update=update, goto=_gatekeeper_route(state, update))
211
 
212
  health = check_financial_health(ticker, lean_info)
213
  if health["status"] == "FAIL":
214
+ update = {
215
  "market_cap": mkt_cap,
216
  "is_small_cap": False,
217
  "status": "FAIL",
 
221
  "final_report": f"**GATEKEEPER REJECT:** {health['reason']}",
222
  "chart_data": chart_bytes,
223
  }
224
+ return Command(update=update, goto=_gatekeeper_route(state, update))
225
 
226
+ update = {
227
  "market_cap": mkt_cap,
228
  "is_small_cap": True,
229
  "status": "PASS",
 
231
  "financial_data": lean_info,
232
  "chart_data": chart_bytes,
233
  }
234
+ return Command(update=update, goto="analyst")
235
 
236
  except Exception as exc:
237
  logger.error("Gatekeeper error for %s: %s", ticker, exc)
238
+ update = {
239
  "is_small_cap": False,
240
  "status": "FAIL",
241
  "retry_count": retries + 1,
242
  "financial_data": {"reason": f"API Error: {exc}"},
243
  }
244
+ return Command(update=update, goto=_gatekeeper_route(state, update))
245
 
246
 
247
  def analyst_node(state):
 
320
  )
321
 
322
  try:
323
+ from src.models.verdict import InvestmentVerdict
324
+ structured_llm = get_llm().with_structured_output(InvestmentVerdict)
325
+ result = structured_llm.invoke(prompt)
326
+ verdict = result.to_report()
327
+ record_paper_trade(ticker, price, verdict, source="Chainlit UI",
328
+ structured_verdict=result.verdict)
329
  except Exception as exc:
330
+ logger.warning("Structured output failed for %s, falling back to plain LLM: %s", ticker, exc)
331
+ try:
332
+ verdict = invoke_with_fallback(prompt, run_name="analyst_node")
333
+ record_paper_trade(ticker, price, verdict, source="Chainlit UI")
334
+ except Exception as exc2:
335
+ logger.error("LLM analysis failed for %s: %s", ticker, exc2)
336
+ verdict = f"Strategy: {strategy}\nLLM analysis unavailable: {exc2}"
337
 
338
  return {"final_verdict": verdict, "final_report": verdict, "chart_data": chart_bytes}
339
 
340
 
341
  # --- GRAPH ---
342
 
343
+ _api_retry = RetryPolicy(max_attempts=3, initial_interval=2.0)
344
+
345
  workflow = StateGraph(AgentState)
346
+ workflow.add_node("chat", chat_node, retry=_api_retry)
347
+ workflow.add_node("scout", scout_node, retry=_api_retry)
348
+ workflow.add_node("gatekeeper", gatekeeper_node, retry=_api_retry)
349
+ workflow.add_node("analyst", analyst_node, retry=_api_retry)
350
 
351
 
352
  def initial_routing(state):
 
357
  return "scout"
358
 
359
 
360
+ workflow.add_conditional_edges(START, initial_routing, ["chat", "scout"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  workflow.add_edge("chat", END)
362
  workflow.add_edge("scout", "gatekeeper")
 
 
 
 
 
363
  workflow.add_edge("analyst", END)
364
 
365
+ app = workflow.compile(checkpointer=InMemorySaver())
src/agents/data_collection_agent.py CHANGED
@@ -46,35 +46,23 @@ async def collect_data(symbol: str, analysis_date: Optional[str] = None) -> Dict
46
  }
47
 
48
 
49
- async def data_collection_agent_node(state: AgentState) -> AgentState:
50
- """
51
- LangGraph node for data collection.
52
-
53
- Args:
54
- state: Current workflow state
55
-
56
- Returns:
57
- Updated state with data collection results
58
- """
59
  try:
60
- # Get first symbol from state
61
  symbol = state['symbols'][0] if state['symbols'] else 'AAPL'
62
  analysis_date = state['analysis_date']
63
 
64
- # Collect data with analysis date
65
  result = await collect_data(symbol, analysis_date)
66
 
67
- # Update state
68
- state['data_collection_results'] = result
69
- state['current_step'] = 'data_collection_complete'
70
-
71
  if not result['success']:
72
- state['error'] = result.get('error', 'Data collection failed')
73
-
74
- return state
75
 
76
  except Exception as e:
77
  print(f"Data collection node error: {e}")
78
- state['error'] = str(e)
79
- state['current_step'] = 'error'
80
- return state
 
46
  }
47
 
48
 
49
+ async def data_collection_agent_node(state: AgentState) -> dict:
50
+ """LangGraph node for data collection. Returns partial state updates."""
 
 
 
 
 
 
 
 
51
  try:
 
52
  symbol = state['symbols'][0] if state['symbols'] else 'AAPL'
53
  analysis_date = state['analysis_date']
54
 
 
55
  result = await collect_data(symbol, analysis_date)
56
 
57
+ updates: dict = {
58
+ "data_collection_results": result,
59
+ "current_step": "data_collection_complete",
60
+ }
61
  if not result['success']:
62
+ updates["error"] = result.get('error', 'Data collection failed')
63
+
64
+ return updates
65
 
66
  except Exception as e:
67
  print(f"Data collection node error: {e}")
68
+ return {"error": str(e), "current_step": "error"}
 
 
src/agents/news_intelligence_agent.py CHANGED
@@ -331,33 +331,25 @@ async def extract_nlp_features(
331
  return None
332
 
333
 
334
- async def news_intelligence_agent_node(state: AgentState) -> AgentState:
335
- """
336
- LangGraph node for news intelligence with complete PrimoGPT workflow.
337
- """
338
  try:
339
- # Get symbol, analysis_date and technical data from previous agents
340
  symbol = state['symbols'][0] if state['symbols'] else 'AAPL'
341
  analysis_date = state['analysis_date']
342
  technical_data = state.get('technical_analysis_results')
343
-
344
- # Get company data from data collection results
345
  company_data = state.get('data_collection_results')
346
 
347
- # Perform complete news analysis with company context
348
  result = await analyze_news(symbol, analysis_date, technical_data, company_data)
349
 
350
- # Update state
351
- state['news_intelligence_results'] = result
352
- state['current_step'] = 'news_intelligence_complete'
353
-
354
  if not result['success']:
355
- state['error'] = result.get('error', 'News intelligence failed')
356
-
357
- return state
358
 
359
  except Exception as e:
360
  print(f"News intelligence node error: {e}")
361
- state['error'] = str(e)
362
- state['current_step'] = 'error'
363
- return state
 
331
  return None
332
 
333
 
334
+ async def news_intelligence_agent_node(state: AgentState) -> dict:
335
+ """LangGraph node for news intelligence. Returns partial state updates."""
 
 
336
  try:
 
337
  symbol = state['symbols'][0] if state['symbols'] else 'AAPL'
338
  analysis_date = state['analysis_date']
339
  technical_data = state.get('technical_analysis_results')
 
 
340
  company_data = state.get('data_collection_results')
341
 
 
342
  result = await analyze_news(symbol, analysis_date, technical_data, company_data)
343
 
344
+ updates: dict = {
345
+ "news_intelligence_results": result,
346
+ "current_step": "news_intelligence_complete",
347
+ }
348
  if not result['success']:
349
+ updates["error"] = result.get('error', 'News intelligence failed')
350
+
351
+ return updates
352
 
353
  except Exception as e:
354
  print(f"News intelligence node error: {e}")
355
+ return {"error": str(e), "current_step": "error"}
 
 
src/agents/portfolio_manager_agent.py CHANGED
@@ -389,31 +389,19 @@ def read_historical_context(symbol: str, analysis_date: Optional[str] = None) ->
389
  return []
390
 
391
 
392
- async def portfolio_manager_agent_node(state: AgentState) -> AgentState:
393
- """
394
- Portfolio Manager Agent node for the workflow.
395
-
396
- Args:
397
- state: Current state of the workflow
398
-
399
- Returns:
400
- Updated state with portfolio management results
401
- """
402
  try:
403
  symbols = state.get('symbols', [])
404
  if not symbols:
405
- state['error'] = "No symbols found in state for Portfolio Manager"
406
- return state
407
-
408
- # Since we process one symbol at a time in this design
409
  symbol = symbols[0]
410
 
411
- # Get data directly from the state
412
  tech_results = state.get('technical_analysis_results', {})
413
  news_results = state.get('news_intelligence_results', {})
414
  data_collection_results = state.get('data_collection_results', {})
415
 
416
- # Add data_collection_results to tech_results for access to basic_financials
417
  if isinstance(tech_results, dict):
418
  tech_results_with_data = {
419
  **tech_results,
@@ -424,22 +412,18 @@ async def portfolio_manager_agent_node(state: AgentState) -> AgentState:
424
  'data_collection_results': data_collection_results
425
  }
426
 
427
- # Analyze portfolio for the single symbol
428
  analysis_date = state.get('analysis_date')
429
  analysis_result = await analyze_portfolio(
430
  symbol, tech_results_with_data, news_results, analysis_date
431
  )
432
 
433
- # Structure the result under the symbol key
434
  all_results = {symbol: analysis_result}
435
 
436
- # Update the main state with the results
437
- state['portfolio_manager_results'] = all_results
438
- state['current_step'] = 'portfolio_management_complete'
439
-
440
- return state
441
 
442
  except Exception as e:
443
  print(f"Error in portfolio_manager_agent_node: {e}")
444
- state['error'] = f"Portfolio Manager Agent failed: {e}"
445
- return state
 
389
  return []
390
 
391
 
392
+ async def portfolio_manager_agent_node(state: AgentState) -> dict:
393
+ """Portfolio Manager Agent node. Returns partial state updates."""
 
 
 
 
 
 
 
 
394
  try:
395
  symbols = state.get('symbols', [])
396
  if not symbols:
397
+ return {"error": "No symbols found in state for Portfolio Manager"}
398
+
 
 
399
  symbol = symbols[0]
400
 
 
401
  tech_results = state.get('technical_analysis_results', {})
402
  news_results = state.get('news_intelligence_results', {})
403
  data_collection_results = state.get('data_collection_results', {})
404
 
 
405
  if isinstance(tech_results, dict):
406
  tech_results_with_data = {
407
  **tech_results,
 
412
  'data_collection_results': data_collection_results
413
  }
414
 
 
415
  analysis_date = state.get('analysis_date')
416
  analysis_result = await analyze_portfolio(
417
  symbol, tech_results_with_data, news_results, analysis_date
418
  )
419
 
 
420
  all_results = {symbol: analysis_result}
421
 
422
+ return {
423
+ "portfolio_manager_results": all_results,
424
+ "current_step": "portfolio_management_complete",
425
+ }
 
426
 
427
  except Exception as e:
428
  print(f"Error in portfolio_manager_agent_node: {e}")
429
+ return {"error": f"Portfolio Manager Agent failed: {e}"}
 
src/agents/technical_analysis_agent.py CHANGED
@@ -106,37 +106,25 @@ async def analyze_technical(symbol: str, analysis_date: Optional[str] = None, ma
106
  }
107
 
108
 
109
- async def technical_analysis_agent_node(state: AgentState) -> AgentState:
110
- """
111
- LangGraph node for technical analysis.
112
-
113
- Args:
114
- state: Current workflow state
115
-
116
- Returns:
117
- Updated state with technical analysis results
118
- """
119
  try:
120
- # Get symbol, analysis_date and market data from previous agent
121
  symbol = state['symbols'][0] if state['symbols'] else 'AAPL'
122
  analysis_date = state['analysis_date']
123
  data_collection_results = state.get('data_collection_results')
124
  market_data = data_collection_results.get('market_data') if data_collection_results else None
125
 
126
- # Perform technical analysis with analysis_date
127
  result = await analyze_technical(symbol, analysis_date, market_data)
128
 
129
- # Update state
130
- state['technical_analysis_results'] = result
131
- state['current_step'] = 'technical_analysis_complete'
132
-
133
  if not result['success']:
134
- state['error'] = result.get('error', 'Technical analysis failed')
135
-
136
- return state
137
 
138
  except Exception as e:
139
  print(f"Technical analysis node error: {e}")
140
- state['error'] = str(e)
141
- state['current_step'] = 'error'
142
- return state
 
106
  }
107
 
108
 
109
+ async def technical_analysis_agent_node(state: AgentState) -> dict:
110
+ """LangGraph node for technical analysis. Returns partial state updates."""
 
 
 
 
 
 
 
 
111
  try:
 
112
  symbol = state['symbols'][0] if state['symbols'] else 'AAPL'
113
  analysis_date = state['analysis_date']
114
  data_collection_results = state.get('data_collection_results')
115
  market_data = data_collection_results.get('market_data') if data_collection_results else None
116
 
 
117
  result = await analyze_technical(symbol, analysis_date, market_data)
118
 
119
+ updates: dict = {
120
+ "technical_analysis_results": result,
121
+ "current_step": "technical_analysis_complete",
122
+ }
123
  if not result['success']:
124
+ updates["error"] = result.get('error', 'Technical analysis failed')
125
+
126
+ return updates
127
 
128
  except Exception as e:
129
  print(f"Technical analysis node error: {e}")
130
+ return {"error": str(e), "current_step": "error"}
 
 
src/core/memory.py CHANGED
@@ -1,6 +1,7 @@
1
  import json
2
  import os
3
  import time
 
4
  from .logger import get_logger
5
 
6
  logger = get_logger(__name__)
@@ -8,12 +9,65 @@ logger = get_logger(__name__)
8
  SEEN_TICKERS_FILE = "seen_tickers.json"
9
  MEMORY_TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days
10
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  def load_seen_tickers() -> dict[str, float]:
13
  """Load the seen-tickers ledger, pruning entries older than 30 days.
14
 
15
  Values are Unix timestamps (float).
 
16
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  if not os.path.exists(SEEN_TICKERS_FILE):
18
  return {}
19
  try:
@@ -23,7 +77,6 @@ def load_seen_tickers() -> dict[str, float]:
23
  now = time.time()
24
  cleaned: dict[str, float] = {}
25
  for ticker, ts in raw.items():
26
- # Support both unix timestamps and ISO strings (legacy)
27
  if isinstance(ts, str):
28
  try:
29
  from datetime import datetime, timezone
@@ -41,13 +94,6 @@ def load_seen_tickers() -> dict[str, float]:
41
  return {}
42
 
43
 
44
- def mark_ticker_seen(ticker: str) -> None:
45
- """Record a ticker as recently analysed."""
46
- data = load_seen_tickers()
47
- data[ticker] = time.time()
48
- _save(data)
49
-
50
-
51
  def _save(data: dict) -> None:
52
  try:
53
  with open(SEEN_TICKERS_FILE, "w") as f:
 
1
  import json
2
  import os
3
  import time
4
+ import requests
5
  from .logger import get_logger
6
 
7
  logger = get_logger(__name__)
 
9
  SEEN_TICKERS_FILE = "seen_tickers.json"
10
  MEMORY_TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days
11
 
12
+ # VPS Data API (optional — falls back to local JSON if not set)
13
+ VPS_API_URL = os.getenv("VPS_API_URL", "").rstrip("/")
14
+ VPS_API_KEY = os.getenv("VPS_API_KEY", "")
15
+
16
+
17
+ def _vps_headers() -> dict:
18
+ return {"X-API-Key": VPS_API_KEY, "Content-Type": "application/json"}
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Public API
23
+ # ---------------------------------------------------------------------------
24
 
25
  def load_seen_tickers() -> dict[str, float]:
26
  """Load the seen-tickers ledger, pruning entries older than 30 days.
27
 
28
  Values are Unix timestamps (float).
29
+ Tries VPS API first, falls back to local JSON file.
30
  """
31
+ if VPS_API_URL:
32
+ try:
33
+ resp = requests.get(f"{VPS_API_URL}/seen-tickers", headers=_vps_headers(), timeout=5)
34
+ resp.raise_for_status()
35
+ data = resp.json()
36
+ logger.debug("Loaded %d seen tickers from VPS", len(data))
37
+ return data
38
+ except Exception as exc:
39
+ logger.warning("VPS seen-tickers unavailable, using local fallback: %s", exc)
40
+
41
+ return _load_local()
42
+
43
+
44
+ def mark_ticker_seen(ticker: str, region: str = "USA") -> None:
45
+ """Record a ticker as recently analysed."""
46
+ if VPS_API_URL:
47
+ try:
48
+ resp = requests.post(
49
+ f"{VPS_API_URL}/seen-tickers",
50
+ headers=_vps_headers(),
51
+ json={"ticker": ticker, "region": region},
52
+ timeout=5,
53
+ )
54
+ resp.raise_for_status()
55
+ logger.debug("Marked %s as seen on VPS", ticker)
56
+ return
57
+ except Exception as exc:
58
+ logger.warning("VPS mark_ticker_seen failed, using local fallback: %s", exc)
59
+
60
+ # Local fallback
61
+ data = _load_local()
62
+ data[ticker] = time.time()
63
+ _save(data)
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Local JSON fallback (original behavior)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def _load_local() -> dict[str, float]:
71
  if not os.path.exists(SEEN_TICKERS_FILE):
72
  return {}
73
  try:
 
77
  now = time.time()
78
  cleaned: dict[str, float] = {}
79
  for ticker, ts in raw.items():
 
80
  if isinstance(ts, str):
81
  try:
82
  from datetime import datetime, timezone
 
94
  return {}
95
 
96
 
 
 
 
 
 
 
 
97
  def _save(data: dict) -> None:
98
  try:
99
  with open(SEEN_TICKERS_FILE, "w") as f:
src/core/state.py CHANGED
@@ -1,4 +1,5 @@
1
- from typing import TypedDict
 
2
 
3
 
4
  class AgentState(TypedDict, total=False):
@@ -8,7 +9,7 @@ class AgentState(TypedDict, total=False):
8
  """
9
  region: str
10
  ticker: str
11
- candidates: list
12
  company_name: str
13
  market_cap: float
14
  is_small_cap: bool
@@ -19,4 +20,4 @@ class AgentState(TypedDict, total=False):
19
  final_report: str
20
  chart_data: bytes
21
  manual_search: bool
22
- candidate_scores: list # [{ticker, score, metrics}, ...]
 
1
+ import operator
2
+ from typing import Annotated, TypedDict
3
 
4
 
5
  class AgentState(TypedDict, total=False):
 
9
  """
10
  region: str
11
  ticker: str
12
+ candidates: Annotated[list, operator.add]
13
  company_name: str
14
  market_cap: float
15
  is_small_cap: bool
 
20
  final_report: str
21
  chart_data: bytes
22
  manual_search: bool
23
+ candidate_scores: Annotated[list, operator.add]
src/models/__init__.py ADDED
File without changes
src/models/verdict.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class InvestmentVerdict(BaseModel):
7
+ """Structured analyst verdict returned by the Senior Broker LLM."""
8
+
9
+ quantitative_base: str = Field(
10
+ description="Price vs calculated valuation, margin of safety math"
11
+ )
12
+ lynch_pitch: str = Field(
13
+ description="What insiders are doing + the one catalyst"
14
+ )
15
+ munger_invert: str = Field(
16
+ description="How an investor could lose money, the bear evidence"
17
+ )
18
+ verdict: Literal["STRONG BUY", "BUY", "WATCH", "AVOID"]
19
+ bottom_line: str = Field(
20
+ description="One-sentence final summary"
21
+ )
22
+
23
+ def to_report(self) -> str:
24
+ """Render as the markdown report format the pipeline expects."""
25
+ return (
26
+ f"### THE QUANTITATIVE BASE (Graham / Asset Play)\n"
27
+ f"{self.quantitative_base}\n\n"
28
+ f"### THE LYNCH PITCH (Why I would own this)\n"
29
+ f"{self.lynch_pitch}\n\n"
30
+ f"### THE MUNGER INVERT (How I could lose money)\n"
31
+ f"{self.munger_invert}\n\n"
32
+ f"### FINAL VERDICT\n"
33
+ f"**{self.verdict}** — {self.bottom_line}"
34
+ )
src/portfolio_tracker.py CHANGED
@@ -1,6 +1,8 @@
1
  import json
2
  import os
3
  from datetime import datetime
 
 
4
  import yfinance as yf
5
  from src.core.logger import get_logger
6
  from src.core.ticker_utils import normalize_price
@@ -9,29 +11,77 @@ logger = get_logger(__name__)
9
 
10
  PORTFOLIO_FILE = "paper_portfolio.json"
11
 
12
-
13
- def record_paper_trade(ticker: str, entry_price: float, verdict: str, source: str) -> None:
14
- """Save a BUY/STRONG BUY/WATCH recommendation to the paper portfolio."""
15
- v_upper = verdict.strip().upper()
16
-
17
- trade_type = None
18
- if "STRONG BUY" in v_upper:
19
- trade_type = "STRONG BUY"
20
- elif " BUY" in v_upper or v_upper.startswith("BUY"):
21
- trade_type = "BUY"
22
- elif "WATCH" in v_upper:
23
- trade_type = "WATCH"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  if not trade_type:
26
  return
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  try:
29
  portfolio = []
30
  if os.path.exists(PORTFOLIO_FILE):
31
  with open(PORTFOLIO_FILE, "r") as f:
32
  portfolio = json.load(f)
33
 
34
- today = datetime.now().strftime("%Y-%m-%d")
35
  for trade in portfolio:
36
  if trade["ticker"] == ticker and trade["date"] == today:
37
  logger.info("Duplicate trade skipped: %s on %s", ticker, today)
@@ -58,6 +108,54 @@ def record_paper_trade(ticker: str, entry_price: float, verdict: str, source: st
58
 
59
  def evaluate_portfolio() -> str:
60
  """Read the paper portfolio and calculate live performance."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  if not os.path.exists(PORTFOLIO_FILE):
62
  return "**Paper Portfolio is empty.** The Agent hasn't tracked any stocks yet."
63
 
@@ -74,7 +172,7 @@ def evaluate_portfolio() -> str:
74
 
75
  report = "## PrimoGreedy Agent Track Record\n\n"
76
  report += "| Ticker | Date Called | Entry Price | Current Price | Return | Verdict |\n"
77
- report += "|--------|-------------|-------------|---------------|--------|---------|\n"
78
 
79
  for trade in portfolio:
80
  ticker = trade["ticker"]
 
1
  import json
2
  import os
3
  from datetime import datetime
4
+
5
+ import requests
6
  import yfinance as yf
7
  from src.core.logger import get_logger
8
  from src.core.ticker_utils import normalize_price
 
11
 
12
  PORTFOLIO_FILE = "paper_portfolio.json"
13
 
14
+ # VPS Data API (optional — falls back to local JSON if not set)
15
+ VPS_API_URL = os.getenv("VPS_API_URL", "").rstrip("/")
16
+ VPS_API_KEY = os.getenv("VPS_API_KEY", "")
17
+
18
+
19
+ def _vps_headers() -> dict:
20
+ return {"X-API-Key": VPS_API_KEY, "Content-Type": "application/json"}
21
+
22
+
23
+ def record_paper_trade(
24
+ ticker: str,
25
+ entry_price: float,
26
+ verdict: str,
27
+ source: str,
28
+ structured_verdict: str | None = None,
29
+ ) -> None:
30
+ """Save a BUY/STRONG BUY/WATCH recommendation to the paper portfolio.
31
+
32
+ When *structured_verdict* is supplied (from ``InvestmentVerdict.verdict``),
33
+ it is used directly, skipping brittle string matching on the full report.
34
+ """
35
+ if structured_verdict:
36
+ _VALID = {"STRONG BUY", "BUY", "WATCH"}
37
+ trade_type = structured_verdict if structured_verdict in _VALID else None
38
+ else:
39
+ v_upper = verdict.strip().upper()
40
+ trade_type = None
41
+ if "STRONG BUY" in v_upper:
42
+ trade_type = "STRONG BUY"
43
+ elif " BUY" in v_upper or v_upper.startswith("BUY"):
44
+ trade_type = "BUY"
45
+ elif "WATCH" in v_upper:
46
+ trade_type = "WATCH"
47
 
48
  if not trade_type:
49
  return
50
 
51
+ today = datetime.now().strftime("%Y-%m-%d")
52
+
53
+ # Try VPS first
54
+ if VPS_API_URL:
55
+ try:
56
+ resp = requests.post(
57
+ f"{VPS_API_URL}/portfolio",
58
+ headers=_vps_headers(),
59
+ json={
60
+ "ticker": ticker,
61
+ "entry_price": entry_price,
62
+ "date": today,
63
+ "verdict": trade_type,
64
+ "source": source,
65
+ },
66
+ timeout=5,
67
+ )
68
+ resp.raise_for_status()
69
+ result = resp.json()
70
+ if result.get("status") == "duplicate":
71
+ logger.info("Duplicate trade skipped (VPS): %s on %s", ticker, today)
72
+ else:
73
+ logger.info("Paper trade recorded (VPS): %s %s @ $%.2f", trade_type, ticker, entry_price)
74
+ return
75
+ except Exception as exc:
76
+ logger.warning("VPS record_paper_trade failed, using local fallback: %s", exc)
77
+
78
+ # Local fallback
79
  try:
80
  portfolio = []
81
  if os.path.exists(PORTFOLIO_FILE):
82
  with open(PORTFOLIO_FILE, "r") as f:
83
  portfolio = json.load(f)
84
 
 
85
  for trade in portfolio:
86
  if trade["ticker"] == ticker and trade["date"] == today:
87
  logger.info("Duplicate trade skipped: %s on %s", ticker, today)
 
108
 
109
  def evaluate_portfolio() -> str:
110
  """Read the paper portfolio and calculate live performance."""
111
+
112
+ # Try VPS first
113
+ if VPS_API_URL:
114
+ try:
115
+ resp = requests.get(
116
+ f"{VPS_API_URL}/portfolio/evaluate",
117
+ headers=_vps_headers(),
118
+ timeout=30,
119
+ )
120
+ resp.raise_for_status()
121
+ data = resp.json()
122
+
123
+ if not data.get("trades"):
124
+ return "**Paper Portfolio is empty.** The Agent hasn't tracked any stocks yet."
125
+
126
+ report = "## PrimoGreedy Agent Track Record\n\n"
127
+ report += "| Ticker | Date Called | Entry Price | Current Price | Return | Verdict |\n"
128
+ report += "|--------|-------------|-------------|---------------|--------|--------|\n"
129
+
130
+ for t in data["trades"]:
131
+ if t["gain_pct"] is not None:
132
+ emoji = "+" if t["gain_pct"] > 0 else ""
133
+ report += (
134
+ f"| **{t['ticker']}** | {t['date']} | ${t['entry']:.2f} | "
135
+ f"${t['current']:.2f} | {emoji}{t['gain_pct']:.2f}% | {t['verdict']} |\n"
136
+ )
137
+ else:
138
+ report += (
139
+ f"| **{t['ticker']}** | {t['date']} | ${t['entry']:.2f} | "
140
+ f"Error | N/A | {t['verdict']} |\n"
141
+ )
142
+
143
+ report += f"\n### Agent Performance Summary\n"
144
+ report += f"- **Total Calls:** {data['total_calls']}\n"
145
+ report += f"- **Win Rate:** {data['win_rate']}%\n"
146
+ report += f"- **Average Return per Trade:** {data['avg_return']}%\n"
147
+
148
+ return report
149
+
150
+ except Exception as exc:
151
+ logger.warning("VPS evaluate_portfolio failed, using local fallback: %s", exc)
152
+
153
+ # Local fallback (original behavior)
154
+ return _evaluate_local()
155
+
156
+
157
+ def _evaluate_local() -> str:
158
+ """Evaluate portfolio from local JSON file."""
159
  if not os.path.exists(PORTFOLIO_FILE):
160
  return "**Paper Portfolio is empty.** The Agent hasn't tracked any stocks yet."
161
 
 
172
 
173
  report = "## PrimoGreedy Agent Track Record\n\n"
174
  report += "| Ticker | Date Called | Entry Price | Current Price | Return | Verdict |\n"
175
+ report += "|--------|-------------|-------------|---------------|--------|--------|\n"
176
 
177
  for trade in portfolio:
178
  ticker = trade["ticker"]
src/whale_hunter.py CHANGED
@@ -1,8 +1,11 @@
1
  """Daily automated micro-cap hunter (GitHub Actions cron).
2
 
3
- Pipeline: Scout -> Gatekeeper -> Analyst -> Email
4
 
5
- The Scout now uses a two-pronged discovery approach:
 
 
 
6
  1. yFinance screener for systematic micro-cap filtering
7
  2. Brave Search for trending/momentum signals
8
  3. Quantitative scoring to pick the best candidate
@@ -11,10 +14,14 @@ Both feeds are merged, scored, and only the top candidate proceeds to
11
  the expensive LLM analyst step.
12
  """
13
 
 
14
  import os
15
  import signal
16
  import time
17
- from langgraph.graph import StateGraph, END
 
 
 
18
 
19
  from src.llm import get_llm, invoke_with_fallback
20
  from src.finance_tools import (
@@ -110,16 +117,22 @@ def scout_node(state):
110
  return {"ticker": ticker, "candidates": rest}
111
 
112
 
113
- def gatekeeper_node(state):
114
- """Validate the candidate against hard financial criteria."""
115
  import yfinance as yf
116
 
117
  ticker = state.get("ticker", "NONE")
118
  retries = state.get("retry_count", 0)
119
 
 
 
 
 
 
120
  if ticker == "NONE":
121
  logger.warning("No ticker to evaluate")
122
- return {"is_small_cap": False, "retry_count": retries + 1}
 
123
 
124
  logger.info("Gatekeeper evaluating %s...", ticker)
125
  try:
@@ -135,16 +148,19 @@ def gatekeeper_node(state):
135
 
136
  if price > MAX_PRICE_PER_SHARE:
137
  logger.info("%s rejected — price $%.2f > $%.2f", ticker, price, MAX_PRICE_PER_SHARE)
138
- return {"is_small_cap": False, "retry_count": retries + 1}
 
139
 
140
  if not (MIN_MARKET_CAP < mkt_cap < MAX_MARKET_CAP):
141
  logger.info("%s rejected — cap $%s out of range", ticker, f"{mkt_cap:,.0f}")
142
- return {"is_small_cap": False, "retry_count": retries + 1}
 
143
 
144
  health = check_financial_health(ticker, info)
145
  if health["status"] == "FAIL":
146
  logger.info("%s rejected — %s", ticker, health["reason"])
147
- return {"is_small_cap": False, "retry_count": retries + 1}
 
148
 
149
  sector = health["metrics"].get("sector", "N/A")
150
  logger.info(
@@ -152,16 +168,18 @@ def gatekeeper_node(state):
152
  ticker, price, f"{mkt_cap:,.0f}", sector,
153
  )
154
 
155
- return {
156
  "market_cap": mkt_cap,
157
  "is_small_cap": True,
158
  "company_name": name,
159
  "financial_data": info,
160
  }
 
161
 
162
  except Exception as exc:
163
  logger.error("yFinance error for %s: %s", ticker, exc)
164
- return {"is_small_cap": False, "retry_count": retries + 1}
 
165
 
166
 
167
  def analyst_node(state):
@@ -237,11 +255,20 @@ def analyst_node(state):
237
  """
238
 
239
  try:
240
- verdict = invoke_with_fallback(prompt)
241
- record_paper_trade(ticker, price, verdict, source="Morning Cron")
 
 
 
 
242
  except Exception as exc:
243
- logger.error("LLM analysis failed for %s: %s", ticker, exc)
244
- verdict = f"LLM analysis unavailable: {exc}"
 
 
 
 
 
245
 
246
  return {"final_verdict": verdict}
247
 
@@ -281,35 +308,71 @@ def email_node(state):
281
  return {}
282
 
283
 
284
- # --- GRAPH ---
 
 
285
 
286
- workflow = StateGraph(AgentState)
287
- workflow.add_node("scout", scout_node)
288
- workflow.add_node("gatekeeper", gatekeeper_node)
289
- workflow.add_node("analyst", analyst_node)
290
- workflow.add_node("email", email_node)
291
- workflow.set_entry_point("scout")
292
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- def check_status(state):
295
- if state.get("is_small_cap"):
296
- return "analyst"
297
- if state.get("retry_count", 0) > MAX_RETRIES:
298
- return "email"
299
- return "scout"
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
- workflow.add_edge("scout", "gatekeeper")
303
- workflow.add_conditional_edges(
304
- "gatekeeper",
305
- check_status,
306
- {"analyst": "analyst", "scout": "scout", "email": "email"},
307
- )
308
- workflow.add_edge("analyst", "email")
309
- workflow.add_edge("email", END)
310
- app = workflow.compile()
311
 
312
- # --- EXECUTION ---
 
 
 
 
 
 
 
 
 
313
 
314
  if __name__ == "__main__":
315
  logger.info("Starting Global Micro-Cap Hunter (Screener + Brave Edition)...")
@@ -323,13 +386,11 @@ if __name__ == "__main__":
323
 
324
  regions = ["USA", "UK", "Canada", "Australia"]
325
 
326
- for market in regions:
327
- logger.info("--- Initiating hunt for %s ---", market)
328
- try:
329
- app.invoke({"region": market, "retry_count": 0, "ticker": ""})
330
- logger.info("%s hunt complete.", market)
331
- time.sleep(5)
332
- except Exception as exc:
333
- logger.error("Error in %s: %s", market, exc, exc_info=True)
334
 
335
  logger.info("Global mission complete.")
 
1
  """Daily automated micro-cap hunter (GitHub Actions cron).
2
 
3
+ Pipeline per region: Scout -> Gatekeeper -> Analyst -> Email
4
 
5
+ An outer orchestrator graph dispatches all regions in parallel via the
6
+ LangGraph ``Send`` API, then collects results.
7
+
8
+ The Scout uses a two-pronged discovery approach:
9
  1. yFinance screener for systematic micro-cap filtering
10
  2. Brave Search for trending/momentum signals
11
  3. Quantitative scoring to pick the best candidate
 
14
  the expensive LLM analyst step.
15
  """
16
 
17
+ import operator
18
  import os
19
  import signal
20
  import time
21
+ from typing import Annotated, Literal, TypedDict
22
+ from langgraph.graph import StateGraph, START, END
23
+ from langgraph.checkpoint.memory import InMemorySaver
24
+ from langgraph.types import Command, RetryPolicy, Send
25
 
26
  from src.llm import get_llm, invoke_with_fallback
27
  from src.finance_tools import (
 
117
  return {"ticker": ticker, "candidates": rest}
118
 
119
 
120
+ def gatekeeper_node(state) -> Command[Literal["analyst", "scout", "email"]]:
121
+ """Validate the candidate against hard financial criteria. Routes via Command."""
122
  import yfinance as yf
123
 
124
  ticker = state.get("ticker", "NONE")
125
  retries = state.get("retry_count", 0)
126
 
127
+ def _fail_route(new_retries: int) -> str:
128
+ if new_retries > MAX_RETRIES:
129
+ return "email"
130
+ return "scout"
131
+
132
  if ticker == "NONE":
133
  logger.warning("No ticker to evaluate")
134
+ update = {"is_small_cap": False, "retry_count": retries + 1}
135
+ return Command(update=update, goto=_fail_route(retries + 1))
136
 
137
  logger.info("Gatekeeper evaluating %s...", ticker)
138
  try:
 
148
 
149
  if price > MAX_PRICE_PER_SHARE:
150
  logger.info("%s rejected — price $%.2f > $%.2f", ticker, price, MAX_PRICE_PER_SHARE)
151
+ update = {"is_small_cap": False, "retry_count": retries + 1}
152
+ return Command(update=update, goto=_fail_route(retries + 1))
153
 
154
  if not (MIN_MARKET_CAP < mkt_cap < MAX_MARKET_CAP):
155
  logger.info("%s rejected — cap $%s out of range", ticker, f"{mkt_cap:,.0f}")
156
+ update = {"is_small_cap": False, "retry_count": retries + 1}
157
+ return Command(update=update, goto=_fail_route(retries + 1))
158
 
159
  health = check_financial_health(ticker, info)
160
  if health["status"] == "FAIL":
161
  logger.info("%s rejected — %s", ticker, health["reason"])
162
+ update = {"is_small_cap": False, "retry_count": retries + 1}
163
+ return Command(update=update, goto=_fail_route(retries + 1))
164
 
165
  sector = health["metrics"].get("sector", "N/A")
166
  logger.info(
 
168
  ticker, price, f"{mkt_cap:,.0f}", sector,
169
  )
170
 
171
+ update = {
172
  "market_cap": mkt_cap,
173
  "is_small_cap": True,
174
  "company_name": name,
175
  "financial_data": info,
176
  }
177
+ return Command(update=update, goto="analyst")
178
 
179
  except Exception as exc:
180
  logger.error("yFinance error for %s: %s", ticker, exc)
181
+ update = {"is_small_cap": False, "retry_count": retries + 1}
182
+ return Command(update=update, goto=_fail_route(retries + 1))
183
 
184
 
185
  def analyst_node(state):
 
255
  """
256
 
257
  try:
258
+ from src.models.verdict import InvestmentVerdict
259
+ structured_llm = get_llm().with_structured_output(InvestmentVerdict)
260
+ result = structured_llm.invoke(prompt)
261
+ verdict = result.to_report()
262
+ record_paper_trade(ticker, price, verdict, source="Morning Cron",
263
+ structured_verdict=result.verdict)
264
  except Exception as exc:
265
+ logger.warning("Structured output failed for %s, falling back to plain LLM: %s", ticker, exc)
266
+ try:
267
+ verdict = invoke_with_fallback(prompt)
268
+ record_paper_trade(ticker, price, verdict, source="Morning Cron")
269
+ except Exception as exc2:
270
+ logger.error("LLM analysis failed for %s: %s", ticker, exc2)
271
+ verdict = f"LLM analysis unavailable: {exc2}"
272
 
273
  return {"final_verdict": verdict}
274
 
 
308
  return {}
309
 
310
 
311
+ # ---------------------------------------------------------------------------
312
+ # Per-region subgraph (scout -> gatekeeper -> analyst -> email)
313
+ # ---------------------------------------------------------------------------
314
 
315
+ _api_retry = RetryPolicy(max_attempts=3, initial_interval=2.0)
 
 
 
 
 
316
 
317
+ _region_workflow = StateGraph(AgentState)
318
+ _region_workflow.add_node("scout", scout_node, retry=_api_retry)
319
+ _region_workflow.add_node("gatekeeper", gatekeeper_node, retry=_api_retry)
320
+ _region_workflow.add_node("analyst", analyst_node, retry=_api_retry)
321
+ _region_workflow.add_node("email", email_node, retry=_api_retry)
322
+ _region_workflow.add_edge(START, "scout")
323
+ _region_workflow.add_edge("scout", "gatekeeper")
324
+ _region_workflow.add_edge("analyst", "email")
325
+ _region_workflow.add_edge("email", END)
326
+ _region_app = _region_workflow.compile(checkpointer=InMemorySaver())
327
 
 
 
 
 
 
 
328
 
329
+ # ---------------------------------------------------------------------------
330
+ # Orchestrator graph (parallel fan-out via Send API)
331
+ # ---------------------------------------------------------------------------
332
+
333
+ class GlobalHunterState(TypedDict, total=False):
334
+ regions: list[str]
335
+ region_results: Annotated[list, operator.add]
336
+
337
+
338
+ def dispatch_regions(state: GlobalHunterState):
339
+ """Fan-out: emit one Send per region so they run in parallel."""
340
+ return [
341
+ Send("hunt_region", {"region": r})
342
+ for r in state["regions"]
343
+ ]
344
+
345
+
346
+ def hunt_region(state) -> dict:
347
+ """Invoke the full per-region pipeline and report back."""
348
+ region = state.get("region", "USA")
349
+ logger.info("--- Initiating hunt for %s ---", region)
350
+ try:
351
+ config = {
352
+ "configurable": {"thread_id": f"hunt-{region.lower()}"},
353
+ "recursion_limit": 30,
354
+ }
355
+ _region_app.invoke(
356
+ {"region": region, "retry_count": 0, "ticker": ""},
357
+ config,
358
+ )
359
+ logger.info("%s hunt complete.", region)
360
+ return {"region_results": [{"region": region, "success": True}]}
361
+ except Exception as exc:
362
+ logger.error("Error in %s: %s", region, exc, exc_info=True)
363
+ return {"region_results": [{"region": region, "success": False, "error": str(exc)}]}
364
 
 
 
 
 
 
 
 
 
 
365
 
366
+ _orchestrator = StateGraph(GlobalHunterState)
367
+ _orchestrator.add_node("hunt_region", hunt_region, retry=_api_retry)
368
+ _orchestrator.add_conditional_edges(START, dispatch_regions, ["hunt_region"])
369
+ _orchestrator.add_edge("hunt_region", END)
370
+ app = _orchestrator.compile(checkpointer=InMemorySaver())
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Execution
375
+ # ---------------------------------------------------------------------------
376
 
377
  if __name__ == "__main__":
378
  logger.info("Starting Global Micro-Cap Hunter (Screener + Brave Edition)...")
 
386
 
387
  regions = ["USA", "UK", "Canada", "Australia"]
388
 
389
+ config = {"configurable": {"thread_id": "global-hunt"}, "recursion_limit": 30}
390
+ result = app.invoke({"regions": regions}, config)
391
+
392
+ for entry in result.get("region_results", []):
393
+ status = "OK" if entry.get("success") else f"FAILED: {entry.get('error', 'unknown')}"
394
+ logger.info("Region %s: %s", entry.get("region"), status)
 
 
395
 
396
  logger.info("Global mission complete.")
src/workflows/workflow.py CHANGED
@@ -1,5 +1,7 @@
1
  from typing import Dict, Any, Optional
2
- from langgraph.graph import StateGraph, END
 
 
3
  from .state import AgentState, create_initial_state
4
  from ..agents.data_collection_agent import data_collection_agent_node
5
  from ..agents.technical_analysis_agent import technical_analysis_agent_node
@@ -7,72 +9,63 @@ from ..agents.news_intelligence_agent import news_intelligence_agent_node
7
  from ..agents.portfolio_manager_agent import portfolio_manager_agent_node
8
 
9
 
10
- def debug_state(state: AgentState, agent_name: str) -> AgentState:
11
- """Debug function to log state after each agent."""
12
  print(f"\n{agent_name} Agent Complete:")
13
-
14
- # Basic info
15
- analysis_date = state.get('analysis_date', 'N/A')
16
- symbol = state['symbols'][0] if state.get('symbols') else 'N/A'
17
- print(f"Date: {analysis_date} | Symbol: {symbol}")
18
-
19
- # Data Collection Results
20
- data_results = state.get('data_collection_results')
21
- if data_results and agent_name == "Data Collection":
22
- market_data = data_results.get('market_data', {})
23
- current_price = market_data.get('current_price', 'N/A')
24
- print(f"Current Price: ${current_price}")
25
-
26
- # Technical Analysis Results
27
- tech_results = state.get('technical_analysis_results')
28
- if tech_results and agent_name == "Technical Analysis":
29
- success = tech_results.get('success', False)
30
- print(f"Technical Success: {success}")
31
-
32
- # News Intelligence Results
33
- news_results = state.get('news_intelligence_results')
34
- if news_results and agent_name == "News Intelligence":
35
- success = news_results.get('success', False)
36
- print(f"News Success: {success}")
37
-
38
- # Portfolio Manager Results
39
- portfolio_results = state.get('portfolio_manager_results')
40
- if portfolio_results and agent_name == "Portfolio Manager":
41
- symbol_data = portfolio_results.get(symbol, {})
42
- if symbol_data and symbol_data.get('success'):
43
- signal = symbol_data.get('trading_signal', 'N/A')
44
- confidence = symbol_data.get('confidence_level', 'N/A')
45
- print(f"Signal: {signal} | Confidence: {confidence}")
46
-
47
- # Error state
48
- if state.get('error'):
49
- print(f"Error: {state.get('error')}")
50
-
51
- return state
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- async def debug_data_collection_node(state: AgentState) -> AgentState:
 
 
 
 
55
  """Data collection node with debug output."""
56
- result = await data_collection_agent_node(state)
57
- return debug_state(result, "Data Collection")
 
58
 
59
 
60
- async def debug_technical_analysis_node(state: AgentState) -> AgentState:
61
- """Technical analysis node with debug output."""
62
- result = await technical_analysis_agent_node(state)
63
- return debug_state(result, "Technical Analysis")
 
64
 
65
 
66
- async def debug_news_intelligence_node(state: AgentState) -> AgentState:
67
  """News intelligence node with debug output."""
68
- result = await news_intelligence_agent_node(state)
69
- return debug_state(result, "News Intelligence")
 
70
 
71
 
72
- async def debug_portfolio_manager_node(state: AgentState) -> AgentState:
73
  """Portfolio manager node with debug output."""
74
- result = await portfolio_manager_agent_node(state)
75
- return debug_state(result, "Portfolio Manager")
 
76
 
77
 
78
  def create_workflow() -> StateGraph:
@@ -83,16 +76,17 @@ def create_workflow() -> StateGraph:
83
  StateGraph: Configured workflow graph
84
  """
85
  # Initialize workflow
 
 
86
  workflow = StateGraph(AgentState)
87
 
88
- # Add nodes with debug output
89
- workflow.add_node("data_collection", debug_data_collection_node)
90
- workflow.add_node("technical_analysis", debug_technical_analysis_node)
91
- workflow.add_node("news_intelligence", debug_news_intelligence_node)
92
- workflow.add_node("portfolio_manager", debug_portfolio_manager_node)
93
 
94
  # Define linear flow
95
- workflow.set_entry_point("data_collection")
96
  workflow.add_edge("data_collection", "technical_analysis")
97
  workflow.add_edge("technical_analysis", "news_intelligence")
98
  workflow.add_edge("news_intelligence", "portfolio_manager")
@@ -116,13 +110,14 @@ async def run_analysis(symbols: list[str], session_id: str = "default", analysis
116
  try:
117
  # Create workflow
118
  workflow = create_workflow()
119
- app = workflow.compile()
120
 
121
  # Initialize state with analysis date
122
  initial_state = create_initial_state(session_id, symbols, analysis_date)
123
 
124
  # Run workflow
125
- result = await app.ainvoke(initial_state)
 
126
 
127
  # Extract results
128
  return {
 
1
  from typing import Dict, Any, Optional
2
+ from langgraph.graph import StateGraph, START, END
3
+ from langgraph.checkpoint.memory import InMemorySaver
4
+ from langgraph.types import RetryPolicy
5
  from .state import AgentState, create_initial_state
6
  from ..agents.data_collection_agent import data_collection_agent_node
7
  from ..agents.technical_analysis_agent import technical_analysis_agent_node
 
9
  from ..agents.portfolio_manager_agent import portfolio_manager_agent_node
10
 
11
 
12
+ def _log_partial(updates: dict, agent_name: str) -> None:
13
+ """Log interesting fields from a partial-state update dict."""
14
  print(f"\n{agent_name} Agent Complete:")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ if agent_name == "Data Collection":
17
+ data_results = updates.get('data_collection_results')
18
+ if data_results:
19
+ market_data = data_results.get('market_data', {})
20
+ print(f"Current Price: ${market_data.get('current_price', 'N/A')}")
21
+
22
+ elif agent_name == "Technical Analysis":
23
+ tech_results = updates.get('technical_analysis_results')
24
+ if tech_results:
25
+ print(f"Technical Success: {tech_results.get('success', False)}")
26
+
27
+ elif agent_name == "News Intelligence":
28
+ news_results = updates.get('news_intelligence_results')
29
+ if news_results:
30
+ print(f"News Success: {news_results.get('success', False)}")
31
+
32
+ elif agent_name == "Portfolio Manager":
33
+ portfolio_results = updates.get('portfolio_manager_results', {})
34
+ for sym, sym_data in portfolio_results.items():
35
+ if sym_data and sym_data.get('success'):
36
+ print(f"Signal: {sym_data.get('trading_signal', 'N/A')} | "
37
+ f"Confidence: {sym_data.get('confidence_level', 'N/A')}")
38
 
39
+ if updates.get('error'):
40
+ print(f"Error: {updates['error']}")
41
+
42
+
43
+ async def debug_data_collection_node(state: AgentState) -> dict:
44
  """Data collection node with debug output."""
45
+ updates = await data_collection_agent_node(state)
46
+ _log_partial(updates, "Data Collection")
47
+ return updates
48
 
49
 
50
+ async def debug_technical_analysis_node(state: AgentState) -> dict:
51
+ """Technical analysis node with debug output."""
52
+ updates = await technical_analysis_agent_node(state)
53
+ _log_partial(updates, "Technical Analysis")
54
+ return updates
55
 
56
 
57
+ async def debug_news_intelligence_node(state: AgentState) -> dict:
58
  """News intelligence node with debug output."""
59
+ updates = await news_intelligence_agent_node(state)
60
+ _log_partial(updates, "News Intelligence")
61
+ return updates
62
 
63
 
64
+ async def debug_portfolio_manager_node(state: AgentState) -> dict:
65
  """Portfolio manager node with debug output."""
66
+ updates = await portfolio_manager_agent_node(state)
67
+ _log_partial(updates, "Portfolio Manager")
68
+ return updates
69
 
70
 
71
  def create_workflow() -> StateGraph:
 
76
  StateGraph: Configured workflow graph
77
  """
78
  # Initialize workflow
79
+ _api_retry = RetryPolicy(max_attempts=3, initial_interval=2.0)
80
+
81
  workflow = StateGraph(AgentState)
82
 
83
+ workflow.add_node("data_collection", debug_data_collection_node, retry=_api_retry)
84
+ workflow.add_node("technical_analysis", debug_technical_analysis_node, retry=_api_retry)
85
+ workflow.add_node("news_intelligence", debug_news_intelligence_node, retry=_api_retry)
86
+ workflow.add_node("portfolio_manager", debug_portfolio_manager_node, retry=_api_retry)
 
87
 
88
  # Define linear flow
89
+ workflow.add_edge(START, "data_collection")
90
  workflow.add_edge("data_collection", "technical_analysis")
91
  workflow.add_edge("technical_analysis", "news_intelligence")
92
  workflow.add_edge("news_intelligence", "portfolio_manager")
 
110
  try:
111
  # Create workflow
112
  workflow = create_workflow()
113
+ app = workflow.compile(checkpointer=InMemorySaver())
114
 
115
  # Initialize state with analysis date
116
  initial_state = create_initial_state(session_id, symbols, analysis_date)
117
 
118
  # Run workflow
119
+ config = {"configurable": {"thread_id": session_id}, "recursion_limit": 30}
120
+ result = await app.ainvoke(initial_state, config)
121
 
122
  # Extract results
123
  return {
vps/api.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PrimoGreedy Data API — FastAPI wrapper around DuckDB.
2
+
3
+ Serves seen tickers, paper portfolio, and agent run logs over HTTP.
4
+ Secured with X-API-Key header. Runs behind Tailscale (private network).
5
+
6
+ Usage:
7
+ uvicorn api:app --host 0.0.0.0 --port 8000
8
+ """
9
+
10
+ import os
11
+ import time
12
+ from contextlib import asynccontextmanager
13
+ from datetime import datetime, timezone
14
+ from typing import Optional
15
+
16
+ import duckdb
17
+ import yfinance as yf
18
+ from dotenv import load_dotenv
19
+ from fastapi import FastAPI, Header, HTTPException, Query
20
+ from pydantic import BaseModel
21
+
22
+ load_dotenv()
23
+
24
+ DB_PATH = os.getenv("DUCKDB_PATH", "/home/ubuntu/primogreedy/data.duckdb")
25
+ API_KEY = os.getenv("VPS_API_KEY", "5ZhJ_T2gTTQp-LAJKdWMvKJgQSqFU8MSfFDAi04tNr0")
26
+ MEMORY_TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Database helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def get_db() -> duckdb.DuckDBPyConnection:
34
+ """Return a fresh connection (DuckDB is single-writer, but reads are fine)."""
35
+ return duckdb.connect(DB_PATH)
36
+
37
+
38
+ def init_db():
39
+ """Create tables if they don't exist."""
40
+ con = get_db()
41
+ con.execute("""
42
+ CREATE SEQUENCE IF NOT EXISTS portfolio_seq START 1;
43
+
44
+ CREATE TABLE IF NOT EXISTS seen_tickers (
45
+ ticker VARCHAR NOT NULL PRIMARY KEY,
46
+ region VARCHAR DEFAULT 'USA',
47
+ seen_at TIMESTAMP DEFAULT current_timestamp
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS paper_portfolio (
51
+ id INTEGER PRIMARY KEY DEFAULT nextval('portfolio_seq'),
52
+ ticker VARCHAR NOT NULL,
53
+ entry_price DOUBLE NOT NULL,
54
+ date DATE NOT NULL,
55
+ verdict VARCHAR NOT NULL,
56
+ source VARCHAR DEFAULT 'unknown',
57
+ created_at TIMESTAMP DEFAULT current_timestamp,
58
+ UNIQUE (ticker, date)
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS agent_runs (
62
+ id VARCHAR PRIMARY KEY,
63
+ ticker VARCHAR NOT NULL,
64
+ timestamp TIMESTAMP DEFAULT current_timestamp,
65
+ status VARCHAR NOT NULL,
66
+ model VARCHAR,
67
+ latency_ms INTEGER,
68
+ region VARCHAR DEFAULT 'USA'
69
+ );
70
+ """)
71
+ con.close()
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # App lifecycle
76
+ # ---------------------------------------------------------------------------
77
+
78
+ @asynccontextmanager
79
+ async def lifespan(app: FastAPI):
80
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
81
+ init_db()
82
+ yield
83
+
84
+
85
+ app = FastAPI(
86
+ title="PrimoGreedy Data API",
87
+ version="1.0.0",
88
+ lifespan=lifespan,
89
+ )
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Auth
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def verify_key(x_api_key: str = Header(...)):
97
+ if x_api_key != API_KEY:
98
+ raise HTTPException(status_code=401, detail="Invalid API key")
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Pydantic models
103
+ # ---------------------------------------------------------------------------
104
+
105
+ class SeenTickerIn(BaseModel):
106
+ ticker: str
107
+ region: str = "USA"
108
+
109
+
110
+ class TradeIn(BaseModel):
111
+ ticker: str
112
+ entry_price: float
113
+ date: str # YYYY-MM-DD
114
+ verdict: str
115
+ source: str = "unknown"
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Health
120
+ # ---------------------------------------------------------------------------
121
+
122
+ @app.get("/health")
123
+ def health():
124
+ return {"status": "ok", "db": DB_PATH}
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Seen Tickers
129
+ # ---------------------------------------------------------------------------
130
+
131
+ @app.get("/seen-tickers")
132
+ def get_seen_tickers(x_api_key: str = Header(...)):
133
+ verify_key(x_api_key)
134
+ con = get_db()
135
+ cutoff = time.time() - MEMORY_TTL_SECONDS
136
+ cutoff_ts = datetime.fromtimestamp(cutoff, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
137
+
138
+ rows = con.execute(
139
+ "SELECT ticker, epoch(seen_at) as ts FROM seen_tickers WHERE seen_at >= ?",
140
+ [cutoff_ts],
141
+ ).fetchall()
142
+ con.close()
143
+
144
+ return {row[0]: row[1] for row in rows}
145
+
146
+
147
+ @app.post("/seen-tickers")
148
+ def mark_seen(body: SeenTickerIn, x_api_key: str = Header(...)):
149
+ verify_key(x_api_key)
150
+ con = get_db()
151
+ con.execute(
152
+ """INSERT INTO seen_tickers (ticker, region, seen_at)
153
+ VALUES (?, ?, now())
154
+ ON CONFLICT (ticker) DO UPDATE SET seen_at = now(), region = ?""",
155
+ [body.ticker, body.region, body.region],
156
+ )
157
+ con.close()
158
+ return {"status": "ok", "ticker": body.ticker}
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Portfolio
163
+ # ---------------------------------------------------------------------------
164
+
165
+ @app.get("/portfolio")
166
+ def get_portfolio(x_api_key: str = Header(...)):
167
+ verify_key(x_api_key)
168
+ con = get_db()
169
+ rows = con.execute(
170
+ "SELECT ticker, entry_price, date, verdict, source FROM paper_portfolio ORDER BY date DESC"
171
+ ).fetchall()
172
+ con.close()
173
+
174
+ return [
175
+ {
176
+ "ticker": r[0],
177
+ "entry_price": r[1],
178
+ "date": str(r[2]),
179
+ "verdict": r[3],
180
+ "source": r[4],
181
+ }
182
+ for r in rows
183
+ ]
184
+
185
+
186
+ @app.post("/portfolio")
187
+ def record_trade(body: TradeIn, x_api_key: str = Header(...)):
188
+ verify_key(x_api_key)
189
+ con = get_db()
190
+ try:
191
+ con.execute(
192
+ """INSERT INTO paper_portfolio (ticker, entry_price, date, verdict, source)
193
+ VALUES (?, ?, ?, ?, ?)""",
194
+ [body.ticker, body.entry_price, body.date, body.verdict, body.source],
195
+ )
196
+ except duckdb.ConstraintException:
197
+ con.close()
198
+ return {"status": "duplicate", "ticker": body.ticker, "date": body.date}
199
+ con.close()
200
+ return {"status": "ok", "ticker": body.ticker}
201
+
202
+
203
+ @app.get("/portfolio/evaluate")
204
+ def evaluate_portfolio(x_api_key: str = Header(...)):
205
+ """Fetch live prices and compute P&L for all portfolio entries."""
206
+ verify_key(x_api_key)
207
+ con = get_db()
208
+ rows = con.execute(
209
+ "SELECT ticker, entry_price, date, verdict, source FROM paper_portfolio ORDER BY date"
210
+ ).fetchall()
211
+ con.close()
212
+
213
+ if not rows:
214
+ return {"report": "Paper Portfolio is empty.", "trades": []}
215
+
216
+ trades = []
217
+ total_roi = 0.0
218
+ winners = 0
219
+ valid = 0
220
+
221
+ for r in rows:
222
+ ticker, entry, date, verdict, source = r[0], r[1], str(r[2]), r[3], r[4]
223
+ try:
224
+ info = yf.Ticker(ticker).info
225
+ price = info.get("currentPrice", 0) or info.get("regularMarketPrice", 0) or 0
226
+ currency = info.get("currency", "USD")
227
+
228
+ # Pence → Pounds
229
+ if ticker.endswith(".L") or currency in ("GBp", "GBX"):
230
+ price = price / 100
231
+
232
+ if price > 0 and entry > 0:
233
+ gain = ((price - entry) / entry) * 100
234
+ if gain > 0:
235
+ winners += 1
236
+ total_roi += gain
237
+ valid += 1
238
+ trades.append({
239
+ "ticker": ticker, "date": date, "entry": entry,
240
+ "current": round(price, 2), "gain_pct": round(gain, 2),
241
+ "verdict": verdict,
242
+ })
243
+ else:
244
+ trades.append({
245
+ "ticker": ticker, "date": date, "entry": entry,
246
+ "current": None, "gain_pct": None, "verdict": verdict,
247
+ })
248
+ except Exception:
249
+ trades.append({
250
+ "ticker": ticker, "date": date, "entry": entry,
251
+ "current": None, "gain_pct": None, "verdict": verdict,
252
+ })
253
+
254
+ avg_roi = total_roi / valid if valid else 0
255
+ win_rate = (winners / valid * 100) if valid else 0
256
+
257
+ return {
258
+ "total_calls": len(rows),
259
+ "win_rate": round(win_rate, 1),
260
+ "avg_return": round(avg_roi, 2),
261
+ "trades": trades,
262
+ }
vps/deploy.sh ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Deploy PrimoGreedy Data API to VPS
3
+ # Usage: bash vps/deploy.sh
4
+
5
+ set -e
6
+
7
+ VPS="ubuntu@100.110.105.118"
8
+ REMOTE_DIR="/home/ubuntu/primogreedy"
9
+
10
+ echo "=== PrimoGreedy VPS Deploy ==="
11
+
12
+ echo "1. Creating remote directory..."
13
+ ssh $VPS "mkdir -p $REMOTE_DIR"
14
+
15
+ echo "2. Copying files..."
16
+ scp vps/api.py vps/requirements.txt vps/.env vps/schema.sql $VPS:$REMOTE_DIR/
17
+
18
+ echo "3. Installing Python dependencies..."
19
+ ssh $VPS "cd $REMOTE_DIR && pip3 install --break-system-packages -r requirements.txt"
20
+
21
+ echo "4. Creating systemd service..."
22
+ ssh $VPS "sudo tee /etc/systemd/system/primogreedy-api.service > /dev/null << 'EOF'
23
+ [Unit]
24
+ Description=PrimoGreedy Data API
25
+ After=network.target
26
+
27
+ [Service]
28
+ Type=simple
29
+ User=ubuntu
30
+ WorkingDirectory=/home/ubuntu/primogreedy
31
+ ExecStart=/usr/bin/python3 -m uvicorn api:app --host 0.0.0.0 --port 8080
32
+ Restart=always
33
+ RestartSec=5
34
+ EnvironmentFile=/home/ubuntu/primogreedy/.env
35
+
36
+ [Install]
37
+ WantedBy=multi-user.target
38
+ EOF"
39
+
40
+ echo "5. Starting service..."
41
+ ssh $VPS "sudo systemctl daemon-reload && sudo systemctl enable primogreedy-api && sudo systemctl restart primogreedy-api"
42
+
43
+ echo "6. Checking status..."
44
+ sleep 2
45
+ ssh $VPS "sudo systemctl status primogreedy-api --no-pager -l" || true
46
+
47
+ echo ""
48
+ echo "=== Deploy complete ==="
49
+ echo "Health check: curl http://100.110.105.118:8000/health"
vps/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn>=0.34.0
3
+ duckdb>=1.2.0
4
+ yfinance>=0.2.50
5
+ python-dotenv>=1.0.0
vps/schema.sql ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- DuckDB schema for PrimoGreedy data layer
2
+ -- Run: duckdb data.duckdb < schema.sql
3
+
4
+ -- Seen tickers — replaces seen_tickers.json
5
+ CREATE TABLE IF NOT EXISTS seen_tickers (
6
+ ticker VARCHAR NOT NULL,
7
+ region VARCHAR DEFAULT 'USA',
8
+ seen_at TIMESTAMP DEFAULT current_timestamp,
9
+ PRIMARY KEY (ticker)
10
+ );
11
+
12
+ -- Paper portfolio — replaces paper_portfolio.json
13
+ CREATE TABLE IF NOT EXISTS paper_portfolio (
14
+ id INTEGER PRIMARY KEY DEFAULT nextval('portfolio_seq'),
15
+ ticker VARCHAR NOT NULL,
16
+ entry_price DOUBLE NOT NULL,
17
+ date DATE NOT NULL,
18
+ verdict VARCHAR NOT NULL,
19
+ source VARCHAR DEFAULT 'unknown',
20
+ created_at TIMESTAMP DEFAULT current_timestamp,
21
+ UNIQUE (ticker, date) -- prevent duplicate same-day entries
22
+ );
23
+
24
+ CREATE SEQUENCE IF NOT EXISTS portfolio_seq START 1;
25
+
26
+ -- Agent run log — operational metrics for LangSmith correlation
27
+ CREATE TABLE IF NOT EXISTS agent_runs (
28
+ id VARCHAR PRIMARY KEY,
29
+ ticker VARCHAR NOT NULL,
30
+ timestamp TIMESTAMP DEFAULT current_timestamp,
31
+ status VARCHAR NOT NULL, -- PASS / FAIL
32
+ model VARCHAR,
33
+ latency_ms INTEGER,
34
+ region VARCHAR DEFAULT 'USA'
35
+ );