diff --git a/.gitignore b/.gitignore
index 7564f41..c6ef7ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.credentials/
+__pycache__/
diff --git a/data/broad_tickers.txt b/data/broad_tickers.txt
new file mode 100644
index 0000000..c1a2d00
--- /dev/null
+++ b/data/broad_tickers.txt
@@ -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
\ No newline at end of file
diff --git a/data/garp-expanded-scan.json b/data/garp-expanded-scan.json
new file mode 100644
index 0000000..a770fc1
--- /dev/null
+++ b/data/garp-expanded-scan.json
@@ -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"
+ }
+]
\ No newline at end of file
diff --git a/data/garp_scan.py b/data/garp_scan.py
new file mode 100644
index 0000000..7894eaa
--- /dev/null
+++ b/data/garp_scan.py
@@ -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}%")
diff --git a/data/garp_scan2.py b/data/garp_scan2.py
new file mode 100644
index 0000000..3e6213a
--- /dev/null
+++ b/data/garp_scan2.py
@@ -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}%")
diff --git a/data/stock-analysis-deep-dive.json b/data/stock-analysis-deep-dive.json
new file mode 100644
index 0000000..91ba058
--- /dev/null
+++ b/data/stock-analysis-deep-dive.json
@@ -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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/data/stock-screener-results.json b/data/stock-screener-results.json
new file mode 100644
index 0000000..6f05e06
--- /dev/null
+++ b/data/stock-screener-results.json
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/data/stock_deep_dive.py b/data/stock_deep_dive.py
new file mode 100644
index 0000000..c1741d4
--- /dev/null
+++ b/data/stock_deep_dive.py
@@ -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}%")
diff --git a/projects/feed-hunter/data/kch123-tracking/stats.json b/projects/feed-hunter/data/kch123-tracking/stats.json
index 1ca257a..c6b4da6 100644
--- a/projects/feed-hunter/data/kch123-tracking/stats.json
+++ b/projects/feed-hunter/data/kch123-tracking/stats.json
@@ -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
}
\ No newline at end of file
diff --git a/projects/feed-hunter/data/simulations/active.json b/projects/feed-hunter/data/simulations/active.json
index ef360e1..06b3d0e 100644
--- a/projects/feed-hunter/data/simulations/active.json
+++ b/projects/feed-hunter/data/simulations/active.json
@@ -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)",
diff --git a/projects/market-watch/README.md b/projects/market-watch/README.md
new file mode 100644
index 0000000..8d9d6fa
--- /dev/null
+++ b/projects/market-watch/README.md
@@ -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.
diff --git a/projects/market-watch/data/games/7ebf65c7/game.json b/projects/market-watch/data/games/7ebf65c7/game.json
new file mode 100644
index 0000000..2fec668
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/game.json
@@ -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"
+}
\ No newline at end of file
diff --git a/projects/market-watch/data/games/7ebf65c7/players/case/portfolio.json b/projects/market-watch/data/games/7ebf65c7/players/case/portfolio.json
new file mode 100644
index 0000000..a1913e3
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/players/case/portfolio.json
@@ -0,0 +1,4 @@
+{
+ "cash": 100000.0,
+ "positions": {}
+}
\ No newline at end of file
diff --git a/projects/market-watch/data/games/7ebf65c7/players/case/snapshots.json b/projects/market-watch/data/games/7ebf65c7/players/case/snapshots.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/players/case/snapshots.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/projects/market-watch/data/games/7ebf65c7/players/case/trades.json b/projects/market-watch/data/games/7ebf65c7/players/case/trades.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/players/case/trades.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/projects/market-watch/data/games/7ebf65c7/players/testplayer/portfolio.json b/projects/market-watch/data/games/7ebf65c7/players/testplayer/portfolio.json
new file mode 100644
index 0000000..a1913e3
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/players/testplayer/portfolio.json
@@ -0,0 +1,4 @@
+{
+ "cash": 100000.0,
+ "positions": {}
+}
\ No newline at end of file
diff --git a/projects/market-watch/data/games/7ebf65c7/players/testplayer/snapshots.json b/projects/market-watch/data/games/7ebf65c7/players/testplayer/snapshots.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/players/testplayer/snapshots.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/projects/market-watch/data/games/7ebf65c7/players/testplayer/trades.json b/projects/market-watch/data/games/7ebf65c7/players/testplayer/trades.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/projects/market-watch/data/games/7ebf65c7/players/testplayer/trades.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/projects/market-watch/data/history.json b/projects/market-watch/data/history.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/projects/market-watch/data/history.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/projects/market-watch/data/portfolio.json b/projects/market-watch/data/portfolio.json
new file mode 100644
index 0000000..ee9fe9e
--- /dev/null
+++ b/projects/market-watch/data/portfolio.json
@@ -0,0 +1 @@
+{"cash": 100000.0, "positions": {}}
\ No newline at end of file
diff --git a/projects/market-watch/data/scans/2026-02-08.json b/projects/market-watch/data/scans/2026-02-08.json
new file mode 100644
index 0000000..2069656
--- /dev/null
+++ b/projects/market-watch/data/scans/2026-02-08.json
@@ -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
+ }
+ ]
+}
\ No newline at end of file
diff --git a/projects/market-watch/data/snapshots.json b/projects/market-watch/data/snapshots.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/projects/market-watch/data/snapshots.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/projects/market-watch/data/tickers.json b/projects/market-watch/data/tickers.json
new file mode 100644
index 0000000..7815f67
--- /dev/null
+++ b/projects/market-watch/data/tickers.json
@@ -0,0 +1 @@
+{"date": "2026-02-08", "tickers": ["A", "AA", "AAL", "AAON", "AAPL", "ABBV", "ABNB", "ABT", "ACGL", "ACI", "ACM", "ACN", "ADBE", "ADC", "ADI", "ADM", "ADP", "ADSK", "AEE", "AEIS", "AEP", "AES", "AFG", "AFL", "AGCO", "AHR", "AIG", "AIT", "AIZ", "AJG", "AKAM", "ALB", "ALGM", "ALGN", "ALK", "ALL", "ALLE", "ALLY", "ALV", "AM", "AMAT", "AMCR", "AMD", "AME", "AMG", "AMGN", "AMH", "AMKR", "AMP", "AMT", "AMZN", "AN", "ANET", "ANF", "AON", "AOS", "APA", "APD", "APG", "APH", "APO", "APP", "APPF", "APTV", "AR", "ARE", "ARES", "ARMK", "ARW", "ASB", "ASGN", "ASH", "ATI", "ATO", "ATR", "AVAV", "AVB", "AVGO", "AVNT", "AVT", "AVTR", "AVY", "AWK", "AXON", "AXP", "AXTA", "AYI", "AZO", "BA", "BAC", "BAH", "BALL", "BAX", "BBWI", "BBY", "BC", "BCO", "BDC", "BDX", "BEN", "BF-B", "BG", "BHF", "BIIB", "BILL", "BIO", "BJ", "BK", "BKH", "BKNG", "BKR", "BLD", "BLDR", "BLK", "BLKB", "BMRN", "BMY", "BR", "BRBR", "BRK-B", "BRKR", "BRO", "BROS", "BRX", "BSX", "BSY", "BURL", "BWA", "BWXT", "BX", "BXP", "BYD", "C", "CACI", "CAG", "CAH", "CAR", "CARR", "CART", "CASY", "CAT", "CAVA", "CB", "CBOE", "CBRE", "CBSH", "CBT", "CCI", "CCK", "CCL", "CDNS", "CDP", "CDW", "CEG", "CELH", "CF", "CFG", "CFR", "CG", "CGNX", "CHD", "CHDN", "CHE", "CHH", "CHRD", "CHRW", "CHTR", "CHWY", "CI", "CIEN", "CINF", "CL", "CLF", "CLH", "CLX", "CMC", "CMCSA", "CME", "CMG", "CMI", "CMS", "CNC", "CNH", "CNM", "CNO", "CNP", "CNX", "CNXC", "COF", "COHR", "COIN", "COKE", "COLB", "COLM", "COO", "COP", "COR", "COST", "COTY", "CPAY", "CPB", "CPRI", "CPRT", "CPT", "CR", "CRBG", "CRH", "CRL", "CRM", "CROX", "CRS", "CRUS", "CRWD", "CSCO", "CSGP", "CSL", "CSX", "CTAS", "CTRA", "CTSH", "CTVA", "CUBE", "CUZ", "CVLT", "CVNA", "CVS", "CVX", "CW", "CXT", "CYTK", "D", "DAL", "DAR", "DASH", "DBX", "DCI", "DD", "DDOG", "DE", "DECK", "DELL", "DG", "DGX", "DHI", "DHR", "DINO", "DIS", "DKS", "DLB", "DLR", "DLTR", "DOC", "DOCS", "DOCU", "DOV", "DOW", "DPZ", "DRI", "DT", "DTE", "DTM", "DUK", "DUOL", "DVA", "DVN", "DXCM", "DY", "EA", "EBAY", "ECL", "ED", "EEFT", "EFX", "EG", "EGP", "EHC", "EIX", "EL", "ELAN", "ELF", "ELS", "ELV", "EME", "EMR", "ENS", "ENSG", "ENTG", "EOG", "EPAM", "EPR", "EQH", "EQIX", "EQR", "EQT", "ERIE", "ES", "ESAB", "ESNT", "ESS", "ETN", "ETR", "EVR", "EVRG", "EW", "EWBC", "EXC", "EXE", "EXEL", "EXLS", "EXP", "EXPD", "EXPE", "EXPO", "EXR", "F", "FAF", "FANG", "FAST", "FBIN", "FCFS", "FCN", "FCX", "FDS", "FDX", "FE", "FFIN", "FFIV", "FHI", "FHN", "FICO", "FIS", "FISV", "FITB", "FIVE", "FIX", "FLEX", "FLG", "FLO", "FLR", "FLS", "FN", "FNB", "FND", "FNF", "FOUR", "FOX", "FOXA", "FR", "FRT", "FSLR", "FTI", "FTNT", "FTV", "G", "GAP", "GATX", "GBCI", "GD", "GDDY", "GE", "GEF", "GEHC", "GEN", "GEV", "GGG", "GHC", "GILD", "GIS", "GL", "GLPI", "GLW", "GM", "GME", "GMED", "GNRC", "GNTX", "GOOG", "GOOGL", "GPC", "GPK", "GPN", "GRMN", "GS", "GT", "GTLS", "GTM", "GWRE", "GWW", "GXO", "H", "HAE", "HAL", "HALO", "HAS", "HBAN", "HCA", "HD", "HGV", "HIG", "HII", "HIMS", "HL", "HLI", "HLNE", "HLT", "HOG", "HOLX", "HOMB", "HON", "HOOD", "HPE", "HPQ", "HQY", "HR", "HRB", "HRL", "HSIC", "HST", "HSY", "HUBB", "HUM", "HWC", "HWM", "HXL", "IBKR", "IBM", "IBOC", "ICE", "IDA", "IDXX", "IEX", "IFF", "ILMN", "INCY", "INGR", "INTC", "INTU", "INVH", "IP", "IPGP", "IQV", "IR", "IRM", "IRT", "ISRG", "IT", "ITT", "ITW", "IVZ", "J", "JAZZ", "JBHT", "JBL", "JCI", "JEF", "JHG", "JKHY", "JLL", "JNJ", "JPM", "KBH", "KBR", "KD", "KDP", "KEX", "KEY", "KEYS", "KHC", "KIM", "KKR", "KLAC", "KMB", "KMI", "KMPR", "KNF", "KNSL", "KNX", "KO", "KR", "KRC", "KRG", "KTOS", "KVUE", "L", "LAD", "LAMR", "LDOS", "LEA", "LECO", "LEN", "LFUS", "LH", "LHX", "LII", "LIN", "LITE", "LIVN", "LLY", "LMT", "LNT", "LNTH", "LOPE", "LOW", "LPX", "LRCX", "LSCC", "LSTR", "LULU", "LUV", "LVS", "LW", "LYB", "LYV", "M", "MA", "MAA", "MANH", "MAR", "MAS", "MASI", "MAT", "MCD", "MCHP", "MCK", "MCO", "MDLZ", "MDT", "MEDP", "MET", "META", "MGM", "MIDD", "MKC", "MKSI", "MLI", "MLM", "MMM", "MMS", "MNST", "MO", "MOH", "MORN", "MOS", "MP", "MPC", "MPWR", "MRK", "MRNA", "MRSH", "MS", "MSA", "MSCI", "MSFT", "MSI", "MSM", "MTB", "MTCH", "MTD", "MTDR", "MTG", "MTN", "MTSI", "MTZ", "MU", "MUR", "MUSA", "MZTI", "NBIX", "NCLH", "NDAQ", "NDSN", "NEE", "NEM", "NEU", "NFG", "NFLX", "NI", "NJR", "NKE", "NLY", "NNN", "NOC", "NOV", "NOVT", "NOW", "NRG", "NSA", "NSC", "NTAP", "NTNX", "NTRS", "NUE", "NVDA", "NVR", "NVST", "NVT", "NWE", "NWS", "NWSA", "NXPI", "NXST", "NXT", "NYT", "O", "OC", "ODFL", "OGE", "OGS", "OHI", "OKE", "OKTA", "OLED", "OLLI", "OLN", "OMC", "ON", "ONB", "ONTO", "OPCH", "ORA", "ORCL", "ORI", "ORLY", "OSK", "OTIS", "OVV", "OXY", "OZK", "PAG", "PANW", "PATH", "PAYC", "PAYX", "PB", "PBF", "PCAR", "PCG", "PCTY", "PEG", "PEGA", "PEN", "PEP", "PFE", "PFG", "PFGC", "PG", "PGR", "PH", "PHM", "PII", "PINS", "PK", "PKG", "PLD", "PLNT", "PLTR", "PM", "PNC", "PNFP", "PNR", "PNW", "PODD", "POOL", "POR", "POST", "PPC", "PPG", "PPL", "PR", "PRI", "PRU", "PSA", "PSKY", "PSN", "PSTG", "PSX", "PTC", "PVH", "PWR", "PYPL", "Q", "QCOM", "QLYS", "R", "RBA", "RBC", "RCL", "REG", "REGN", "REXR", "RF", "RGA", "RGEN", "RGLD", "RH", "RJF", "RL", "RLI", "RMBS", "RMD", "RNR", "ROIV", "ROK", "ROL", "ROP", "ROST", "RPM", "RRC", "RRX", "RS", "RSG", "RTX", "RVTY", "RYAN", "RYN", "SAIA", "SAIC", "SAM", "SARO", "SATS", "SBAC", "SBRA", "SBUX", "SCHW", "SCI", "SEIC", "SF", "SFM", "SGI", "SHC", "SHW", "SIGI", "SJM", "SLAB", "SLB", "SLGN", "SLM", "SMCI", "SMG", "SNA", "SNDK", "SNPS", "SNX", "SO", "SOLV", "SON", "SPG", "SPGI", "SPXC", "SR", "SRE", "SSB", "SSD", "ST", "STAG", "STE", "STLD", "STRL", "STT", "STWD", "STX", "STZ", "SW", "SWK", "SWKS", "SWX", "SYF", "SYK", "SYNA", "SYY", "T", "TAP", "TCBI", "TDG", "TDY", "TECH", "TEL", "TER", "TEX", "TFC", "TGT", "THC", "THG", "THO", "TJX", "TKO", "TKR", "TLN", "TMHC", "TMO", "TMUS", "TNL", "TOL", "TPL", "TPR", "TREX", "TRGP", "TRMB", "TROW", "TRU", "TRV", "TSCO", "TSLA", "TSN", "TT", "TTC", "TTD", "TTEK", "TTMI", "TTWO", "TWLO", "TXN", "TXNM", "TXRH", "TXT", "TYL", "UAL", "UBER", "UBSI", "UDR", "UFPI", "UGI", "UHS", "ULS", "ULTA", "UMBF", "UNH", "UNM", "UNP", "UPS", "URI", "USB", "USFD", "UTHR", "V", "VAL", "VC", "VFC", "VICI", "VLO", "VLTO", "VLY", "VMC", "VMI", "VNO", "VNOM", "VNT", "VOYA", "VRSK", "VRSN", "VRTX", "VST", "VTR", "VTRS", "VVV", "VZ", "WAB", "WAL", "WAT", "WBD", "WBS", "WCC", "WDAY", "WDC", "WEC", "WELL", "WEX", "WFC", "WFRD", "WH", "WHR", "WING", "WLK", "WM", "WMB", "WMG", "WMS", "WMT", "WPC", "WRB", "WSM", "WSO", "WST", "WTFC", "WTRG", "WTS", "WTW", "WWD", "WY", "WYNN", "XEL", "XOM", "XPO", "XRAY", "XYL", "XYZ", "YETI", "YUM", "ZBH", "ZBRA", "ZION", "ZTS"], "sp500": 503, "sp400": 400}
\ No newline at end of file
diff --git a/projects/market-watch/game_engine.py b/projects/market-watch/game_engine.py
new file mode 100755
index 0000000..5044fb5
--- /dev/null
+++ b/projects/market-watch/game_engine.py
@@ -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}%)")
diff --git a/projects/market-watch/portal/server.py b/projects/market-watch/portal/server.py
new file mode 100755
index 0000000..d5ffb5a
--- /dev/null
+++ b/projects/market-watch/portal/server.py
@@ -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"""
+ 📊 Market Watch
+ """
+
+
+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"
500 {e} ".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"500 {e} ".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 = 'Active ' if g["status"] == "active" else 'Ended '
+ rows += f"""
+ {g['name']}
+ {players}
+ ${g['starting_cash']:,.0f}
+ {g['start_date']}
+ {g.get('end_date', '—') or '—'}
+ {status_badge}
+ """
+
+ html = f"""
+Market Watch
+{nav('home')}
+
+
+
+
Game Players Starting Cash Started Ends Status
+ {rows if rows else 'No games yet — create one! '}
+
+
"""
+ self.send_html(html)
+
+ def serve_create_game(self):
+ html = f"""
+Create Game - Market Watch
+{nav()}
+"""
+ 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 = ' AI ' if entry["username"] == "case" else ""
+ rank_rows += f"""
+ {medal}
+ {entry['username']} {badge}
+ ${entry['total_value']:,.2f}
+ {entry['pnl_pct']:+.2f}%
+ ${entry['total_pnl']:+,.2f}
+ {entry['num_positions']}
+ {entry['num_trades']}
+ """
+
+ html = f"""
+{game['name']} - Market Watch
+{nav()}
+
+
+
🏆 {game['name']}
+ {game['status'].upper()}
+
+
Started {game['start_date']} · ${game['starting_cash']:,.0f} starting cash · {len(game['players'])} players
+
+
+
Leaderboard
+
Rank Player Portfolio Return P&L Positions Trades
+ {rank_rows if rank_rows else 'No players yet '}
+
+
+
+
"""
+ 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 = 'AI Player ' if is_ai else 'Human '
+
+ # Positions table
+ pos_rows = ""
+ for ticker, pos in sorted(p["positions"].items()):
+ pc = "positive" if pos["unrealized_pnl"] >= 0 else "negative"
+ pos_rows += f"""
+ {ticker} {pos['shares']}
+ ${pos['avg_cost']:.2f} ${pos['current_price']:.2f}
+ ${pos['market_value']:,.2f} ${pos['unrealized_pnl']:+,.2f}
+ ${pos.get('trailing_stop',0):.2f}
+ """
+ if not pos_rows:
+ pos_rows = 'No positions '
+
+ # 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'${rpnl:+,.2f} '
+ trade_rows += f"""
+ {t['action']} {t['ticker']} {t['shares']}
+ ${t['price']:.2f} {pnl_cell}
+ {t.get('reason','')[:40]} {t['timestamp'][:16]}
+ """
+
+ 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"""
+ """
+
+ html = f"""
+{username} - {game['name']}
+{nav()}
+
+
+
{username} {badge}
+
+
Portfolio Value ${p['total_value']:,.2f}
Started at ${starting:,.0f}
+
Cash ${p['cash']:,.2f}
{p['cash']/max(p['total_value'],1)*100:.1f}% available
+
Return {p['pnl_pct']:+.2f}%
${p['total_pnl']:+,.2f}
+
Positions {p['num_positions']}
{len(trades)} total trades
+
+
+
Performance
+
+ {trade_form}
+
+
Positions
+
Ticker Shares Avg Cost Price Value P&L Stop
+ {pos_rows}
+
+
+
Trade Log
+
Action Ticker Shares Price P&L Reason Time
+ {trade_rows if trade_rows else 'No trades yet '}
+
+
+"""
+ 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'{sf.replace(".json","")} {data.get("total_scanned",0)} {n} {top or "—"} '
+
+ html = f"""
+Scans - Market Watch
+{nav('scans')}
+📡 GARP Scan History
+
Date Scanned Candidates Top Picks
+ {rows if rows else 'No scans yet '}
+
"""
+ 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()
diff --git a/projects/market-watch/portfolio.py b/projects/market-watch/portfolio.py
new file mode 100755
index 0000000..99ad3aa
--- /dev/null
+++ b/projects/market-watch/portfolio.py
@@ -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")
diff --git a/projects/market-watch/run_daily.py b/projects/market-watch/run_daily.py
new file mode 100755
index 0000000..1cbb112
--- /dev/null
+++ b/projects/market-watch/run_daily.py
@@ -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"📊 Market Watch Daily \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{num_trades} trades executed \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\nLeaderboard: \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()
diff --git a/projects/market-watch/scanner.py b/projects/market-watch/scanner.py
new file mode 100755
index 0000000..2c65142
--- /dev/null
+++ b/projects/market-watch/scanner.py
@@ -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.")
diff --git a/projects/market-watch/trader.py b/projects/market-watch/trader.py
new file mode 100755
index 0000000..fb85562
--- /dev/null
+++ b/projects/market-watch/trader.py
@@ -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.")