Market Watch: multiplayer GARP paper trading simulator

- 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
This commit is contained in:
2026-02-08 15:18:41 -06:00
parent b6095ec964
commit be43231c3f
29 changed files with 4169 additions and 4 deletions

View File

@ -1,5 +1,5 @@
{
"last_check": "2026-02-08T18:47:00.023346+00:00",
"last_check": "2026-02-08T21:14:59.952296+00:00",
"total_tracked": 3100,
"new_this_check": 0
}

View File

@ -101,9 +101,9 @@
"quantity": 186,
"stop_loss": null,
"take_profit": null,
"current_price": 0.475,
"unrealized_pnl": -0.93,
"unrealized_pnl_pct": -1.04,
"current_price": 0.465,
"unrealized_pnl": -2.79,
"unrealized_pnl_pct": -3.12,
"source_post": "https://polymarket.com/profile/kch123",
"thesis": "Copy kch123 proportional. Spread: Seahawks (-5.5) (Seahawks). Weight: 9.0%",
"notes": "kch123 has $203,779 on this (9.0% of active book)",

View File

@ -0,0 +1,86 @@
# Market Watch - Multiplayer GARP Paper Trading Simulator
Multiplayer paper trading simulator implementing a **Growth at a Reasonable Price (GARP)** strategy. Compete against Case (AI) and other players.
## How It Works
- **Create or join a game** with configurable starting cash
- **Trade manually** via the web portal or Telegram
- **Case (AI)** trades autonomously using the GARP strategy
- **Leaderboard** tracks who's winning by % return
## GARP Filter Criteria
| Metric | Threshold |
|--------|-----------|
| Revenue Growth | ≥ 10% |
| Trailing P/E | < 25 |
| Forward P/E | < 15 |
| PEG Ratio | < 1.2 (if available) |
| EPS Growth | > 15% |
| ROE | > 5% |
| Quick Ratio | > 1.5 (if available) |
| Debt/Equity | < 35% (if available) |
| Market Cap | > $5B |
### Case's Trading Rules
- **Buy:** GARP filter pass + RSI < 70 + not within 2% of 52wk high + max 10% per position + max 15 positions
- **Sell:** Fails GARP rescan OR 10% trailing stop-loss OR RSI > 80
- **Universe:** S&P 500 + S&P 400 MidCap (~900 stocks)
## Architecture
| File | Description |
|------|-------------|
| `game_engine.py` | Multiplayer game/player/portfolio engine |
| `scanner.py` | GARP scanner across S&P 500 + 400 |
| `trader.py` | Case's autonomous trading logic |
| `run_daily.py` | Daily orchestrator (scan → trade → snapshot → alert) |
| `portfolio.py` | Backward-compatible wrapper for single-player |
| `portal/server.py` | Web dashboard with multiplayer UI |
### Data Structure
```
data/games/{game_id}/
├── game.json # Game config, players, rules
└── players/{username}/
├── portfolio.json # Current positions & cash
├── trades.json # Trade history
└── snapshots.json # Daily value snapshots
```
## Web Portal
**URL:** http://marketwatch.local (or http://localhost:8889)
- **Home:** List of games, create new game
- **Game page:** Leaderboard, join game
- **Player page:** Portfolio, trade form, performance chart, trade history
### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/games` | Create game (form: name, starting_cash, end_date) |
| POST | `/api/games/{id}/join` | Join game (form: username) |
| POST | `/api/games/{id}/players/{user}/trade` | Trade (form: action, ticker, shares) |
| GET | `/api/games/{id}/leaderboard` | Get leaderboard JSON |
| GET | `/api/games/{id}/players/{user}/portfolio` | Get portfolio JSON |
## Systemd Services
| Service | Schedule |
|---------|----------|
| `market-watch.timer` | Mon-Fri 9:00 AM + 3:30 PM CST |
| `market-watch-portal.service` | Always running (port 8889) |
```bash
systemctl --user status market-watch.timer
systemctl --user status market-watch-portal
journalctl --user -u market-watch -f
```
## Telegram
Players can trade via: `buy AAPL 10` or `sell BAC 50`
Daily summaries with leaderboard sent automatically.

View File

@ -0,0 +1,14 @@
{
"game_id": "7ebf65c7",
"name": "GARP Challenge",
"starting_cash": 100000.0,
"start_date": "2026-02-08",
"end_date": null,
"creator": "case",
"created_at": "2026-02-08T15:15:43.402301",
"players": [
"case",
"testplayer"
],
"status": "active"
}

View File

@ -0,0 +1,4 @@
{
"cash": 100000.0,
"positions": {}
}

View File

@ -0,0 +1,4 @@
{
"cash": 100000.0,
"positions": {}
}

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
{"cash": 100000.0, "positions": {}}

View File

