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

1
.gitignore vendored
View File

@ -1 +1,2 @@
.credentials/ .credentials/
__pycache__/

902
data/broad_tickers.txt Normal file
View File

@ -0,0 +1,902 @@
SLB
ADBE
WMG
DT
WBD
ELS
TKO
VZ
MTZ
SYNA
AZO
FITB
OPCH
AVTR
UNH
TFC
MANH
LDOS
MOS
MTB
SON
CFG
BLDR
LOW
TTMI
USB
SPGI
AA
CDP
WLK
SRE
GDDY
ALL
CARR
DECK
SW
ULTA
FND
IDA
COST
CSL
DG
CMS
IPGP
HOG
PBF
INVH
TSCO
MET
FNF
VNT
PII
KEYS
PWR
CHWY
NDAQ
PVH
PRI
MSCI
BXP
MSI
ARE
CINF
IR
HWC
APA
KRG
AMD
WING
LEN
FLR
KKR
USFD
GS
TAP
KNF
WWD
EBAY
ALLE
YETI
CAT
KMPR
DD
CAH
FCX
CTSH
RTX
TYL
MCO
ABNB
MO
OLLI
FICO
MNST
SSD
LVS
BMRN
CFR
PKG
WYNN
NFLX
RSG
WRB
RYAN
BHF
JPM
GPC
TXRH
AVB
IBM
PAYX
HRB
KBH
OTIS
FTNT
OLN
JCI
DLB
COF
HBAN
BSY
COTY
WTS
LRCX
AEP
PPL
VRTX
WMS
GTLS
V
CMC
WH
NRG
CPB
PB
CPRI
AXTA
RPM
IRT
SYF
HII
PLNT
HPE
WFRD
TXNM
AMP
ATO
WSO
AEE
CL
MMS
HLI
AMZN
AMCR
NXST
SBRA
CBRE
KVUE
HAE
LEA
ZBH
PK
SIGI
SHC
SLM
OGE
ULS
J
XPO
BEN
BRKR
VC
AKAM
FHI
MGM
KMI
WAB
CBSH
FIX
RH
IDXX
RF
GWW
SR
SLAB
SWX
INCY
LW
GE
SYY
EXPE
MAR
SEIC
IT
COIN
RRC
KLAC
HOMB
ADP
MRNA
BYD
CRL
GLW
ROL
VICI
AAON
ARMK
CRBG
STWD
TOL
MUSA
PPG
DY
CG
CBT
OC
VAL
BBY
DAR
XOM
VTRS
JAZZ
XYZ
MEDP
FIS
KIM
CHRD
MPC
WFC
BDX
IEX
AES
HOLX
RL
ACM
CART
BWA
DTE
PGR
ITT
ROP
CHDN
NBIX
WST
GMED
DOW
STLD
SFM
CDNS
WTRG
XRAY
UAL
WAT
DASH
JBL
FFIV
ETR
TTC
CPRT
D
THC
IBOC
ALGN
ILMN
H
DXCM
FISV
MLI
SWK
COO
IBKR
KNX
FLEX
STX
GEHC
HQY
HL
PSN
NTAP
ESNT
LPX
CCI
VMC
FDX
ERIE
L
TMUS
CTRA
COLB
BRBR
UFPI
CACI
CVS
WPC
RVTY
GD
FAF
TNL
ASB
REGN
MIDD
COHR
CNM
AME
HAL
PTC
AFL
MTD
LSCC
MRSH
TSN
GILD
HLNE
LAD
ADSK
NLY
R
THG
TLN
FE
PNFP
UPS
REXR
SNDK
MSM
OLED
TRU
UNM
IP
HR
COLM
EQIX
CSX
ANF
FRT
BDC
PSTG
BJ
COP
JHG
FCFS
MU
BWXT
HIG
INTC
NWS
NJR
JKHY
KEY
SARO
GM
SCHW
ES
VRSN
ON
APG
FTV
HIMS
BRO
GOOG
PLD
SJM
TRGP
FANG
JLL
MTG
STZ
ANET
MDLZ
WSM
DTM
SWKS
RBA
APP
BLD
WBS
QCOM
AMT
RGEN
AYI
YUM
AMH
OMC
G
SATS
ALB
APO
FAST
PSKY
PCAR
TTEK
ALK
CELH
HD
CRWD
PSA
PNC
RRX
LYV
LULU
ENSG
AOS
MCD
MCHP
ZTS
EEFT
GWRE
TEX
NEU
CNH
GOOGL
ODFL
CIEN
TTWO
KBR
UTHR
INTU
IQV
O
EVR
POR
VNO
FCN
STAG
SOLV
GLPI
COR
LIVN
MKC
MA
FOX
CNXC
Q
MCK
FOUR
BC
AON
CMI
CBOE
SMG
GPK
BLKB
NVDA
PANW
HPQ
ATR
DVA
ACN
HOOD
NSC
PNR
POOL
TT
AIT
CLH
EXR
GIS
TRV
CNO
MSFT
DLR
ESAB
GEN
NVT
WAL
UGI
PEP
NWSA
CRH
LHX
GAP
PH
LOPE
RCL
LNT
WEC
EPAM
TSLA
PEN
CRS
RYN
SNX
KTOS
ADI
GNRC
OXY
RJF
ONTO
NOC
CXT
TREX
CHD
JBHT
RBC
AXP
TPL
KNSL
ROIV
VST
META
REG
NOV
CVNA
DRI
BAX
ASGN
GGG
EQT
CHTR
PPC
CF
RS
DLTR
STRL
PNW
CEG
SBAC
PAYC
BBWI
LECO
APD
BSX
AMAT
OKE
ABT
CAR
COKE
CDW
NSA
NDSN
VFC
JNJ
NOVT
SYK
PATH
LAMR
EQH
AMGN
BURL
WTFC
HSY
AN
NXPI
KO
UBER
CPT
ORLY
HLT
WMB
EIX
INGR
PFE
CGNX
CMCSA
GEV
PR
APTV
FLS
PCG
PYPL
ELAN
GRMN
EGP
DOC
CVX
DIS
FR
LNTH
CSGP
DUOL
CME
FN
TXT
NOW
WHR
CHH
AHR
EXLS
PLTR
BKR
ASH
HSIC
BLK
T
NXT
MOH
XEL
MPWR
EFX
AVAV
EPR
VVV
DOCU
KHC
MAA
EXE
AR
DBX
ALLY
MMM
BALL
FNB
SAIA
UMBF
CAVA
AFG
TDY
IFF
CCK
POST
ATI
ORI
PINS
SGI
TECH
ARW
AAPL
ACGL
EL
HST
UBSI
SBUX
EA
RLI
C
KDP
PHM
BRK.B
CROX
NUE
KEX
FLG
MTN
PM
HGV
SCI
DKS
LII
MLM
ARES
CRM
AJG
ISRG
SMCI
EXP
NEE
LLY
LSTR
TKR
DDOG
DCI
DAL
TXN
LMT
ST
DELL
TJX
CYTK
GNTX
MTCH
BROS
VLO
VRSK
SNA
TEL
MDT
ACI
MUR
WEX
AM
UNP
GTM
MASI
PEG
MTDR
CPAY
SSB
ENS
OVV
ZION
EHC
UDR
BAH
AMKR
MORN
STE
VMI
MTSI
F
FBIN
EVRG
CTAS
AVY
AVGO
DOCS
EOG
NFG
EG
CNX
VNOM
SF
PCTY
BAC
HON
FHN
RNR
PEGA
TWLO
WDC
OGS
ED
AIG
MRK
HUBB
LITE
TPR
HRL
EME
AAL
MAT
CNC
DOV
TDG
DINO
EWBC
BKH
GEF
RGLD
ROST
ZBRA
BG
WCC
KD
EW
RMBS
MS
SHW
NI
KMB
STT
OSK
XYL
HXL
ITW
OZK
CB
PODD
PG
NVST
CMG
IRM
CNP
TER
ENTG
FLO
GL
TTD
BR
BA
ROK
CUZ
GT
BILL
AXON
MZTI
GPN
VLTO
VTR
CRUS
FIVE
KR
MSA
LYB
BX
ALGM
A
DHI
LUV
TMHC
CLX
ETN
CLF
BIIB
EXEL
SAM
AVT
VOYA
ECL
UHS
RMD
EXPO
LFUS
DVN
BK
M
TMO
SAIC
OKTA
LH
NKE
GATX
KRC
RGA
TROW
CR
BMY
ESS
GXO
ICE
WY
JEF
MP
WELL
FTI
IVZ
EMR
LIN
HUM
BKNG
ELF
FOXA
HAS
CI
TRMB
AEIS
GHC
CTVA
EQR
AGCO
GME
WM
NCLH
GBCI
HWM
AMG
DE
BCO
ONB
CASY
ABBV
HALO
DHR
WTW
ORCL
MAS
PFG
WMT
SNPS
ALV
NYT
FDS
MKSI
CCL
PRU
URI
TCBI
BRX
BF.B
EXC
WDAY
FFIN
AWK
CUBE
NTNX
CAG
NWE
ADC
APPF
DUK
SLGN
THO
CW
SPG
NEM
CSCO
PAG
APH
FSLR
VLY
DPZ
EXPD
PFGC
PSX
AVNT
CHE
OHI
HCA
CHRW
TGT
SPXC
ADM
NVR
BIO
QLYS
ELV
SO
DGX
ORA
AIZ
CVLT
NNN
NTRS

