#!/usr/bin/env python3 """GARP trading decision engine — multiplayer aware.""" import json import os from datetime import datetime import yfinance as yf import game_engine import scanner MAX_POSITIONS = 15 MAX_POSITION_PCT = 0.10 RSI_BUY_LIMIT = 70 RSI_SELL_LIMIT = 80 NEAR_HIGH_PCT = 2.0 LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "logs") def log_decision(action, ticker, reason, details=None): os.makedirs(LOG_DIR, exist_ok=True) entry = { "timestamp": datetime.now().isoformat(), "action": action, "ticker": ticker, "reason": reason, "details": details or {}, } log_file = os.path.join(LOG_DIR, f"{datetime.now().strftime('%Y-%m-%d')}.json") logs = [] if os.path.exists(log_file): with open(log_file) as f: logs = json.load(f) logs.append(entry) with open(log_file, "w") as f: json.dump(logs, f, indent=2, default=str) return entry def update_all_prices(game_id, username): """Update current prices for all held positions.""" p = game_engine.get_portfolio(game_id, username) updated = [] for ticker in p["positions"]: try: stock = yf.Ticker(ticker) hist = stock.history(period="5d") if not hist.empty: price = float(hist["Close"].iloc[-1]) game_engine.update_price(game_id, username, ticker, price) updated.append((ticker, price)) except Exception as e: print(f" Warning: Could not update {ticker}: {e}") return updated def check_sell_signals(game_id, username): """Check existing positions for sell signals.""" p = game_engine.get_portfolio(game_id, username) sells = [] if not p["positions"]: return sells latest_scan = scanner.load_latest_scan() scan_tickers = set() if latest_scan: scan_tickers = {c["ticker"] for c in latest_scan.get("candidates", [])} for ticker, pos in list(p["positions"].items()): sell_reason = None if pos["current_price"] <= pos.get("trailing_stop", 0): sell_reason = f"Trailing stop hit (stop={pos.get('trailing_stop', 0):.2f}, price={pos['current_price']:.2f})" if not sell_reason: try: stock = yf.Ticker(ticker) hist = stock.history(period="3mo") if not hist.empty and len(hist) >= 15: rsi = scanner.compute_rsi(hist["Close"].values) if rsi and rsi > RSI_SELL_LIMIT: sell_reason = f"RSI overbought ({rsi:.1f} > {RSI_SELL_LIMIT})" except: pass if not sell_reason and latest_scan and ticker not in scan_tickers: sell_reason = f"No longer passes GARP filter" if sell_reason: result = game_engine.sell(game_id, username, ticker, price=pos["current_price"], reason=sell_reason) log_entry = log_decision("SELL", ticker, sell_reason, result) sells.append(log_entry) print(f" SELL {ticker}: {sell_reason}") return sells def check_buy_signals(game_id, username, candidates=None): """Check scan candidates for buy signals.""" p = game_engine.get_portfolio(game_id, username) buys = [] if p["num_positions"] >= MAX_POSITIONS: print(f" Max positions reached ({MAX_POSITIONS}), skipping buys") return buys if candidates is None: latest_scan = scanner.load_latest_scan() if not latest_scan: print(" No scan data available") return buys candidates = latest_scan.get("candidates", []) position_size = p["total_value"] / MAX_POSITIONS max_per_position = p["total_value"] * MAX_POSITION_PCT existing_tickers = set(p["positions"].keys()) for c in candidates: if p["num_positions"] + len(buys) >= MAX_POSITIONS: break ticker = c["ticker"] if ticker in existing_tickers: continue rsi = c.get("rsi") if rsi and rsi > RSI_BUY_LIMIT: log_decision("SKIP", ticker, f"RSI too high ({rsi:.1f} > {RSI_BUY_LIMIT})") continue pct_from_high = c.get("pct_from_52wk_high", 0) if pct_from_high < NEAR_HIGH_PCT: log_decision("SKIP", ticker, f"Too close to 52wk high ({pct_from_high:.1f}% away)") continue price = c["price"] # Refresh cash from current portfolio state current_p = game_engine.get_portfolio(game_id, username) amount = min(position_size, max_per_position, current_p["cash"]) if amount < price: continue shares = int(amount / price) if shares < 1: continue reason = (f"GARP signal: PE={c['trailing_pe']}, FwdPE={c['forward_pe']}, " f"RevGr={c['revenue_growth']}%, EPSGr={c['earnings_growth']}%, RSI={rsi}") result = game_engine.buy(game_id, username, ticker, shares, price, reason=reason) if result["success"]: log_entry = log_decision("BUY", ticker, reason, result) buys.append(log_entry) print(f" BUY {ticker}: {shares} shares @ ${price:.2f} = ${shares * price:,.2f}") else: log_decision("SKIP", ticker, f"Buy failed: {result.get('error', 'unknown')}") return buys def run_trading_logic(game_id, username, candidates=None): """Run full trading cycle for a player.""" print(f"\n--- Trading Logic [{username}@{game_id}] ---") print("\nUpdating prices...") updated = update_all_prices(game_id, username) for ticker, price in updated: print(f" {ticker}: ${price:.2f}") print("\nChecking sell signals...") sells = check_sell_signals(game_id, username) if not sells: print(" No sell signals") print("\nChecking buy signals...") buys = check_buy_signals(game_id, username, candidates) if not buys: print(" No buy signals") return {"sells": sells, "buys": buys, "price_updates": len(updated)} if __name__ == "__main__": gid = game_engine.get_default_game_id() if gid: run_trading_logic(gid, "case") else: print("No default game found. Run game_engine.py first.")