Spaces:
Sleeping
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 +2 -0
- app.py +2 -2
- requirements.txt +6 -8
- src/agent.py +51 -39
- src/agents/data_collection_agent.py +10 -22
- src/agents/news_intelligence_agent.py +10 -18
- src/agents/portfolio_manager_agent.py +9 -25
- src/agents/technical_analysis_agent.py +10 -22
- src/core/memory.py +54 -8
- src/core/state.py +4 -3
- src/models/__init__.py +0 -0
- src/models/verdict.py +34 -0
- src/portfolio_tracker.py +112 -14
- src/whale_hunter.py +107 -46
- src/workflows/workflow.py +58 -63
- vps/api.py +262 -0
- vps/deploy.sh +49 -0
- vps/requirements.txt +5 -0
- vps/schema.sql +35 -0
|
@@ -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
|
|
@@ -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,
|
|
@@ -17,10 +17,10 @@ httpx-sse==0.4.3
|
|
| 17 |
idna==3.11
|
| 18 |
jsonpatch==1.33
|
| 19 |
jsonpointer==3.0.0
|
| 20 |
-
langchain
|
| 21 |
-
langchain-community=
|
| 22 |
-
langchain-core=
|
| 23 |
-
langchain-text-splitters=
|
| 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 |
-
|
| 50 |
-
|
| 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
|
|
@@ -10,7 +10,10 @@ import random
|
|
| 10 |
import time
|
| 11 |
import yfinance as yf
|
| 12 |
import matplotlib.pyplot as plt
|
| 13 |
-
from
|
|
|
|
|
|
|
|
|
|
| 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
|
| 132 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
ticker = state.get("ticker", "NONE")
|
| 134 |
retries = state.get("retry_count", 0)
|
| 135 |
|
| 136 |
if ticker == "NONE":
|
| 137 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
except Exception as exc:
|
| 305 |
-
logger.
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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())
|
|
@@ -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) ->
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
if not result['success']:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
return
|
| 75 |
|
| 76 |
except Exception as e:
|
| 77 |
print(f"Data collection node error: {e}")
|
| 78 |
-
|
| 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"}
|
|
|
|
|
|
|
@@ -331,33 +331,25 @@ async def extract_nlp_features(
|
|
| 331 |
return None
|
| 332 |
|
| 333 |
|
| 334 |
-
async def news_intelligence_agent_node(state: 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 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
if not result['success']:
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
return
|
| 358 |
|
| 359 |
except Exception as e:
|
| 360 |
print(f"News intelligence node error: {e}")
|
| 361 |
-
|
| 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"}
|
|
|
|
|
|
|
@@ -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) ->
|
| 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 |
-
|
| 406 |
-
|
| 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 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
return state
|
| 441 |
|
| 442 |
except Exception as e:
|
| 443 |
print(f"Error in portfolio_manager_agent_node: {e}")
|
| 444 |
-
|
| 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}"}
|
|
|
|
@@ -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) ->
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
if not result['success']:
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
return
|
| 137 |
|
| 138 |
except Exception as e:
|
| 139 |
print(f"Technical analysis node error: {e}")
|
| 140 |
-
|
| 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"}
|
|
|
|
|
|
|
@@ -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:
|
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
|
|
|
|
| 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:
|
|
|
|
| 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]
|
|
File without changes
|
|
@@ -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 |
+
)
|
|
@@ -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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 += "|--------|-------------|-------------|---------------|--------|--------
|
| 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"]
|
|
@@ -1,8 +1,11 @@
|
|
| 1 |
"""Daily automated micro-cap hunter (GitHub Actions cron).
|
| 2 |
|
| 3 |
-
Pipeline: Scout -> Gatekeeper -> Analyst -> Email
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 143 |
|
| 144 |
health = check_financial_health(ticker, info)
|
| 145 |
if health["status"] == "FAIL":
|
| 146 |
logger.info("%s rejected — %s", ticker, health["reason"])
|
| 147 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
def analyst_node(state):
|
|
@@ -237,11 +255,20 @@ def analyst_node(state):
|
|
| 237 |
"""
|
| 238 |
|
| 239 |
try:
|
| 240 |
-
verdict
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
except Exception as exc:
|
| 243 |
-
logger.
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
return {"final_verdict": verdict}
|
| 247 |
|
|
@@ -281,35 +308,71 @@ def email_node(state):
|
|
| 281 |
return {}
|
| 282 |
|
| 283 |
|
| 284 |
-
# ---
|
|
|
|
|
|
|
| 285 |
|
| 286 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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.")
|
|
@@ -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
|
| 11 |
-
"""
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"""Data collection node with debug output."""
|
| 56 |
-
|
| 57 |
-
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
-
async def debug_technical_analysis_node(state: AgentState) ->
|
| 61 |
-
"""Technical analysis node with debug output."""
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
-
async def debug_news_intelligence_node(state: AgentState) ->
|
| 67 |
"""News intelligence node with debug output."""
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
-
async def debug_portfolio_manager_node(state: AgentState) ->
|
| 73 |
"""Portfolio manager node with debug output."""
|
| 74 |
-
|
| 75 |
-
|
|
|
|
| 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 |
-
|
| 89 |
-
workflow.add_node("
|
| 90 |
-
workflow.add_node("
|
| 91 |
-
workflow.add_node("
|
| 92 |
-
workflow.add_node("portfolio_manager", debug_portfolio_manager_node)
|
| 93 |
|
| 94 |
# Define linear flow
|
| 95 |
-
workflow.
|
| 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 |
-
|
|
|
|
| 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 {
|
|
@@ -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 |
+
}
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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 |
+
);
|