View File

@ -0,0 +1,310 @@
[
{
"ticker": "ALLY",
"name": "Ally Financial Inc.",
"market_cap_B": 13.1,
"trailing_pe": 17.85,
"forward_pe": 6.7,
"peg": null,
"revenue_growth_pct": 12.0,
"earnings_growth_pct": 265.4,
"roe_pct": 5.8,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Credit Services"
},
{
"ticker": "WAL",
"name": "Western Alliance Bancorporation",
"market_cap_B": 10.4,
"trailing_pe": 10.81,
"forward_pe": 7.93,
"peg": null,
"revenue_growth_pct": 16.6,
"earnings_growth_pct": 32.9,
"roe_pct": 13.5,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "CART",
"name": "Maplebear Inc.",
"market_cap_B": 9.1,
"trailing_pe": 19.03,
"forward_pe": 8.84,
"peg": null,
"revenue_growth_pct": 10.2,
"earnings_growth_pct": 21.1,
"roe_pct": 15.3,
"debt_to_equity": 1.0,
"sector": "Consumer Cyclical",
"industry": "Internet Retail"
},
{
"ticker": "ONB",
"name": "Old National Bancorp",
"market_cap_B": 10.1,
"trailing_pe": 14.46,
"forward_pe": 9.02,
"peg": null,
"revenue_growth_pct": 41.4,
"earnings_growth_pct": 17.2,
"roe_pct": 9.0,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "VLY",
"name": "Valley National Bancorp",
"market_cap_B": 7.6,
"trailing_pe": 13.57,
"forward_pe": 9.19,
"peg": null,
"revenue_growth_pct": 38.3,
"earnings_growth_pct": 66.3,
"roe_pct": 7.8,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "FSLR",
"name": "First Solar, Inc.",
"market_cap_B": 23.5,
"trailing_pe": 16.77,
"forward_pe": 9.4,
"peg": null,
"revenue_growth_pct": 79.7,
"earnings_growth_pct": 45.7,
"roe_pct": 16.9,
"debt_to_equity": 9.9,
"sector": "Technology",
"industry": "Solar"
},
{
"ticker": "FNB",
"name": "F.N.B. Corporation",
"market_cap_B": 6.8,
"trailing_pe": 12.12,
"forward_pe": 9.66,
"peg": null,
"revenue_growth_pct": 26.4,
"earnings_growth_pct": 56.5,
"roe_pct": 8.7,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "WBS",
"name": "Webster Financial Corporation",
"market_cap_B": 11.8,
"trailing_pe": 12.39,
"forward_pe": 9.77,
"peg": null,
"revenue_growth_pct": 18.2,
"earnings_growth_pct": 53.4,
"roe_pct": 10.8,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "ZION",
"name": "Zions Bancorporation, National Association",
"market_cap_B": 9.6,
"trailing_pe": 10.86,
"forward_pe": 9.99,
"peg": null,
"revenue_growth_pct": 13.6,
"earnings_growth_pct": 31.4,
"roe_pct": 13.5,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "JHG",
"name": "Janus Henderson Group plc",
"market_cap_B": 7.4,
"trailing_pe": 9.22,
"forward_pe": 10.12,
"peg": null,
"revenue_growth_pct": 61.3,
"earnings_growth_pct": 243.6,
"roe_pct": 16.2,
"debt_to_equity": 6.5,
"sector": "Financial Services",
"industry": "Asset Management"
},
{
"ticker": "SSB",
"name": "SouthState Bank Corporation",
"market_cap_B": 10.8,
"trailing_pe": 13.7,
"forward_pe": 10.19,
"peg": null,
"revenue_growth_pct": 53.2,
"earnings_growth_pct": 30.9,
"roe_pct": 10.7,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "PINS",
"name": "Pinterest, Inc.",
"market_cap_B": 13.3,
"trailing_pe": 6.88,
"forward_pe": 10.37,
"peg": null,
"revenue_growth_pct": 16.8,
"earnings_growth_pct": 225.0,
"roe_pct": 51.5,
"debt_to_equity": 4.3,
"sector": "Communication Services",
"industry": "Internet Content & Information"
},
{
"ticker": "RRC",
"name": "Range Resources Corporation",
"market_cap_B": 8.7,
"trailing_pe": 15.37,
"forward_pe": 10.45,
"peg": null,
"revenue_growth_pct": 16.1,
"earnings_growth_pct": 189.8,
"roe_pct": 14.2,
"debt_to_equity": 32.7,
"sector": "Energy",
"industry": "Oil & Gas E&P"
},
{
"ticker": "EWBC",
"name": "East West Bancorp, Inc.",
"market_cap_B": 16.9,
"trailing_pe": 12.87,
"forward_pe": 11.18,
"peg": null,
"revenue_growth_pct": 21.6,
"earnings_growth_pct": 21.3,
"roe_pct": 15.9,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "FHN",
"name": "First Horizon Corporation",
"market_cap_B": 12.9,
"trailing_pe": 14.03,
"forward_pe": 11.19,
"peg": null,
"revenue_growth_pct": 23.7,
"earnings_growth_pct": 74.9,
"roe_pct": 10.9,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "ORI",
"name": "Old Republic International Corporation",
"market_cap_B": 10.3,
"trailing_pe": 11.23,
"forward_pe": 11.99,
"peg": null,
"revenue_growth_pct": 19.3,
"earnings_growth_pct": 97.3,
"roe_pct": 16.3,
"debt_to_equity": 26.8,
"sector": "Financial Services",
"industry": "Insurance - Property & Casualty"
},
{
"ticker": "WTFC",
"name": "Wintrust Financial Corporation",
"market_cap_B": 10.8,
"trailing_pe": 14.14,
"forward_pe": 12.03,
"peg": null,
"revenue_growth_pct": 10.5,
"earnings_growth_pct": 19.4,
"roe_pct": 12.1,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "UBSI",
"name": "United Bankshares, Inc.",
"market_cap_B": 6.3,
"trailing_pe": 13.92,
"forward_pe": 12.08,
"peg": null,
"revenue_growth_pct": 22.1,
"earnings_growth_pct": 32.1,
"roe_pct": 8.9,
"debt_to_equity": null,
"sector": "Financial Services",
"industry": "Banks - Regional"
},
{
"ticker": "PGR",
"name": "The Progressive Corporation",
"market_cap_B": 118.6,
"trailing_pe": 10.51,
"forward_pe": 12.49,
"peg": null,
"revenue_growth_pct": 12.2,
"earnings_growth_pct": 25.2,
"roe_pct": 40.4,
"debt_to_equity": 22.7,
"sector": "Financial Services",
"industry": "Insurance - Property & Casualty"
},
{
"ticker": "EXEL",
"name": "Exelixis, Inc.",
"market_cap_B": 11.8,
"trailing_pe": 18.45,
"forward_pe": 12.79,
"peg": null,
"revenue_growth_pct": 10.8,
"earnings_growth_pct": 72.5,
"roe_pct": 30.6,
"debt_to_equity": 8.2,
"sector": "Healthcare",
"industry": "Biotechnology"
},
{
"ticker": "NEM",
"name": "Newmont Corporation",
"market_cap_B": 126.7,
"trailing_pe": 17.93,
"forward_pe": 12.89,
"peg": null,
"revenue_growth_pct": 20.0,
"earnings_growth_pct": 108.1,
"roe_pct": 22.9,
"debt_to_equity": 16.9,
"sector": "Basic Materials",
"industry": "Gold"
},
{
"ticker": "CTRA",
"name": "Coterra Energy Inc.",
"market_cap_B": 23.3,
"trailing_pe": 14.19,
"forward_pe": 13.95,
"peg": null,
"revenue_growth_pct": 34.9,
"earnings_growth_pct": 23.5,
"roe_pct": 11.9,
"debt_to_equity": 28.0,
"sector": "Energy",
"industry": "Oil & Gas E&P"
}
]

