Skip to content

Commit

Permalink
Add Ben Graham agent
Browse files Browse the repository at this point in the history
  • Loading branch information
virattt committed Feb 15, 2025
1 parent 3056a24 commit 75c1b8d
Show file tree
Hide file tree
Showing 4 changed files with 406 additions and 26 deletions.
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ This is a proof of concept for an AI-powered hedge fund. The goal of this proje

This system employs several agents working together:

1. Bill Ackman Agent - Uses Bill Ackman's principles to generate trading signals
2. Warren Buffett Agent - Uses Warren Buffett's principles to generate trading signals
3. Valuation Agent - Calculates the intrinsic value of a stock and generates trading signals
4. Sentiment Agent - Analyzes market sentiment and generates trading signals
5. Fundamentals Agent - Analyzes fundamental data and generates trading signals
6. Technicals Agent - Analyzes technical indicators and generates trading signals
7. Risk Manager - Calculates risk metrics and sets position limits
8. Portfolio Manager - Makes final trading decisions and generates orders
1. Ben Graham Agent - Uses Ben Graham's principles to generate trading signals
2. Bill Ackman Agent - Uses Bill Ackman's principles to generate trading signals
3. Warren Buffett Agent - Uses Warren Buffett's principles to generate trading signals
4. Valuation Agent - Calculates the intrinsic value of a stock and generates trading signals
5. Sentiment Agent - Analyzes market sentiment and generates trading signals
6. Fundamentals Agent - Analyzes fundamental data and generates trading signals
7. Technicals Agent - Analyzes technical indicators and generates trading signals
8. Risk Manager - Calculates risk metrics and sets position limits
9. Portfolio Manager - Makes final trading decisions and generates orders

<img width="1117" alt="Screenshot 2025-02-09 at 11 26 14 AM" src="https://github.com/user-attachments/assets/16509cc2-4b64-4c67-8de6-00d224893d58" />

Expand Down
336 changes: 336 additions & 0 deletions src/agents/ben_graham.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
from langchain_openai import ChatOpenAI
from graph.state import AgentState, show_agent_reasoning
from tools.api import get_financial_metrics, get_market_cap, search_line_items
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.messages import HumanMessage
from pydantic import BaseModel
import json
from typing_extensions import Literal
from utils.progress import progress
from utils.llm import call_llm
import math


class BenGrahamSignal(BaseModel):
signal: Literal["bullish", "bearish", "neutral"]
confidence: float
reasoning: str


def ben_graham_agent(state: AgentState):
"""
Analyzes stocks using Benjamin Graham's classic value-investing principles:
1. Earnings stability over multiple years.
2. Solid financial strength (low debt, adequate liquidity).
3. Discount to intrinsic value (e.g. Graham Number or net-net).
4. Adequate margin of safety.
"""
data = state["data"]
end_date = data["end_date"]
tickers = data["tickers"]

analysis_data = {}
graham_analysis = {}

for ticker in tickers:
progress.update_status("ben_graham_agent", ticker, "Fetching financial metrics")
metrics = get_financial_metrics(ticker, end_date, period="annual", limit=5)

progress.update_status("ben_graham_agent", ticker, "Gathering financial line items")
financial_line_items = search_line_items(ticker, ["earnings_per_share", "revenue", "net_income", "book_value_per_share", "total_assets", "total_liabilities", "current_assets", "current_liabilities", "dividends_and_other_cash_distributions", "outstanding_shares"], end_date, period="annual", limit=5)

progress.update_status("ben_graham_agent", ticker, "Getting market cap")
market_cap = get_market_cap(ticker, end_date)

# Perform sub-analyses
progress.update_status("ben_graham_agent", ticker, "Analyzing earnings stability")
earnings_analysis = analyze_earnings_stability(metrics, financial_line_items)

progress.update_status("ben_graham_agent", ticker, "Analyzing financial strength")
strength_analysis = analyze_financial_strength(metrics, financial_line_items)