@ -0,0 +1,206 @@
{
"date": "2026-02-08",
"timestamp": "2026-02-08T15:18:03.800566",
"total_scanned": 902,
"candidates_found": 11,
"candidates": [
{
"ticker": "ALLY",
"price": 42.31,
"market_cap": 13052339200,
"market_cap_b": 13.1,
"trailing_pe": 17.85,
"forward_pe": 6.7,
"peg_ratio": null,
"revenue_growth": 12.0,
"earnings_growth": 265.4,
"roe": 5.8,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 44.58,
"week52_high": 47.27,
"pct_from_52wk_high": 10.5,
"score": -21.04
},
{
"ticker": "JHG",
"price": 48.22,
"market_cap": 7448852992,
"market_cap_b": 7.4,
"trailing_pe": 9.22,
"forward_pe": 10.12,
"peg_ratio": null,
"revenue_growth": 61.3,
"earnings_growth": 243.6,
"roe": 16.2,
"quick_ratio": 69.46,
"debt_to_equity": 6.5,
"rsi": 63.83,
"week52_high": 49.42,
"pct_from_52wk_high": 2.4,
"score": -20.37
},
{
"ticker": "INCY",
"price": 108.39,
"market_cap": 21279418368,
"market_cap_b": 21.3,
"trailing_pe": 18.37,
"forward_pe": 13.61,
"peg_ratio": null,
"revenue_growth": 20.0,
"earnings_growth": 290.7,
"roe": 30.4,
"quick_ratio": 2.86,
"debt_to_equity": 0.9,
"rsi": 54.22,
"week52_high": 112.29,
"pct_from_52wk_high": 3.5,
"score": -17.46
},
{
"ticker": "FHN",
"price": 26.23,
"market_cap": 12915496960,
"market_cap_b": 12.9,
"trailing_pe": 14.03,
"forward_pe": 11.19,
"peg_ratio": null,
"revenue_growth": 23.7,
"earnings_growth": 74.9,
"roe": 10.9,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 72.21,
"week52_high": 26.56,
"pct_from_52wk_high": 1.2,
"score": 1.3299999999999992
},
{
"ticker": "FNB",
"price": 18.9,
"market_cap": 6768781312,
"market_cap_b": 6.8,
"trailing_pe": 12.12,
"forward_pe": 9.66,
"peg_ratio": null,
"revenue_growth": 26.4,
"earnings_growth": 56.5,
"roe": 8.7,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 69.25,
"week52_high": 19.04,
"pct_from_52wk_high": 0.7,
"score": 1.37
},
{
"ticker": "EXEL",
"price": 43.9,
"market_cap": 11817991168,
"market_cap_b": 11.8,
"trailing_pe": 18.45,
"forward_pe": 12.79,
"peg_ratio": null,
"revenue_growth": 10.8,
"earnings_growth": 72.5,
"roe": 30.6,
"quick_ratio": 3.5,
"debt_to_equity": 8.2,
"rsi": 49.65,
"week52_high": 49.62,
"pct_from_52wk_high": 11.5,
"score": 4.459999999999999
},
{
"ticker": "CART",
"price": 34.64,
"market_cap": 9125501952,
"market_cap_b": 9.1,
"trailing_pe": 19.03,
"forward_pe": 8.84,
"peg_ratio": null,
"revenue_growth": 10.2,
"earnings_growth": 21.1,
"roe": 15.3,
"quick_ratio": 3.33,
"debt_to_equity": 1.0,
"rsi": 30.92,
"week52_high": 53.5,
"pct_from_52wk_high": 35.3,
"score": 5.709999999999999
},
{
"ticker": "CFG",
"price": 68.12,
"market_cap": 29256599552,
"market_cap_b": 29.3,
"trailing_pe": 17.65,
"forward_pe": 10.85,
"peg_ratio": null,
"revenue_growth": 10.7,
"earnings_growth": 35.9,
"roe": 7.2,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 75.46,
"week52_high": 68.36,
"pct_from_52wk_high": 0.4,
"score": 6.1899999999999995
},
{
"ticker": "EWBC",
"price": 122.5,
"market_cap": 16854236160,
"market_cap_b": 16.9,
"trailing_pe": 12.87,
"forward_pe": 11.18,
"peg_ratio": null,
"revenue_growth": 21.6,
"earnings_growth": 21.3,
"roe": 15.9,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 67.58,
"week52_high": 123.22,
"pct_from_52wk_high": 0.6,
"score": 6.890000000000001
},
{
"ticker": "BAC",
"price": 56.53,
"market_cap": 412810084352,
"market_cap_b": 412.8,
"trailing_pe": 14.84,
"forward_pe": 11.41,
"peg_ratio": null,
"revenue_growth": 13.2,
"earnings_growth": 20.9,
"roe": 10.2,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 71.14,
"week52_high": 57.55,
"pct_from_52wk_high": 1.8,
"score": 8.0
},
{
"ticker": "FITB",
"price": 55.08,
"market_cap": 49574670336,
"market_cap_b": 49.6,
"trailing_pe": 15.6,
"forward_pe": 11.24,
"peg_ratio": null,
"revenue_growth": 11.5,
"earnings_growth": 20.8,
"roe": 12.2,
"quick_ratio": null,
"debt_to_equity": null,
"rsi": 71.83,
"week52_high": 55.36,
"pct_from_52wk_high": 0.5,
"score": 8.01
}
]
}

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""Multiplayer game engine for Market Watch paper trading."""
import json
import os
import uuid
from datetime import datetime, date
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
GAMES_DIR = os.path.join(DATA_DIR, "games")
def _load_json(path, default=None):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return default if default is not None else {}
def _save_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2, default=str)
def _game_dir(game_id):
return os.path.join(GAMES_DIR, game_id)
def _player_dir(game_id, username):
return os.path.join(_game_dir(game_id), "players", username)
def _portfolio_path(game_id, username):
return os.path.join(_player_dir(game_id, username), "portfolio.json")
def _trades_path(game_id, username):
return os.path.join(_player_dir(game_id, username), "trades.json")
def _snapshots_path(game_id, username):
return os.path.join(_player_dir(game_id, username), "snapshots.json")
def _game_config_path(game_id):
return os.path.join(_game_dir(game_id), "game.json")
# ── Game Management ──
def create_game(name, starting_cash=100_000.0, end_date=None, creator="system"):
"""Create a new game. Returns game_id."""
game_id = str(uuid.uuid4())[:8]
config = {
"game_id": game_id,
"name": name,
"starting_cash": starting_cash,
"start_date": date.today().isoformat(),
"end_date": end_date,
"creator": creator,
"created_at": datetime.now().isoformat(),
"players": [],
"status": "active",
}
os.makedirs(_game_dir(game_id), exist_ok=True)
_save_json(_game_config_path(game_id), config)
return game_id
def list_games(active_only=True):
"""List all games."""
games = []
if not os.path.exists(GAMES_DIR):
return games
for gid in os.listdir(GAMES_DIR):
config_path = _game_config_path(gid)
if os.path.exists(config_path):
config = _load_json(config_path)
if active_only and config.get("status") != "active":
continue
games.append(config)
return sorted(games, key=lambda g: g.get("created_at", ""), reverse=True)
def get_game(game_id):
"""Get game config."""
return _load_json(_game_config_path(game_id))
def join_game(game_id, username):
"""Add a player to a game. Initializes their portfolio."""
config = get_game(game_id)
if not config:
return {"success": False, "error": "Game not found"}
if username in config["players"]:
return {"success": False, "error": f"{username} already in game"}
config["players"].append(username)
_save_json(_game_config_path(game_id), config)
# Initialize player portfolio
player_dir = _player_dir(game_id, username)
os.makedirs(player_dir, exist_ok=True)
_save_json(_portfolio_path(game_id, username), {
"cash": config["starting_cash"],
"positions": {},
})
_save_json(_trades_path(game_id, username), [])
_save_json(_snapshots_path(game_id, username), [])
return {"success": True, "game_id": game_id, "username": username, "starting_cash": config["starting_cash"]}
# ── Trading ──
def buy(game_id, username, ticker, shares, price, reason="Manual"):
"""Buy shares for a player in a game."""
pf = _load_json(_portfolio_path(game_id, username))
if not pf:
return {"success": False, "error": "Player portfolio not found"}
cost = shares * price
if cost > pf["cash"]:
return {"success": False, "error": f"Insufficient cash. Need ${cost:,.2f}, have ${pf['cash']:,.2f}"}
pf["cash"] -= cost
if ticker in pf["positions"]:
pos = pf["positions"][ticker]
total_shares = pos["shares"] + shares
pos["avg_cost"] = ((pos["avg_cost"] * pos["shares"]) + cost) / total_shares
pos["shares"] = total_shares
pos["current_price"] = price
# Update trailing stop
new_stop = price * 0.90
if new_stop > pos.get("trailing_stop", 0):
pos["trailing_stop"] = new_stop
else:
pf["positions"][ticker] = {
"shares": shares,
"avg_cost": price,
"current_price": price,
"entry_date": datetime.now().isoformat(),
"entry_reason": reason,
"trailing_stop": price * 0.90,
}
_save_json(_portfolio_path(game_id, username), pf)
# Log trade
trades = _load_json(_trades_path(game_id, username), [])
trades.append({
"action": "BUY",
"ticker": ticker,
"shares": shares,
"price": price,
"cost": round(cost, 2),
"reason": reason,
"timestamp": datetime.now().isoformat(),
})
_save_json(_trades_path(game_id, username), trades)
return {"success": True, "ticker": ticker, "shares": shares, "price": price, "cost": round(cost, 2), "cash_remaining": round(pf["cash"], 2)}
def sell(game_id, username, ticker, shares=None, price=None, reason="Manual"):
"""Sell shares for a player."""
pf = _load_json(_portfolio_path(game_id, username))
if not pf:
return {"success": False, "error": "Player portfolio not found"}
if ticker not in pf["positions"]:
return {"success": False, "error": f"No position in {ticker}"}
pos = pf["positions"][ticker]
if shares is None:
shares = pos["shares"]
if shares > pos["shares"]:
return {"success": False, "error": f"Only have {pos['shares']} shares of {ticker}"}
if price is None:
price = pos["current_price"]
proceeds = shares * price
pf["cash"] += proceeds
realized_pnl = (price - pos["avg_cost"]) * shares
if shares >= pos["shares"]:
del pf["positions"][ticker]
else:
pos["shares"] -= shares
_save_json(_portfolio_path(game_id, username), pf)
# Log trade
trades = _load_json(_trades_path(game_id, username), [])
trades.append({
"action": "SELL",
"ticker": ticker,
"shares": shares,
"price": price,
"proceeds": round(proceeds, 2),
"realized_pnl": round(realized_pnl, 2),
"entry_price": pos["avg_cost"],
"reason": reason,
"timestamp": datetime.now().isoformat(),
})
_save_json(_trades_path(game_id, username), trades)
return {"success": True, "ticker": ticker, "shares": shares, "price": price, "proceeds": round(proceeds, 2), "realized_pnl": round(realized_pnl, 2)}
def update_price(game_id, username, ticker, price):
"""Update current price for a position."""
pf = _load_json(_portfolio_path(game_id, username))
if pf and ticker in pf["positions"]:
pos = pf["positions"][ticker]
pos["current_price"] = price
new_stop = price * 0.90
if new_stop > pos.get("trailing_stop", 0):
pos["trailing_stop"] = new_stop
_save_json(_portfolio_path(game_id, username), pf)
def get_portfolio(game_id, username):
"""Get player's portfolio with unrealized P&L."""
pf = _load_json(_portfolio_path(game_id, username))
if not pf:
return None
game = get_game(game_id)
starting_cash = game.get("starting_cash", 100_000) if game else 100_000
total_value = pf["cash"]
positions_out = {}
for ticker, pos in pf["positions"].items():
unrealized_pnl = (pos["current_price"] - pos["avg_cost"]) * pos["shares"]
market_value = pos["current_price"] * pos["shares"]
total_value += market_value
positions_out[ticker] = {
**pos,
"unrealized_pnl": round(unrealized_pnl, 2),
"market_value": round(market_value, 2),
}
total_pnl = total_value - starting_cash
return {
"username": username,
"game_id": game_id,
"cash": round(pf["cash"], 2),
"positions": positions_out,
"total_value": round(total_value, 2),
"total_pnl": round(total_pnl, 2),
"pnl_pct": round(total_pnl / starting_cash * 100, 2),
"num_positions": len(positions_out),
}
def get_trades(game_id, username):
"""Get player's trade history."""
return _load_json(_trades_path(game_id, username), [])
def daily_snapshot(game_id, username):
"""Take daily snapshot for a player."""
p = get_portfolio(game_id, username)
if not p:
return None
snapshots = _load_json(_snapshots_path(game_id, username), [])
today = date.today().isoformat()
snapshots = [s for s in snapshots if s["date"] != today]
snapshots.append({
"date": today,
"total_value": p["total_value"],
"total_pnl": p["total_pnl"],
"pnl_pct": p["pnl_pct"],
"cash": p["cash"],
"num_positions": p["num_positions"],
})
_save_json(_snapshots_path(game_id, username), snapshots)
return snapshots[-1]
def get_snapshots(game_id, username):
"""Get player's daily snapshots."""
return _load_json(_snapshots_path(game_id, username), [])
def get_leaderboard(game_id):
"""Get game leaderboard sorted by % return."""
game = get_game(game_id)
if not game:
return []
board = []
for username in game["players"]:
p = get_portfolio(game_id, username)
if p:
trades = get_trades(game_id, username)
num_trades = len([t for t in trades if t.get("action") == "SELL"])
board.append({
"username": username,
"total_value": p["total_value"],
"total_pnl": p["total_pnl"],
"pnl_pct": p["pnl_pct"],
"num_positions": p["num_positions"],
"num_trades": num_trades,
"cash": p["cash"],
})
return sorted(board, key=lambda x: x["pnl_pct"], reverse=True)
# ── Convenience: find default game ──
def get_default_game_id():
"""Get the first active game (usually 'GARP Challenge')."""
games = list_games(active_only=True)
if games:
return games[0]["game_id"]
return None
# ── Initialize default game ──
def ensure_default_game():
"""Create default GARP Challenge game with 'case' player if it doesn't exist."""
games = list_games(active_only=True)
for g in games:
if g["name"] == "GARP Challenge":
return g["game_id"]
game_id = create_game("GARP Challenge", starting_cash=100_000.0, creator="case")
join_game(game_id, "case")
return game_id
if __name__ == "__main__":
game_id = ensure_default_game()
game = get_game(game_id)
print(f"Game: {game['name']} ({game_id})")
print(f"Players: {game['players']}")
board = get_leaderboard(game_id)
for entry in board:
print(f" {entry['username']}: ${entry['total_value']:,.2f} ({entry['pnl_pct']:+.2f}%)")

