- Game engine with multiplayer support (create games, join, leaderboard) - GARP stock screener (S&P 500 + 400 MidCap, 900+ tickers) - Automated trading logic for AI player (Case) - Web portal at marketwatch.local:8889 with dark theme - Systemd timer for Mon-Fri market hours - Telegram alerts on trades and daily summary - Stock analysis deep dive data (BAC, CFG, FITB, INCY) - Expanded scan results (22 GARP candidates) - Craigslist account setup + credentials
192 lines
6.2 KiB
Python
Executable File
192 lines
6.2 KiB
Python
Executable File
#!/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.")
|