progress.update_status("ben_graham_agent", ticker, "Analyzing Graham valuation")
valuation_analysis = analyze_valuation_graham(metrics, financial_line_items, market_cap)

# Aggregate scoring
total_score = earnings_analysis["score"] + strength_analysis["score"] + valuation_analysis["score"]
max_possible_score = 15 # total possible from the three analysis functions

# Map total_score to signal
if total_score >= 0.7 * max_possible_score:
signal = "bullish"
elif total_score <= 0.3 * max_possible_score:
signal = "bearish"
else:
signal = "neutral"

analysis_data[ticker] = {"signal": signal, "score": total_score, "max_score": max_possible_score, "earnings_analysis": earnings_analysis, "strength_analysis": strength_analysis, "valuation_analysis": valuation_analysis}

progress.update_status("ben_graham_agent", ticker, "Generating Graham-style analysis")
graham_output = generate_graham_output(
ticker=ticker,
analysis_data=analysis_data,
model_name=state["metadata"]["model_name"],
model_provider=state["metadata"]["model_provider"],
)

graham_analysis[ticker] = {"signal": graham_output.signal, "confidence": graham_output.confidence, "reasoning": graham_output.reasoning}

progress.update_status("ben_graham_agent", ticker, "Done")

# Wrap results in a single message for the chain
message = HumanMessage(content=json.dumps(graham_analysis), name="ben_graham_agent")

# Optionally display reasoning
if state["metadata"]["show_reasoning"]:
show_agent_reasoning(graham_analysis, "Ben Graham Agent")

# Store signals in the overall state
state["data"]["analyst_signals"]["ben_graham_agent"] = graham_analysis

return {"messages": [message], "data": state["data"]}


def analyze_earnings_stability(metrics: list, financial_line_items: list) -> dict:
"""
Graham wants at least several years of consistently positive earnings (ideally 5+).
We'll check:
1. Number of years with positive EPS.
2. Growth in EPS from first to last period.
"""
score = 0
details = []

if not metrics or not financial_line_items:
return {"score": score, "details": "Insufficient data for earnings stability analysis"}

eps_vals = []
for item in financial_line_items:
if item.earnings_per_share is not None:
eps_vals.append(item.earnings_per_share)

if len(eps_vals) < 2:
details.append("Not enough multi-year EPS data.")
return {"score": score, "details": "; ".join(details)}

# 1. Consistently positive EPS
positive_eps_years = sum(1 for e in eps_vals if e > 0)
total_eps_years = len(eps_vals)
if positive_eps_years == total_eps_years:
score += 3
details.append("EPS was positive in all available periods.")
elif positive_eps_years >= (total_eps_years * 0.8):
score += 2
details.append("EPS was positive in most periods.")
else:
details.append("EPS was negative in multiple periods.")

# 2. EPS growth from earliest to latest
if eps_vals[-1] > eps_vals[0]:
score += 1
details.append("EPS grew from earliest to latest period.")
else:
details.append("EPS did not grow from earliest to latest period.")

return {"score": score, "details": "; ".join(details)}


def analyze_financial_strength(metrics: list, financial_line_items: list) -> dict:
"""
Graham checks liquidity (current ratio >= 2), manageable debt,
and dividend record (preferably some history of dividends).
"""
score = 0
details = []

if not financial_line_items:
return {"score": score, "details": "No data for financial strength analysis"}

latest_item = financial_line_items[-1]
total_assets = latest_item.total_assets or 0
total_liabilities = latest_item.total_liabilities or 0
current_assets = latest_item.current_assets or 0
current_liabilities = latest_item.current_liabilities or 0

# 1. Current ratio
if current_liabilities > 0:
current_ratio = current_assets / current_liabilities
if current_ratio >= 2.0:
score += 2
details.append(f"Current ratio = {current_ratio:.2f} (>=2.0: solid).")
elif current_ratio >= 1.5:
score += 1
details.append(f"Current ratio = {current_ratio:.2f} (moderately strong).")
else:
details.append(f"Current ratio = {current_ratio:.2f} (<1.5: weaker liquidity).")
else:
details.append("Cannot compute current ratio (missing or zero current_liabilities).")