74
data/garp_scan.py Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""GARP scan on broad ticker list."""
import yfinance as yf
import json, time
EXCLUDE = {"BAC", "CFG", "FITB", "INCY"}
with open("broad_tickers.txt") as f:
tickers = [l.strip().replace(".", "-") for l in f if l.strip()]
print(f"Screening {len(tickers)} tickers...")
passed = []
for i, tick in enumerate(tickers):
if tick in EXCLUDE:
continue
if i % 100 == 0:
print(f" Progress: {i}/{len(tickers)} ({len(passed)} passed so far)")
try:
t = yf.Ticker(tick)
info = t.info or {}
mc = info.get("marketCap", 0) or 0
if mc < 5e9: continue
tpe = info.get("trailingPE")
if not tpe or tpe >= 25 or tpe <= 0: continue
fpe = info.get("forwardPE")
if not fpe or fpe >= 15 or fpe <= 0: continue
rg = info.get("revenueGrowth")
if rg is None or rg < 0.10: continue
eg = info.get("earningsGrowth")
if eg is None or eg < 0.15: continue
roe = info.get("returnOnEquity")
if roe is None or roe < 0.05: continue
peg = info.get("pegRatio")
if peg is not None and peg > 1.2: continue
dte = info.get("debtToEquity")
if dte is not None and dte > 35: continue
entry = {
"ticker": tick,
"name": info.get("longName", tick),
"market_cap_B": round(mc / 1e9, 1),
"trailing_pe": round(tpe, 2),
"forward_pe": round(fpe, 2),
"peg": round(peg, 2) if peg else None,
"revenue_growth_pct": round(rg * 100, 1),
"earnings_growth_pct": round(eg * 100, 1),
"roe_pct": round(roe * 100, 1),
"debt_to_equity": round(dte, 1) if dte else None,
"sector": info.get("sector"),
"industry": info.get("industry"),
}
passed.append(entry)
print(f"{tick}: PE={tpe:.1f} FPE={fpe:.1f} RG={rg*100:.0f}% EG={eg*100:.0f}% ROE={roe*100:.0f}%")
except Exception as e:
continue
passed.sort(key=lambda x: x.get("forward_pe", 99))
with open("garp-expanded-scan.json", "w") as f:
json.dump(passed, f, indent=2)
print(f"\nDone! {len(passed)} stocks passed GARP screen")
for s in passed:
print(f" {s['ticker']:6s} ${s['market_cap_B']:6.1f}B PE:{s['trailing_pe']:5.1f} FPE:{s['forward_pe']:5.1f} RG:{s['revenue_growth_pct']:5.1f}% EG:{s['earnings_growth_pct']:5.1f}% ROE:{s['roe_pct']:5.1f}%")

85
data/garp_scan2.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""GARP scan - batch download approach for speed."""
import yfinance as yf
import json, sys
sys.stdout.reconfigure(line_buffering=True)
EXCLUDE = {"BAC", "CFG", "FITB", "INCY"}
with open("broad_tickers.txt") as f:
tickers = [l.strip().replace(".", "-") for l in f if l.strip()]
print(f"Screening {len(tickers)} tickers in batches...")
passed = []
batch_size = 20
for batch_start in range(0, len(tickers), batch_size):
batch = tickers[batch_start:batch_start + batch_size]
batch = [t for t in batch if t not in EXCLUDE]
if not batch:
continue
print(f"Batch {batch_start}-{batch_start+len(batch)} / {len(tickers)}...")
try:
data = yf.Tickers(" ".join(batch))
for tick in batch:
try:
info = data.tickers[tick].info or {}
mc = info.get("marketCap", 0) or 0
if mc < 5e9: continue
tpe = info.get("trailingPE")
if not tpe or tpe >= 25 or tpe <= 0: continue
fpe = info.get("forwardPE")
if not fpe or fpe >= 15 or fpe <= 0: continue
rg = info.get("revenueGrowth")
if rg is None or rg < 0.10: continue
eg = info.get("earningsGrowth")
if eg is None or eg < 0.15: continue
roe = info.get("returnOnEquity")
if roe is None or roe < 0.05: continue
peg = info.get("pegRatio")
if peg is not None and peg > 1.2: continue
dte = info.get("debtToEquity")
if dte is not None and dte > 35: continue
entry = {
"ticker": tick,
"name": info.get("longName", tick),
"market_cap_B": round(mc / 1e9, 1),
"trailing_pe": round(tpe, 2),
"forward_pe": round(fpe, 2),
"peg": round(peg, 2) if peg else None,
"revenue_growth_pct": round(rg * 100, 1),
"earnings_growth_pct": round(eg * 100, 1),
"roe_pct": round(roe * 100, 1),
"debt_to_equity": round(dte, 1) if dte else None,
"sector": info.get("sector"),
"industry": info.get("industry"),
}
passed.append(entry)
print(f"{tick}: PE={tpe:.1f} FPE={fpe:.1f} RG={rg*100:.0f}% EG={eg*100:.0f}% ROE={roe*100:.0f}%")
except:
continue
except Exception as e:
print(f" Batch error: {e}")
continue
passed.sort(key=lambda x: x.get("forward_pe", 99))
with open("garp-expanded-scan.json", "w") as f:
json.dump(passed, f, indent=2)
print(f"\nDone! {len(passed)} stocks passed GARP screen")
for s in passed:
print(f" {s['ticker']:6s} ${s['market_cap_B']:6.1f}B PE:{s['trailing_pe']:5.1f} FPE:{s['forward_pe']:5.1f} RG:{s['revenue_growth_pct']:5.1f}% EG:{s['earnings_growth_pct']:5.1f}% ROE:{s['roe_pct']:5.1f}%")

