Full sync - all projects, memory, configs
This commit is contained in:
@ -6,9 +6,15 @@ import os
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
import yfinance as yf
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||
GAMES_DIR = os.path.join(DATA_DIR, "games")
|
||||
|
||||
# Risk management constants
|
||||
MAX_SECTOR_PCT = 0.30 # Max 30% in any single sector
|
||||
MIN_CASH_PCT = 0.15 # Keep minimum 15% cash
|
||||
|
||||
|
||||
def _load_json(path, default=None):
|
||||
if os.path.exists(path):
|
||||
@ -47,6 +53,43 @@ def _game_config_path(game_id):
|
||||
return os.path.join(_game_dir(game_id), "game.json")
|
||||
|
||||
|
||||
def get_stock_sector(ticker):
|
||||
"""Get sector information for a stock ticker. Returns sector name or None."""
|
||||
try:
|
||||
stock = yf.Ticker(ticker)
|
||||
info = stock.info
|
||||
return info.get("sector")
|
||||
except Exception as e:
|
||||
print(f" Warning: Could not get sector for {ticker}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_sector_allocation(game_id, username):
|
||||
"""Get current sector allocation as percentage of total portfolio value."""
|
||||
pf = _load_json(_portfolio_path(game_id, username))
|
||||
if not pf or not pf["positions"]:
|
||||
return {}
|
||||
|
||||
p = get_portfolio(game_id, username)
|
||||
if not p:
|
||||
return {}
|
||||
|
||||
sector_values = {}
|
||||
total_invested = p["total_value"] - p["cash"]
|
||||
|
||||
for ticker, pos in p["positions"].items():
|
||||
sector = get_stock_sector(ticker)
|
||||
if sector:
|
||||
sector_values[sector] = sector_values.get(sector, 0) + pos["market_value"]
|
||||
|
||||
# Convert to percentages of total portfolio
|
||||
sector_pcts = {}
|
||||
for sector, value in sector_values.items():
|
||||
sector_pcts[sector] = (value / p["total_value"]) * 100 if p["total_value"] > 0 else 0
|
||||
|
||||
return sector_pcts
|
||||
|
||||
|
||||
# ── Game Management ──
|
||||
|
||||
def create_game(name, starting_cash=100_000.0, end_date=None, creator="system"):
|
||||
@ -115,7 +158,7 @@ def join_game(game_id, username):
|
||||
# ── Trading ──
|
||||
|
||||
def buy(game_id, username, ticker, shares, price, reason="Manual"):
|
||||
"""Buy shares for a player in a game."""
|
||||
"""Buy shares for a player in a game with sector diversification and cash reserve checks."""
|
||||
pf = _load_json(_portfolio_path(game_id, username))
|
||||
if not pf:
|
||||
return {"success": False, "error": "Player portfolio not found"}
|
||||
@ -124,6 +167,30 @@ def buy(game_id, username, ticker, shares, price, reason="Manual"):
|
||||
if cost > pf["cash"]:
|
||||
return {"success": False, "error": f"Insufficient cash. Need ${cost:,.2f}, have ${pf['cash']:,.2f}"}
|
||||
|
||||
# Get current portfolio to check constraints
|
||||
p = get_portfolio(game_id, username)
|
||||
if not p:
|
||||
return {"success": False, "error": "Could not get current portfolio"}
|
||||
|
||||
# Check minimum cash reserve (15%)
|
||||
min_cash_required = p["total_value"] * MIN_CASH_PCT
|
||||
cash_after_purchase = pf["cash"] - cost
|
||||
if cash_after_purchase < min_cash_required:
|
||||
return {"success": False, "error": f"Cash reserve violation. Need ${min_cash_required:,.2f} minimum cash, would have ${cash_after_purchase:,.2f}"}
|
||||
|
||||
# Check sector diversification (30% max per sector)
|
||||
stock_sector = get_stock_sector(ticker)
|
||||
if stock_sector:
|
||||
current_sectors = get_sector_allocation(game_id, username)
|
||||
current_sector_pct = current_sectors.get(stock_sector, 0)
|
||||
|
||||
# Calculate what sector percentage would be after this purchase
|
||||
new_sector_value = current_sectors.get(stock_sector, 0) * p["total_value"] / 100 + cost
|
||||
new_sector_pct = (new_sector_value / p["total_value"]) * 100
|
||||
|
||||
if new_sector_pct > MAX_SECTOR_PCT * 100:
|
||||
return {"success": False, "error": f"Sector cap violation. {stock_sector} would be {new_sector_pct:.1f}%, max allowed is {MAX_SECTOR_PCT*100:.1f}%"}
|
||||
|
||||
pf["cash"] -= cost
|
||||
|
||||
if ticker in pf["positions"]:
|
||||
@ -144,6 +211,7 @@ def buy(game_id, username, ticker, shares, price, reason="Manual"):
|
||||
"entry_date": datetime.now().isoformat(),
|
||||
"entry_reason": reason,
|
||||
"trailing_stop": price * 0.90,
|
||||
"sector": stock_sector, # Store sector info
|
||||
}
|
||||
|
||||
_save_json(_portfolio_path(game_id, username), pf)
|
||||
@ -285,6 +353,108 @@ def get_snapshots(game_id, username):
|
||||
return _load_json(_snapshots_path(game_id, username), [])
|
||||
|
||||
|
||||
def rebalance_portfolio(game_id, username, dry_run=True):
|
||||
"""Rebalance portfolio to enforce sector caps and cash reserves.
|
||||
|
||||
Args:
|
||||
game_id: Game ID
|
||||
username: Username
|
||||
dry_run: If True, return recommendations without executing trades
|
||||
|
||||
Returns:
|
||||
Dict with rebalance actions and results
|
||||
"""
|
||||
p = get_portfolio(game_id, username)
|
||||
if not p:
|
||||
return {"success": False, "error": "Portfolio not found"}
|
||||
|
||||
actions = []
|
||||
violations = []
|
||||
|
||||
# Check cash reserve
|
||||
min_cash_required = p["total_value"] * MIN_CASH_PCT
|
||||
cash_deficit = max(0, min_cash_required - p["cash"])
|
||||
|
||||
if cash_deficit > 0:
|
||||
violations.append(f"Cash reserve deficit: ${cash_deficit:,.2f} (need ${min_cash_required:,.2f}, have ${p['cash']:,.2f})")
|
||||
|
||||
# Check sector allocations
|
||||
sector_allocations = get_sector_allocation(game_id, username)
|
||||
sector_violations = {}
|
||||
|
||||
for sector, pct in sector_allocations.items():
|
||||
if pct > MAX_SECTOR_PCT * 100:
|
||||
excess_pct = pct - (MAX_SECTOR_PCT * 100)
|
||||
excess_value = (excess_pct / 100) * p["total_value"]
|
||||
sector_violations[sector] = {
|
||||
"current_pct": pct,
|
||||
"excess_pct": excess_pct,
|
||||
"excess_value": excess_value,
|
||||
}
|
||||
violations.append(f"{sector} sector: {pct:.1f}% (excess: {excess_pct:.1f}%, ${excess_value:,.2f})")
|
||||
|
||||
# Calculate total excess to sell
|
||||
total_excess = cash_deficit + sum(v["excess_value"] for v in sector_violations.values())
|
||||
|
||||
if total_excess > 0:
|
||||
# Find positions to sell (prioritize largest positions in violated sectors)
|
||||
sell_candidates = []
|
||||
|
||||
for ticker, pos in p["positions"].items():
|
||||
sector = pos.get("sector") or get_stock_sector(ticker)
|
||||
if sector in sector_violations:
|
||||
sell_candidates.append({
|
||||
"ticker": ticker,
|
||||
"sector": sector,
|
||||
"market_value": pos["market_value"],
|
||||
"shares": pos["shares"],
|
||||
"current_price": pos["current_price"],
|
||||
"priority": sector_violations[sector]["excess_pct"],
|
||||
})
|
||||
|
||||
# Sort by priority (highest excess first) and market value
|
||||
sell_candidates.sort(key=lambda x: (x["priority"], -x["market_value"]), reverse=True)
|
||||
|
||||
# Calculate sells needed
|
||||
remaining_to_sell = total_excess
|
||||
for candidate in sell_candidates:
|
||||
if remaining_to_sell <= 0:
|
||||
break
|
||||
|
||||
# Sell partial or full position
|
||||
sell_value = min(remaining_to_sell, candidate["market_value"])
|
||||
shares_to_sell = min(candidate["shares"], int(sell_value / candidate["current_price"]))
|
||||
|
||||
if shares_to_sell > 0:
|
||||
action = {
|
||||
"action": "SELL",
|
||||
"ticker": candidate["ticker"],
|
||||
"shares": shares_to_sell,
|
||||
"price": candidate["current_price"],
|
||||
"value": shares_to_sell * candidate["current_price"],
|
||||
"reason": f"Rebalance: {candidate['sector']} sector cap violation",
|
||||
}
|
||||
actions.append(action)
|
||||
remaining_to_sell -= action["value"]
|
||||
|
||||
# Execute if not dry run
|
||||
if not dry_run:
|
||||
result = sell(game_id, username, candidate["ticker"],
|
||||
shares_to_sell, candidate["current_price"],
|
||||
action["reason"])
|
||||
action["result"] = result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": dry_run,
|
||||
"violations": violations,
|
||||
"actions": actions,
|
||||
"total_excess": total_excess,
|
||||
"sector_allocations": sector_allocations,
|
||||
"cash_deficit": cash_deficit,
|
||||
}
|
||||
|
||||
|
||||
def get_leaderboard(game_id):
|
||||
"""Get game leaderboard sorted by % return."""
|
||||
game = get_game(game_id)
|
||||
|
||||
Reference in New Issue
Block a user