From cd2236079fecfc4d3afdf60c50617889a4704bf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 15:03:18 +0000 Subject: [PATCH] Add Centillion Investment Partners trading agents setup - centillion_config.py: Custom config for long-only SMID cap fund (US + EU), portfolio/watchlist tickers, benchmark definitions, Anthropic Claude as LLM - centillion_run.py: Batch analysis runner with CLI flags for ticker/region/date filtering, outputs JSON results with buy/sell/hold signals - centillion_portfolio_tracker.py: Virtual portfolio tracker for paper trading with position sizing, NAV tracking, and performance reporting https://claude.ai/code/session_01Wq18jXYiJNdb9po7EzmnUZ --- centillion_config.py | 93 +++++++++++ centillion_portfolio_tracker.py | 281 ++++++++++++++++++++++++++++++++ centillion_run.py | 235 ++++++++++++++++++++++++++ 3 files changed, 609 insertions(+) create mode 100644 centillion_config.py create mode 100644 centillion_portfolio_tracker.py create mode 100644 centillion_run.py diff --git a/centillion_config.py b/centillion_config.py new file mode 100644 index 00000000..8fa49dc6 --- /dev/null +++ b/centillion_config.py @@ -0,0 +1,93 @@ +""" +Centillion Investment Partners - TradingAgents Configuration + +Tailored for a long-only SMID cap fund focused on US and European stocks. +Two modes: + 1. Virtual portfolio tracking (paper trading to test alpha generation) + 2. Buy/sell recommendation engine for active portfolio + watchlist +""" + +import os +from tradingagents.default_config import DEFAULT_CONFIG + +# --------------------------------------------------------------------------- +# Base config — inherit defaults and override for Centillion's needs +# --------------------------------------------------------------------------- +CENTILLION_CONFIG = DEFAULT_CONFIG.copy() + +# LLM provider — change to "anthropic" or "google" if you prefer +CENTILLION_CONFIG["llm_provider"] = "anthropic" +CENTILLION_CONFIG["deep_think_llm"] = "claude-sonnet-4-20250514" +CENTILLION_CONFIG["quick_think_llm"] = "claude-sonnet-4-20250514" + +# More thorough analysis: 2 rounds of debate for better signal quality +CENTILLION_CONFIG["max_debate_rounds"] = 2 +CENTILLION_CONFIG["max_risk_discuss_rounds"] = 2 + +# Data vendors — yfinance is free and covers US + European equities +CENTILLION_CONFIG["data_vendors"] = { + "core_stock_apis": "yfinance", + "technical_indicators": "yfinance", + "fundamental_data": "yfinance", + "news_data": "yfinance", +} + +# --------------------------------------------------------------------------- +# Portfolio & Watchlist +# --------------------------------------------------------------------------- +# US SMID cap holdings/watchlist — add your actual tickers here +US_PORTFOLIO = [ + # Example SMID cap US names — replace with your actual holdings + "CVLT", # Commvault Systems + "CSWI", # CSW Industrials + "EXPO", # Exponent + "LOPE", # Grand Canyon Education + "NOVT", # Novanta + "PCVX", # Vaxcyte + "STEP", # StepStone Group + "TNET", # TriNet Group + "ACIW", # ACI Worldwide + "CADE", # Cadence Bank +] + +US_WATCHLIST = [ + # Stocks you're considering but don't own yet + "CORT", # Corcept Therapeutics + "ELF", # e.l.f. Beauty + "DUOL", # Duolingo + "GWRE", # Guidewire Software + "KNSL", # Kingsdale Advisors +] + +# European SMID cap — use Yahoo Finance tickers (exchange suffix) +# .L = London, .AS = Amsterdam, .DE = Frankfurt, .PA = Paris, .ST = Stockholm +EU_PORTFOLIO = [ + # Example European SMID names — replace with your actual holdings + "DARK.L", # Darktrace (London) + "IMCD.AS", # IMCD (Amsterdam) + "RHM.DE", # Rheinmetall (Frankfurt) + "DSY.PA", # Dassault Systèmes (Paris) + "ALFA.ST", # Alfa Laval (Stockholm) +] + +EU_WATCHLIST = [ + "ASM.AS", # ASM International + "BESI.AS", # BE Semiconductor + "MONY.L", # Moneysupermarket +] + +# --------------------------------------------------------------------------- +# Combined universe +# --------------------------------------------------------------------------- +ALL_PORTFOLIO = US_PORTFOLIO + EU_PORTFOLIO +ALL_WATCHLIST = US_WATCHLIST + EU_WATCHLIST +ALL_TICKERS = ALL_PORTFOLIO + ALL_WATCHLIST + +# --------------------------------------------------------------------------- +# Benchmarks for performance tracking +# --------------------------------------------------------------------------- +BENCHMARKS = { + "us": "^RUT", # Russell 2000 + "eu": "^STOXX", # STOXX Europe 600 + "combined": "ACWI", # MSCI ACWI (broad global) +} diff --git a/centillion_portfolio_tracker.py b/centillion_portfolio_tracker.py new file mode 100644 index 00000000..3bbcbea8 --- /dev/null +++ b/centillion_portfolio_tracker.py @@ -0,0 +1,281 @@ +""" +Centillion Investment Partners - Virtual Portfolio Tracker + +Maintains a paper-trading portfolio based on TradingAgents signals. +Tracks performance over time against benchmarks to evaluate whether +the agent-driven strategy generates alpha. + +Usage: + # Initialize a new virtual portfolio with $1M + python centillion_portfolio_tracker.py init --cash 1000000 + + # Record today's signals and update positions + python centillion_portfolio_tracker.py update + + # Show current portfolio and performance + python centillion_portfolio_tracker.py status + + # Show performance vs benchmarks + python centillion_portfolio_tracker.py performance +""" + +import argparse +import json +import os +from datetime import datetime, timedelta + +import yfinance as yf + +PORTFOLIO_FILE = "results/virtual_portfolio.json" + + +def load_portfolio(): + if not os.path.exists(PORTFOLIO_FILE): + print("No portfolio found. Run 'init' first.") + return None + with open(PORTFOLIO_FILE) as f: + return json.load(f) + + +def save_portfolio(portfolio): + os.makedirs(os.path.dirname(PORTFOLIO_FILE), exist_ok=True) + with open(PORTFOLIO_FILE, "w") as f: + json.dump(portfolio, f, indent=2, default=str) + + +def cmd_init(args): + """Initialize a fresh virtual portfolio.""" + portfolio = { + "inception_date": datetime.now().strftime("%Y-%m-%d"), + "initial_cash": args.cash, + "cash": args.cash, + "positions": {}, # ticker -> {"shares": n, "avg_cost": x, "entry_date": d} + "history": [], # list of {date, action, ticker, shares, price, total_value} + "daily_nav": [], # list of {date, nav, benchmark_values: {}} + } + save_portfolio(portfolio) + print(f"Virtual portfolio initialized with ${args.cash:,.0f} cash.") + print(f"Saved to {PORTFOLIO_FILE}") + + +def cmd_update(args): + """Apply signals from the latest analysis run to the portfolio.""" + portfolio = load_portfolio() + if not portfolio: + return + + # Load latest analysis results + results_dir = "results" + result_files = sorted( + [f for f in os.listdir(results_dir) if f.startswith("centillion_") and f.endswith(".json") and f != "virtual_portfolio.json"], + reverse=True, + ) + + if not result_files: + print("No analysis results found. Run centillion_run.py first.") + return + + latest_file = os.path.join(results_dir, result_files[0]) + print(f"Using signals from: {latest_file}") + + with open(latest_file) as f: + signals = json.load(f) + + date = datetime.now().strftime("%Y-%m-%d") + position_size = portfolio["cash"] + _total_position_value(portfolio) + target_per_position = position_size * 0.05 # 5% position sizing (long-only) + + actions_taken = [] + + for ticker, data in signals.items(): + if data["status"] == "error": + continue + + signal = _extract_signal(data.get("decision", "")) + + if signal == "BUY" and ticker not in portfolio["positions"]: + # Buy new position — allocate ~5% of portfolio + try: + price = _get_current_price(ticker) + if price and portfolio["cash"] >= target_per_position: + shares = int(target_per_position / price) + if shares > 0: + cost = shares * price + portfolio["cash"] -= cost + portfolio["positions"][ticker] = { + "shares": shares, + "avg_cost": price, + "entry_date": date, + } + actions_taken.append(f" BUY {shares} {ticker} @ ${price:.2f} = ${cost:,.0f}") + portfolio["history"].append({ + "date": date, "action": "BUY", "ticker": ticker, + "shares": shares, "price": price, + }) + except Exception as e: + print(f" Could not buy {ticker}: {e}") + + elif signal == "SELL" and ticker in portfolio["positions"]: + # Sell entire position (long-only fund, no shorting) + pos = portfolio["positions"][ticker] + try: + price = _get_current_price(ticker) + if price: + proceeds = pos["shares"] * price + pnl = (price - pos["avg_cost"]) * pos["shares"] + portfolio["cash"] += proceeds + actions_taken.append( + f" SELL {pos['shares']} {ticker} @ ${price:.2f} = ${proceeds:,.0f} " + f"(PnL: ${pnl:+,.0f})" + ) + portfolio["history"].append({ + "date": date, "action": "SELL", "ticker": ticker, + "shares": pos["shares"], "price": price, "pnl": pnl, + }) + del portfolio["positions"][ticker] + except Exception as e: + print(f" Could not sell {ticker}: {e}") + + # Record daily NAV + nav = portfolio["cash"] + _total_position_value(portfolio) + portfolio["daily_nav"].append({"date": date, "nav": nav}) + + save_portfolio(portfolio) + + if actions_taken: + print(f"\nActions taken on {date}:") + print("\n".join(actions_taken)) + else: + print("No actions taken (all signals were HOLD or positions unchanged).") + + print(f"\nPortfolio NAV: ${nav:,.0f}") + print(f"Cash: ${portfolio['cash']:,.0f}") + print(f"Positions: {len(portfolio['positions'])}") + + +def cmd_status(args): + """Show current portfolio status.""" + portfolio = load_portfolio() + if not portfolio: + return + + print(f"\n{'='*60}") + print(" CENTILLION VIRTUAL PORTFOLIO") + print(f"{'='*60}") + print(f" Inception: {portfolio['inception_date']}") + print(f" Cash: ${portfolio['cash']:,.0f}") + + total_mkt = 0 + total_cost = 0 + + if portfolio["positions"]: + print(f"\n {'Ticker':<10} {'Shares':<8} {'AvgCost':<10} {'Current':<10} {'MktVal':<12} {'PnL':<12}") + print(f" {'-'*62}") + + for ticker, pos in sorted(portfolio["positions"].items()): + price = _get_current_price(ticker) or pos["avg_cost"] + mkt_val = pos["shares"] * price + cost_basis = pos["shares"] * pos["avg_cost"] + pnl = mkt_val - cost_basis + total_mkt += mkt_val + total_cost += cost_basis + print( + f" {ticker:<10} {pos['shares']:<8} ${pos['avg_cost']:<9.2f} ${price:<9.2f} " + f"${mkt_val:<11,.0f} ${pnl:<+11,.0f}" + ) + + nav = portfolio["cash"] + total_mkt + total_return = ((nav / portfolio["initial_cash"]) - 1) * 100 + + print(f"\n Total Market Value: ${total_mkt:,.0f}") + print(f" Total NAV: ${nav:,.0f}") + print(f" Total Return: {total_return:+.2f}%") + print(f" # Positions: {len(portfolio['positions'])}") + print(f"{'='*60}\n") + + +def cmd_performance(args): + """Show performance history and comparison to benchmarks.""" + portfolio = load_portfolio() + if not portfolio: + return + + if not portfolio["daily_nav"]: + print("No NAV history yet. Run 'update' after each analysis.") + return + + initial = portfolio["initial_cash"] + print(f"\n NAV History:") + print(f" {'Date':<12} {'NAV':<15} {'Return':<10}") + print(f" {'-'*37}") + + for entry in portfolio["daily_nav"]: + ret = ((entry["nav"] / initial) - 1) * 100 + print(f" {entry['date']:<12} ${entry['nav']:<14,.0f} {ret:+.2f}%") + + # Trade history summary + trades = portfolio["history"] + if trades: + total_pnl = sum(t.get("pnl", 0) for t in trades if t["action"] == "SELL") + wins = sum(1 for t in trades if t["action"] == "SELL" and t.get("pnl", 0) > 0) + losses = sum(1 for t in trades if t["action"] == "SELL" and t.get("pnl", 0) <= 0) + print(f"\n Closed Trades: {wins + losses} (Wins: {wins}, Losses: {losses})") + print(f" Realized PnL: ${total_pnl:+,.0f}") + + +def _get_current_price(ticker): + """Fetch current/latest price for a ticker via yfinance.""" + try: + tk = yf.Ticker(ticker) + hist = tk.history(period="5d") + if not hist.empty: + return float(hist["Close"].iloc[-1]) + except Exception: + pass + return None + + +def _total_position_value(portfolio): + """Calculate total market value of all positions.""" + total = 0 + for ticker, pos in portfolio["positions"].items(): + price = _get_current_price(ticker) or pos["avg_cost"] + total += pos["shares"] * price + return total + + +def _extract_signal(decision_text): + text_upper = decision_text.upper() if isinstance(decision_text, str) else "" + for signal in ["BUY", "SELL", "HOLD"]: + if signal in text_upper: + return signal + return "HOLD" + + +def main(): + parser = argparse.ArgumentParser(description="Centillion Virtual Portfolio Tracker") + sub = parser.add_subparsers(dest="command") + + init_p = sub.add_parser("init", help="Initialize new virtual portfolio") + init_p.add_argument("--cash", type=float, default=1_000_000, help="Starting cash (default $1M)") + + sub.add_parser("update", help="Apply latest signals to portfolio") + sub.add_parser("status", help="Show current portfolio") + sub.add_parser("performance", help="Show performance history") + + args = parser.parse_args() + + if args.command == "init": + cmd_init(args) + elif args.command == "update": + cmd_update(args) + elif args.command == "status": + cmd_status(args) + elif args.command == "performance": + cmd_performance(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/centillion_run.py b/centillion_run.py new file mode 100644 index 00000000..3b6294c3 --- /dev/null +++ b/centillion_run.py @@ -0,0 +1,235 @@ +""" +Centillion Investment Partners - Portfolio Analysis Runner + +Runs the TradingAgents framework across all portfolio + watchlist tickers +and outputs buy/sell/hold recommendations. + +Usage: + # Analyze full portfolio + watchlist + python centillion_run.py + + # Analyze a single ticker + python centillion_run.py --ticker CVLT + + # Analyze only the watchlist + python centillion_run.py --watchlist-only + + # Analyze only US names + python centillion_run.py --region us + + # Custom analysis date + python centillion_run.py --date 2025-03-21 +""" + +import argparse +import json +import os +import sys +from datetime import datetime, timedelta + +from dotenv import load_dotenv + +load_dotenv() + +from centillion_config import ( + ALL_PORTFOLIO, + ALL_TICKERS, + ALL_WATCHLIST, + CENTILLION_CONFIG, + EU_PORTFOLIO, + EU_WATCHLIST, + US_PORTFOLIO, + US_WATCHLIST, +) +from tradingagents.graph.trading_graph import TradingAgentsGraph + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Centillion Investment Partners - Trading Agent Analysis" + ) + parser.add_argument("--ticker", type=str, help="Analyze a single ticker") + parser.add_argument( + "--watchlist-only", action="store_true", help="Only analyze watchlist" + ) + parser.add_argument( + "--portfolio-only", action="store_true", help="Only analyze current holdings" + ) + parser.add_argument( + "--region", + choices=["us", "eu", "all"], + default="all", + help="Region filter (default: all)", + ) + parser.add_argument( + "--date", + type=str, + default=None, + help="Analysis date in YYYY-MM-DD format (default: yesterday)", + ) + parser.add_argument( + "--output", + type=str, + default=None, + help="Output file path for JSON results", + ) + parser.add_argument( + "--debug", action="store_true", help="Enable debug output from agents" + ) + return parser.parse_args() + + +def get_tickers(args): + """Determine which tickers to analyze based on flags.""" + if args.ticker: + return [args.ticker.upper()] + + region = args.region + + if args.portfolio_only: + if region == "us": + return US_PORTFOLIO + elif region == "eu": + return EU_PORTFOLIO + return ALL_PORTFOLIO + + if args.watchlist_only: + if region == "us": + return US_WATCHLIST + elif region == "eu": + return EU_WATCHLIST + return ALL_WATCHLIST + + # Default: everything + if region == "us": + return US_PORTFOLIO + US_WATCHLIST + elif region == "eu": + return EU_PORTFOLIO + EU_WATCHLIST + return ALL_TICKERS + + +def get_analysis_date(args): + """Get the analysis date — defaults to yesterday (last trading day approximation).""" + if args.date: + return args.date + yesterday = datetime.now() - timedelta(days=1) + # Skip weekends + if yesterday.weekday() == 6: # Sunday + yesterday -= timedelta(days=2) + elif yesterday.weekday() == 5: # Saturday + yesterday -= timedelta(days=1) + return yesterday.strftime("%Y-%m-%d") + + +def run_analysis(tickers, date, debug=False): + """Run the trading agents analysis on each ticker.""" + ta = TradingAgentsGraph(debug=debug, config=CENTILLION_CONFIG) + + results = {} + total = len(tickers) + + for i, ticker in enumerate(tickers, 1): + print(f"\n{'='*60}") + print(f" [{i}/{total}] Analyzing {ticker} as of {date}") + print(f"{'='*60}\n") + + try: + _, decision = ta.propagate(ticker, date) + results[ticker] = { + "decision": decision, + "date": date, + "status": "success", + "in_portfolio": ticker in ALL_PORTFOLIO, + } + + # Print summary + print(f"\n >> {ticker}: {_extract_signal(decision)}") + + except Exception as e: + print(f"\n >> {ticker}: ERROR - {e}") + results[ticker] = { + "decision": str(e), + "date": date, + "status": "error", + "in_portfolio": ticker in ALL_PORTFOLIO, + } + + return results + + +def _extract_signal(decision_text): + """Extract the BUY/SELL/HOLD signal from the decision text.""" + text_upper = decision_text.upper() if isinstance(decision_text, str) else "" + for signal in ["BUY", "SELL", "HOLD"]: + if signal in text_upper: + return signal + return "UNCLEAR" + + +def print_summary(results): + """Print a clean summary table of all results.""" + print(f"\n\n{'='*70}") + print(" CENTILLION INVESTMENT PARTNERS — ANALYSIS SUMMARY") + print(f"{'='*70}\n") + + buys, sells, holds, errors = [], [], [], [] + + for ticker, res in results.items(): + if res["status"] == "error": + errors.append(ticker) + continue + + signal = _extract_signal(res["decision"]) + tag = " [HELD]" if res["in_portfolio"] else " [WATCH]" + + if signal == "BUY": + buys.append(f" {ticker}{tag}") + elif signal == "SELL": + sells.append(f" {ticker}{tag}") + else: + holds.append(f" {ticker}{tag}") + + if buys: + print("BUY signals:") + print("\n".join(buys)) + if sells: + print("\nSELL signals:") + print("\n".join(sells)) + if holds: + print("\nHOLD signals:") + print("\n".join(holds)) + if errors: + print(f"\nErrors: {', '.join(errors)}") + + print(f"\n{'='*70}\n") + + +def save_results(results, output_path): + """Save results to JSON file.""" + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f"Results saved to {output_path}") + + +def main(): + args = parse_args() + tickers = get_tickers(args) + date = get_analysis_date(args) + + print(f"\n Centillion Investment Partners — TradingAgents Analysis") + print(f" Date: {date}") + print(f" Tickers ({len(tickers)}): {', '.join(tickers)}") + print(f" LLM: {CENTILLION_CONFIG['llm_provider']} / {CENTILLION_CONFIG['deep_think_llm']}") + print() + + results = run_analysis(tickers, date, debug=args.debug) + print_summary(results) + + # Save results + output_path = args.output or f"results/centillion_{date}.json" + save_results(results, output_path) + + +if __name__ == "__main__": + main()