View File

@ -0,0 +1,762 @@
{
"BAC": {
"ticker": "BAC",
"name": "Bank of America Corporation",
"price_action": {
"current": 56.53,
"1yr_ago": 46.33,
"1yr_return_pct": 22.02,
"52wk_high": 57.25,
"52wk_low": 33.82,
"pct_from_52wk_high": -1.26,
"50d_ma": 54.25,
"200d_ma": 49.18,
"above_50d_ma": true,
"above_200d_ma": true
},
"insider_transactions": [
{
"date": "2026-01-15 00:00:00",
"insider": "MOYNIHAN BRIAN T",
"transaction": "",
"shares": "17891",
"value": "nan"
},
{
"date": "2025-12-15 00:00:00",
"insider": "MOYNIHAN BRIAN T",
"transaction": "",
"shares": "17892",
"value": "nan"
},
{
"date": "2025-12-11 00:00:00",
"insider": "MOYNIHAN BRIAN T",
"transaction": "",
"shares": "130000",
"value": "0.0"
},
{
"date": "2025-12-09 00:00:00",
"insider": "GREENER GEOFFREY S",
"transaction": "",
"shares": "9265",
"value": "0.0"
},
{
"date": "2025-12-03 00:00:00",
"insider": "SCRIVENER THOMAS M",
"transaction": "",
"shares": "3000",
"value": "0.0"
},
{
"date": "2025-11-28 00:00:00",
"insider": "OKPARA JOHNBULL",
"transaction": "",
"shares": "26784",
"value": "nan"
},
{
"date": "2025-11-14 00:00:00",
"insider": "MOYNIHAN BRIAN T",
"transaction": "",
"shares": "17891",
"value": "nan"
},
{
"date": "2025-11-14 00:00:00",
"insider": "SCHIMPF ERIC A",
"transaction": "",
"shares": "1234",
"value": "nan"
},
{
"date": "2025-11-14 00:00:00",
"insider": "HANS LINDSAY D",
"transaction": "",
"shares": "975",
"value": "nan"
},
{
"date": "2025-11-14 00:00:00",
"insider": "GOPALKRISHNAN HARI",
"transaction": "",
"shares": "2703",
"value": "nan"
}
],
"top_institutional_holders": [
{
"holder": "Vanguard Group Inc",
"shares": 651076825,
"pct_held": "0.0892"
},
{
"holder": "Berkshire Hathaway, Inc",
"shares": 568070012,
"pct_held": "0.077800006"
},
{
"holder": "Blackrock Inc.",
"shares": 535326028,
"pct_held": "0.0733"
},
{
"holder": "JPMORGAN CHASE & CO",
"shares": 363341282,
"pct_held": "0.0498"
},
{
"holder": "State Street Corporation",
"shares": 301312466,
"pct_held": "0.041300002"
}
],
"institutional_pct": 0.71739,
"earnings": [
{
"date": "2026-04-15 09:00:00-04:00",
"eps_estimate": 0.99,
"eps_actual": null,
"surprise_pct": null
},
{
"date": "2026-01-14 06:00:00-05:00",
"eps_estimate": 0.96,
"eps_actual": 0.98,
"surprise_pct": 2.23
},
{
"date": "2025-10-15 06:00:00-04:00",
"eps_estimate": 0.95,
"eps_actual": 1.06,
"surprise_pct": 11.43
},
{
"date": "2025-07-16 06:00:00-04:00",
"eps_estimate": 0.86,
"eps_actual": 0.89,
"surprise_pct": 3.61
},
{
"date": "2025-04-15 06:00:00-04:00",
"eps_estimate": 0.82,
"eps_actual": 0.9,
"surprise_pct": 10.29
},
{
"date": "2025-01-16 06:00:00-05:00",
"eps_estimate": 0.77,
"eps_actual": 0.82,
"surprise_pct": 6.84
},
{
"date": "2024-10-15 06:00:00-04:00",
"eps_estimate": 0.76,
"eps_actual": 0.81,
"surprise_pct": 6.34
},
{
"date": "2024-07-16 06:00:00-04:00",
"eps_estimate": 0.8,
"eps_actual": 0.83,
"surprise_pct": 3.58
}
],
"analyst": {
"target_mean": 62.20833,
"target_low": 56.0,
"target_high": 71.0,
"recommendation": "buy",
"num_analysts": 24,
"upside_pct": 10.04
},
"fundamentals": {
"trailing_pe": 14.83727,
"forward_pe": 11.407365,
"peg_ratio": null,
"market_cap": 412810084352,
"revenue_growth": 0.132,
"earnings_growth": 0.209,
"roe": 0.1019,
"debt_to_equity": null,
"dividend_yield": 1.95,
"beta": 1.273
},
"technical": {
"rsi_14": 71.14,
"trend": "bullish"
}
},
"CFG": {
"ticker": "CFG",
"name": "Citizens Financial Group, Inc.",
"price_action": {
"current": 68.12,
"1yr_ago": 46.24,
"1yr_return_pct": 47.32,
"52wk_high": 68.12,
"52wk_low": 33.06,
"pct_from_52wk_high": 0.0,
"50d_ma": 59.58,
"200d_ma": 49.69,
"above_50d_ma": true,
"above_200d_ma": true
},
"insider_transactions": [
{
"date": "2025-12-11 00:00:00",
"insider": "VAN SAUN BRUCE W",
"transaction": "",
"shares": "8000",
"value": "458558"
},
{
"date": "2025-12-03 00:00:00",
"insider": "LAMONICA SUSAN",
"transaction": "",
"shares": "20128",
"value": "1120727"
},
{
"date": "2025-11-12 00:00:00",
"insider": "KELLY EDWARD J. III",
"transaction": "",
"shares": "316",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "HANKOWSKY WILLIAM P",
"transaction": "",
"shares": "347",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "ZURAITIS MARITA",
"transaction": "",
"shares": "347",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "CUMMING CHRISTINE M",
"transaction": "",
"shares": "347",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "ATKINSON TRACY A",
"transaction": "",
"shares": "85",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "CUMMINGS KEVIN",
"transaction": "",
"shares": "162",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "SWIFT CHRISTOPHER J",
"transaction": "",
"shares": "200",
"value": "0"
},
{
"date": "2025-11-12 00:00:00",
"insider": "SIEKERKA MICHELE N",
"transaction": "",
"shares": "162",
"value": "0"
}
],
"top_institutional_holders": [
{
"holder": "Vanguard Group Inc",
"shares": 51303226,
"pct_held": "0.1195"
},
{
"holder": "Blackrock Inc.",
"shares": 43851199,
"pct_held": "0.1021"
},
{
"holder": "Capital World Investors",
"shares": 37289711,
"pct_held": "0.0868"
},
{
"holder": "Invesco Ltd.",
"shares": 24064513,
"pct_held": "0.055999998"
},
{
"holder": "State Street Corporation",
"shares": 22969833,
"pct_held": "0.0535"
}
],
"institutional_pct": 0.99170995,
"earnings": [
{
"date": "2026-04-16 09:00:00-04:00",
"eps_estimate": 1.09,
"eps_actual": null,
"surprise_pct": null
},
{
"date": "2026-01-21 06:00:00-05:00",
"eps_estimate": 1.11,
"eps_actual": 1.13,
"surprise_pct": 2.24
},
{
"date": "2025-10-15 06:00:00-04:00",
"eps_estimate": 1.03,
"eps_actual": 1.05,
"surprise_pct": 2.27
},
{
"date": "2025-07-17 06:00:00-04:00",
"eps_estimate": 0.88,
"eps_actual": 0.92,
"surprise_pct": 4.13
},
{
"date": "2025-04-16 06:00:00-04:00",
"eps_estimate": 0.75,
"eps_actual": 0.77,
"surprise_pct": 2.68
},
{
"date": "2025-01-17 06:00:00-05:00",
"eps_estimate": 0.83,
"eps_actual": 0.85,
"surprise_pct": 2.3
},
{
"date": "2024-10-16 06:00:00-04:00",
"eps_estimate": 0.79,
"eps_actual": 0.79,
"surprise_pct": -0.24
},
{
"date": "2024-07-17 03:00:00-04:00",
"eps_estimate": 0.79,
"eps_actual": 0.82,
"surprise_pct": 3.5
}
],
"analyst": {
"target_mean": 72.275,
"target_low": 62.5,
"target_high": 80.0,
"recommendation": "buy",
"num_analysts": 20,
"upside_pct": 6.1
},
"fundamentals": {
"trailing_pe": 17.647669,
"forward_pe": 10.848602,
"peg_ratio": null,
"market_cap": 29256599552,
"revenue_growth": 0.107,
"earnings_growth": 0.359,
"roe": 0.07241,
"debt_to_equity": null,
"dividend_yield": 2.7,
"beta": 1.071
},
"technical": {
"rsi_14": 75.46,
"trend": "bullish"
}
},
"FITB": {
"ticker": "FITB",
"name": "Fifth Third Bancorp",
"price_action": {
"current": 55.08,
"1yr_ago": 42.49,
"1yr_return_pct": 29.63,
"52wk_high": 55.08,
"52wk_low": 32.46,
"pct_from_52wk_high": 0.0,
"50d_ma": 48.27,
"200d_ma": 42.82,
"above_50d_ma": true,
"above_200d_ma": true
},
"insider_transactions": [
{
"date": "2026-02-02 00:00:00",
"insider": "SMITH BARBARA",
"transaction": "",
"shares": "40498",
"value": "0"
},
{
"date": "2026-02-02 00:00:00",
"insider": "SEFZIK PETER L",
"transaction": "",
"shares": "209382",
"value": "0"
},
{
"date": "2026-02-02 00:00:00",
"insider": "KERR DEREK J",
"transaction": "",
"shares": "14189",
"value": "0"
},
{
"date": "2026-02-02 00:00:00",
"insider": "VAN DE VEN MICHAEL G",
"transaction": "",
"shares": "47972",
"value": "0"
},
{
"date": "2026-01-07 00:00:00",
"insider": "ALMODOVAR PRISCILLA",
"transaction": "",
"shares": "848",
"value": "0"
},
{
"date": "2025-12-15 00:00:00",
"insider": "PRESTON BRYAN D",
"transaction": "",
"shares": "1100",
"value": "0"
},
{
"date": "2025-12-11 00:00:00",
"insider": "SCHRAMM JUDE",
"transaction": "",
"shares": "2250",
"value": "109125"
},
{
"date": "2025-10-20 00:00:00",
"insider": "BAYH B EVAN III",
"transaction": "",
"shares": "3000",
"value": "123650"
},
{
"date": "2025-10-09 00:00:00",
"insider": "GONZALEZ CHRISTIAN",
"transaction": "",
"shares": "5709",
"value": "0"
},
{
"date": "2025-08-28 00:00:00",
"insider": "SHAFFER ROBERT P",
"transaction": "",
"shares": "14035",
"value": "372208"
}
],
"top_institutional_holders": [
{
"holder": "Vanguard Group Inc",
"shares": 107237083,
"pct_held": "0.16219999"
},
{
"holder": "Blackrock Inc.",
"shares": 88421716,
"pct_held": "0.1338"
},
{
"holder": "JPMORGAN CHASE & CO",
"shares": 65878286,
"pct_held": "0.099700004"
},
{
"holder": "State Street Corporation",
"shares": 39653081,
"pct_held": "0.06"
},
{
"holder": "Charles Schwab Investment Management, Inc.",
"shares": 32138466,
"pct_held": "0.048600003"
}
],
"institutional_pct": 0.66008,
"earnings": [
{
"date": "2026-04-17 09:00:00-04:00",
"eps_estimate": 0.43,
"eps_actual": null,
"surprise_pct": null
},
{
"date": "2026-01-20 06:00:00-05:00",
"eps_estimate": 0.99,
"eps_actual": 1.04,
"surprise_pct": 4.93
},
{
"date": "2025-10-17 06:00:00-04:00",
"eps_estimate": 0.86,
"eps_actual": 0.91,
"surprise_pct": 5.47
},
{
"date": "2025-07-17 06:00:00-04:00",
"eps_estimate": 0.87,
"eps_actual": 0.9,
"surprise_pct": 3.85
},
{
"date": "2025-04-17 06:00:00-04:00",
"eps_estimate": 0.7,
"eps_actual": 0.71,
"surprise_pct": 1.42
},
{
"date": "2025-01-21 06:00:00-05:00",
"eps_estimate": 0.87,
"eps_actual": 0.9,
"surprise_pct": 3.01
},
{
"date": "2024-10-18 06:00:00-04:00",
"eps_estimate": 0.82,
"eps_actual": 0.85,
"surprise_pct": 3.17
},
{
"date": "2024-07-19 06:00:00-04:00",
"eps_estimate": 0.84,
"eps_actual": 0.86,
"surprise_pct": 1.79
}
],
"analyst": {
"target_mean": 57.15789,
"target_low": 49.0,
"target_high": 61.0,
"recommendation": "buy",
"num_analysts": 19,
"upside_pct": 3.77
},
"fundamentals": {
"trailing_pe": 15.6034,
"forward_pe": 11.235359,
"peg_ratio": null,
"market_cap": 49574670336,
"revenue_growth": 0.115,
"earnings_growth": 0.208,
"roe": 0.121929996,
"debt_to_equity": null,
"dividend_yield": 2.9,
"beta": 0.977
},
"technical": {
"rsi_14": 71.83,
"trend": "bullish"
}
},
"INCY": {
"ticker": "INCY",
"name": "Incyte Corporation",
"price_action": {
"current": 108.39,
"1yr_ago": 74.13,
"1yr_return_pct": 46.22,
"52wk_high": 110.57,
"52wk_low": 55.17,
"pct_from_52wk_high": -1.97,
"50d_ma": 101.9,
"200d_ma": 84.49,
"above_50d_ma": true,
"above_200d_ma": true
},
"insider_transactions": [
{
"date": "2026-01-16 00:00:00",
"insider": "MORRISSEY MICHAEL JAMES",
"transaction": "",
"shares": "7426",
"value": "0"
},
{
"date": "2026-01-16 00:00:00",
"insider": "HEESON LEE",
"transaction": "",
"shares": "8911",
"value": "0"
},
{
"date": "2026-01-07 00:00:00",
"insider": "ISSA MOHAMED KHAIRIE",
"transaction": "",
"shares": "10856",
"value": "1184064"
},
{
"date": "2026-01-05 00:00:00",
"insider": "STEIN STEVEN H.",
"transaction": "",
"shares": "15634",
"value": "1589978"
},
{
"date": "2025-12-31 00:00:00",
"insider": "BAKER BROS ADVISORS L.P.",
"transaction": "",
"shares": "656",
"value": "0"
},
{
"date": "2025-12-31 00:00:00",
"insider": "HARRIGAN EDMUND P. M.D.",
"transaction": "",
"shares": "245",
"value": "24199"
},
{
"date": "2025-12-31 00:00:00",
"insider": "CLANCY PAUL J.",
"transaction": "",
"shares": "241",
"value": "23804"
},
{
"date": "2025-12-19 00:00:00",
"insider": "TRAY THOMAS R",
"transaction": "",
"shares": "3374",
"value": "336350"
},
{
"date": "2025-12-19 00:00:00",
"insider": "TRAY THOMAS R",
"transaction": "",
"shares": "2774",
"value": "265638"
},
{
"date": "2025-12-17 00:00:00",
"insider": "MORRISSEY MICHAEL JAMES",
"transaction": "",
"shares": "58331",
"value": "5675002"
}
],
"top_institutional_holders": [
{
"holder": "Baker Bros. Advisors, LP",
"shares": 30743663,
"pct_held": "0.1566"
},
{
"holder": "Vanguard Group Inc",
"shares": 19911434,
"pct_held": "0.1014"
},
{
"holder": "Blackrock Inc.",
"shares": 17894297,
"pct_held": "0.0911"
},
{
"holder": "Dodge & Cox Inc.",
"shares": 13932416,
"pct_held": "0.071"
},
{
"holder": "State Street Corporation",
"shares": 9676796,
"pct_held": "0.0493"
}
],
"institutional_pct": 1.05931,
"earnings": [
{
"date": "2026-02-10 08:00:00-05:00",
"eps_estimate": 1.95,
"eps_actual": null,
"surprise_pct": null
},
{
"date": "2025-10-28 07:00:00-04:00",
"eps_estimate": 1.64,
"eps_actual": 2.26,
"surprise_pct": 38.04
},
{
"date": "2025-07-29 07:00:00-04:00",
"eps_estimate": 1.47,
"eps_actual": 1.57,
"surprise_pct": 6.59
},
{
"date": "2025-04-29 07:00:00-04:00",
"eps_estimate": 1.03,
"eps_actual": 1.16,
"surprise_pct": 12.32
},
{
"date": "2025-02-10 07:00:00-05:00",
"eps_estimate": 1.55,
"eps_actual": 1.43,
"surprise_pct": -8.01
},
{
"date": "2024-10-29 07:00:00-04:00",
"eps_estimate": 1.09,
"eps_actual": 1.07,
"surprise_pct": -2.03
},
{
"date": "2024-07-30 07:00:00-04:00",
"eps_estimate": 1.11,
"eps_actual": -1.82,
"surprise_pct": -264.53
},
{
"date": "2024-04-30 07:00:00-04:00",
"eps_estimate": 0.83,
"eps_actual": 0.64,
"surprise_pct": -23.09
}
],
"analyst": {
"target_mean": 104.22727,
"target_low": 70.0,
"target_high": 135.0,
"recommendation": "buy",
"num_analysts": 22,
"upside_pct": -3.84
},
"fundamentals": {
"trailing_pe": 18.371185,
"forward_pe": 13.606971,
"peg_ratio": null,
"market_cap": 21279418368,
"revenue_growth": 0.2,
"earnings_growth": 2.907,
"roe": 0.30389,
"debt_to_equity": 0.887,
"dividend_yield": null,
"beta": 0.847
},
"technical": {
"rsi_14": 54.22,
"trend": "bullish"
}
}
}