# 2. Debt vs. Assets
if total_assets > 0:
debt_ratio = total_liabilities / total_assets
if debt_ratio < 0.5:
score += 2
details.append(f"Debt ratio = {debt_ratio:.2f}, under 0.50 (conservative).")
elif debt_ratio < 0.8:
score += 1
details.append(f"Debt ratio = {debt_ratio:.2f}, somewhat high but could be acceptable.")
else:
details.append(f"Debt ratio = {debt_ratio:.2f}, quite high by Graham standards.")
else:
details.append("Cannot compute debt ratio (missing total_assets).")

# 3. Dividend track record
div_periods = [item.dividends_and_other_cash_distributions for item in financial_line_items if item.dividends_and_other_cash_distributions is not None]
if div_periods:
# In many data feeds, dividend outflow is shown as a negative number
# (money going out to shareholders). We'll consider any negative as 'paid a dividend'.
div_paid_years = sum(1 for d in div_periods if d < 0)
if div_paid_years > 0:
# e.g. if at least half the periods had dividends
if div_paid_years >= (len(div_periods) // 2 + 1):
score += 1
details.append("Company paid dividends in the majority of the reported years.")
else:
details.append("Company has some dividend payments, but not most years.")
else:
details.append("Company did not pay dividends in these periods.")
else:
details.append("No dividend data available to assess payout consistency.")

return {"score": score, "details": "; ".join(details)}


def analyze_valuation_graham(metrics: list, financial_line_items: list, market_cap: float) -> dict:
"""
Core Graham approach to valuation:
1. Net-Net Check: (Current Assets - Total Liabilities) vs. Market Cap
2. Graham Number: sqrt(22.5 * EPS * Book Value per Share)
3. Compare per-share price to Graham Number => margin of safety
"""
if not financial_line_items or not market_cap or market_cap <= 0:
return {"score": 0, "details": "Insufficient data to perform valuation"}

latest = financial_line_items[-1]
current_assets = latest.current_assets or 0
total_liabilities = latest.total_liabilities or 0
book_value_ps = latest.book_value_per_share or 0
eps = latest.earnings_per_share or 0
shares_outstanding = latest.outstanding_shares or 0

details = []
score = 0

# 1. Net-Net Check
# NCAV = Current Assets - Total Liabilities
# If NCAV > Market Cap => historically a strong buy signal
net_current_asset_value = current_assets - total_liabilities
if net_current_asset_value > 0 and shares_outstanding > 0:
net_current_asset_value_per_share = net_current_asset_value / shares_outstanding
price_per_share = market_cap / shares_outstanding if shares_outstanding else 0

details.append(f"Net Current Asset Value = {net_current_asset_value:,.2f}")
details.append(f"NCAV Per Share = {net_current_asset_value_per_share:,.2f}")
details.append(f"Price Per Share = {price_per_share:,.2f}")

if net_current_asset_value > market_cap:
score += 4 # Very strong Graham signal
details.append("Net-Net: NCAV > Market Cap (classic Graham deep value).")
else:
# For partial net-net discount
if net_current_asset_value_per_share >= (price_per_share * 0.67):
score += 2
details.append("NCAV Per Share >= 2/3 of Price Per Share (moderate net-net discount).")
else:
details.append("NCAV not exceeding market cap or insufficient data for net-net approach.")

# 2. Graham Number
# GrahamNumber = sqrt(22.5 * EPS * BVPS).
# Compare the result to the current price_per_share
# If GrahamNumber >> price, indicates undervaluation
graham_number = None
if eps > 0 and book_value_ps > 0:
graham_number = math.sqrt(22.5 * eps * book_value_ps)
details.append(f"Graham Number = {graham_number:.2f}")
else:
details.append("Unable to compute Graham Number (EPS or Book Value missing/<=0).")

# 3. Margin of Safety relative to Graham Number
if graham_number and shares_outstanding > 0:
current_price = market_cap / shares_outstanding
if current_price > 0:
margin_of_safety = (graham_number - current_price) / current_price
details.append(f"Margin of Safety (Graham Number) = {margin_of_safety:.2%}")
if margin_of_safety > 0.5:
score += 3
details.append("Price is well below Graham Number (>=50% margin).")
elif margin_of_safety > 0.2:
score += 1
details.append("Some margin of safety relative to Graham Number.")
else:
details.append("Price close to or above Graham Number, low margin of safety.")
else:
details.append("Current price is zero or invalid; can't compute margin of safety.")
# else: already appended details for missing graham_number

return {"score": score, "details": "; ".join(details)}


def generate_graham_output(
ticker: str,
analysis_data: dict[str, any],
model_name: str,
model_provider: str,
) -> BenGrahamSignal:
"""
Generates an investment decision in the style of Benjamin Graham:
- Value emphasis, margin of safety, net-nets, conservative balance sheet, stable earnings.
- Return the result in a JSON structure: { signal, confidence, reasoning }.
"""

template = ChatPromptTemplate.from_messages([
(
"system",
"""You are a Benjamin Graham AI agent, making investment decisions using his principles:
1. Insist on a margin of safety by buying below intrinsic value (e.g., using Graham Number, net-net).
2. Emphasize the company's financial strength (low leverage, ample current assets).
3. Prefer stable earnings over multiple years.
4. Consider dividend record for extra safety.
5. Avoid speculative or high-growth assumptions; focus on proven metrics.
Return a rational recommendation: bullish, bearish, or neutral, with a confidence level (0-100) and concise reasoning.
"""
),
(
"human",
"""Based on the following analysis, create a Graham-style investment signal:
Analysis Data for {ticker}:
{analysis_data}
Return JSON exactly in this format:
{{
"signal": "bullish" or "bearish" or "neutral",
"confidence": float (0-100),
"reasoning": "string"
}}
"""
)
])

prompt = template.invoke({
"analysis_data": json.dumps(analysis_data, indent=2),
"ticker": ticker
})

def create_default_ben_graham_signal():
return BenGrahamSignal(signal="neutral", confidence=0.0, reasoning="Error in generating analysis; defaulting to neutral.")

return call_llm(
prompt=prompt,
model_name=model_name,
model_provider=model_provider,
pydantic_model=BenGrahamSignal,
agent_name="ben_graham_agent",
default_factory=create_default_ben_graham_signal,
)
15 changes: 4 additions & 11 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from langgraph.graph import END, StateGraph
from colorama import Fore, Back, Style, init
import questionary

from agents.ben_graham import ben_graham_agent
from agents.bill_ackman import bill_ackman_agent
from agents.fundamentals import fundamentals_agent
from agents.portfolio_manager import portfolio_management_agent
Expand All @@ -16,7 +16,7 @@
from graph.state import AgentState
from agents.valuation import valuation_agent
from utils.display import print_trading_output
from utils.analysts import ANALYST_ORDER
from utils.analysts import ANALYST_ORDER, get_analyst_nodes
from utils.progress import progress
from llm.models import LLM_ORDER, get_model_info

Expand Down Expand Up @@ -105,15 +105,8 @@ def create_workflow(selected_analysts=None):
workflow = StateGraph(AgentState)
workflow.add_node("start_node", start)

# Dictionary of all available analysts
analyst_nodes = {
"technical_analyst": ("technical_analyst_agent", technical_analyst_agent),
"fundamentals_analyst": ("fundamentals_agent", fundamentals_agent),
"sentiment_analyst": ("sentiment_agent", sentiment_agent),
"valuation_analyst": ("valuation_agent", valuation_agent),
"warren_buffett": ("warren_buffett_agent", warren_buffett_agent),
"bill_ackman": ("bill_ackman_agent", bill_ackman_agent),
}
# Get analyst nodes from the configuration
analyst_nodes = get_analyst_nodes()

# Default to all analysts if none selected
if selected_analysts is None:
Expand Down
Loading

0 comments on commit 75c1b8d

Please sign in to comment.