View File

@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""Market Watch Web Portal - Multiplayer GARP Paper Trading."""
import json
import os
import sys
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import urlparse, parse_qs
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import game_engine
PORT = 8889
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCANS_DIR = os.path.join(PROJECT_DIR, "data", "scans")
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
CSS = """:root{--bg-primary:#0d1117;--bg-secondary:#161b22;--bg-tertiary:#21262d;--text-primary:#f0f6fc;--text-secondary:#8b949e;--border-color:#30363d;--accent-blue:#58a6ff;--accent-purple:#bc8cff;--positive-green:#3fb950;--negative-red:#f85149;--gold:#f0c000;--silver:#c0c0c0;--bronze:#cd7f32}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.5}
a{color:var(--accent-blue);text-decoration:none}a:hover{text-decoration:underline}
.navbar{background:var(--bg-secondary);border-bottom:1px solid var(--border-color);padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
.nav-brand{font-size:1.5rem;font-weight:bold;color:var(--accent-blue)}
.nav-links{display:flex;gap:1.5rem}
.nav-links a{color:var(--text-secondary);text-decoration:none;padding:.5rem 1rem;border-radius:6px;transition:all .2s}
.nav-links a:hover{color:var(--text-primary);background:var(--bg-tertiary)}
.nav-links a.active{color:var(--accent-blue);background:var(--bg-tertiary)}
.container{max-width:1400px;margin:0 auto;padding:2rem}
.card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
.card h3{color:var(--text-primary);margin-bottom:1rem;font-size:1.1rem}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.5rem;margin-bottom:2rem}
.metric-large{font-size:2rem;font-weight:bold;margin-bottom:.3rem}
.metric-small{color:var(--text-secondary);font-size:.85rem}
.positive{color:var(--positive-green)!important}.negative{color:var(--negative-red)!important}
table{width:100%;border-collapse:collapse}
th,td{padding:.6rem .8rem;text-align:left;border-bottom:1px solid var(--border-color)}
th{color:var(--text-secondary);font-size:.8rem;text-transform:uppercase}
td{font-size:.9rem}
.rank-1{color:var(--gold);font-weight:bold}.rank-2{color:var(--silver)}.rank-3{color:var(--bronze)}
.btn{display:inline-block;padding:.5rem 1.2rem;background:var(--accent-blue);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.9rem;text-decoration:none;transition:opacity .2s}
.btn:hover{opacity:.85;text-decoration:none}
.btn-outline{background:transparent;border:1px solid var(--border-color);color:var(--text-primary)}
.btn-outline:hover{border-color:var(--accent-blue)}
.btn-green{background:var(--positive-green)}.btn-red{background:var(--negative-red)}
input,select{background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-primary);padding:.5rem .8rem;border-radius:6px;font-size:.9rem}
.form-row{display:flex;gap:1rem;align-items:end;flex-wrap:wrap;margin-bottom:1rem}
.form-group{display:flex;flex-direction:column;gap:.3rem}
.form-group label{font-size:.8rem;color:var(--text-secondary);text-transform:uppercase}
.badge{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:bold}
.badge-ai{background:var(--accent-purple);color:#fff}
.badge-human{background:var(--accent-blue);color:#fff}
.player-link{color:var(--text-primary);font-weight:500}
@media(max-width:768px){.navbar{flex-direction:column;gap:1rem}.cards{grid-template-columns:1fr}.container{padding:1rem}.form-row{flex-direction:column}}"""
def nav(active=""):
return f"""<nav class="navbar">
<a href="/" style="text-decoration:none"><div class="nav-brand">📊 Market Watch</div></a>
<div class="nav-links">
<a href="/" class="{'active' if active=='home' else ''}">Games</a>
<a href="/scans" class="{'active' if active=='scans' else ''}">Scans</a>
</div></nav>"""
class MarketWatchHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
params = parse_qs(parsed.query)
if path == "" or path == "/":
self.serve_home()
elif path == "/create-game":
self.serve_create_game()
elif path.startswith("/game/") and "/player/" in path:
parts = path.split("/") # /game/{gid}/player/{user}
self.serve_player(parts[2], parts[4])
elif path.startswith("/game/"):
game_id = path.split("/")[2]
self.serve_game(game_id)
elif path == "/scans":
self.serve_scans()
# API
elif path.startswith("/api/games") and len(path.split("/")) == 3:
self.send_json(game_engine.list_games(active_only=False))
elif path.startswith("/api/games/") and path.endswith("/leaderboard"):
gid = path.split("/")[3]
self.send_json(game_engine.get_leaderboard(gid))
elif "/portfolio" in path:
parts = path.split("/")
self.send_json(game_engine.get_portfolio(parts[3], parts[5]))
else:
self.send_error(404)
except Exception as e:
self.send_response(500)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
def do_POST(self):
try:
content_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_len).decode() if content_len else ""
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
if path == "/api/games":
data = parse_form(body)
name = data.get("name", "Untitled Game")
cash = float(data.get("starting_cash", 100000))
end_date = data.get("end_date") or None
gid = game_engine.create_game(name, cash, end_date)
self.redirect(f"/game/{gid}")
elif path.endswith("/join"):
data = parse_form(body)
parts = path.split("/")
gid = parts[3]
username = data.get("username", "").strip().lower()
if username:
game_engine.join_game(gid, username)
self.redirect(f"/game/{gid}")
elif path.endswith("/trade"):
data = parse_form(body)
parts = path.split("/")
gid, username = parts[3], parts[5]
action = data.get("action", "").upper()
ticker = data.get("ticker", "").upper().strip()
shares = int(data.get("shares", 0))
if ticker and shares > 0:
import yfinance as yf
price = yf.Ticker(ticker).info.get("currentPrice") or yf.Ticker(ticker).info.get("regularMarketPrice", 0)
if price and price > 0:
if action == "BUY":
game_engine.buy(gid, username, ticker, shares, price, reason="Manual trade")
elif action == "SELL":
game_engine.sell(gid, username, ticker, shares, price, reason="Manual trade")
self.redirect(f"/game/{gid}/player/{username}")
else:
self.send_error(404)
except Exception as e:
self.send_response(500)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
def serve_home(self):
games = game_engine.list_games(active_only=False)
rows = ""
for g in games:
players = len(g.get("players", []))
status_badge = '<span class="badge badge-ai">Active</span>' if g["status"] == "active" else '<span class="badge">Ended</span>'
rows += f"""<tr>
<td><a href="/game/{g['game_id']}" class="player-link">{g['name']}</a></td>
<td>{players}</td>
<td>${g['starting_cash']:,.0f}</td>
<td>{g['start_date']}</td>
<td>{g.get('end_date', '') or ''}</td>
<td>{status_badge}</td>
</tr>"""
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Market Watch</title><style>{CSS}</style></head><body>
{nav('home')}
<div class="container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
<h2>🎮 Active Games</h2>
<a href="/create-game" class="btn">+ New Game</a>
</div>
<div class="card">
<table><thead><tr><th>Game</th><th>Players</th><th>Starting Cash</th><th>Started</th><th>Ends</th><th>Status</th></tr></thead>
<tbody>{rows if rows else '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary)">No games yet — create one!</td></tr>'}</tbody></table>
</div>
</div></body></html>"""
self.send_html(html)
def serve_create_game(self):
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Create Game - Market Watch</title><style>{CSS}</style></head><body>
{nav()}
<div class="container">
<div class="card">
<h3>🎮 Create New Game</h3>
<form method="POST" action="/api/games">
<div class="form-row">
<div class="form-group"><label>Game Name</label><input type="text" name="name" placeholder="GARP Challenge" required></div>
<div class="form-group"><label>Starting Cash ($)</label><input type="number" name="starting_cash" value="100000" min="1000" step="1000"></div>
<div class="form-group"><label>End Date (optional)</label><input type="date" name="end_date"></div>
</div>
<button type="submit" class="btn">Create Game</button>
</form>
</div>
</div></body></html>"""
self.send_html(html)
def serve_game(self, game_id):
game = game_engine.get_game(game_id)
if not game:
return self.send_error(404)
board = game_engine.get_leaderboard(game_id)
rank_rows = ""
for i, entry in enumerate(board):
rank_class = f"rank-{i+1}" if i < 3 else ""
medal = ["🥇", "🥈", "🥉"][i] if i < 3 else f"#{i+1}"
pnl_class = "positive" if entry["pnl_pct"] >= 0 else "negative"
badge = ' <span class="badge badge-ai">AI</span>' if entry["username"] == "case" else ""
rank_rows += f"""<tr>
<td class="{rank_class}">{medal}</td>
<td><a href="/game/{game_id}/player/{entry['username']}" class="player-link">{entry['username']}</a>{badge}</td>
<td>${entry['total_value']:,.2f}</td>
<td class="{pnl_class}">{entry['pnl_pct']:+.2f}%</td>
<td class="{pnl_class}">${entry['total_pnl']:+,.2f}</td>
<td>{entry['num_positions']}</td>
<td>{entry['num_trades']}</td>
</tr>"""
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{game['name']} - Market Watch</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
{nav()}
<div class="container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
<h2>🏆 {game['name']}</h2>
<span class="badge badge-ai">{game['status'].upper()}</span>
</div>
<p style="color:var(--text-secondary);margin-bottom:1.5rem">Started {game['start_date']} · ${game['starting_cash']:,.0f} starting cash · {len(game['players'])} players</p>
<div class="card">
<h3>Leaderboard</h3>
<table><thead><tr><th>Rank</th><th>Player</th><th>Portfolio</th><th>Return</th><th>P&L</th><th>Positions</th><th>Trades</th></tr></thead>
<tbody>{rank_rows if rank_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No players yet</td></tr>'}</tbody></table>
</div>
<div class="card">
<h3>Join This Game</h3>
<form method="POST" action="/api/games/{game_id}/join">
<div class="form-row">
<div class="form-group"><label>Username</label><input type="text" name="username" placeholder="your name" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, dashes, underscores only"></div>
<button type="submit" class="btn">Join Game</button>
</div>
</form>
</div>
</div></body></html>"""
self.send_html(html)
def serve_player(self, game_id, username):
game = game_engine.get_game(game_id)
p = game_engine.get_portfolio(game_id, username)
if not game or not p:
return self.send_error(404)
trades = game_engine.get_trades(game_id, username)
snapshots = game_engine.get_snapshots(game_id, username)
pnl_class = "positive" if p["total_pnl"] >= 0 else "negative"
is_ai = username == "case"
badge = '<span class="badge badge-ai">AI Player</span>' if is_ai else '<span class="badge badge-human">Human</span>'
# Positions table
pos_rows = ""
for ticker, pos in sorted(p["positions"].items()):
pc = "positive" if pos["unrealized_pnl"] >= 0 else "negative"
pos_rows += f"""<tr>
<td><strong>{ticker}</strong></td><td>{pos['shares']}</td>
<td>${pos['avg_cost']:.2f}</td><td>${pos['current_price']:.2f}</td>
<td>${pos['market_value']:,.2f}</td><td class="{pc}">${pos['unrealized_pnl']:+,.2f}</td>
<td>${pos.get('trailing_stop',0):.2f}</td>
</tr>"""
if not pos_rows:
pos_rows = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No positions</td></tr>'
# Trade log
trade_rows = ""
for t in reversed(trades[-30:]):
action_class = "positive" if t["action"] == "BUY" else "negative"
pnl_cell = ""
if t["action"] == "SELL":
rpnl = t.get("realized_pnl", 0)
rpnl_class = "positive" if rpnl >= 0 else "negative"
pnl_cell = f'<span class="{rpnl_class}">${rpnl:+,.2f}</span>'
trade_rows += f"""<tr>
<td class="{action_class}">{t['action']}</td><td>{t['ticker']}</td><td>{t['shares']}</td>
<td>${t['price']:.2f}</td><td>{pnl_cell}</td>
<td>{t.get('reason','')[:40]}</td><td>{t['timestamp'][:16]}</td>
</tr>"""
chart_labels = json.dumps([s["date"] for s in snapshots])
chart_values = json.dumps([s["total_value"] for s in snapshots])
starting = game.get("starting_cash", 100000)
# Trade form (only for humans)
trade_form = "" if is_ai else f"""
<div class="card">
<h3>📝 Place Trade</h3>
<form method="POST" action="/api/games/{game_id}/players/{username}/trade">
<div class="form-row">
<div class="form-group"><label>Action</label>
<select name="action"><option value="BUY">BUY</option><option value="SELL">SELL</option></select></div>
<div class="form-group"><label>Ticker</label><input type="text" name="ticker" placeholder="AAPL" required style="text-transform:uppercase"></div>
<div class="form-group"><label>Shares</label><input type="number" name="shares" min="1" value="10" required></div>
<button type="submit" class="btn btn-green">Execute</button>
</div>
<p style="color:var(--text-secondary);font-size:.8rem;margin-top:.5rem">Trades execute at current market price via Yahoo Finance</p>
</form>
</div>"""
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{username} - {game['name']}</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
{nav()}
<div class="container">
<div style="margin-bottom:.5rem"><a href="/game/{game_id}" style="color:var(--text-secondary)">← {game['name']}</a></div>
<h2>{username} {badge}</h2>
<div class="cards" style="margin-top:1rem">
<div class="card"><h3>Portfolio Value</h3><div class="metric-large">${p['total_value']:,.2f}</div><div class="metric-small">Started at ${starting:,.0f}</div></div>
<div class="card"><h3>Cash</h3><div class="metric-large">${p['cash']:,.2f}</div><div class="metric-small">{p['cash']/max(p['total_value'],1)*100:.1f}% available</div></div>
<div class="card"><h3>Return</h3><div class="metric-large {pnl_class}">{p['pnl_pct']:+.2f}%</div><div class="metric-small {pnl_class}">${p['total_pnl']:+,.2f}</div></div>
<div class="card"><h3>Positions</h3><div class="metric-large">{p['num_positions']}</div><div class="metric-small">{len(trades)} total trades</div></div>
</div>
<div class="card"><h3>Performance</h3><canvas id="chart" height="80"></canvas></div>
{trade_form}
<div class="card"><h3>Positions</h3>
<table><thead><tr><th>Ticker</th><th>Shares</th><th>Avg Cost</th><th>Price</th><th>Value</th><th>P&L</th><th>Stop</th></tr></thead>
<tbody>{pos_rows}</tbody></table>
</div>
<div class="card"><h3>Trade Log</h3>
<table><thead><tr><th>Action</th><th>Ticker</th><th>Shares</th><th>Price</th><th>P&L</th><th>Reason</th><th>Time</th></tr></thead>
<tbody>{trade_rows if trade_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No trades yet</td></tr>'}</tbody></table>
</div>
</div>
<script>
const ctx = document.getElementById('chart').getContext('2d');
const labels = {chart_labels}; const values = {chart_values};
if (labels.length > 0) {{
new Chart(ctx, {{type:'line',data:{{labels:labels,datasets:[
{{label:'Portfolio',data:values,borderColor:'#58a6ff',backgroundColor:'rgba(88,166,255,0.1)',fill:true,tension:0.3}},
{{label:'Starting',data:labels.map(()=>{starting}),borderColor:'#30363d',borderDash:[5,5],pointRadius:0}}
]}},options:{{responsive:true,plugins:{{legend:{{labels:{{color:'#f0f6fc'}}}}}},scales:{{x:{{ticks:{{color:'#8b949e'}},grid:{{color:'#21262d'}}}},y:{{ticks:{{color:'#8b949e',callback:v=>'$'+v.toLocaleString()}},grid:{{color:'#21262d'}}}}}}}}
}});
}} else {{ ctx.canvas.parentElement.innerHTML += '<div style="text-align:center;color:#8b949e;padding:2rem">Chart populates after first trading day</div>'; }}
</script></body></html>"""
self.send_html(html)
def serve_scans(self):
rows = ""
if os.path.exists(SCANS_DIR):
for sf in sorted(os.listdir(SCANS_DIR), reverse=True)[:30]:
if not sf.endswith(".json"): continue
data = {}
with open(os.path.join(SCANS_DIR, sf)) as f:
data = json.load(f)
n = data.get("candidates_found", len(data.get("candidates", [])))
top = ", ".join(c.get("ticker","?") for c in data.get("candidates", [])[:8])
rows += f'<tr><td>{sf.replace(".json","")}</td><td>{data.get("total_scanned",0)}</td><td>{n}</td><td>{top or ""}</td></tr>'
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Scans - Market Watch</title><style>{CSS}</style></head><body>
{nav('scans')}
<div class="container"><div class="card"><h3>📡 GARP Scan History</h3>
<table><thead><tr><th>Date</th><th>Scanned</th><th>Candidates</th><th>Top Picks</th></tr></thead>
<tbody>{rows if rows else '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary)">No scans yet</td></tr>'}</tbody></table>
</div></div></body></html>"""
self.send_html(html)
def redirect(self, url):
self.send_response(303)
self.send_header("Location", url)
self.end_headers()
def send_html(self, content):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(content.encode())
def send_json(self, data):
self.send_response(200)
self.send_header("Content-type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data, default=str).encode())
def log_message(self, format, *args):
pass
def parse_form(body):
"""Parse URL-encoded form data."""
result = {}
for pair in body.split("&"):
if "=" in pair:
k, v = pair.split("=", 1)
from urllib.parse import unquote_plus
result[unquote_plus(k)] = unquote_plus(v)
return result
def main():
game_engine.ensure_default_game()
print(f"📊 Market Watch Portal starting on localhost:{PORT}")
server = ThreadedHTTPServer(("0.0.0.0", PORT), MarketWatchHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nPortal stopped")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Portfolio module — backward-compatible wrapper around game_engine.
All operations now delegate to game_engine using the default game and 'case' player.
"""
import json
import game_engine
INITIAL_CASH = 100_000.0
def _default():
"""Get default game ID."""
return game_engine.get_default_game_id() or game_engine.ensure_default_game()
def buy(ticker, shares, price, reason="GARP signal"):
return game_engine.buy(_default(), "case", ticker, shares, price, reason)
def sell(ticker, shares=None, price=None, reason="GARP exit"):
return game_engine.sell(_default(), "case", ticker, shares, price, reason)
def update_price(ticker, price):
game_engine.update_price(_default(), "case", ticker, price)
def get_portfolio():
return game_engine.get_portfolio(_default(), "case")
def get_history():
return game_engine.get_trades(_default(), "case")
def daily_snapshot():
return game_engine.daily_snapshot(_default(), "case")
def get_snapshots():
return game_engine.get_snapshots(_default(), "case")
if __name__ == "__main__":
p = get_portfolio()
if p:
print(json.dumps(p, indent=2))
else:
print("No default game found. Run: python3 game_engine.py")

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Daily runner for Market Watch - scans, trades (as Case), snapshots, alerts."""
import json
import os
import sys
import requests
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import game_engine
from scanner import run_scan
from trader import run_trading_logic
CREDS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".credentials", "telegram-bot.env")
CHAT_ID = "6443752046"
def load_telegram_token():
if os.path.exists(CREDS_FILE):
with open(CREDS_FILE) as f:
for line in f:
if line.startswith("TELEGRAM_BOT_TOKEN="):
return line.strip().split("=", 1)[1]
return os.environ.get("TELEGRAM_BOT_TOKEN")
def send_telegram(message, token):
if not token:
return
try:
url = f"https://api.telegram.org/bot{token}/sendMessage"
requests.post(url, json={"chat_id": CHAT_ID, "text": message, "parse_mode": "HTML"}, timeout=10)
except Exception as e:
print(f"Telegram error: {e}")
def main():
print(f"📊 Market Watch Daily Run — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
token = load_telegram_token()
game_id = game_engine.ensure_default_game()
game = game_engine.get_game(game_id)
# 1. Run GARP scan
print("\n[1/3] Running GARP scan...")
scan = run_scan()
candidates = scan.get("candidates", [])
print(f" Found {len(candidates)} candidates from {scan.get('total_scanned', 0)} stocks")
# 2. Run trading logic for Case
print("\n[2/3] Running trading logic for Case...")
result = run_trading_logic(game_id, "case", candidates)
# 3. Snapshots for all players
print("\n[3/3] Taking snapshots...")
for username in game["players"]:
snap = game_engine.daily_snapshot(game_id, username)
if snap:
print(f" {username}: ${snap['total_value']:,.2f} ({snap['pnl_pct']:+.2f}%)")
# Telegram summary
p = game_engine.get_portfolio(game_id, "case")
pnl_emoji = "📈" if p["total_pnl"] >= 0 else "📉"
summary = f"📊 <b>Market Watch Daily</b>\n"
summary += f"{pnl_emoji} Portfolio: ${p['total_value']:,.2f} ({p['pnl_pct']:+.2f}%)\n"
summary += f"💰 Cash: ${p['cash']:,.2f} | Positions: {p['num_positions']}\n"
num_trades = len(result.get("sells", [])) + len(result.get("buys", []))
if num_trades:
summary += f"\n<b>{num_trades} trades executed</b>\n"
for s in result.get("sells", []):
summary += f"🔴 SELL {s['ticker']}{s['reason'][:50]}\n"
for b in result.get("buys", []):
summary += f"🟢 BUY {b['ticker']}{b['reason'][:50]}\n"
else:
summary += "\nNo trades today."
if candidates:
top5 = ", ".join(c["ticker"] for c in candidates[:5])
summary += f"\n🔍 Top picks: {top5}"
# Leaderboard
board = game_engine.get_leaderboard(game_id)
if len(board) > 1:
summary += "\n\n<b>Leaderboard:</b>\n"
medals = ["🥇", "🥈", "🥉"]
for i, entry in enumerate(board[:5]):
medal = medals[i] if i < 3 else f"#{i+1}"
summary += f"{medal} {entry['username']}: {entry['pnl_pct']:+.2f}%\n"
send_telegram(summary, token)
print("\n✅ Daily run complete")
if __name__ == "__main__":
main()

233
projects/market-watch/scanner.py Executable file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""GARP stock scanner - scans S&P 500 + S&P 400 MidCap for growth-at-reasonable-price candidates."""
import json
import os
import re
import sys
import time
from datetime import date, datetime
import numpy as np
import requests
import yfinance as yf
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
SCANS_DIR = os.path.join(DATA_DIR, "scans")
TICKERS_CACHE = os.path.join(DATA_DIR, "tickers.json")
HEADERS = {"User-Agent": "MarketWatch/1.0 (paper trading bot; contact: case-lgn@protonmail.com)"}
def _scrape_tickers(url):
"""Scrape tickers from a Wikipedia S&P constituents page."""
import io
import pandas as pd
resp = requests.get(url, timeout=30, headers=HEADERS)
tables = pd.read_html(io.StringIO(resp.text))
if tables:
df = tables[0]
col = "Symbol" if "Symbol" in df.columns else df.columns[0]
tickers = df[col].astype(str).str.strip().tolist()
tickers = [t.replace(".", "-") for t in tickers if re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', t.replace("-", "."))]
return tickers
return []
def get_sp500_tickers():
return _scrape_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")
def get_sp400_tickers():
return _scrape_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_400_companies")
def get_all_tickers(use_cache=True):
"""Get combined ticker list, with caching."""
if use_cache and os.path.exists(TICKERS_CACHE):
cache = json.loads(open(TICKERS_CACHE).read())
# Use cache if less than 7 days old
cached_date = cache.get("date", "")
if cached_date and (date.today() - date.fromisoformat(cached_date)).days < 7:
return cache["tickers"]
print("Fetching ticker lists from Wikipedia...")
sp500 = get_sp500_tickers()
print(f" S&P 500: {len(sp500)} tickers")
sp400 = get_sp400_tickers()
print(f" S&P 400: {len(sp400)} tickers")
all_tickers = sorted(set(sp500 + sp400))
os.makedirs(DATA_DIR, exist_ok=True)
with open(TICKERS_CACHE, "w") as f:
json.dump({"date": date.today().isoformat(), "tickers": all_tickers, "sp500": len(sp500), "sp400": len(sp400)}, f)
print(f" Combined: {len(all_tickers)} unique tickers")
return all_tickers
def compute_rsi(prices, period=14):
"""Compute RSI from a price series."""
if len(prices) < period + 1:
return None
deltas = np.diff(prices)
gains = np.where(deltas > 0, deltas, 0)
losses = np.where(deltas < 0, -deltas, 0)
avg_gain = np.mean(gains[-period:])
avg_loss = np.mean(losses[-period:])
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return round(100 - (100 / (1 + rs)), 2)
def scan_ticker(ticker):
"""Evaluate a single ticker against GARP criteria. Returns dict or None."""
try:
stock = yf.Ticker(ticker)
info = stock.info
if not info or info.get("regularMarketPrice") is None:
return None
# Market cap filter
market_cap = info.get("marketCap", 0)
if not market_cap or market_cap < 5e9:
return None
# P/E filters
trailing_pe = info.get("trailingPE")
forward_pe = info.get("forwardPE")
if trailing_pe is None or trailing_pe <= 0 or trailing_pe >= 25:
return None
if forward_pe is None or forward_pe <= 0 or forward_pe >= 15:
return None
# Revenue growth
revenue_growth = info.get("revenueGrowth")
if revenue_growth is None or revenue_growth < 0.10:
return None
# EPS growth (earnings growth)
earnings_growth = info.get("earningsGrowth")
if earnings_growth is None or earnings_growth < 0.15:
return None
# ROE
roe = info.get("returnOnEquity")
if roe is None or roe < 0.05:
return None
# Optional filters (don't disqualify if unavailable)
peg = info.get("pegRatio")
if peg is not None and peg > 1.2:
return None
quick_ratio = info.get("quickRatio")
if quick_ratio is not None and quick_ratio < 1.5:
return None
de_ratio = info.get("debtToEquity")
if de_ratio is not None and de_ratio > 35:
return None
# Get price history for RSI and 52-week high
hist = stock.history(period="3mo")
if hist.empty or len(hist) < 20:
return None
closes = hist["Close"].values
current_price = closes[-1]
rsi = compute_rsi(closes)
# 52-week high
week52_high = info.get("fiftyTwoWeekHigh", current_price)
pct_from_high = ((week52_high - current_price) / week52_high) * 100 if week52_high else 0
return {
"ticker": ticker,
"price": round(current_price, 2),
"market_cap": market_cap,
"market_cap_b": round(market_cap / 1e9, 1),
"trailing_pe": round(trailing_pe, 2),
"forward_pe": round(forward_pe, 2),
"peg_ratio": round(peg, 2) if peg else None,
"revenue_growth": round(revenue_growth * 100, 1),
"earnings_growth": round(earnings_growth * 100, 1),
"roe": round(roe * 100, 1),
"quick_ratio": round(quick_ratio, 2) if quick_ratio else None,
"debt_to_equity": round(de_ratio, 1) if de_ratio else None,
"rsi": rsi,
"week52_high": round(week52_high, 2) if week52_high else None,
"pct_from_52wk_high": round(pct_from_high, 1),
}
except Exception as e:
return None
def run_scan(batch_size=5, delay=1.0):
"""Run full GARP scan. Returns list of candidates sorted by score."""
tickers = get_all_tickers()
candidates = []
total = len(tickers)
print(f"\nScanning {total} tickers...")
for i in range(0, total, batch_size):
batch = tickers[i:i + batch_size]
for ticker in batch:
idx = i + batch.index(ticker) + 1
sys.stdout.write(f"\r [{idx}/{total}] Scanning {ticker}... ")
sys.stdout.flush()
result = scan_ticker(ticker)
if result:
candidates.append(result)
print(f"\n{ticker} passed GARP filter (PE={result['trailing_pe']}, FwdPE={result['forward_pe']}, RevGr={result['revenue_growth']}%)")
if i + batch_size < total:
time.sleep(delay)
print(f"\n\nScan complete: {len(candidates)} candidates from {total} tickers")
# Sort by a composite score: lower forward PE + higher earnings growth
for c in candidates:
# Simple ranking score: lower is better
c["score"] = c["forward_pe"] - (c["earnings_growth"] / 10) - (c["revenue_growth"] / 10)
candidates.sort(key=lambda x: x["score"])
# Save results
os.makedirs(SCANS_DIR, exist_ok=True)
scan_file = os.path.join(SCANS_DIR, f"{date.today().isoformat()}.json")
scan_data = {
"date": date.today().isoformat(),
"timestamp": datetime.now().isoformat(),
"total_scanned": total,
"candidates_found": len(candidates),
"candidates": candidates,
}
with open(scan_file, "w") as f:
json.dump(scan_data, f, indent=2)
print(f"Results saved to {scan_file}")
return candidates
def load_latest_scan():
"""Load the most recent scan results."""
if not os.path.exists(SCANS_DIR):
return None
files = sorted(f for f in os.listdir(SCANS_DIR) if f.endswith(".json"))
if not files:
return None
with open(os.path.join(SCANS_DIR, files[-1])) as f:
return json.load(f)
if __name__ == "__main__":
candidates = run_scan()
if candidates:
print(f"\nTop candidates:")
for c in candidates[:10]:
print(f" {c['ticker']:6s} Price=${c['price']:8.2f} PE={c['trailing_pe']:5.1f} FwdPE={c['forward_pe']:5.1f} "
f"RevGr={c['revenue_growth']:5.1f}% EPSGr={c['earnings_growth']:5.1f}% RSI={c['rsi']}")
else:
print("No candidates found matching GARP criteria.")

191
projects/market-watch/trader.py Executable file
View File

@ -0,0 +1,191 @@
#!/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.")