View File

@ -0,0 +1,71 @@
{
"date": "2026-02-08",
"filters": "GARP",
"results": [
{
"ticker": "BAC",
"name": "Bank of America Corporation",
"mcap_b": 412.8,
"rev_growth": 13.2,
"trail_pe": 14.8,
"fwd_pe": 11.4,
"peg": "N/A",
"eps_growth": 20.9,
"roe": 10.2,
"quick": "N/A",
"de": "N/A"
},
{
"ticker": "CFG",
"name": "Citizens Financial Group, Inc.",
"mcap_b": 29.3,
"rev_growth": 10.7,
"trail_pe": 17.6,
"fwd_pe": 10.8,
"peg": "N/A",
"eps_growth": 35.9,
"roe": 7.2,
"quick": "N/A",
"de": "N/A"
},
{
"ticker": "FITB",
"name": "Fifth Third Bancorp",
"mcap_b": 49.6,
"rev_growth": 11.5,
"trail_pe": 15.6,
"fwd_pe": 11.2,
"peg": "N/A",
"eps_growth": 20.8,
"roe": 12.2,
"quick": "N/A",
"de": "N/A"
},
{
"ticker": "INCY",
"name": "Incyte Corporation",
"mcap_b": 21.3,
"rev_growth": 20.0,
"trail_pe": 18.4,
"fwd_pe": 13.6,
"peg": "N/A",
"eps_growth": 290.7,
"roe": 30.4,
"quick": 2.86,
"de": 0.9
}
],
"stats": {
"scanned": 503,
"revenue": 316,
"pe": 121,
"fwd_pe": 32,
"peg": 0,
"eps": 14,
"roe": 1,
"quick": 11,
"de": 4,
"mcap": 0,
"err": 0
}
}

