297 lines
11 KiB
Python
297 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Enhanced GARP trading engine with automatic sector diversification and position management"""
|
|
|
|
import sys
|
|
sys.path.append('/home/wdjones/.openclaw/workspace/projects/market-watch')
|
|
|
|
import game_engine
|
|
import scanner
|
|
import trader
|
|
import yfinance as yf
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
|
|
# Enhanced constants
|
|
MIN_POSITION_SIZE = 1000 # Minimum position size
|
|
MAX_SECTOR_PCT = 30 # Maximum sector allocation
|
|
MIN_CASH_PCT = 15 # Minimum cash reserve
|
|
OPTIMAL_POSITIONS = 12 # Target number of positions
|
|
SECTOR_BALANCE_THRESHOLD = 5 # Rebalance if any sector is 5%+ over target
|
|
|
|
# Target sector allocation (more conservative than aggressive growth)
|
|
TARGET_SECTORS = {
|
|
"Financial Services": 25,
|
|
"Technology": 20,
|
|
"Healthcare": 15,
|
|
"Consumer Cyclical": 10,
|
|
"Communication Services": 8,
|
|
"Industrials": 8,
|
|
"Consumer Defensive": 7,
|
|
"Energy": 7
|
|
}
|
|
|
|
def cleanup_small_positions(game_id, username, dry_run=False):
|
|
"""Automatically clean up positions under minimum size"""
|
|
p = game_engine.get_portfolio(game_id, username)
|
|
if not p:
|
|
return []
|
|
|
|
cleanup_actions = []
|
|
for ticker, pos in p["positions"].items():
|
|
if pos["market_value"] < MIN_POSITION_SIZE:
|
|
reason = f"Position cleanup: ${pos['market_value']:.0f} < ${MIN_POSITION_SIZE:,} minimum"
|
|
|
|
if not dry_run:
|
|
result = game_engine.sell(game_id, username, ticker, pos["shares"],
|
|
pos["current_price"], reason)
|
|
cleanup_actions.append({
|
|
"action": "SELL",
|
|
"ticker": ticker,
|
|
"shares": pos["shares"],
|
|
"value": pos["market_value"],
|
|
"reason": reason,
|
|
"result": result
|
|
})
|
|
print(f" CLEANUP: Sold {ticker} - {reason}")
|
|
|
|
return cleanup_actions
|
|
|
|
def check_sector_balance(game_id, username):
|
|
"""Check if sector rebalancing is needed beyond basic violations"""
|
|
sectors = game_engine.get_sector_allocation(game_id, username)
|
|
p = game_engine.get_portfolio(game_id, username)
|
|
|
|
imbalances = []
|
|
for sector, current_pct in sectors.items():
|
|
target_pct = TARGET_SECTORS.get(sector, 5) # Default 5% for unknown sectors
|
|
|
|
if current_pct > target_pct + SECTOR_BALANCE_THRESHOLD:
|
|
excess = current_pct - target_pct
|
|
excess_value = (excess / 100) * p["total_value"]
|
|
imbalances.append({
|
|
"sector": sector,
|
|
"current_pct": current_pct,
|
|
"target_pct": target_pct,
|
|
"excess_pct": excess,
|
|
"excess_value": excess_value
|
|
})
|
|
|
|
return imbalances
|
|
|
|
def enhanced_buy_logic(game_id, username, candidates=None):
|
|
"""Enhanced buy logic with sector diversification priority"""
|
|
p = game_engine.get_portfolio(game_id, username)
|
|
buys = []
|
|
|
|
if p["num_positions"] >= trader.MAX_POSITIONS:
|
|
print(f" Max positions reached ({trader.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", [])
|
|
|
|
# Get current sector allocations
|
|
current_sectors = game_engine.get_sector_allocation(game_id, username)
|
|
existing_tickers = set(p["positions"].keys())
|
|
|
|
# Calculate sector priorities (higher score for underallocated sectors)
|
|
sector_priorities = {}
|
|
for sector, target_pct in TARGET_SECTORS.items():
|
|
current_pct = current_sectors.get(sector, 0)
|
|
shortage = max(0, target_pct - current_pct)
|
|
sector_priorities[sector] = shortage
|
|
|
|
# Enhance candidates with sector priority scoring
|
|
enhanced_candidates = []
|
|
for c in candidates:
|
|
if c["ticker"] in existing_tickers:
|
|
continue
|
|
|
|
# Skip if RSI too high or too close to 52-week high
|
|
rsi = c.get("rsi")
|
|
if rsi and rsi > trader.RSI_BUY_LIMIT:
|
|
continue
|
|
|
|
pct_from_high = c.get("pct_from_52wk_high", 0)
|
|
if pct_from_high < trader.NEAR_HIGH_PCT:
|
|
continue
|
|
|
|
# Calculate enhanced score with sector priority
|
|
sector = c.get("sector", "Unknown")
|
|
base_score = c.get("score", 0)
|
|
|
|
# Sector priority bonus (negative to improve ranking)
|
|
sector_bonus = -sector_priorities.get(sector, 0) * 0.8
|
|
|
|
# Sector cap check
|
|
current_sector_pct = current_sectors.get(sector, 0)
|
|
if current_sector_pct >= MAX_SECTOR_PCT:
|
|
continue # Skip if sector already at cap
|
|
|
|
enhanced_score = base_score + sector_bonus
|
|
|
|
enhanced_candidates.append({
|
|
**c,
|
|
"enhanced_score": enhanced_score,
|
|
"sector_priority": sector_priorities.get(sector, 0),
|
|
"current_sector_pct": current_sector_pct
|
|
})
|
|
|
|
# Sort by enhanced score (lower is better)
|
|
enhanced_candidates.sort(key=lambda x: x["enhanced_score"])
|
|
|
|
# Execute buys with diversification preference
|
|
sectors_bought = set()
|
|
cash_available = p["cash"]
|
|
min_cash_required = p["total_value"] * MIN_CASH_PCT / 100
|
|
|
|
for c in enhanced_candidates[:20]: # Consider top 20 candidates
|
|
if len(buys) >= 3: # Limit buys per session
|
|
break
|
|
|
|
ticker = c["ticker"]
|
|
sector = c.get("sector", "Unknown")
|
|
price = c["price"]
|
|
|
|
# Position sizing
|
|
target_position_value = min(
|
|
p["total_value"] * trader.MAX_POSITION_PCT, # Max 10% per position
|
|
(cash_available - min_cash_required) * 0.8 # Don't use all available cash
|
|
)
|
|
|
|
if target_position_value < MIN_POSITION_SIZE:
|
|
continue
|
|
|
|
shares = max(1, int(target_position_value / price))
|
|
cost = shares * price
|
|
|
|
# Final checks
|
|
if cost > (cash_available - min_cash_required):
|
|
continue
|
|
|
|
# Prefer sectors we haven't bought yet in this session
|
|
diversity_bonus = 0 if sector in sectors_bought else 1
|
|
|
|
reason = (f"Enhanced GARP: PE={c['trailing_pe']:.1f}, FwdPE={c['forward_pe']:.1f}, "
|
|
f"RevGr={c['revenue_growth']:.1f}%, EPSGr={c['earnings_growth']:.1f}%, "
|
|
f"Sector={sector} (priority={c['sector_priority']:.1f}%)")
|
|
|
|
result = game_engine.buy(game_id, username, ticker, shares, price, reason=reason)
|
|
if result["success"]:
|
|
buys.append({
|
|
"ticker": ticker,
|
|
"sector": sector,
|
|
"shares": shares,
|
|
"price": price,
|
|
"cost": cost,
|
|
"reason": reason,
|
|
"result": result
|
|
})
|
|
sectors_bought.add(sector)
|
|
cash_available -= cost
|
|
print(f" BUY {ticker} ({sector}): {shares} shares @ ${price:.2f} = ${cost:,.0f} [Priority: {c['sector_priority']:.1f}%]")
|
|
else:
|
|
print(f" SKIP {ticker}: {result.get('error', 'Unknown error')}")
|
|
|
|
return buys
|
|
|
|
def enhanced_trading_session(game_id, username):
|
|
"""Enhanced trading session with comprehensive portfolio management"""
|
|
print(f"\n=== ENHANCED TRADING SESSION [{username}@{game_id}] ===")
|
|
|
|
# 1. Update all prices
|
|
print("\n1. Updating prices...")
|
|
updated = trader.update_all_prices(game_id, username)
|
|
for ticker, price in updated:
|
|
print(f" {ticker}: ${price:.2f}")
|
|
|
|
# 2. Portfolio cleanup
|
|
print("\n2. Position cleanup...")
|
|
cleanup_actions = cleanup_small_positions(game_id, username, dry_run=False)
|
|
if not cleanup_actions:
|
|
print(" No cleanup needed")
|
|
|
|
# 3. Check sector balance
|
|
print("\n3. Sector balance check...")
|
|
imbalances = check_sector_balance(game_id, username)
|
|
if imbalances:
|
|
print(" Sector imbalances detected:")
|
|
for imbalance in imbalances:
|
|
print(f" {imbalance['sector']}: {imbalance['current_pct']:.1f}% vs {imbalance['target_pct']:.1f}% target ({imbalance['excess_pct']:+.1f}%)")
|
|
else:
|
|
print(" Sectors balanced within targets")
|
|
|
|
# 4. Standard violation checks and rebalancing
|
|
print("\n4. Violation checks...")
|
|
rebalance_result = game_engine.rebalance_portfolio(game_id, username, dry_run=True)
|
|
if rebalance_result["violations"]:
|
|
print(" Executing automatic rebalance...")
|
|
exec_result = game_engine.rebalance_portfolio(game_id, username, dry_run=False)
|
|
print(f" Rebalanced {len(exec_result.get('actions', []))} positions")
|
|
else:
|
|
print(" No violations found")
|
|
|
|
# 5. Sell signals
|
|
print("\n5. Checking sell signals...")
|
|
sells = trader.check_sell_signals(game_id, username)
|
|
if not sells:
|
|
print(" No sell signals")
|
|
|
|
# 6. Enhanced buy signals
|
|
print("\n6. Enhanced buy analysis...")
|
|
buys = enhanced_buy_logic(game_id, username)
|
|
if not buys:
|
|
print(" No suitable buy candidates")
|
|
|
|
# 7. Portfolio summary
|
|
print("\n7. Updated Portfolio Summary:")
|
|
p = game_engine.get_portfolio(game_id, username)
|
|
sectors = game_engine.get_sector_allocation(game_id, username)
|
|
cash_pct = (p["cash"] / p["total_value"]) * 100
|
|
|
|
print(f" Total Value: ${p['total_value']:,.2f}")
|
|
print(f" Cash: ${p['cash']:,.2f} ({cash_pct:.1f}%)")
|
|
print(f" Positions: {p['num_positions']}")
|
|
|
|
if sectors:
|
|
print(f" Sector Allocation:")
|
|
for sector, pct in sorted(sectors.items(), key=lambda x: x[1], reverse=True):
|
|
target = TARGET_SECTORS.get(sector, 5)
|
|
status = ""
|
|
if pct > target + SECTOR_BALANCE_THRESHOLD:
|
|
status = f" (OVER by {pct-target:.1f}%)"
|
|
elif pct < target - SECTOR_BALANCE_THRESHOLD:
|
|
status = f" (UNDER by {target-pct:.1f}%)"
|
|
print(f" {sector}: {pct:.1f}% (target: {target}%){status}")
|
|
|
|
return {
|
|
"price_updates": len(updated),
|
|
"cleanup": len(cleanup_actions),
|
|
"sells": len(sells),
|
|
"buys": len(buys),
|
|
"sector_imbalances": len(imbalances),
|
|
"violations": len(rebalance_result.get("violations", []))
|
|
}
|
|
|
|
def main():
|
|
gid = game_engine.get_default_game_id()
|
|
if gid:
|
|
result = enhanced_trading_session(gid, "case")
|
|
print(f"\n=== SESSION SUMMARY ===")
|
|
print(f"Price updates: {result['price_updates']}")
|
|
print(f"Positions cleaned: {result['cleanup']}")
|
|
print(f"Sells executed: {result['sells']}")
|
|
print(f"Buys executed: {result['buys']}")
|
|
print(f"Sector imbalances: {result['sector_imbalances']}")
|
|
print(f"Violations fixed: {result['violations']}")
|
|
else:
|
|
print("No default game found")
|
|
|
|
if __name__ == "__main__":
|
|
main() |