295
data/stock_deep_dive.py Normal file
View File

@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Deep dive analysis on BAC, CFG, FITB, INCY + expanded GARP scan."""
import yfinance as yf
import json
import datetime
import numpy as np
from pathlib import Path
TARGETS = ["BAC", "CFG", "FITB", "INCY"]
def safe_get(d, *keys, default=None):
for k in keys:
if isinstance(d, dict):
d = d.get(k, default)
else:
return default
return d
def analyze_stock(ticker_str):
print(f"\n{'='*60}\nAnalyzing {ticker_str}...")
t = yf.Ticker(ticker_str)
info = t.info or {}
result = {"ticker": ticker_str, "name": info.get("longName", ticker_str)}
# 1. Price action (1yr)
hist = t.history(period="1y")
if not hist.empty:
prices = hist["Close"]
result["price_action"] = {
"current": round(float(prices.iloc[-1]), 2),
"1yr_ago": round(float(prices.iloc[0]), 2),
"1yr_return_pct": round(float((prices.iloc[-1] / prices.iloc[0] - 1) * 100), 2),
"52wk_high": round(float(prices.max()), 2),
"52wk_low": round(float(prices.min()), 2),
"pct_from_52wk_high": round(float((prices.iloc[-1] / prices.max() - 1) * 100), 2),
"50d_ma": round(float(prices.tail(50).mean()), 2),
"200d_ma": round(float(prices.tail(200).mean()), 2) if len(prices) >= 200 else None,
}
cur = float(prices.iloc[-1])
ma50 = result["price_action"]["50d_ma"]
ma200 = result["price_action"]["200d_ma"]
result["price_action"]["above_50d_ma"] = cur > ma50
result["price_action"]["above_200d_ma"] = cur > ma200 if ma200 else None
# 2. Insider activity
try:
insiders = t.insider_transactions
if insiders is not None and not insiders.empty:
recent = insiders.head(10)
txns = []
for _, row in recent.iterrows():
txns.append({
"date": str(row.get("Start Date", "")),
"insider": str(row.get("Insider", "")),
"transaction": str(row.get("Transaction", "")),
"shares": str(row.get("Shares", "")),
"value": str(row.get("Value", "")),
})
result["insider_transactions"] = txns
else:
result["insider_transactions"] = []
except:
result["insider_transactions"] = []
# 3. Institutional ownership
try:
inst = t.institutional_holders
if inst is not None and not inst.empty:
top5 = []
for _, row in inst.head(5).iterrows():
top5.append({
"holder": str(row.get("Holder", "")),
"shares": int(row.get("Shares", 0)) if row.get("Shares") else 0,
"pct_held": str(row.get("pctHeld", "")),
})
result["top_institutional_holders"] = top5
result["institutional_pct"] = info.get("heldPercentInstitutions")
except:
result["top_institutional_holders"] = []
# 4. Earnings surprises
try:
earn = t.earnings_dates
if earn is not None and not earn.empty:
surprises = []
for idx, row in earn.head(8).iterrows():
s = {
"date": str(idx),
"eps_estimate": row.get("EPS Estimate"),
"eps_actual": row.get("Reported EPS"),
"surprise_pct": row.get("Surprise(%)"),
}
# clean NaN
s = {k: (None if isinstance(v, float) and np.isnan(v) else v) for k, v in s.items()}
surprises.append(s)
result["earnings"] = surprises
else:
result["earnings"] = []
except:
result["earnings"] = []
# 5. Analyst consensus
result["analyst"] = {
"target_mean": info.get("targetMeanPrice"),
"target_low": info.get("targetLowPrice"),
"target_high": info.get("targetHighPrice"),
"recommendation": info.get("recommendationKey"),
"num_analysts": info.get("numberOfAnalystOpinions"),
}
if result["price_action"].get("current") and info.get("targetMeanPrice"):
upside = (info["targetMeanPrice"] / result["price_action"]["current"] - 1) * 100
result["analyst"]["upside_pct"] = round(upside, 2)
# 6. Key fundamentals snapshot
result["fundamentals"] = {
"trailing_pe": info.get("trailingPE"),
"forward_pe": info.get("forwardPE"),
"peg_ratio": info.get("pegRatio"),
"market_cap": info.get("marketCap"),
"revenue_growth": info.get("revenueGrowth"),
"earnings_growth": info.get("earningsGrowth"),
"roe": info.get("returnOnEquity"),
"debt_to_equity": info.get("debtToEquity"),
"dividend_yield": info.get("dividendYield"),
"beta": info.get("beta"),
}
# 7. Technical - RSI approximation
if not hist.empty and len(hist) >= 14:
delta = hist["Close"].diff()
gain = delta.where(delta > 0, 0).rolling(14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
result["technical"] = {
"rsi_14": round(float(rsi.iloc[-1]), 2) if not np.isnan(rsi.iloc[-1]) else None,
"trend": "bullish" if cur > ma50 and (ma200 is None or cur > ma200) else "bearish" if cur < ma50 and (ma200 and cur < ma200) else "mixed",
}
print(f" Done: {result['name']} @ ${result['price_action']['current']}")
return result
# ============================================================
# EXPANDED GARP SCAN
# ============================================================
def get_broad_ticker_list():
"""Get a broad list of US tickers to screen."""
import urllib.request
# Use Wikipedia's S&P 500 + S&P 400 midcap for broader coverage
# Plus some known Russell 1000 members not in S&P 500
# Start with S&P 500
sp500 = []
try:
import pandas as pd
tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")
sp500 = tables[0]["Symbol"].str.replace(".", "-", regex=False).tolist()
except:
pass
# S&P 400 midcap
sp400 = []
try:
import pandas as pd
tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_400_companies")
sp400 = tables[0]["Symbol"].str.replace(".", "-", regex=False).tolist()
except:
pass
all_tickers = list(set(sp500 + sp400))
print(f"Total tickers to screen: {len(all_tickers)}")
return all_tickers
def garp_screen(tickers, exclude=None):
"""Apply GARP filters to ticker list."""
exclude = set(exclude or [])
passed = []
for i, tick in enumerate(tickers):
if tick in exclude:
continue
if i % 50 == 0:
print(f" Screening {i}/{len(tickers)}...")
try:
t = yf.Ticker(tick)
info = t.info or {}
mc = info.get("marketCap", 0) or 0
if mc < 5e9:
continue
tpe = info.get("trailingPE")
if tpe is None or tpe >= 25 or tpe <= 0:
continue
fpe = info.get("forwardPE")
if fpe is None or fpe >= 15 or fpe <= 0:
continue
rg = info.get("revenueGrowth")
if rg is None or rg < 0.10:
continue
eg = info.get("earningsGrowth")
if eg is None or eg < 0.15:
continue
roe = info.get("returnOnEquity")
if roe is None or roe < 0.05:
continue
# Optional filters
peg = info.get("pegRatio")
if peg is not None and peg > 1.2:
continue
dte = info.get("debtToEquity")
if dte is not None and dte > 35:
continue
passed.append({
"ticker": tick,
"name": info.get("longName", tick),
"market_cap": mc,
"trailing_pe": round(tpe, 2),
"forward_pe": round(fpe, 2),
"peg": round(peg, 2) if peg else None,
"revenue_growth": round(rg * 100, 1),
"earnings_growth": round(eg * 100, 1),
"roe": round(roe * 100, 1),
"debt_to_equity": round(dte, 1) if dte else None,
})
print(f" ✅ PASS: {tick} (PE:{tpe:.1f} FPE:{fpe:.1f} RG:{rg*100:.0f}% EG:{eg*100:.0f}%)")
except Exception as e:
continue
return passed
# ============================================================
# MAIN
# ============================================================
if __name__ == "__main__":
output_dir = Path("/home/wdjones/.openclaw/workspace/data")
output_dir.mkdir(exist_ok=True)
# Deep dive on 4 stocks
print("=" * 60)
print("DEEP DIVE ANALYSIS")
print("=" * 60)
analyses = {}
for tick in TARGETS:
analyses[tick] = analyze_stock(tick)
# Save deep dive
with open(output_dir / "stock-analysis-deep-dive.json", "w") as f:
json.dump(analyses, f, indent=2, default=str)
print(f"\nSaved deep dive to stock-analysis-deep-dive.json")
# Expanded GARP scan
print("\n" + "=" * 60)
print("EXPANDED GARP SCAN (S&P 500 + S&P 400 MidCap)")
print("=" * 60)
tickers = get_broad_ticker_list()
new_passes = garp_screen(tickers, exclude=set(TARGETS))
with open(output_dir / "garp-expanded-scan.json", "w") as f:
json.dump(new_passes, f, indent=2, default=str)
print(f"\nExpanded scan found {len(new_passes)} additional stocks")
print("Saved to garp-expanded-scan.json")
# Print summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
for tick, data in analyses.items():
pa = data.get("price_action", {})
an = data.get("analyst", {})
fu = data.get("fundamentals", {})
te = data.get("technical", {})
print(f"\n{tick} - {data['name']}")
print(f" Price: ${pa.get('current')} | 1yr Return: {pa.get('1yr_return_pct')}%")
print(f" From 52wk High: {pa.get('pct_from_52wk_high')}%")
print(f" PE: {fu.get('trailing_pe')} | Fwd PE: {fu.get('forward_pe')} | PEG: {fu.get('peg_ratio')}")
print(f" Target: ${an.get('target_mean')} ({an.get('upside_pct')}% upside) | Rec: {an.get('recommendation')}")
print(f" RSI: {te.get('rsi_14')} | Trend: {te.get('trend')}")
print(f" Insiders: {len(data.get('insider_transactions', []))} recent txns")
if new_passes:
print(f"\nNew GARP Candidates ({len(new_passes)}):")
for s in sorted(new_passes, key=lambda x: x.get("forward_pe", 99)):
print(f" {s['ticker']:6s} PE:{s['trailing_pe']:5.1f} FPE:{s['forward_pe']:5.1f} RG:{s['revenue_growth']:5.1f}% EG:{s['earnings_growth']:5.1f}% ROE:{s['roe']:5.1f}%")

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, "total_tracked": 3100,
"new_this_check": 0 "new_this_check": 0
} }

View File

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