Market Watch: multiplayer GARP paper trading simulator
- Game engine with multiplayer support (create games, join, leaderboard) - GARP stock screener (S&P 500 + 400 MidCap, 900+ tickers) - Automated trading logic for AI player (Case) - Web portal at marketwatch.local:8889 with dark theme - Systemd timer for Mon-Fri market hours - Telegram alerts on trades and daily summary - Stock analysis deep dive data (BAC, CFG, FITB, INCY) - Expanded scan results (22 GARP candidates) - Craigslist account setup + credentials
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
.credentials/
|
.credentials/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
902
data/broad_tickers.txt
Normal file
902
data/broad_tickers.txt
Normal file
@ -0,0 +1,902 @@
|
|||||||
|
SLB
|
||||||
|
ADBE
|
||||||
|
WMG
|
||||||
|
DT
|
||||||
|
WBD
|
||||||
|
ELS
|
||||||
|
TKO
|
||||||
|
VZ
|
||||||
|
MTZ
|
||||||
|
SYNA
|
||||||
|
AZO
|
||||||
|
FITB
|
||||||
|
OPCH
|
||||||
|
AVTR
|
||||||
|
UNH
|
||||||
|
TFC
|
||||||
|
MANH
|
||||||
|
LDOS
|
||||||
|
MOS
|
||||||
|
MTB
|
||||||
|
SON
|
||||||
|
CFG
|
||||||
|
BLDR
|
||||||
|
LOW
|
||||||
|
TTMI
|
||||||
|
USB
|
||||||
|
SPGI
|
||||||
|
AA
|
||||||
|
CDP
|
||||||
|
WLK
|
||||||
|
SRE
|
||||||
|
GDDY
|
||||||
|
ALL
|
||||||
|
CARR
|
||||||
|
DECK
|
||||||
|
SW
|
||||||
|
ULTA
|
||||||
|
FND
|
||||||
|
IDA
|
||||||
|
COST
|
||||||
|
CSL
|
||||||
|
DG
|
||||||
|
CMS
|
||||||
|
IPGP
|
||||||
|
HOG
|
||||||
|
PBF
|
||||||
|
INVH
|
||||||
|
TSCO
|
||||||
|
MET
|
||||||
|
FNF
|
||||||
|
VNT
|
||||||
|
PII
|
||||||
|
KEYS
|
||||||
|
PWR
|
||||||
|
CHWY
|
||||||
|
NDAQ
|
||||||
|
PVH
|
||||||
|
PRI
|
||||||
|
MSCI
|
||||||
|
BXP
|
||||||
|
MSI
|
||||||
|
ARE
|
||||||
|
CINF
|
||||||
|
IR
|
||||||
|
HWC
|
||||||
|
APA
|
||||||
|
KRG
|
||||||
|
AMD
|
||||||
|
WING
|
||||||
|
LEN
|
||||||
|
FLR
|
||||||
|
KKR
|
||||||
|
USFD
|
||||||
|
GS
|
||||||
|
TAP
|
||||||
|
KNF
|
||||||
|
WWD
|
||||||
|
EBAY
|
||||||
|
ALLE
|
||||||
|
YETI
|
||||||
|
CAT
|
||||||
|
KMPR
|
||||||
|
DD
|
||||||
|
CAH
|
||||||
|
FCX
|
||||||
|
CTSH
|
||||||
|
RTX
|
||||||
|
TYL
|
||||||
|
MCO
|
||||||
|
ABNB
|
||||||
|
MO
|
||||||
|
OLLI
|
||||||
|
FICO
|
||||||
|
MNST
|
||||||
|
SSD
|
||||||
|
LVS
|
||||||
|
BMRN
|
||||||
|
CFR
|
||||||
|
PKG
|
||||||
|
WYNN
|
||||||
|
NFLX
|
||||||
|
RSG
|
||||||
|
WRB
|
||||||
|
RYAN
|
||||||
|
BHF
|
||||||
|
JPM
|
||||||
|
GPC
|
||||||
|
TXRH
|
||||||
|
AVB
|
||||||
|
IBM
|
||||||
|
PAYX
|
||||||
|
HRB
|
||||||
|
KBH
|
||||||
|
OTIS
|
||||||
|
FTNT
|
||||||
|
OLN
|
||||||
|
JCI
|
||||||
|
DLB
|
||||||
|
COF
|
||||||
|
HBAN
|
||||||
|
BSY
|
||||||
|
COTY
|
||||||
|
WTS
|
||||||
|
LRCX
|
||||||
|
AEP
|
||||||
|
PPL
|
||||||
|
VRTX
|
||||||
|
WMS
|
||||||
|
GTLS
|
||||||
|
V
|
||||||
|
CMC
|
||||||
|
WH
|
||||||
|
NRG
|
||||||
|
CPB
|
||||||
|
PB
|
||||||
|
CPRI
|
||||||
|
AXTA
|
||||||
|
RPM
|
||||||
|
IRT
|
||||||
|
SYF
|
||||||
|
HII
|
||||||
|
PLNT
|
||||||
|
HPE
|
||||||
|
WFRD
|
||||||
|
TXNM
|
||||||
|
AMP
|
||||||
|
ATO
|
||||||
|
WSO
|
||||||
|
AEE
|
||||||
|
CL
|
||||||
|
MMS
|
||||||
|
HLI
|
||||||
|
AMZN
|
||||||
|
AMCR
|
||||||
|
NXST
|
||||||
|
SBRA
|
||||||
|
CBRE
|
||||||
|
KVUE
|
||||||
|
HAE
|
||||||
|
LEA
|
||||||
|
ZBH
|
||||||
|
PK
|
||||||
|
SIGI
|
||||||
|
SHC
|
||||||
|
SLM
|
||||||
|
OGE
|
||||||
|
ULS
|
||||||
|
J
|
||||||
|
XPO
|
||||||
|
BEN
|
||||||
|
BRKR
|
||||||
|
VC
|
||||||
|
AKAM
|
||||||
|
FHI
|
||||||
|
MGM
|
||||||
|
KMI
|
||||||
|
WAB
|
||||||
|
CBSH
|
||||||
|
FIX
|
||||||
|
RH
|
||||||
|
IDXX
|
||||||
|
RF
|
||||||
|
GWW
|
||||||
|
SR
|
||||||
|
SLAB
|
||||||
|
SWX
|
||||||
|
INCY
|
||||||
|
LW
|
||||||
|
GE
|
||||||
|
SYY
|
||||||
|
EXPE
|
||||||
|
MAR
|
||||||
|
SEIC
|
||||||
|
IT
|
||||||
|
COIN
|
||||||
|
RRC
|
||||||
|
KLAC
|
||||||
|
HOMB
|
||||||
|
ADP
|
||||||
|
MRNA
|
||||||
|
BYD
|
||||||
|
CRL
|
||||||
|
GLW
|
||||||
|
ROL
|
||||||
|
VICI
|
||||||
|
AAON
|
||||||
|
ARMK
|
||||||
|
CRBG
|
||||||
|
STWD
|
||||||
|
TOL
|
||||||
|
MUSA
|
||||||
|
PPG
|
||||||
|
DY
|
||||||
|
CG
|
||||||
|
CBT
|
||||||
|
OC
|
||||||
|
VAL
|
||||||
|
BBY
|
||||||
|
DAR
|
||||||
|
XOM
|
||||||
|
VTRS
|
||||||
|
JAZZ
|
||||||
|
XYZ
|
||||||
|
MEDP
|
||||||
|
FIS
|
||||||
|
KIM
|
||||||
|
CHRD
|
||||||
|
MPC
|
||||||
|
WFC
|
||||||
|
BDX
|
||||||
|
IEX
|
||||||
|
AES
|
||||||
|
HOLX
|
||||||
|
RL
|
||||||
|
ACM
|
||||||
|
CART
|
||||||
|
BWA
|
||||||
|
DTE
|
||||||
|
PGR
|
||||||
|
ITT
|
||||||
|
ROP
|
||||||
|
CHDN
|
||||||
|
NBIX
|
||||||
|
WST
|
||||||
|
GMED
|
||||||
|
DOW
|
||||||
|
STLD
|
||||||
|
SFM
|
||||||
|
CDNS
|
||||||
|
WTRG
|
||||||
|
XRAY
|
||||||
|
UAL
|
||||||
|
WAT
|
||||||
|
DASH
|
||||||
|
JBL
|
||||||
|
FFIV
|
||||||
|
ETR
|
||||||
|
TTC
|
||||||
|
CPRT
|
||||||
|
D
|
||||||
|
THC
|
||||||
|
IBOC
|
||||||
|
ALGN
|
||||||
|
ILMN
|
||||||
|
H
|
||||||
|
DXCM
|
||||||
|
FISV
|
||||||
|
MLI
|
||||||
|
SWK
|
||||||
|
COO
|
||||||
|
IBKR
|
||||||
|
KNX
|
||||||
|
FLEX
|
||||||
|
STX
|
||||||
|
GEHC
|
||||||
|
HQY
|
||||||
|
HL
|
||||||
|
PSN
|
||||||
|
NTAP
|
||||||
|
ESNT
|
||||||
|
LPX
|
||||||
|
CCI
|
||||||
|
VMC
|
||||||
|
FDX
|
||||||
|
ERIE
|
||||||
|
L
|
||||||
|
TMUS
|
||||||
|
CTRA
|
||||||
|
COLB
|
||||||
|
BRBR
|
||||||
|
UFPI
|
||||||
|
CACI
|
||||||
|
CVS
|
||||||
|
WPC
|
||||||
|
RVTY
|
||||||
|
GD
|
||||||
|
FAF
|
||||||
|
TNL
|
||||||
|
ASB
|
||||||
|
REGN
|
||||||
|
MIDD
|
||||||
|
COHR
|
||||||
|
CNM
|
||||||
|
AME
|
||||||
|
HAL
|
||||||
|
PTC
|
||||||
|
AFL
|
||||||
|
MTD
|
||||||
|
LSCC
|
||||||
|
MRSH
|
||||||
|
TSN
|
||||||
|
GILD
|
||||||
|
HLNE
|
||||||
|
LAD
|
||||||
|
ADSK
|
||||||
|
NLY
|
||||||
|
R
|
||||||
|
THG
|
||||||
|
TLN
|
||||||
|
FE
|
||||||
|
PNFP
|
||||||
|
UPS
|
||||||
|
REXR
|
||||||
|
SNDK
|
||||||
|
MSM
|
||||||
|
OLED
|
||||||
|
TRU
|
||||||
|
UNM
|
||||||
|
IP
|
||||||
|
HR
|
||||||
|
COLM
|
||||||
|
EQIX
|
||||||
|
CSX
|
||||||
|
ANF
|
||||||
|
FRT
|
||||||
|
BDC
|
||||||
|
PSTG
|
||||||
|
BJ
|
||||||
|
COP
|
||||||
|
JHG
|
||||||
|
FCFS
|
||||||
|
MU
|
||||||
|
BWXT
|
||||||
|
HIG
|
||||||
|
INTC
|
||||||
|
NWS
|
||||||
|
NJR
|
||||||
|
JKHY
|
||||||
|
KEY
|
||||||
|
SARO
|
||||||
|
GM
|
||||||
|
SCHW
|
||||||
|
ES
|
||||||
|
VRSN
|
||||||
|
ON
|
||||||
|
APG
|
||||||
|
FTV
|
||||||
|
HIMS
|
||||||
|
BRO
|
||||||
|
GOOG
|
||||||
|
PLD
|
||||||
|
SJM
|
||||||
|
TRGP
|
||||||
|
FANG
|
||||||
|
JLL
|
||||||
|
MTG
|
||||||
|
STZ
|
||||||
|
ANET
|
||||||
|
MDLZ
|
||||||
|
WSM
|
||||||
|
DTM
|
||||||
|
SWKS
|
||||||
|
RBA
|
||||||
|
APP
|
||||||
|
BLD
|
||||||
|
WBS
|
||||||
|
QCOM
|
||||||
|
AMT
|
||||||
|
RGEN
|
||||||
|
AYI
|
||||||
|
YUM
|
||||||
|
AMH
|
||||||
|
OMC
|
||||||
|
G
|
||||||
|
SATS
|
||||||
|
ALB
|
||||||
|
APO
|
||||||
|
FAST
|
||||||
|
PSKY
|
||||||
|
PCAR
|
||||||
|
TTEK
|
||||||
|
ALK
|
||||||
|
CELH
|
||||||
|
HD
|
||||||
|
CRWD
|
||||||
|
PSA
|
||||||
|
PNC
|
||||||
|
RRX
|
||||||
|
LYV
|
||||||
|
LULU
|
||||||
|
ENSG
|
||||||
|
AOS
|
||||||
|
MCD
|
||||||
|
MCHP
|
||||||
|
ZTS
|
||||||
|
EEFT
|
||||||
|
GWRE
|
||||||
|
TEX
|
||||||
|
NEU
|
||||||
|
CNH
|
||||||
|
GOOGL
|
||||||
|
ODFL
|
||||||
|
CIEN
|
||||||
|
TTWO
|
||||||
|
KBR
|
||||||
|
UTHR
|
||||||
|
INTU
|
||||||
|
IQV
|
||||||
|
O
|
||||||
|
EVR
|
||||||
|
POR
|
||||||
|
VNO
|
||||||
|
FCN
|
||||||
|
STAG
|
||||||
|
SOLV
|
||||||
|
GLPI
|
||||||
|
COR
|
||||||
|
LIVN
|
||||||
|
MKC
|
||||||
|
MA
|
||||||
|
FOX
|
||||||
|
CNXC
|
||||||
|
Q
|
||||||
|
MCK
|
||||||
|
FOUR
|
||||||
|
BC
|
||||||
|
AON
|
||||||
|
CMI
|
||||||
|
CBOE
|
||||||
|
SMG
|
||||||
|
GPK
|
||||||
|
BLKB
|
||||||
|
NVDA
|
||||||
|
PANW
|
||||||
|
HPQ
|
||||||
|
ATR
|
||||||
|
DVA
|
||||||
|
ACN
|
||||||
|
HOOD
|
||||||
|
NSC
|
||||||
|
PNR
|
||||||
|
POOL
|
||||||
|
TT
|
||||||
|
AIT
|
||||||
|
CLH
|
||||||
|
EXR
|
||||||
|
GIS
|
||||||
|
TRV
|
||||||
|
CNO
|
||||||
|
MSFT
|
||||||
|
DLR
|
||||||
|
ESAB
|
||||||
|
GEN
|
||||||
|
NVT
|
||||||
|
WAL
|
||||||
|
UGI
|
||||||
|
PEP
|
||||||
|
NWSA
|
||||||
|
CRH
|
||||||
|
LHX
|
||||||
|
GAP
|
||||||
|
PH
|
||||||
|
LOPE
|
||||||
|
RCL
|
||||||
|
LNT
|
||||||
|
WEC
|
||||||
|
EPAM
|
||||||
|
TSLA
|
||||||
|
PEN
|
||||||
|
CRS
|
||||||
|
RYN
|
||||||
|
SNX
|
||||||
|
KTOS
|
||||||
|
ADI
|
||||||
|
GNRC
|
||||||
|
OXY
|
||||||
|
RJF
|
||||||
|
ONTO
|
||||||
|
NOC
|
||||||
|
CXT
|
||||||
|
TREX
|
||||||
|
CHD
|
||||||
|
JBHT
|
||||||
|
RBC
|
||||||
|
AXP
|
||||||
|
TPL
|
||||||
|
KNSL
|
||||||
|
ROIV
|
||||||
|
VST
|
||||||
|
META
|
||||||
|
REG
|
||||||
|
NOV
|
||||||
|
CVNA
|
||||||
|
DRI
|
||||||
|
BAX
|
||||||
|
ASGN
|
||||||
|
GGG
|
||||||
|
EQT
|
||||||
|
CHTR
|
||||||
|
PPC
|
||||||
|
CF
|
||||||
|
RS
|
||||||
|
DLTR
|
||||||
|
STRL
|
||||||
|
PNW
|
||||||
|
CEG
|
||||||
|
SBAC
|
||||||
|
PAYC
|
||||||
|
BBWI
|
||||||
|
LECO
|
||||||
|
APD
|
||||||
|
BSX
|
||||||
|
AMAT
|
||||||
|
OKE
|
||||||
|
ABT
|
||||||
|
CAR
|
||||||
|
COKE
|
||||||
|
CDW
|
||||||
|
NSA
|
||||||
|
NDSN
|
||||||
|
VFC
|
||||||
|
JNJ
|
||||||
|
NOVT
|
||||||
|
SYK
|
||||||
|
PATH
|
||||||
|
LAMR
|
||||||
|
EQH
|
||||||
|
AMGN
|
||||||
|
BURL
|
||||||
|
WTFC
|
||||||
|
HSY
|
||||||
|
AN
|
||||||
|
NXPI
|
||||||
|
KO
|
||||||
|
UBER
|
||||||
|
CPT
|
||||||
|
ORLY
|
||||||
|
HLT
|
||||||
|
WMB
|
||||||
|
EIX
|
||||||
|
INGR
|
||||||
|
PFE
|
||||||
|
CGNX
|
||||||
|
CMCSA
|
||||||
|
GEV
|
||||||
|
PR
|
||||||
|
APTV
|
||||||
|
FLS
|
||||||
|
PCG
|
||||||
|
PYPL
|
||||||
|
ELAN
|
||||||
|
GRMN
|
||||||
|
EGP
|
||||||
|
DOC
|
||||||
|
CVX
|
||||||
|
DIS
|
||||||
|
FR
|
||||||
|
LNTH
|
||||||
|
CSGP
|
||||||
|
DUOL
|
||||||
|
CME
|
||||||
|
FN
|
||||||
|
TXT
|
||||||
|
NOW
|
||||||
|
WHR
|
||||||
|
CHH
|
||||||
|
AHR
|
||||||
|
EXLS
|
||||||
|
PLTR
|
||||||
|
BKR
|
||||||
|
ASH
|
||||||
|
HSIC
|
||||||
|
BLK
|
||||||
|
T
|
||||||
|
NXT
|
||||||
|
MOH
|
||||||
|
XEL
|
||||||
|
MPWR
|
||||||
|
EFX
|
||||||
|
AVAV
|
||||||
|
EPR
|
||||||
|
VVV
|
||||||
|
DOCU
|
||||||
|
KHC
|
||||||
|
MAA
|
||||||
|
EXE
|
||||||
|
AR
|
||||||
|
DBX
|
||||||
|
ALLY
|
||||||
|
MMM
|
||||||
|
BALL
|
||||||
|
FNB
|
||||||
|
SAIA
|
||||||
|
UMBF
|
||||||
|
CAVA
|
||||||
|
AFG
|
||||||
|
TDY
|
||||||
|
IFF
|
||||||
|
CCK
|
||||||
|
POST
|
||||||
|
ATI
|
||||||
|
ORI
|
||||||
|
PINS
|
||||||
|
SGI
|
||||||
|
TECH
|
||||||
|
ARW
|
||||||
|
AAPL
|
||||||
|
ACGL
|
||||||
|
EL
|
||||||
|
HST
|
||||||
|
UBSI
|
||||||
|
SBUX
|
||||||
|
EA
|
||||||
|
RLI
|
||||||
|
C
|
||||||
|
KDP
|
||||||
|
PHM
|
||||||
|
BRK.B
|
||||||
|
CROX
|
||||||
|
NUE
|
||||||
|
KEX
|
||||||
|
FLG
|
||||||
|
MTN
|
||||||
|
PM
|
||||||
|
HGV
|
||||||
|
SCI
|
||||||
|
DKS
|
||||||
|
LII
|
||||||
|
MLM
|
||||||
|
ARES
|
||||||
|
CRM
|
||||||
|
AJG
|
||||||
|
ISRG
|
||||||
|
SMCI
|
||||||
|
EXP
|
||||||
|
NEE
|
||||||
|
LLY
|
||||||
|
LSTR
|
||||||
|
TKR
|
||||||
|
DDOG
|
||||||
|
DCI
|
||||||
|
DAL
|
||||||
|
TXN
|
||||||
|
LMT
|
||||||
|
ST
|
||||||
|
DELL
|
||||||
|
TJX
|
||||||
|
CYTK
|
||||||
|
GNTX
|
||||||
|
MTCH
|
||||||
|
BROS
|
||||||
|
VLO
|
||||||
|
VRSK
|
||||||
|
SNA
|
||||||
|
TEL
|
||||||
|
MDT
|
||||||
|
ACI
|
||||||
|
MUR
|
||||||
|
WEX
|
||||||
|
AM
|
||||||
|
UNP
|
||||||
|
GTM
|
||||||
|
MASI
|
||||||
|
PEG
|
||||||
|
MTDR
|
||||||
|
CPAY
|
||||||
|
SSB
|
||||||
|
ENS
|
||||||
|
OVV
|
||||||
|
ZION
|
||||||
|
EHC
|
||||||
|
UDR
|
||||||
|
BAH
|
||||||
|
AMKR
|
||||||
|
MORN
|
||||||
|
STE
|
||||||
|
VMI
|
||||||
|
MTSI
|
||||||
|
F
|
||||||
|
FBIN
|
||||||
|
EVRG
|
||||||
|
CTAS
|
||||||
|
AVY
|
||||||
|
AVGO
|
||||||
|
DOCS
|
||||||
|
EOG
|
||||||
|
NFG
|
||||||
|
EG
|
||||||
|
CNX
|
||||||
|
VNOM
|
||||||
|
SF
|
||||||
|
PCTY
|
||||||
|
BAC
|
||||||
|
HON
|
||||||
|
FHN
|
||||||
|
RNR
|
||||||
|
PEGA
|
||||||
|
TWLO
|
||||||
|
WDC
|
||||||
|
OGS
|
||||||
|
ED
|
||||||
|
AIG
|
||||||
|
MRK
|
||||||
|
HUBB
|
||||||
|
LITE
|
||||||
|
TPR
|
||||||
|
HRL
|
||||||
|
EME
|
||||||
|
AAL
|
||||||
|
MAT
|
||||||
|
CNC
|
||||||
|
DOV
|
||||||
|
TDG
|
||||||
|
DINO
|
||||||
|
EWBC
|
||||||
|
BKH
|
||||||
|
GEF
|
||||||
|
RGLD
|
||||||
|
ROST
|
||||||
|
ZBRA
|
||||||
|
BG
|
||||||
|
WCC
|
||||||
|
KD
|
||||||
|
EW
|
||||||
|
RMBS
|
||||||
|
MS
|
||||||
|
SHW
|
||||||
|
NI
|
||||||
|
KMB
|
||||||
|
STT
|
||||||
|
OSK
|
||||||
|
XYL
|
||||||
|
HXL
|
||||||
|
ITW
|
||||||
|
OZK
|
||||||
|
CB
|
||||||
|
PODD
|
||||||
|
PG
|
||||||
|
NVST
|
||||||
|
CMG
|
||||||
|
IRM
|
||||||
|
CNP
|
||||||
|
TER
|
||||||
|
ENTG
|
||||||
|
FLO
|
||||||
|
GL
|
||||||
|
TTD
|
||||||
|
BR
|
||||||
|
BA
|
||||||
|
ROK
|
||||||
|
CUZ
|
||||||
|
GT
|
||||||
|
BILL
|
||||||
|
AXON
|
||||||
|
MZTI
|
||||||
|
GPN
|
||||||
|
VLTO
|
||||||
|
VTR
|
||||||
|
CRUS
|
||||||
|
FIVE
|
||||||
|
KR
|
||||||
|
MSA
|
||||||
|
LYB
|
||||||
|
BX
|
||||||
|
ALGM
|
||||||
|
A
|
||||||
|
DHI
|
||||||
|
LUV
|
||||||
|
TMHC
|
||||||
|
CLX
|
||||||
|
ETN
|
||||||
|
CLF
|
||||||
|
BIIB
|
||||||
|
EXEL
|
||||||
|
SAM
|
||||||
|
AVT
|
||||||
|
VOYA
|
||||||
|
ECL
|
||||||
|
UHS
|
||||||
|
RMD
|
||||||
|
EXPO
|
||||||
|
LFUS
|
||||||
|
DVN
|
||||||
|
BK
|
||||||
|
M
|
||||||
|
TMO
|
||||||
|
SAIC
|
||||||
|
OKTA
|
||||||
|
LH
|
||||||
|
NKE
|
||||||
|
GATX
|
||||||
|
KRC
|
||||||
|
RGA
|
||||||
|
TROW
|
||||||
|
CR
|
||||||
|
BMY
|
||||||
|
ESS
|
||||||
|
GXO
|
||||||
|
ICE
|
||||||
|
WY
|
||||||
|
JEF
|
||||||
|
MP
|
||||||
|
WELL
|
||||||
|
FTI
|
||||||
|
IVZ
|
||||||
|
EMR
|
||||||
|
LIN
|
||||||
|
HUM
|
||||||
|
BKNG
|
||||||
|
ELF
|
||||||
|
FOXA
|
||||||
|
HAS
|
||||||
|
CI
|
||||||
|
TRMB
|
||||||
|
AEIS
|
||||||
|
GHC
|
||||||
|
CTVA
|
||||||
|
EQR
|
||||||
|
AGCO
|
||||||
|
GME
|
||||||
|
WM
|
||||||
|
NCLH
|
||||||
|
GBCI
|
||||||
|
HWM
|
||||||
|
AMG
|
||||||
|
DE
|
||||||
|
BCO
|
||||||
|
ONB
|
||||||
|
CASY
|
||||||
|
ABBV
|
||||||
|
HALO
|
||||||
|
DHR
|
||||||
|
WTW
|
||||||
|
ORCL
|
||||||
|
MAS
|
||||||
|
PFG
|
||||||
|
WMT
|
||||||
|
SNPS
|
||||||
|
ALV
|
||||||
|
NYT
|
||||||
|
FDS
|
||||||
|
MKSI
|
||||||
|
CCL
|
||||||
|
PRU
|
||||||
|
URI
|
||||||
|
TCBI
|
||||||
|
BRX
|
||||||
|
BF.B
|
||||||
|
EXC
|
||||||
|
WDAY
|
||||||
|
FFIN
|
||||||
|
AWK
|
||||||
|
CUBE
|
||||||
|
NTNX
|
||||||
|
CAG
|
||||||
|
NWE
|
||||||
|
ADC
|
||||||
|
APPF
|
||||||
|
DUK
|
||||||
|
SLGN
|
||||||
|
THO
|
||||||
|
CW
|
||||||
|
SPG
|
||||||
|
NEM
|
||||||
|
CSCO
|
||||||
|
PAG
|
||||||
|
APH
|
||||||
|
FSLR
|
||||||
|
VLY
|
||||||
|
DPZ
|
||||||
|
EXPD
|
||||||
|
PFGC
|
||||||
|
PSX
|
||||||
|
AVNT
|
||||||
|
CHE
|
||||||
|
OHI
|
||||||
|
HCA
|
||||||
|
CHRW
|
||||||
|
TGT
|
||||||
|
SPXC
|
||||||
|
ADM
|
||||||
|
NVR
|
||||||
|
BIO
|
||||||
|
QLYS
|
||||||
|
ELV
|
||||||
|
SO
|
||||||
|
DGX
|
||||||
|
ORA
|
||||||
|
AIZ
|
||||||
|
CVLT
|
||||||
|
NNN
|
||||||
|
NTRS
|
||||||
310
data/garp-expanded-scan.json
Normal file
310
data/garp-expanded-scan.json
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"ticker": "ALLY",
|
||||||
|
"name": "Ally Financial Inc.",
|
||||||
|
"market_cap_B": 13.1,
|
||||||
|
"trailing_pe": 17.85,
|
||||||
|
"forward_pe": 6.7,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 12.0,
|
||||||
|
"earnings_growth_pct": 265.4,
|
||||||
|
"roe_pct": 5.8,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Credit Services"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "WAL",
|
||||||
|
"name": "Western Alliance Bancorporation",
|
||||||
|
"market_cap_B": 10.4,
|
||||||
|
"trailing_pe": 10.81,
|
||||||
|
"forward_pe": 7.93,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 16.6,
|
||||||
|
"earnings_growth_pct": 32.9,
|
||||||
|
"roe_pct": 13.5,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "CART",
|
||||||
|
"name": "Maplebear Inc.",
|
||||||
|
"market_cap_B": 9.1,
|
||||||
|
"trailing_pe": 19.03,
|
||||||
|
"forward_pe": 8.84,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 10.2,
|
||||||
|
"earnings_growth_pct": 21.1,
|
||||||
|
"roe_pct": 15.3,
|
||||||
|
"debt_to_equity": 1.0,
|
||||||
|
"sector": "Consumer Cyclical",
|
||||||
|
"industry": "Internet Retail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "ONB",
|
||||||
|
"name": "Old National Bancorp",
|
||||||
|
"market_cap_B": 10.1,
|
||||||
|
"trailing_pe": 14.46,
|
||||||
|
"forward_pe": 9.02,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 41.4,
|
||||||
|
"earnings_growth_pct": 17.2,
|
||||||
|
"roe_pct": 9.0,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "VLY",
|
||||||
|
"name": "Valley National Bancorp",
|
||||||
|
"market_cap_B": 7.6,
|
||||||
|
"trailing_pe": 13.57,
|
||||||
|
"forward_pe": 9.19,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 38.3,
|
||||||
|
"earnings_growth_pct": 66.3,
|
||||||
|
"roe_pct": 7.8,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FSLR",
|
||||||
|
"name": "First Solar, Inc.",
|
||||||
|
"market_cap_B": 23.5,
|
||||||
|
"trailing_pe": 16.77,
|
||||||
|
"forward_pe": 9.4,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 79.7,
|
||||||
|
"earnings_growth_pct": 45.7,
|
||||||
|
"roe_pct": 16.9,
|
||||||
|
"debt_to_equity": 9.9,
|
||||||
|
"sector": "Technology",
|
||||||
|
"industry": "Solar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FNB",
|
||||||
|
"name": "F.N.B. Corporation",
|
||||||
|
"market_cap_B": 6.8,
|
||||||
|
"trailing_pe": 12.12,
|
||||||
|
"forward_pe": 9.66,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 26.4,
|
||||||
|
"earnings_growth_pct": 56.5,
|
||||||
|
"roe_pct": 8.7,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "WBS",
|
||||||
|
"name": "Webster Financial Corporation",
|
||||||
|
"market_cap_B": 11.8,
|
||||||
|
"trailing_pe": 12.39,
|
||||||
|
"forward_pe": 9.77,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 18.2,
|
||||||
|
"earnings_growth_pct": 53.4,
|
||||||
|
"roe_pct": 10.8,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "ZION",
|
||||||
|
"name": "Zions Bancorporation, National Association",
|
||||||
|
"market_cap_B": 9.6,
|
||||||
|
"trailing_pe": 10.86,
|
||||||
|
"forward_pe": 9.99,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 13.6,
|
||||||
|
"earnings_growth_pct": 31.4,
|
||||||
|
"roe_pct": 13.5,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "JHG",
|
||||||
|
"name": "Janus Henderson Group plc",
|
||||||
|
"market_cap_B": 7.4,
|
||||||
|
"trailing_pe": 9.22,
|
||||||
|
"forward_pe": 10.12,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 61.3,
|
||||||
|
"earnings_growth_pct": 243.6,
|
||||||
|
"roe_pct": 16.2,
|
||||||
|
"debt_to_equity": 6.5,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Asset Management"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "SSB",
|
||||||
|
"name": "SouthState Bank Corporation",
|
||||||
|
"market_cap_B": 10.8,
|
||||||
|
"trailing_pe": 13.7,
|
||||||
|
"forward_pe": 10.19,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 53.2,
|
||||||
|
"earnings_growth_pct": 30.9,
|
||||||
|
"roe_pct": 10.7,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "PINS",
|
||||||
|
"name": "Pinterest, Inc.",
|
||||||
|
"market_cap_B": 13.3,
|
||||||
|
"trailing_pe": 6.88,
|
||||||
|
"forward_pe": 10.37,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 16.8,
|
||||||
|
"earnings_growth_pct": 225.0,
|
||||||
|
"roe_pct": 51.5,
|
||||||
|
"debt_to_equity": 4.3,
|
||||||
|
"sector": "Communication Services",
|
||||||
|
"industry": "Internet Content & Information"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "RRC",
|
||||||
|
"name": "Range Resources Corporation",
|
||||||
|
"market_cap_B": 8.7,
|
||||||
|
"trailing_pe": 15.37,
|
||||||
|
"forward_pe": 10.45,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 16.1,
|
||||||
|
"earnings_growth_pct": 189.8,
|
||||||
|
"roe_pct": 14.2,
|
||||||
|
"debt_to_equity": 32.7,
|
||||||
|
"sector": "Energy",
|
||||||
|
"industry": "Oil & Gas E&P"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "EWBC",
|
||||||
|
"name": "East West Bancorp, Inc.",
|
||||||
|
"market_cap_B": 16.9,
|
||||||
|
"trailing_pe": 12.87,
|
||||||
|
"forward_pe": 11.18,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 21.6,
|
||||||
|
"earnings_growth_pct": 21.3,
|
||||||
|
"roe_pct": 15.9,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FHN",
|
||||||
|
"name": "First Horizon Corporation",
|
||||||
|
"market_cap_B": 12.9,
|
||||||
|
"trailing_pe": 14.03,
|
||||||
|
"forward_pe": 11.19,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 23.7,
|
||||||
|
"earnings_growth_pct": 74.9,
|
||||||
|
"roe_pct": 10.9,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "ORI",
|
||||||
|
"name": "Old Republic International Corporation",
|
||||||
|
"market_cap_B": 10.3,
|
||||||
|
"trailing_pe": 11.23,
|
||||||
|
"forward_pe": 11.99,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 19.3,
|
||||||
|
"earnings_growth_pct": 97.3,
|
||||||
|
"roe_pct": 16.3,
|
||||||
|
"debt_to_equity": 26.8,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Insurance - Property & Casualty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "WTFC",
|
||||||
|
"name": "Wintrust Financial Corporation",
|
||||||
|
"market_cap_B": 10.8,
|
||||||
|
"trailing_pe": 14.14,
|
||||||
|
"forward_pe": 12.03,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 10.5,
|
||||||
|
"earnings_growth_pct": 19.4,
|
||||||
|
"roe_pct": 12.1,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "UBSI",
|
||||||
|
"name": "United Bankshares, Inc.",
|
||||||
|
"market_cap_B": 6.3,
|
||||||
|
"trailing_pe": 13.92,
|
||||||
|
"forward_pe": 12.08,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 22.1,
|
||||||
|
"earnings_growth_pct": 32.1,
|
||||||
|
"roe_pct": 8.9,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Banks - Regional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "PGR",
|
||||||
|
"name": "The Progressive Corporation",
|
||||||
|
"market_cap_B": 118.6,
|
||||||
|
"trailing_pe": 10.51,
|
||||||
|
"forward_pe": 12.49,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 12.2,
|
||||||
|
"earnings_growth_pct": 25.2,
|
||||||
|
"roe_pct": 40.4,
|
||||||
|
"debt_to_equity": 22.7,
|
||||||
|
"sector": "Financial Services",
|
||||||
|
"industry": "Insurance - Property & Casualty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "EXEL",
|
||||||
|
"name": "Exelixis, Inc.",
|
||||||
|
"market_cap_B": 11.8,
|
||||||
|
"trailing_pe": 18.45,
|
||||||
|
"forward_pe": 12.79,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 10.8,
|
||||||
|
"earnings_growth_pct": 72.5,
|
||||||
|
"roe_pct": 30.6,
|
||||||
|
"debt_to_equity": 8.2,
|
||||||
|
"sector": "Healthcare",
|
||||||
|
"industry": "Biotechnology"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "NEM",
|
||||||
|
"name": "Newmont Corporation",
|
||||||
|
"market_cap_B": 126.7,
|
||||||
|
"trailing_pe": 17.93,
|
||||||
|
"forward_pe": 12.89,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 20.0,
|
||||||
|
"earnings_growth_pct": 108.1,
|
||||||
|
"roe_pct": 22.9,
|
||||||
|
"debt_to_equity": 16.9,
|
||||||
|
"sector": "Basic Materials",
|
||||||
|
"industry": "Gold"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "CTRA",
|
||||||
|
"name": "Coterra Energy Inc.",
|
||||||
|
"market_cap_B": 23.3,
|
||||||
|
"trailing_pe": 14.19,
|
||||||
|
"forward_pe": 13.95,
|
||||||
|
"peg": null,
|
||||||
|
"revenue_growth_pct": 34.9,
|
||||||
|
"earnings_growth_pct": 23.5,
|
||||||
|
"roe_pct": 11.9,
|
||||||
|
"debt_to_equity": 28.0,
|
||||||
|
"sector": "Energy",
|
||||||
|
"industry": "Oil & Gas E&P"
|
||||||
|
}
|
||||||
|
]
|
||||||
74
data/garp_scan.py
Normal file
74
data/garp_scan.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GARP scan on broad ticker list."""
|
||||||
|
import yfinance as yf
|
||||||
|
import json, time
|
||||||
|
|
||||||
|
EXCLUDE = {"BAC", "CFG", "FITB", "INCY"}
|
||||||
|
|
||||||
|
with open("broad_tickers.txt") as f:
|
||||||
|
tickers = [l.strip().replace(".", "-") for l in f if l.strip()]
|
||||||
|
|
||||||
|
print(f"Screening {len(tickers)} tickers...")
|
||||||
|
passed = []
|
||||||
|
|
||||||
|
for i, tick in enumerate(tickers):
|
||||||
|
if tick in EXCLUDE:
|
||||||
|
continue
|
||||||
|
if i % 100 == 0:
|
||||||
|
print(f" Progress: {i}/{len(tickers)} ({len(passed)} passed so far)")
|
||||||
|
try:
|
||||||
|
t = yf.Ticker(tick)
|
||||||
|
info = t.info or {}
|
||||||
|
|
||||||
|
mc = info.get("marketCap", 0) or 0
|
||||||
|
if mc < 5e9: continue
|
||||||
|
|
||||||
|
tpe = info.get("trailingPE")
|
||||||
|
if not tpe or tpe >= 25 or tpe <= 0: continue
|
||||||
|
|
||||||
|
fpe = info.get("forwardPE")
|
||||||
|
if not fpe or fpe >= 15 or fpe <= 0: continue
|
||||||
|
|
||||||
|
rg = info.get("revenueGrowth")
|
||||||
|
if rg is None or rg < 0.10: continue
|
||||||
|
|
||||||
|
eg = info.get("earningsGrowth")
|
||||||
|
if eg is None or eg < 0.15: continue
|
||||||
|
|
||||||
|
roe = info.get("returnOnEquity")
|
||||||
|
if roe is None or roe < 0.05: continue
|
||||||
|
|
||||||
|
peg = info.get("pegRatio")
|
||||||
|
if peg is not None and peg > 1.2: continue
|
||||||
|
|
||||||
|
dte = info.get("debtToEquity")
|
||||||
|
if dte is not None and dte > 35: continue
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"ticker": tick,
|
||||||
|
"name": info.get("longName", tick),
|
||||||
|
"market_cap_B": round(mc / 1e9, 1),
|
||||||
|
"trailing_pe": round(tpe, 2),
|
||||||
|
"forward_pe": round(fpe, 2),
|
||||||
|
"peg": round(peg, 2) if peg else None,
|
||||||
|
"revenue_growth_pct": round(rg * 100, 1),
|
||||||
|
"earnings_growth_pct": round(eg * 100, 1),
|
||||||
|
"roe_pct": round(roe * 100, 1),
|
||||||
|
"debt_to_equity": round(dte, 1) if dte else None,
|
||||||
|
"sector": info.get("sector"),
|
||||||
|
"industry": info.get("industry"),
|
||||||
|
}
|
||||||
|
passed.append(entry)
|
||||||
|
print(f" ✅ {tick}: PE={tpe:.1f} FPE={fpe:.1f} RG={rg*100:.0f}% EG={eg*100:.0f}% ROE={roe*100:.0f}%")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
passed.sort(key=lambda x: x.get("forward_pe", 99))
|
||||||
|
|
||||||
|
with open("garp-expanded-scan.json", "w") as f:
|
||||||
|
json.dump(passed, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nDone! {len(passed)} stocks passed GARP screen")
|
||||||
|
for s in passed:
|
||||||
|
print(f" {s['ticker']:6s} ${s['market_cap_B']:6.1f}B PE:{s['trailing_pe']:5.1f} FPE:{s['forward_pe']:5.1f} RG:{s['revenue_growth_pct']:5.1f}% EG:{s['earnings_growth_pct']:5.1f}% ROE:{s['roe_pct']:5.1f}%")
|
||||||
85
data/garp_scan2.py
Normal file
85
data/garp_scan2.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GARP scan - batch download approach for speed."""
|
||||||
|
import yfinance as yf
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
|
||||||
|
EXCLUDE = {"BAC", "CFG", "FITB", "INCY"}
|
||||||
|
|
||||||
|
with open("broad_tickers.txt") as f:
|
||||||
|
tickers = [l.strip().replace(".", "-") for l in f if l.strip()]
|
||||||
|
|
||||||
|
print(f"Screening {len(tickers)} tickers in batches...")
|
||||||
|
|
||||||
|
passed = []
|
||||||
|
batch_size = 20
|
||||||
|
|
||||||
|
for batch_start in range(0, len(tickers), batch_size):
|
||||||
|
batch = tickers[batch_start:batch_start + batch_size]
|
||||||
|
batch = [t for t in batch if t not in EXCLUDE]
|
||||||
|
if not batch:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"Batch {batch_start}-{batch_start+len(batch)} / {len(tickers)}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yf.Tickers(" ".join(batch))
|
||||||
|
for tick in batch:
|
||||||
|
try:
|
||||||
|
info = data.tickers[tick].info or {}
|
||||||
|
|
||||||
|
mc = info.get("marketCap", 0) or 0
|
||||||
|
if mc < 5e9: continue
|
||||||
|
|
||||||
|
tpe = info.get("trailingPE")
|
||||||
|
if not tpe or tpe >= 25 or tpe <= 0: continue
|
||||||
|
|
||||||
|
fpe = info.get("forwardPE")
|
||||||
|
if not fpe or fpe >= 15 or fpe <= 0: continue
|
||||||
|
|
||||||
|
rg = info.get("revenueGrowth")
|
||||||
|
if rg is None or rg < 0.10: continue
|
||||||
|
|
||||||
|
eg = info.get("earningsGrowth")
|
||||||
|
if eg is None or eg < 0.15: continue
|
||||||
|
|
||||||
|
roe = info.get("returnOnEquity")
|
||||||
|
if roe is None or roe < 0.05: continue
|
||||||
|
|
||||||
|
peg = info.get("pegRatio")
|
||||||
|
if peg is not None and peg > 1.2: continue
|
||||||
|
|
||||||
|
dte = info.get("debtToEquity")
|
||||||
|
if dte is not None and dte > 35: continue
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"ticker": tick,
|
||||||
|
"name": info.get("longName", tick),
|
||||||
|
"market_cap_B": round(mc / 1e9, 1),
|
||||||
|
"trailing_pe": round(tpe, 2),
|
||||||
|
"forward_pe": round(fpe, 2),
|
||||||
|
"peg": round(peg, 2) if peg else None,
|
||||||
|
"revenue_growth_pct": round(rg * 100, 1),
|
||||||
|
"earnings_growth_pct": round(eg * 100, 1),
|
||||||
|
"roe_pct": round(roe * 100, 1),
|
||||||
|
"debt_to_equity": round(dte, 1) if dte else None,
|
||||||
|
"sector": info.get("sector"),
|
||||||
|
"industry": info.get("industry"),
|
||||||
|
}
|
||||||
|
passed.append(entry)
|
||||||
|
print(f" ✅ {tick}: PE={tpe:.1f} FPE={fpe:.1f} RG={rg*100:.0f}% EG={eg*100:.0f}% ROE={roe*100:.0f}%")
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Batch error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
passed.sort(key=lambda x: x.get("forward_pe", 99))
|
||||||
|
|
||||||
|
with open("garp-expanded-scan.json", "w") as f:
|
||||||
|
json.dump(passed, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nDone! {len(passed)} stocks passed GARP screen")
|
||||||
|
for s in passed:
|
||||||
|
print(f" {s['ticker']:6s} ${s['market_cap_B']:6.1f}B PE:{s['trailing_pe']:5.1f} FPE:{s['forward_pe']:5.1f} RG:{s['revenue_growth_pct']:5.1f}% EG:{s['earnings_growth_pct']:5.1f}% ROE:{s['roe_pct']:5.1f}%")
|
||||||
762
data/stock-analysis-deep-dive.json
Normal file
762
data/stock-analysis-deep-dive.json
Normal file
@ -0,0 +1,762 @@
|
|||||||
|
{
|
||||||
|
"BAC": {
|
||||||
|
"ticker": "BAC",
|
||||||
|
"name": "Bank of America Corporation",
|
||||||
|
"price_action": {
|
||||||
|
"current": 56.53,
|
||||||
|
"1yr_ago": 46.33,
|
||||||
|
"1yr_return_pct": 22.02,
|
||||||
|
"52wk_high": 57.25,
|
||||||
|
"52wk_low": 33.82,
|
||||||
|
"pct_from_52wk_high": -1.26,
|
||||||
|
"50d_ma": 54.25,
|
||||||
|
"200d_ma": 49.18,
|
||||||
|
"above_50d_ma": true,
|
||||||
|
"above_200d_ma": true
|
||||||
|
},
|
||||||
|
"insider_transactions": [
|
||||||
|
{
|
||||||
|
"date": "2026-01-15 00:00:00",
|
||||||
|
"insider": "MOYNIHAN BRIAN T",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "17891",
|
||||||
|
"value": "nan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-15 00:00:00",
|
||||||
|
"insider": "MOYNIHAN BRIAN T",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "17892",
|
||||||
|
"value": "nan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-11 00:00:00",
|
||||||
|
"insider": "MOYNIHAN BRIAN T",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "130000",
|
||||||
|
"value": "0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-09 00:00:00",
|
||||||
|
"insider": "GREENER GEOFFREY S",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "9265",
|
||||||
|
"value": "0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-03 00:00:00",
|
||||||
|
"insider": "SCRIVENER THOMAS M",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "3000",
|
||||||
|
"value": "0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-28 00:00:00",
|
||||||
|
"insider": "OKPARA JOHNBULL",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "26784",
|
||||||
|
"value": "nan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-14 00:00:00",
|
||||||
|
"insider": "MOYNIHAN BRIAN T",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "17891",
|
||||||
|
"value": "nan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-14 00:00:00",
|
||||||
|
"insider": "SCHIMPF ERIC A",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "1234",
|
||||||
|
"value": "nan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-14 00:00:00",
|
||||||
|
"insider": "HANS LINDSAY D",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "975",
|
||||||
|
"value": "nan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-14 00:00:00",
|
||||||
|
"insider": "GOPALKRISHNAN HARI",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "2703",
|
||||||
|
"value": "nan"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"top_institutional_holders": [
|
||||||
|
{
|
||||||
|
"holder": "Vanguard Group Inc",
|
||||||
|
"shares": 651076825,
|
||||||
|
"pct_held": "0.0892"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Berkshire Hathaway, Inc",
|
||||||
|
"shares": 568070012,
|
||||||
|
"pct_held": "0.077800006"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Blackrock Inc.",
|
||||||
|
"shares": 535326028,
|
||||||
|
"pct_held": "0.0733"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "JPMORGAN CHASE & CO",
|
||||||
|
"shares": 363341282,
|
||||||
|
"pct_held": "0.0498"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "State Street Corporation",
|
||||||
|
"shares": 301312466,
|
||||||
|
"pct_held": "0.041300002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"institutional_pct": 0.71739,
|
||||||
|
"earnings": [
|
||||||
|
{
|
||||||
|
"date": "2026-04-15 09:00:00-04:00",
|
||||||
|
"eps_estimate": 0.99,
|
||||||
|
"eps_actual": null,
|
||||||
|
"surprise_pct": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-14 06:00:00-05:00",
|
||||||
|
"eps_estimate": 0.96,
|
||||||
|
"eps_actual": 0.98,
|
||||||
|
"surprise_pct": 2.23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-10-15 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.95,
|
||||||
|
"eps_actual": 1.06,
|
||||||
|
"surprise_pct": 11.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-07-16 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.86,
|
||||||
|
"eps_actual": 0.89,
|
||||||
|
"surprise_pct": 3.61
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-04-15 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.82,
|
||||||
|
"eps_actual": 0.9,
|
||||||
|
"surprise_pct": 10.29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-01-16 06:00:00-05:00",
|
||||||
|
"eps_estimate": 0.77,
|
||||||
|
"eps_actual": 0.82,
|
||||||
|
"surprise_pct": 6.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-10-15 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.76,
|
||||||
|
"eps_actual": 0.81,
|
||||||
|
"surprise_pct": 6.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-07-16 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.8,
|
||||||
|
"eps_actual": 0.83,
|
||||||
|
"surprise_pct": 3.58
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analyst": {
|
||||||
|
"target_mean": 62.20833,
|
||||||
|
"target_low": 56.0,
|
||||||
|
"target_high": 71.0,
|
||||||
|
"recommendation": "buy",
|
||||||
|
"num_analysts": 24,
|
||||||
|
"upside_pct": 10.04
|
||||||
|
},
|
||||||
|
"fundamentals": {
|
||||||
|
"trailing_pe": 14.83727,
|
||||||
|
"forward_pe": 11.407365,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"market_cap": 412810084352,
|
||||||
|
"revenue_growth": 0.132,
|
||||||
|
"earnings_growth": 0.209,
|
||||||
|
"roe": 0.1019,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"dividend_yield": 1.95,
|
||||||
|
"beta": 1.273
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"rsi_14": 71.14,
|
||||||
|
"trend": "bullish"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CFG": {
|
||||||
|
"ticker": "CFG",
|
||||||
|
"name": "Citizens Financial Group, Inc.",
|
||||||
|
"price_action": {
|
||||||
|
"current": 68.12,
|
||||||
|
"1yr_ago": 46.24,
|
||||||
|
"1yr_return_pct": 47.32,
|
||||||
|
"52wk_high": 68.12,
|
||||||
|
"52wk_low": 33.06,
|
||||||
|
"pct_from_52wk_high": 0.0,
|
||||||
|
"50d_ma": 59.58,
|
||||||
|
"200d_ma": 49.69,
|
||||||
|
"above_50d_ma": true,
|
||||||
|
"above_200d_ma": true
|
||||||
|
},
|
||||||
|
"insider_transactions": [
|
||||||
|
{
|
||||||
|
"date": "2025-12-11 00:00:00",
|
||||||
|
"insider": "VAN SAUN BRUCE W",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "8000",
|
||||||
|
"value": "458558"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-03 00:00:00",
|
||||||
|
"insider": "LAMONICA SUSAN",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "20128",
|
||||||
|
"value": "1120727"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "KELLY EDWARD J. III",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "316",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "HANKOWSKY WILLIAM P",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "347",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "ZURAITIS MARITA",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "347",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "CUMMING CHRISTINE M",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "347",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "ATKINSON TRACY A",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "85",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "CUMMINGS KEVIN",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "162",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "SWIFT CHRISTOPHER J",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "200",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-11-12 00:00:00",
|
||||||
|
"insider": "SIEKERKA MICHELE N",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "162",
|
||||||
|
"value": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"top_institutional_holders": [
|
||||||
|
{
|
||||||
|
"holder": "Vanguard Group Inc",
|
||||||
|
"shares": 51303226,
|
||||||
|
"pct_held": "0.1195"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Blackrock Inc.",
|
||||||
|
"shares": 43851199,
|
||||||
|
"pct_held": "0.1021"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Capital World Investors",
|
||||||
|
"shares": 37289711,
|
||||||
|
"pct_held": "0.0868"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Invesco Ltd.",
|
||||||
|
"shares": 24064513,
|
||||||
|
"pct_held": "0.055999998"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "State Street Corporation",
|
||||||
|
"shares": 22969833,
|
||||||
|
"pct_held": "0.0535"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"institutional_pct": 0.99170995,
|
||||||
|
"earnings": [
|
||||||
|
{
|
||||||
|
"date": "2026-04-16 09:00:00-04:00",
|
||||||
|
"eps_estimate": 1.09,
|
||||||
|
"eps_actual": null,
|
||||||
|
"surprise_pct": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-21 06:00:00-05:00",
|
||||||
|
"eps_estimate": 1.11,
|
||||||
|
"eps_actual": 1.13,
|
||||||
|
"surprise_pct": 2.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-10-15 06:00:00-04:00",
|
||||||
|
"eps_estimate": 1.03,
|
||||||
|
"eps_actual": 1.05,
|
||||||
|
"surprise_pct": 2.27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-07-17 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.88,
|
||||||
|
"eps_actual": 0.92,
|
||||||
|
"surprise_pct": 4.13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-04-16 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.75,
|
||||||
|
"eps_actual": 0.77,
|
||||||
|
"surprise_pct": 2.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-01-17 06:00:00-05:00",
|
||||||
|
"eps_estimate": 0.83,
|
||||||
|
"eps_actual": 0.85,
|
||||||
|
"surprise_pct": 2.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-10-16 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.79,
|
||||||
|
"eps_actual": 0.79,
|
||||||
|
"surprise_pct": -0.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-07-17 03:00:00-04:00",
|
||||||
|
"eps_estimate": 0.79,
|
||||||
|
"eps_actual": 0.82,
|
||||||
|
"surprise_pct": 3.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analyst": {
|
||||||
|
"target_mean": 72.275,
|
||||||
|
"target_low": 62.5,
|
||||||
|
"target_high": 80.0,
|
||||||
|
"recommendation": "buy",
|
||||||
|
"num_analysts": 20,
|
||||||
|
"upside_pct": 6.1
|
||||||
|
},
|
||||||
|
"fundamentals": {
|
||||||
|
"trailing_pe": 17.647669,
|
||||||
|
"forward_pe": 10.848602,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"market_cap": 29256599552,
|
||||||
|
"revenue_growth": 0.107,
|
||||||
|
"earnings_growth": 0.359,
|
||||||
|
"roe": 0.07241,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"dividend_yield": 2.7,
|
||||||
|
"beta": 1.071
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"rsi_14": 75.46,
|
||||||
|
"trend": "bullish"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FITB": {
|
||||||
|
"ticker": "FITB",
|
||||||
|
"name": "Fifth Third Bancorp",
|
||||||
|
"price_action": {
|
||||||
|
"current": 55.08,
|
||||||
|
"1yr_ago": 42.49,
|
||||||
|
"1yr_return_pct": 29.63,
|
||||||
|
"52wk_high": 55.08,
|
||||||
|
"52wk_low": 32.46,
|
||||||
|
"pct_from_52wk_high": 0.0,
|
||||||
|
"50d_ma": 48.27,
|
||||||
|
"200d_ma": 42.82,
|
||||||
|
"above_50d_ma": true,
|
||||||
|
"above_200d_ma": true
|
||||||
|
},
|
||||||
|
"insider_transactions": [
|
||||||
|
{
|
||||||
|
"date": "2026-02-02 00:00:00",
|
||||||
|
"insider": "SMITH BARBARA",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "40498",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-02 00:00:00",
|
||||||
|
"insider": "SEFZIK PETER L",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "209382",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-02 00:00:00",
|
||||||
|
"insider": "KERR DEREK J",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "14189",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-02 00:00:00",
|
||||||
|
"insider": "VAN DE VEN MICHAEL G",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "47972",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-07 00:00:00",
|
||||||
|
"insider": "ALMODOVAR PRISCILLA",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "848",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-15 00:00:00",
|
||||||
|
"insider": "PRESTON BRYAN D",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "1100",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-11 00:00:00",
|
||||||
|
"insider": "SCHRAMM JUDE",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "2250",
|
||||||
|
"value": "109125"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-10-20 00:00:00",
|
||||||
|
"insider": "BAYH B EVAN III",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "3000",
|
||||||
|
"value": "123650"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-10-09 00:00:00",
|
||||||
|
"insider": "GONZALEZ CHRISTIAN",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "5709",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-08-28 00:00:00",
|
||||||
|
"insider": "SHAFFER ROBERT P",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "14035",
|
||||||
|
"value": "372208"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"top_institutional_holders": [
|
||||||
|
{
|
||||||
|
"holder": "Vanguard Group Inc",
|
||||||
|
"shares": 107237083,
|
||||||
|
"pct_held": "0.16219999"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Blackrock Inc.",
|
||||||
|
"shares": 88421716,
|
||||||
|
"pct_held": "0.1338"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "JPMORGAN CHASE & CO",
|
||||||
|
"shares": 65878286,
|
||||||
|
"pct_held": "0.099700004"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "State Street Corporation",
|
||||||
|
"shares": 39653081,
|
||||||
|
"pct_held": "0.06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Charles Schwab Investment Management, Inc.",
|
||||||
|
"shares": 32138466,
|
||||||
|
"pct_held": "0.048600003"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"institutional_pct": 0.66008,
|
||||||
|
"earnings": [
|
||||||
|
{
|
||||||
|
"date": "2026-04-17 09:00:00-04:00",
|
||||||
|
"eps_estimate": 0.43,
|
||||||
|
"eps_actual": null,
|
||||||
|
"surprise_pct": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-20 06:00:00-05:00",
|
||||||
|
"eps_estimate": 0.99,
|
||||||
|
"eps_actual": 1.04,
|
||||||
|
"surprise_pct": 4.93
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-10-17 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.86,
|
||||||
|
"eps_actual": 0.91,
|
||||||
|
"surprise_pct": 5.47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-07-17 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.87,
|
||||||
|
"eps_actual": 0.9,
|
||||||
|
"surprise_pct": 3.85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-04-17 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.7,
|
||||||
|
"eps_actual": 0.71,
|
||||||
|
"surprise_pct": 1.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-01-21 06:00:00-05:00",
|
||||||
|
"eps_estimate": 0.87,
|
||||||
|
"eps_actual": 0.9,
|
||||||
|
"surprise_pct": 3.01
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-10-18 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.82,
|
||||||
|
"eps_actual": 0.85,
|
||||||
|
"surprise_pct": 3.17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-07-19 06:00:00-04:00",
|
||||||
|
"eps_estimate": 0.84,
|
||||||
|
"eps_actual": 0.86,
|
||||||
|
"surprise_pct": 1.79
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analyst": {
|
||||||
|
"target_mean": 57.15789,
|
||||||
|
"target_low": 49.0,
|
||||||
|
"target_high": 61.0,
|
||||||
|
"recommendation": "buy",
|
||||||
|
"num_analysts": 19,
|
||||||
|
"upside_pct": 3.77
|
||||||
|
},
|
||||||
|
"fundamentals": {
|
||||||
|
"trailing_pe": 15.6034,
|
||||||
|
"forward_pe": 11.235359,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"market_cap": 49574670336,
|
||||||
|
"revenue_growth": 0.115,
|
||||||
|
"earnings_growth": 0.208,
|
||||||
|
"roe": 0.121929996,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"dividend_yield": 2.9,
|
||||||
|
"beta": 0.977
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"rsi_14": 71.83,
|
||||||
|
"trend": "bullish"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"INCY": {
|
||||||
|
"ticker": "INCY",
|
||||||
|
"name": "Incyte Corporation",
|
||||||
|
"price_action": {
|
||||||
|
"current": 108.39,
|
||||||
|
"1yr_ago": 74.13,
|
||||||
|
"1yr_return_pct": 46.22,
|
||||||
|
"52wk_high": 110.57,
|
||||||
|
"52wk_low": 55.17,
|
||||||
|
"pct_from_52wk_high": -1.97,
|
||||||
|
"50d_ma": 101.9,
|
||||||
|
"200d_ma": 84.49,
|
||||||
|
"above_50d_ma": true,
|
||||||
|
"above_200d_ma": true
|
||||||
|
},
|
||||||
|
"insider_transactions": [
|
||||||
|
{
|
||||||
|
"date": "2026-01-16 00:00:00",
|
||||||
|
"insider": "MORRISSEY MICHAEL JAMES",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "7426",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-16 00:00:00",
|
||||||
|
"insider": "HEESON LEE",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "8911",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-07 00:00:00",
|
||||||
|
"insider": "ISSA MOHAMED KHAIRIE",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "10856",
|
||||||
|
"value": "1184064"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-01-05 00:00:00",
|
||||||
|
"insider": "STEIN STEVEN H.",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "15634",
|
||||||
|
"value": "1589978"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-31 00:00:00",
|
||||||
|
"insider": "BAKER BROS ADVISORS L.P.",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "656",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-31 00:00:00",
|
||||||
|
"insider": "HARRIGAN EDMUND P. M.D.",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "245",
|
||||||
|
"value": "24199"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-31 00:00:00",
|
||||||
|
"insider": "CLANCY PAUL J.",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "241",
|
||||||
|
"value": "23804"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-19 00:00:00",
|
||||||
|
"insider": "TRAY THOMAS R",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "3374",
|
||||||
|
"value": "336350"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-19 00:00:00",
|
||||||
|
"insider": "TRAY THOMAS R",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "2774",
|
||||||
|
"value": "265638"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-12-17 00:00:00",
|
||||||
|
"insider": "MORRISSEY MICHAEL JAMES",
|
||||||
|
"transaction": "",
|
||||||
|
"shares": "58331",
|
||||||
|
"value": "5675002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"top_institutional_holders": [
|
||||||
|
{
|
||||||
|
"holder": "Baker Bros. Advisors, LP",
|
||||||
|
"shares": 30743663,
|
||||||
|
"pct_held": "0.1566"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Vanguard Group Inc",
|
||||||
|
"shares": 19911434,
|
||||||
|
"pct_held": "0.1014"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Blackrock Inc.",
|
||||||
|
"shares": 17894297,
|
||||||
|
"pct_held": "0.0911"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "Dodge & Cox Inc.",
|
||||||
|
"shares": 13932416,
|
||||||
|
"pct_held": "0.071"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"holder": "State Street Corporation",
|
||||||
|
"shares": 9676796,
|
||||||
|
"pct_held": "0.0493"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"institutional_pct": 1.05931,
|
||||||
|
"earnings": [
|
||||||
|
{
|
||||||
|
"date": "2026-02-10 08:00:00-05:00",
|
||||||
|
"eps_estimate": 1.95,
|
||||||
|
"eps_actual": null,
|
||||||
|
"surprise_pct": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-10-28 07:00:00-04:00",
|
||||||
|
"eps_estimate": 1.64,
|
||||||
|
"eps_actual": 2.26,
|
||||||
|
"surprise_pct": 38.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-07-29 07:00:00-04:00",
|
||||||
|
"eps_estimate": 1.47,
|
||||||
|
"eps_actual": 1.57,
|
||||||
|
"surprise_pct": 6.59
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-04-29 07:00:00-04:00",
|
||||||
|
"eps_estimate": 1.03,
|
||||||
|
"eps_actual": 1.16,
|
||||||
|
"surprise_pct": 12.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-02-10 07:00:00-05:00",
|
||||||
|
"eps_estimate": 1.55,
|
||||||
|
"eps_actual": 1.43,
|
||||||
|
"surprise_pct": -8.01
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-10-29 07:00:00-04:00",
|
||||||
|
"eps_estimate": 1.09,
|
||||||
|
"eps_actual": 1.07,
|
||||||
|
"surprise_pct": -2.03
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-07-30 07:00:00-04:00",
|
||||||
|
"eps_estimate": 1.11,
|
||||||
|
"eps_actual": -1.82,
|
||||||
|
"surprise_pct": -264.53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-04-30 07:00:00-04:00",
|
||||||
|
"eps_estimate": 0.83,
|
||||||
|
"eps_actual": 0.64,
|
||||||
|
"surprise_pct": -23.09
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analyst": {
|
||||||
|
"target_mean": 104.22727,
|
||||||
|
"target_low": 70.0,
|
||||||
|
"target_high": 135.0,
|
||||||
|
"recommendation": "buy",
|
||||||
|
"num_analysts": 22,
|
||||||
|
"upside_pct": -3.84
|
||||||
|
},
|
||||||
|
"fundamentals": {
|
||||||
|
"trailing_pe": 18.371185,
|
||||||
|
"forward_pe": 13.606971,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"market_cap": 21279418368,
|
||||||
|
"revenue_growth": 0.2,
|
||||||
|
"earnings_growth": 2.907,
|
||||||
|
"roe": 0.30389,
|
||||||
|
"debt_to_equity": 0.887,
|
||||||
|
"dividend_yield": null,
|
||||||
|
"beta": 0.847
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"rsi_14": 54.22,
|
||||||
|
"trend": "bullish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
data/stock-screener-results.json
Normal file
71
data/stock-screener-results.json
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-02-08",
|
||||||
|
"filters": "GARP",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"ticker": "BAC",
|
||||||
|
"name": "Bank of America Corporation",
|
||||||
|
"mcap_b": 412.8,
|
||||||
|
"rev_growth": 13.2,
|
||||||
|
"trail_pe": 14.8,
|
||||||
|
"fwd_pe": 11.4,
|
||||||
|
"peg": "N/A",
|
||||||
|
"eps_growth": 20.9,
|
||||||
|
"roe": 10.2,
|
||||||
|
"quick": "N/A",
|
||||||
|
"de": "N/A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "CFG",
|
||||||
|
"name": "Citizens Financial Group, Inc.",
|
||||||
|
"mcap_b": 29.3,
|
||||||
|
"rev_growth": 10.7,
|
||||||
|
"trail_pe": 17.6,
|
||||||
|
"fwd_pe": 10.8,
|
||||||
|
"peg": "N/A",
|
||||||
|
"eps_growth": 35.9,
|
||||||
|
"roe": 7.2,
|
||||||
|
"quick": "N/A",
|
||||||
|
"de": "N/A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FITB",
|
||||||
|
"name": "Fifth Third Bancorp",
|
||||||
|
"mcap_b": 49.6,
|
||||||
|
"rev_growth": 11.5,
|
||||||
|
"trail_pe": 15.6,
|
||||||
|
"fwd_pe": 11.2,
|
||||||
|
"peg": "N/A",
|
||||||
|
"eps_growth": 20.8,
|
||||||
|
"roe": 12.2,
|
||||||
|
"quick": "N/A",
|
||||||
|
"de": "N/A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "INCY",
|
||||||
|
"name": "Incyte Corporation",
|
||||||
|
"mcap_b": 21.3,
|
||||||
|
"rev_growth": 20.0,
|
||||||
|
"trail_pe": 18.4,
|
||||||
|
"fwd_pe": 13.6,
|
||||||
|
"peg": "N/A",
|
||||||
|
"eps_growth": 290.7,
|
||||||
|
"roe": 30.4,
|
||||||
|
"quick": 2.86,
|
||||||
|
"de": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"scanned": 503,
|
||||||
|
"revenue": 316,
|
||||||
|
"pe": 121,
|
||||||
|
"fwd_pe": 32,
|
||||||
|
"peg": 0,
|
||||||
|
"eps": 14,
|
||||||
|
"roe": 1,
|
||||||
|
"quick": 11,
|
||||||
|
"de": 4,
|
||||||
|
"mcap": 0,
|
||||||
|
"err": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
295
data/stock_deep_dive.py
Normal file
295
data/stock_deep_dive.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deep dive analysis on BAC, CFG, FITB, INCY + expanded GARP scan."""
|
||||||
|
|
||||||
|
import yfinance as yf
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TARGETS = ["BAC", "CFG", "FITB", "INCY"]
|
||||||
|
|
||||||
|
def safe_get(d, *keys, default=None):
|
||||||
|
for k in keys:
|
||||||
|
if isinstance(d, dict):
|
||||||
|
d = d.get(k, default)
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
return d
|
||||||
|
|
||||||
|
def analyze_stock(ticker_str):
|
||||||
|
print(f"\n{'='*60}\nAnalyzing {ticker_str}...")
|
||||||
|
t = yf.Ticker(ticker_str)
|
||||||
|
info = t.info or {}
|
||||||
|
result = {"ticker": ticker_str, "name": info.get("longName", ticker_str)}
|
||||||
|
|
||||||
|
# 1. Price action (1yr)
|
||||||
|
hist = t.history(period="1y")
|
||||||
|
if not hist.empty:
|
||||||
|
prices = hist["Close"]
|
||||||
|
result["price_action"] = {
|
||||||
|
"current": round(float(prices.iloc[-1]), 2),
|
||||||
|
"1yr_ago": round(float(prices.iloc[0]), 2),
|
||||||
|
"1yr_return_pct": round(float((prices.iloc[-1] / prices.iloc[0] - 1) * 100), 2),
|
||||||
|
"52wk_high": round(float(prices.max()), 2),
|
||||||
|
"52wk_low": round(float(prices.min()), 2),
|
||||||
|
"pct_from_52wk_high": round(float((prices.iloc[-1] / prices.max() - 1) * 100), 2),
|
||||||
|
"50d_ma": round(float(prices.tail(50).mean()), 2),
|
||||||
|
"200d_ma": round(float(prices.tail(200).mean()), 2) if len(prices) >= 200 else None,
|
||||||
|
}
|
||||||
|
cur = float(prices.iloc[-1])
|
||||||
|
ma50 = result["price_action"]["50d_ma"]
|
||||||
|
ma200 = result["price_action"]["200d_ma"]
|
||||||
|
result["price_action"]["above_50d_ma"] = cur > ma50
|
||||||
|
result["price_action"]["above_200d_ma"] = cur > ma200 if ma200 else None
|
||||||
|
|
||||||
|
# 2. Insider activity
|
||||||
|
try:
|
||||||
|
insiders = t.insider_transactions
|
||||||
|
if insiders is not None and not insiders.empty:
|
||||||
|
recent = insiders.head(10)
|
||||||
|
txns = []
|
||||||
|
for _, row in recent.iterrows():
|
||||||
|
txns.append({
|
||||||
|
"date": str(row.get("Start Date", "")),
|
||||||
|
"insider": str(row.get("Insider", "")),
|
||||||
|
"transaction": str(row.get("Transaction", "")),
|
||||||
|
"shares": str(row.get("Shares", "")),
|
||||||
|
"value": str(row.get("Value", "")),
|
||||||
|
})
|
||||||
|
result["insider_transactions"] = txns
|
||||||
|
else:
|
||||||
|
result["insider_transactions"] = []
|
||||||
|
except:
|
||||||
|
result["insider_transactions"] = []
|
||||||
|
|
||||||
|
# 3. Institutional ownership
|
||||||
|
try:
|
||||||
|
inst = t.institutional_holders
|
||||||
|
if inst is not None and not inst.empty:
|
||||||
|
top5 = []
|
||||||
|
for _, row in inst.head(5).iterrows():
|
||||||
|
top5.append({
|
||||||
|
"holder": str(row.get("Holder", "")),
|
||||||
|
"shares": int(row.get("Shares", 0)) if row.get("Shares") else 0,
|
||||||
|
"pct_held": str(row.get("pctHeld", "")),
|
||||||
|
})
|
||||||
|
result["top_institutional_holders"] = top5
|
||||||
|
result["institutional_pct"] = info.get("heldPercentInstitutions")
|
||||||
|
except:
|
||||||
|
result["top_institutional_holders"] = []
|
||||||
|
|
||||||
|
# 4. Earnings surprises
|
||||||
|
try:
|
||||||
|
earn = t.earnings_dates
|
||||||
|
if earn is not None and not earn.empty:
|
||||||
|
surprises = []
|
||||||
|
for idx, row in earn.head(8).iterrows():
|
||||||
|
s = {
|
||||||
|
"date": str(idx),
|
||||||
|
"eps_estimate": row.get("EPS Estimate"),
|
||||||
|
"eps_actual": row.get("Reported EPS"),
|
||||||
|
"surprise_pct": row.get("Surprise(%)"),
|
||||||
|
}
|
||||||
|
# clean NaN
|
||||||
|
s = {k: (None if isinstance(v, float) and np.isnan(v) else v) for k, v in s.items()}
|
||||||
|
surprises.append(s)
|
||||||
|
result["earnings"] = surprises
|
||||||
|
else:
|
||||||
|
result["earnings"] = []
|
||||||
|
except:
|
||||||
|
result["earnings"] = []
|
||||||
|
|
||||||
|
# 5. Analyst consensus
|
||||||
|
result["analyst"] = {
|
||||||
|
"target_mean": info.get("targetMeanPrice"),
|
||||||
|
"target_low": info.get("targetLowPrice"),
|
||||||
|
"target_high": info.get("targetHighPrice"),
|
||||||
|
"recommendation": info.get("recommendationKey"),
|
||||||
|
"num_analysts": info.get("numberOfAnalystOpinions"),
|
||||||
|
}
|
||||||
|
if result["price_action"].get("current") and info.get("targetMeanPrice"):
|
||||||
|
upside = (info["targetMeanPrice"] / result["price_action"]["current"] - 1) * 100
|
||||||
|
result["analyst"]["upside_pct"] = round(upside, 2)
|
||||||
|
|
||||||
|
# 6. Key fundamentals snapshot
|
||||||
|
result["fundamentals"] = {
|
||||||
|
"trailing_pe": info.get("trailingPE"),
|
||||||
|
"forward_pe": info.get("forwardPE"),
|
||||||
|
"peg_ratio": info.get("pegRatio"),
|
||||||
|
"market_cap": info.get("marketCap"),
|
||||||
|
"revenue_growth": info.get("revenueGrowth"),
|
||||||
|
"earnings_growth": info.get("earningsGrowth"),
|
||||||
|
"roe": info.get("returnOnEquity"),
|
||||||
|
"debt_to_equity": info.get("debtToEquity"),
|
||||||
|
"dividend_yield": info.get("dividendYield"),
|
||||||
|
"beta": info.get("beta"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7. Technical - RSI approximation
|
||||||
|
if not hist.empty and len(hist) >= 14:
|
||||||
|
delta = hist["Close"].diff()
|
||||||
|
gain = delta.where(delta > 0, 0).rolling(14).mean()
|
||||||
|
loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
|
||||||
|
rs = gain / loss
|
||||||
|
rsi = 100 - (100 / (1 + rs))
|
||||||
|
result["technical"] = {
|
||||||
|
"rsi_14": round(float(rsi.iloc[-1]), 2) if not np.isnan(rsi.iloc[-1]) else None,
|
||||||
|
"trend": "bullish" if cur > ma50 and (ma200 is None or cur > ma200) else "bearish" if cur < ma50 and (ma200 and cur < ma200) else "mixed",
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f" Done: {result['name']} @ ${result['price_action']['current']}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EXPANDED GARP SCAN
|
||||||
|
# ============================================================
|
||||||
|
def get_broad_ticker_list():
|
||||||
|
"""Get a broad list of US tickers to screen."""
|
||||||
|
import urllib.request
|
||||||
|
# Use Wikipedia's S&P 500 + S&P 400 midcap for broader coverage
|
||||||
|
# Plus some known Russell 1000 members not in S&P 500
|
||||||
|
|
||||||
|
# Start with S&P 500
|
||||||
|
sp500 = []
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")
|
||||||
|
sp500 = tables[0]["Symbol"].str.replace(".", "-", regex=False).tolist()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# S&P 400 midcap
|
||||||
|
sp400 = []
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_400_companies")
|
||||||
|
sp400 = tables[0]["Symbol"].str.replace(".", "-", regex=False).tolist()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
all_tickers = list(set(sp500 + sp400))
|
||||||
|
print(f"Total tickers to screen: {len(all_tickers)}")
|
||||||
|
return all_tickers
|
||||||
|
|
||||||
|
def garp_screen(tickers, exclude=None):
|
||||||
|
"""Apply GARP filters to ticker list."""
|
||||||
|
exclude = set(exclude or [])
|
||||||
|
passed = []
|
||||||
|
|
||||||
|
for i, tick in enumerate(tickers):
|
||||||
|
if tick in exclude:
|
||||||
|
continue
|
||||||
|
if i % 50 == 0:
|
||||||
|
print(f" Screening {i}/{len(tickers)}...")
|
||||||
|
try:
|
||||||
|
t = yf.Ticker(tick)
|
||||||
|
info = t.info or {}
|
||||||
|
|
||||||
|
mc = info.get("marketCap", 0) or 0
|
||||||
|
if mc < 5e9:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tpe = info.get("trailingPE")
|
||||||
|
if tpe is None or tpe >= 25 or tpe <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fpe = info.get("forwardPE")
|
||||||
|
if fpe is None or fpe >= 15 or fpe <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rg = info.get("revenueGrowth")
|
||||||
|
if rg is None or rg < 0.10:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eg = info.get("earningsGrowth")
|
||||||
|
if eg is None or eg < 0.15:
|
||||||
|
continue
|
||||||
|
|
||||||
|
roe = info.get("returnOnEquity")
|
||||||
|
if roe is None or roe < 0.05:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Optional filters
|
||||||
|
peg = info.get("pegRatio")
|
||||||
|
if peg is not None and peg > 1.2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dte = info.get("debtToEquity")
|
||||||
|
if dte is not None and dte > 35:
|
||||||
|
continue
|
||||||
|
|
||||||
|
passed.append({
|
||||||
|
"ticker": tick,
|
||||||
|
"name": info.get("longName", tick),
|
||||||
|
"market_cap": mc,
|
||||||
|
"trailing_pe": round(tpe, 2),
|
||||||
|
"forward_pe": round(fpe, 2),
|
||||||
|
"peg": round(peg, 2) if peg else None,
|
||||||
|
"revenue_growth": round(rg * 100, 1),
|
||||||
|
"earnings_growth": round(eg * 100, 1),
|
||||||
|
"roe": round(roe * 100, 1),
|
||||||
|
"debt_to_equity": round(dte, 1) if dte else None,
|
||||||
|
})
|
||||||
|
print(f" ✅ PASS: {tick} (PE:{tpe:.1f} FPE:{fpe:.1f} RG:{rg*100:.0f}% EG:{eg*100:.0f}%)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return passed
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MAIN
|
||||||
|
# ============================================================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
output_dir = Path("/home/wdjones/.openclaw/workspace/data")
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Deep dive on 4 stocks
|
||||||
|
print("=" * 60)
|
||||||
|
print("DEEP DIVE ANALYSIS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
analyses = {}
|
||||||
|
for tick in TARGETS:
|
||||||
|
analyses[tick] = analyze_stock(tick)
|
||||||
|
|
||||||
|
# Save deep dive
|
||||||
|
with open(output_dir / "stock-analysis-deep-dive.json", "w") as f:
|
||||||
|
json.dump(analyses, f, indent=2, default=str)
|
||||||
|
print(f"\nSaved deep dive to stock-analysis-deep-dive.json")
|
||||||
|
|
||||||
|
# Expanded GARP scan
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("EXPANDED GARP SCAN (S&P 500 + S&P 400 MidCap)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
tickers = get_broad_ticker_list()
|
||||||
|
new_passes = garp_screen(tickers, exclude=set(TARGETS))
|
||||||
|
|
||||||
|
with open(output_dir / "garp-expanded-scan.json", "w") as f:
|
||||||
|
json.dump(new_passes, f, indent=2, default=str)
|
||||||
|
print(f"\nExpanded scan found {len(new_passes)} additional stocks")
|
||||||
|
print("Saved to garp-expanded-scan.json")
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
for tick, data in analyses.items():
|
||||||
|
pa = data.get("price_action", {})
|
||||||
|
an = data.get("analyst", {})
|
||||||
|
fu = data.get("fundamentals", {})
|
||||||
|
te = data.get("technical", {})
|
||||||
|
print(f"\n{tick} - {data['name']}")
|
||||||
|
print(f" Price: ${pa.get('current')} | 1yr Return: {pa.get('1yr_return_pct')}%")
|
||||||
|
print(f" From 52wk High: {pa.get('pct_from_52wk_high')}%")
|
||||||
|
print(f" PE: {fu.get('trailing_pe')} | Fwd PE: {fu.get('forward_pe')} | PEG: {fu.get('peg_ratio')}")
|
||||||
|
print(f" Target: ${an.get('target_mean')} ({an.get('upside_pct')}% upside) | Rec: {an.get('recommendation')}")
|
||||||
|
print(f" RSI: {te.get('rsi_14')} | Trend: {te.get('trend')}")
|
||||||
|
print(f" Insiders: {len(data.get('insider_transactions', []))} recent txns")
|
||||||
|
|
||||||
|
if new_passes:
|
||||||
|
print(f"\nNew GARP Candidates ({len(new_passes)}):")
|
||||||
|
for s in sorted(new_passes, key=lambda x: x.get("forward_pe", 99)):
|
||||||
|
print(f" {s['ticker']:6s} PE:{s['trailing_pe']:5.1f} FPE:{s['forward_pe']:5.1f} RG:{s['revenue_growth']:5.1f}% EG:{s['earnings_growth']:5.1f}% ROE:{s['roe']:5.1f}%")
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"last_check": "2026-02-08T18:47:00.023346+00:00",
|
"last_check": "2026-02-08T21:14:59.952296+00:00",
|
||||||
"total_tracked": 3100,
|
"total_tracked": 3100,
|
||||||
"new_this_check": 0
|
"new_this_check": 0
|
||||||
}
|
}
|
||||||
@ -101,9 +101,9 @@
|
|||||||
"quantity": 186,
|
"quantity": 186,
|
||||||
"stop_loss": null,
|
"stop_loss": null,
|
||||||
"take_profit": null,
|
"take_profit": null,
|
||||||
"current_price": 0.475,
|
"current_price": 0.465,
|
||||||
"unrealized_pnl": -0.93,
|
"unrealized_pnl": -2.79,
|
||||||
"unrealized_pnl_pct": -1.04,
|
"unrealized_pnl_pct": -3.12,
|
||||||
"source_post": "https://polymarket.com/profile/kch123",
|
"source_post": "https://polymarket.com/profile/kch123",
|
||||||
"thesis": "Copy kch123 proportional. Spread: Seahawks (-5.5) (Seahawks). Weight: 9.0%",
|
"thesis": "Copy kch123 proportional. Spread: Seahawks (-5.5) (Seahawks). Weight: 9.0%",
|
||||||
"notes": "kch123 has $203,779 on this (9.0% of active book)",
|
"notes": "kch123 has $203,779 on this (9.0% of active book)",
|
||||||
|
|||||||
86
projects/market-watch/README.md
Normal file
86
projects/market-watch/README.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Market Watch - Multiplayer GARP Paper Trading Simulator
|
||||||
|
|
||||||
|
Multiplayer paper trading simulator implementing a **Growth at a Reasonable Price (GARP)** strategy. Compete against Case (AI) and other players.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
- **Create or join a game** with configurable starting cash
|
||||||
|
- **Trade manually** via the web portal or Telegram
|
||||||
|
- **Case (AI)** trades autonomously using the GARP strategy
|
||||||
|
- **Leaderboard** tracks who's winning by % return
|
||||||
|
|
||||||
|
## GARP Filter Criteria
|
||||||
|
|
||||||
|
| Metric | Threshold |
|
||||||
|
|--------|-----------|
|
||||||
|
| Revenue Growth | ≥ 10% |
|
||||||
|
| Trailing P/E | < 25 |
|
||||||
|
| Forward P/E | < 15 |
|
||||||
|
| PEG Ratio | < 1.2 (if available) |
|
||||||
|
| EPS Growth | > 15% |
|
||||||
|
| ROE | > 5% |
|
||||||
|
| Quick Ratio | > 1.5 (if available) |
|
||||||
|
| Debt/Equity | < 35% (if available) |
|
||||||
|
| Market Cap | > $5B |
|
||||||
|
|
||||||
|
### Case's Trading Rules
|
||||||
|
- **Buy:** GARP filter pass + RSI < 70 + not within 2% of 52wk high + max 10% per position + max 15 positions
|
||||||
|
- **Sell:** Fails GARP rescan OR 10% trailing stop-loss OR RSI > 80
|
||||||
|
- **Universe:** S&P 500 + S&P 400 MidCap (~900 stocks)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `game_engine.py` | Multiplayer game/player/portfolio engine |
|
||||||
|
| `scanner.py` | GARP scanner across S&P 500 + 400 |
|
||||||
|
| `trader.py` | Case's autonomous trading logic |
|
||||||
|
| `run_daily.py` | Daily orchestrator (scan → trade → snapshot → alert) |
|
||||||
|
| `portfolio.py` | Backward-compatible wrapper for single-player |
|
||||||
|
| `portal/server.py` | Web dashboard with multiplayer UI |
|
||||||
|
|
||||||
|
### Data Structure
|
||||||
|
```
|
||||||
|
data/games/{game_id}/
|
||||||
|
├── game.json # Game config, players, rules
|
||||||
|
└── players/{username}/
|
||||||
|
├── portfolio.json # Current positions & cash
|
||||||
|
├── trades.json # Trade history
|
||||||
|
└── snapshots.json # Daily value snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web Portal
|
||||||
|
|
||||||
|
**URL:** http://marketwatch.local (or http://localhost:8889)
|
||||||
|
|
||||||
|
- **Home:** List of games, create new game
|
||||||
|
- **Game page:** Leaderboard, join game
|
||||||
|
- **Player page:** Portfolio, trade form, performance chart, trade history
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/games` | Create game (form: name, starting_cash, end_date) |
|
||||||
|
| POST | `/api/games/{id}/join` | Join game (form: username) |
|
||||||
|
| POST | `/api/games/{id}/players/{user}/trade` | Trade (form: action, ticker, shares) |
|
||||||
|
| GET | `/api/games/{id}/leaderboard` | Get leaderboard JSON |
|
||||||
|
| GET | `/api/games/{id}/players/{user}/portfolio` | Get portfolio JSON |
|
||||||
|
|
||||||
|
## Systemd Services
|
||||||
|
|
||||||
|
| Service | Schedule |
|
||||||
|
|---------|----------|
|
||||||
|
| `market-watch.timer` | Mon-Fri 9:00 AM + 3:30 PM CST |
|
||||||
|
| `market-watch-portal.service` | Always running (port 8889) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status market-watch.timer
|
||||||
|
systemctl --user status market-watch-portal
|
||||||
|
journalctl --user -u market-watch -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
Players can trade via: `buy AAPL 10` or `sell BAC 50`
|
||||||
|
Daily summaries with leaderboard sent automatically.
|
||||||
14
projects/market-watch/data/games/7ebf65c7/game.json
Normal file
14
projects/market-watch/data/games/7ebf65c7/game.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"game_id": "7ebf65c7",
|
||||||
|
"name": "GARP Challenge",
|
||||||
|
"starting_cash": 100000.0,
|
||||||
|
"start_date": "2026-02-08",
|
||||||
|
"end_date": null,
|
||||||
|
"creator": "case",
|
||||||
|
"created_at": "2026-02-08T15:15:43.402301",
|
||||||
|
"players": [
|
||||||
|
"case",
|
||||||
|
"testplayer"
|
||||||
|
],
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"cash": 100000.0,
|
||||||
|
"positions": {}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"cash": 100000.0,
|
||||||
|
"positions": {}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
projects/market-watch/data/history.json
Normal file
1
projects/market-watch/data/history.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
projects/market-watch/data/portfolio.json
Normal file
1
projects/market-watch/data/portfolio.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"cash": 100000.0, "positions": {}}
|
||||||
206
projects/market-watch/data/scans/2026-02-08.json
Normal file
206
projects/market-watch/data/scans/2026-02-08.json
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-02-08",
|
||||||
|
"timestamp": "2026-02-08T15:18:03.800566",
|
||||||
|
"total_scanned": 902,
|
||||||
|
"candidates_found": 11,
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"ticker": "ALLY",
|
||||||
|
"price": 42.31,
|
||||||
|
"market_cap": 13052339200,
|
||||||
|
"market_cap_b": 13.1,
|
||||||
|
"trailing_pe": 17.85,
|
||||||
|
"forward_pe": 6.7,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 12.0,
|
||||||
|
"earnings_growth": 265.4,
|
||||||
|
"roe": 5.8,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 44.58,
|
||||||
|
"week52_high": 47.27,
|
||||||
|
"pct_from_52wk_high": 10.5,
|
||||||
|
"score": -21.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "JHG",
|
||||||
|
"price": 48.22,
|
||||||
|
"market_cap": 7448852992,
|
||||||
|
"market_cap_b": 7.4,
|
||||||
|
"trailing_pe": 9.22,
|
||||||
|
"forward_pe": 10.12,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 61.3,
|
||||||
|
"earnings_growth": 243.6,
|
||||||
|
"roe": 16.2,
|
||||||
|
"quick_ratio": 69.46,
|
||||||
|
"debt_to_equity": 6.5,
|
||||||
|
"rsi": 63.83,
|
||||||
|
"week52_high": 49.42,
|
||||||
|
"pct_from_52wk_high": 2.4,
|
||||||
|
"score": -20.37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "INCY",
|
||||||
|
"price": 108.39,
|
||||||
|
"market_cap": 21279418368,
|
||||||
|
"market_cap_b": 21.3,
|
||||||
|
"trailing_pe": 18.37,
|
||||||
|
"forward_pe": 13.61,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 20.0,
|
||||||
|
"earnings_growth": 290.7,
|
||||||
|
"roe": 30.4,
|
||||||
|
"quick_ratio": 2.86,
|
||||||
|
"debt_to_equity": 0.9,
|
||||||
|
"rsi": 54.22,
|
||||||
|
"week52_high": 112.29,
|
||||||
|
"pct_from_52wk_high": 3.5,
|
||||||
|
"score": -17.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FHN",
|
||||||
|
"price": 26.23,
|
||||||
|
"market_cap": 12915496960,
|
||||||
|
"market_cap_b": 12.9,
|
||||||
|
"trailing_pe": 14.03,
|
||||||
|
"forward_pe": 11.19,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 23.7,
|
||||||
|
"earnings_growth": 74.9,
|
||||||
|
"roe": 10.9,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 72.21,
|
||||||
|
"week52_high": 26.56,
|
||||||
|
"pct_from_52wk_high": 1.2,
|
||||||
|
"score": 1.3299999999999992
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FNB",
|
||||||
|
"price": 18.9,
|
||||||
|
"market_cap": 6768781312,
|
||||||
|
"market_cap_b": 6.8,
|
||||||
|
"trailing_pe": 12.12,
|
||||||
|
"forward_pe": 9.66,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 26.4,
|
||||||
|
"earnings_growth": 56.5,
|
||||||
|
"roe": 8.7,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 69.25,
|
||||||
|
"week52_high": 19.04,
|
||||||
|
"pct_from_52wk_high": 0.7,
|
||||||
|
"score": 1.37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "EXEL",
|
||||||
|
"price": 43.9,
|
||||||
|
"market_cap": 11817991168,
|
||||||
|
"market_cap_b": 11.8,
|
||||||
|
"trailing_pe": 18.45,
|
||||||
|
"forward_pe": 12.79,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 10.8,
|
||||||
|
"earnings_growth": 72.5,
|
||||||
|
"roe": 30.6,
|
||||||
|
"quick_ratio": 3.5,
|
||||||
|
"debt_to_equity": 8.2,
|
||||||
|
"rsi": 49.65,
|
||||||
|
"week52_high": 49.62,
|
||||||
|
"pct_from_52wk_high": 11.5,
|
||||||
|
"score": 4.459999999999999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "CART",
|
||||||
|
"price": 34.64,
|
||||||
|
"market_cap": 9125501952,
|
||||||
|
"market_cap_b": 9.1,
|
||||||
|
"trailing_pe": 19.03,
|
||||||
|
"forward_pe": 8.84,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 10.2,
|
||||||
|
"earnings_growth": 21.1,
|
||||||
|
"roe": 15.3,
|
||||||
|
"quick_ratio": 3.33,
|
||||||
|
"debt_to_equity": 1.0,
|
||||||
|
"rsi": 30.92,
|
||||||
|
"week52_high": 53.5,
|
||||||
|
"pct_from_52wk_high": 35.3,
|
||||||
|
"score": 5.709999999999999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "CFG",
|
||||||
|
"price": 68.12,
|
||||||
|
"market_cap": 29256599552,
|
||||||
|
"market_cap_b": 29.3,
|
||||||
|
"trailing_pe": 17.65,
|
||||||
|
"forward_pe": 10.85,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 10.7,
|
||||||
|
"earnings_growth": 35.9,
|
||||||
|
"roe": 7.2,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 75.46,
|
||||||
|
"week52_high": 68.36,
|
||||||
|
"pct_from_52wk_high": 0.4,
|
||||||
|
"score": 6.1899999999999995
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "EWBC",
|
||||||
|
"price": 122.5,
|
||||||
|
"market_cap": 16854236160,
|
||||||
|
"market_cap_b": 16.9,
|
||||||
|
"trailing_pe": 12.87,
|
||||||
|
"forward_pe": 11.18,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 21.6,
|
||||||
|
"earnings_growth": 21.3,
|
||||||
|
"roe": 15.9,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 67.58,
|
||||||
|
"week52_high": 123.22,
|
||||||
|
"pct_from_52wk_high": 0.6,
|
||||||
|
"score": 6.890000000000001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "BAC",
|
||||||
|
"price": 56.53,
|
||||||
|
"market_cap": 412810084352,
|
||||||
|
"market_cap_b": 412.8,
|
||||||
|
"trailing_pe": 14.84,
|
||||||
|
"forward_pe": 11.41,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 13.2,
|
||||||
|
"earnings_growth": 20.9,
|
||||||
|
"roe": 10.2,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 71.14,
|
||||||
|
"week52_high": 57.55,
|
||||||
|
"pct_from_52wk_high": 1.8,
|
||||||
|
"score": 8.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "FITB",
|
||||||
|
"price": 55.08,
|
||||||
|
"market_cap": 49574670336,
|
||||||
|
"market_cap_b": 49.6,
|
||||||
|
"trailing_pe": 15.6,
|
||||||
|
"forward_pe": 11.24,
|
||||||
|
"peg_ratio": null,
|
||||||
|
"revenue_growth": 11.5,
|
||||||
|
"earnings_growth": 20.8,
|
||||||
|
"roe": 12.2,
|
||||||
|
"quick_ratio": null,
|
||||||
|
"debt_to_equity": null,
|
||||||
|
"rsi": 71.83,
|
||||||
|
"week52_high": 55.36,
|
||||||
|
"pct_from_52wk_high": 0.5,
|
||||||
|
"score": 8.01
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
projects/market-watch/data/snapshots.json
Normal file
1
projects/market-watch/data/snapshots.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
projects/market-watch/data/tickers.json
Normal file
1
projects/market-watch/data/tickers.json
Normal file
File diff suppressed because one or more lines are too long
344
projects/market-watch/game_engine.py
Executable file
344
projects/market-watch/game_engine.py
Executable file
@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Multiplayer game engine for Market Watch paper trading."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||||
|
GAMES_DIR = os.path.join(DATA_DIR, "games")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path, default=None):
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return default if default is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_json(path, data):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _game_dir(game_id):
|
||||||
|
return os.path.join(GAMES_DIR, game_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _player_dir(game_id, username):
|
||||||
|
return os.path.join(_game_dir(game_id), "players", username)
|
||||||
|
|
||||||
|
|
||||||
|
def _portfolio_path(game_id, username):
|
||||||
|
return os.path.join(_player_dir(game_id, username), "portfolio.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _trades_path(game_id, username):
|
||||||
|
return os.path.join(_player_dir(game_id, username), "trades.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshots_path(game_id, username):
|
||||||
|
return os.path.join(_player_dir(game_id, username), "snapshots.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _game_config_path(game_id):
|
||||||
|
return os.path.join(_game_dir(game_id), "game.json")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Game Management ──
|
||||||
|
|
||||||
|
def create_game(name, starting_cash=100_000.0, end_date=None, creator="system"):
|
||||||
|
"""Create a new game. Returns game_id."""
|
||||||
|
game_id = str(uuid.uuid4())[:8]
|
||||||
|
config = {
|
||||||
|
"game_id": game_id,
|
||||||
|
"name": name,
|
||||||
|
"starting_cash": starting_cash,
|
||||||
|
"start_date": date.today().isoformat(),
|
||||||
|
"end_date": end_date,
|
||||||
|
"creator": creator,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"players": [],
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
os.makedirs(_game_dir(game_id), exist_ok=True)
|
||||||
|
_save_json(_game_config_path(game_id), config)
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
|
def list_games(active_only=True):
|
||||||
|
"""List all games."""
|
||||||
|
games = []
|
||||||
|
if not os.path.exists(GAMES_DIR):
|
||||||
|
return games
|
||||||
|
for gid in os.listdir(GAMES_DIR):
|
||||||
|
config_path = _game_config_path(gid)
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
config = _load_json(config_path)
|
||||||
|
if active_only and config.get("status") != "active":
|
||||||
|
continue
|
||||||
|
games.append(config)
|
||||||
|
return sorted(games, key=lambda g: g.get("created_at", ""), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_game(game_id):
|
||||||
|
"""Get game config."""
|
||||||
|
return _load_json(_game_config_path(game_id))
|
||||||
|
|
||||||
|
|
||||||
|
def join_game(game_id, username):
|
||||||
|
"""Add a player to a game. Initializes their portfolio."""
|
||||||
|
config = get_game(game_id)
|
||||||
|
if not config:
|
||||||
|
return {"success": False, "error": "Game not found"}
|
||||||
|
if username in config["players"]:
|
||||||
|
return {"success": False, "error": f"{username} already in game"}
|
||||||
|
|
||||||
|
config["players"].append(username)
|
||||||
|
_save_json(_game_config_path(game_id), config)
|
||||||
|
|
||||||
|
# Initialize player portfolio
|
||||||
|
player_dir = _player_dir(game_id, username)
|
||||||
|
os.makedirs(player_dir, exist_ok=True)
|
||||||
|
_save_json(_portfolio_path(game_id, username), {
|
||||||
|
"cash": config["starting_cash"],
|
||||||
|
"positions": {},
|
||||||
|
})
|
||||||
|
_save_json(_trades_path(game_id, username), [])
|
||||||
|
_save_json(_snapshots_path(game_id, username), [])
|
||||||
|
|
||||||
|
return {"success": True, "game_id": game_id, "username": username, "starting_cash": config["starting_cash"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Trading ──
|
||||||
|
|
||||||
|
def buy(game_id, username, ticker, shares, price, reason="Manual"):
|
||||||
|
"""Buy shares for a player in a game."""
|
||||||
|
pf = _load_json(_portfolio_path(game_id, username))
|
||||||
|
if not pf:
|
||||||
|
return {"success": False, "error": "Player portfolio not found"}
|
||||||
|
|
||||||
|
cost = shares * price
|
||||||
|
if cost > pf["cash"]:
|
||||||
|
return {"success": False, "error": f"Insufficient cash. Need ${cost:,.2f}, have ${pf['cash']:,.2f}"}
|
||||||
|
|
||||||
|
pf["cash"] -= cost
|
||||||
|
|
||||||
|
if ticker in pf["positions"]:
|
||||||
|
pos = pf["positions"][ticker]
|
||||||
|
total_shares = pos["shares"] + shares
|
||||||
|
pos["avg_cost"] = ((pos["avg_cost"] * pos["shares"]) + cost) / total_shares
|
||||||
|
pos["shares"] = total_shares
|
||||||
|
pos["current_price"] = price
|
||||||
|
# Update trailing stop
|
||||||
|
new_stop = price * 0.90
|
||||||
|
if new_stop > pos.get("trailing_stop", 0):
|
||||||
|
pos["trailing_stop"] = new_stop
|
||||||
|
else:
|
||||||
|
pf["positions"][ticker] = {
|
||||||
|
"shares": shares,
|
||||||
|
"avg_cost": price,
|
||||||
|
"current_price": price,
|
||||||
|
"entry_date": datetime.now().isoformat(),
|
||||||
|
"entry_reason": reason,
|
||||||
|
"trailing_stop": price * 0.90,
|
||||||
|
}
|
||||||
|
|
||||||
|
_save_json(_portfolio_path(game_id, username), pf)
|
||||||
|
|
||||||
|
# Log trade
|
||||||
|
trades = _load_json(_trades_path(game_id, username), [])
|
||||||
|
trades.append({
|
||||||
|
"action": "BUY",
|
||||||
|
"ticker": ticker,
|
||||||
|
"shares": shares,
|
||||||
|
"price": price,
|
||||||
|
"cost": round(cost, 2),
|
||||||
|
"reason": reason,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
_save_json(_trades_path(game_id, username), trades)
|
||||||
|
|
||||||
|
return {"success": True, "ticker": ticker, "shares": shares, "price": price, "cost": round(cost, 2), "cash_remaining": round(pf["cash"], 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def sell(game_id, username, ticker, shares=None, price=None, reason="Manual"):
|
||||||
|
"""Sell shares for a player."""
|
||||||
|
pf = _load_json(_portfolio_path(game_id, username))
|
||||||
|
if not pf:
|
||||||
|
return {"success": False, "error": "Player portfolio not found"}
|
||||||
|
if ticker not in pf["positions"]:
|
||||||
|
return {"success": False, "error": f"No position in {ticker}"}
|
||||||
|
|
||||||
|
pos = pf["positions"][ticker]
|
||||||
|
if shares is None:
|
||||||
|
shares = pos["shares"]
|
||||||
|
if shares > pos["shares"]:
|
||||||
|
return {"success": False, "error": f"Only have {pos['shares']} shares of {ticker}"}
|
||||||
|
if price is None:
|
||||||
|
price = pos["current_price"]
|
||||||
|
|
||||||
|
proceeds = shares * price
|
||||||
|
pf["cash"] += proceeds
|
||||||
|
realized_pnl = (price - pos["avg_cost"]) * shares
|
||||||
|
|
||||||
|
if shares >= pos["shares"]:
|
||||||
|
del pf["positions"][ticker]
|
||||||
|
else:
|
||||||
|
pos["shares"] -= shares
|
||||||
|
|
||||||
|
_save_json(_portfolio_path(game_id, username), pf)
|
||||||
|
|
||||||
|
# Log trade
|
||||||
|
trades = _load_json(_trades_path(game_id, username), [])
|
||||||
|
trades.append({
|
||||||
|
"action": "SELL",
|
||||||
|
"ticker": ticker,
|
||||||
|
"shares": shares,
|
||||||
|
"price": price,
|
||||||
|
"proceeds": round(proceeds, 2),
|
||||||
|
"realized_pnl": round(realized_pnl, 2),
|
||||||
|
"entry_price": pos["avg_cost"],
|
||||||
|
"reason": reason,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
_save_json(_trades_path(game_id, username), trades)
|
||||||
|
|
||||||
|
return {"success": True, "ticker": ticker, "shares": shares, "price": price, "proceeds": round(proceeds, 2), "realized_pnl": round(realized_pnl, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def update_price(game_id, username, ticker, price):
|
||||||
|
"""Update current price for a position."""
|
||||||
|
pf = _load_json(_portfolio_path(game_id, username))
|
||||||
|
if pf and ticker in pf["positions"]:
|
||||||
|
pos = pf["positions"][ticker]
|
||||||
|
pos["current_price"] = price
|
||||||
|
new_stop = price * 0.90
|
||||||
|
if new_stop > pos.get("trailing_stop", 0):
|
||||||
|
pos["trailing_stop"] = new_stop
|
||||||
|
_save_json(_portfolio_path(game_id, username), pf)
|
||||||
|
|
||||||
|
|
||||||
|
def get_portfolio(game_id, username):
|
||||||
|
"""Get player's portfolio with unrealized P&L."""
|
||||||
|
pf = _load_json(_portfolio_path(game_id, username))
|
||||||
|
if not pf:
|
||||||
|
return None
|
||||||
|
|
||||||
|
game = get_game(game_id)
|
||||||
|
starting_cash = game.get("starting_cash", 100_000) if game else 100_000
|
||||||
|
total_value = pf["cash"]
|
||||||
|
positions_out = {}
|
||||||
|
|
||||||
|
for ticker, pos in pf["positions"].items():
|
||||||
|
unrealized_pnl = (pos["current_price"] - pos["avg_cost"]) * pos["shares"]
|
||||||
|
market_value = pos["current_price"] * pos["shares"]
|
||||||
|
total_value += market_value
|
||||||
|
positions_out[ticker] = {
|
||||||
|
**pos,
|
||||||
|
"unrealized_pnl": round(unrealized_pnl, 2),
|
||||||
|
"market_value": round(market_value, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
total_pnl = total_value - starting_cash
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"game_id": game_id,
|
||||||
|
"cash": round(pf["cash"], 2),
|
||||||
|
"positions": positions_out,
|
||||||
|
"total_value": round(total_value, 2),
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"pnl_pct": round(total_pnl / starting_cash * 100, 2),
|
||||||
|
"num_positions": len(positions_out),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_trades(game_id, username):
|
||||||
|
"""Get player's trade history."""
|
||||||
|
return _load_json(_trades_path(game_id, username), [])
|
||||||
|
|
||||||
|
|
||||||
|
def daily_snapshot(game_id, username):
|
||||||
|
"""Take daily snapshot for a player."""
|
||||||
|
p = get_portfolio(game_id, username)
|
||||||
|
if not p:
|
||||||
|
return None
|
||||||
|
snapshots = _load_json(_snapshots_path(game_id, username), [])
|
||||||
|
today = date.today().isoformat()
|
||||||
|
snapshots = [s for s in snapshots if s["date"] != today]
|
||||||
|
snapshots.append({
|
||||||
|
"date": today,
|
||||||
|
"total_value": p["total_value"],
|
||||||
|
"total_pnl": p["total_pnl"],
|
||||||
|
"pnl_pct": p["pnl_pct"],
|
||||||
|
"cash": p["cash"],
|
||||||
|
"num_positions": p["num_positions"],
|
||||||
|
})
|
||||||
|
_save_json(_snapshots_path(game_id, username), snapshots)
|
||||||
|
return snapshots[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_snapshots(game_id, username):
|
||||||
|
"""Get player's daily snapshots."""
|
||||||
|
return _load_json(_snapshots_path(game_id, username), [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_leaderboard(game_id):
|
||||||
|
"""Get game leaderboard sorted by % return."""
|
||||||
|
game = get_game(game_id)
|
||||||
|
if not game:
|
||||||
|
return []
|
||||||
|
|
||||||
|
board = []
|
||||||
|
for username in game["players"]:
|
||||||
|
p = get_portfolio(game_id, username)
|
||||||
|
if p:
|
||||||
|
trades = get_trades(game_id, username)
|
||||||
|
num_trades = len([t for t in trades if t.get("action") == "SELL"])
|
||||||
|
board.append({
|
||||||
|
"username": username,
|
||||||
|
"total_value": p["total_value"],
|
||||||
|
"total_pnl": p["total_pnl"],
|
||||||
|
"pnl_pct": p["pnl_pct"],
|
||||||
|
"num_positions": p["num_positions"],
|
||||||
|
"num_trades": num_trades,
|
||||||
|
"cash": p["cash"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted(board, key=lambda x: x["pnl_pct"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Convenience: find default game ──
|
||||||
|
|
||||||
|
def get_default_game_id():
|
||||||
|
"""Get the first active game (usually 'GARP Challenge')."""
|
||||||
|
games = list_games(active_only=True)
|
||||||
|
if games:
|
||||||
|
return games[0]["game_id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Initialize default game ──
|
||||||
|
|
||||||
|
def ensure_default_game():
|
||||||
|
"""Create default GARP Challenge game with 'case' player if it doesn't exist."""
|
||||||
|
games = list_games(active_only=True)
|
||||||
|
for g in games:
|
||||||
|
if g["name"] == "GARP Challenge":
|
||||||
|
return g["game_id"]
|
||||||
|
|
||||||
|
game_id = create_game("GARP Challenge", starting_cash=100_000.0, creator="case")
|
||||||
|
join_game(game_id, "case")
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
game_id = ensure_default_game()
|
||||||
|
game = get_game(game_id)
|
||||||
|
print(f"Game: {game['name']} ({game_id})")
|
||||||
|
print(f"Players: {game['players']}")
|
||||||
|
board = get_leaderboard(game_id)
|
||||||
|
for entry in board:
|
||||||
|
print(f" {entry['username']}: ${entry['total_value']:,.2f} ({entry['pnl_pct']:+.2f}%)")
|
||||||
425
projects/market-watch/portal/server.py
Executable file
425
projects/market-watch/portal/server.py
Executable file
@ -0,0 +1,425 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Market Watch Web Portal - Multiplayer GARP Paper Trading."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import game_engine
|
||||||
|
|
||||||
|
PORT = 8889
|
||||||
|
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SCANS_DIR = os.path.join(PROJECT_DIR, "data", "scans")
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
CSS = """:root{--bg-primary:#0d1117;--bg-secondary:#161b22;--bg-tertiary:#21262d;--text-primary:#f0f6fc;--text-secondary:#8b949e;--border-color:#30363d;--accent-blue:#58a6ff;--accent-purple:#bc8cff;--positive-green:#3fb950;--negative-red:#f85149;--gold:#f0c000;--silver:#c0c0c0;--bronze:#cd7f32}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.5}
|
||||||
|
a{color:var(--accent-blue);text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
.navbar{background:var(--bg-secondary);border-bottom:1px solid var(--border-color);padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.nav-brand{font-size:1.5rem;font-weight:bold;color:var(--accent-blue)}
|
||||||
|
.nav-links{display:flex;gap:1.5rem}
|
||||||
|
.nav-links a{color:var(--text-secondary);text-decoration:none;padding:.5rem 1rem;border-radius:6px;transition:all .2s}
|
||||||
|
.nav-links a:hover{color:var(--text-primary);background:var(--bg-tertiary)}
|
||||||
|
.nav-links a.active{color:var(--accent-blue);background:var(--bg-tertiary)}
|
||||||
|
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
||||||
|
.card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
|
||||||
|
.card h3{color:var(--text-primary);margin-bottom:1rem;font-size:1.1rem}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.5rem;margin-bottom:2rem}
|
||||||
|
.metric-large{font-size:2rem;font-weight:bold;margin-bottom:.3rem}
|
||||||
|
.metric-small{color:var(--text-secondary);font-size:.85rem}
|
||||||
|
.positive{color:var(--positive-green)!important}.negative{color:var(--negative-red)!important}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{padding:.6rem .8rem;text-align:left;border-bottom:1px solid var(--border-color)}
|
||||||
|
th{color:var(--text-secondary);font-size:.8rem;text-transform:uppercase}
|
||||||
|
td{font-size:.9rem}
|
||||||
|
.rank-1{color:var(--gold);font-weight:bold}.rank-2{color:var(--silver)}.rank-3{color:var(--bronze)}
|
||||||
|
.btn{display:inline-block;padding:.5rem 1.2rem;background:var(--accent-blue);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.9rem;text-decoration:none;transition:opacity .2s}
|
||||||
|
.btn:hover{opacity:.85;text-decoration:none}
|
||||||
|
.btn-outline{background:transparent;border:1px solid var(--border-color);color:var(--text-primary)}
|
||||||
|
.btn-outline:hover{border-color:var(--accent-blue)}
|
||||||
|
.btn-green{background:var(--positive-green)}.btn-red{background:var(--negative-red)}
|
||||||
|
input,select{background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-primary);padding:.5rem .8rem;border-radius:6px;font-size:.9rem}
|
||||||
|
.form-row{display:flex;gap:1rem;align-items:end;flex-wrap:wrap;margin-bottom:1rem}
|
||||||
|
.form-group{display:flex;flex-direction:column;gap:.3rem}
|
||||||
|
.form-group label{font-size:.8rem;color:var(--text-secondary);text-transform:uppercase}
|
||||||
|
.badge{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:bold}
|
||||||
|
.badge-ai{background:var(--accent-purple);color:#fff}
|
||||||
|
.badge-human{background:var(--accent-blue);color:#fff}
|
||||||
|
.player-link{color:var(--text-primary);font-weight:500}
|
||||||
|
@media(max-width:768px){.navbar{flex-direction:column;gap:1rem}.cards{grid-template-columns:1fr}.container{padding:1rem}.form-row{flex-direction:column}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def nav(active=""):
|
||||||
|
return f"""<nav class="navbar">
|
||||||
|
<a href="/" style="text-decoration:none"><div class="nav-brand">📊 Market Watch</div></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/" class="{'active' if active=='home' else ''}">Games</a>
|
||||||
|
<a href="/scans" class="{'active' if active=='scans' else ''}">Scans</a>
|
||||||
|
</div></nav>"""
|
||||||
|
|
||||||
|
|
||||||
|
class MarketWatchHandler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
try:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if path == "" or path == "/":
|
||||||
|
self.serve_home()
|
||||||
|
elif path == "/create-game":
|
||||||
|
self.serve_create_game()
|
||||||
|
elif path.startswith("/game/") and "/player/" in path:
|
||||||
|
parts = path.split("/") # /game/{gid}/player/{user}
|
||||||
|
self.serve_player(parts[2], parts[4])
|
||||||
|
elif path.startswith("/game/"):
|
||||||
|
game_id = path.split("/")[2]
|
||||||
|
self.serve_game(game_id)
|
||||||
|
elif path == "/scans":
|
||||||
|
self.serve_scans()
|
||||||
|
# API
|
||||||
|
elif path.startswith("/api/games") and len(path.split("/")) == 3:
|
||||||
|
self.send_json(game_engine.list_games(active_only=False))
|
||||||
|
elif path.startswith("/api/games/") and path.endswith("/leaderboard"):
|
||||||
|
gid = path.split("/")[3]
|
||||||
|
self.send_json(game_engine.get_leaderboard(gid))
|
||||||
|
elif "/portfolio" in path:
|
||||||
|
parts = path.split("/")
|
||||||
|
self.send_json(game_engine.get_portfolio(parts[3], parts[5]))
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header("Content-type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
try:
|
||||||
|
content_len = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(content_len).decode() if content_len else ""
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
if path == "/api/games":
|
||||||
|
data = parse_form(body)
|
||||||
|
name = data.get("name", "Untitled Game")
|
||||||
|
cash = float(data.get("starting_cash", 100000))
|
||||||
|
end_date = data.get("end_date") or None
|
||||||
|
gid = game_engine.create_game(name, cash, end_date)
|
||||||
|
self.redirect(f"/game/{gid}")
|
||||||
|
|
||||||
|
elif path.endswith("/join"):
|
||||||
|
data = parse_form(body)
|
||||||
|
parts = path.split("/")
|
||||||
|
gid = parts[3]
|
||||||
|
username = data.get("username", "").strip().lower()
|
||||||
|
if username:
|
||||||
|
game_engine.join_game(gid, username)
|
||||||
|
self.redirect(f"/game/{gid}")
|
||||||
|
|
||||||
|
elif path.endswith("/trade"):
|
||||||
|
data = parse_form(body)
|
||||||
|
parts = path.split("/")
|
||||||
|
gid, username = parts[3], parts[5]
|
||||||
|
action = data.get("action", "").upper()
|
||||||
|
ticker = data.get("ticker", "").upper().strip()
|
||||||
|
shares = int(data.get("shares", 0))
|
||||||
|
|
||||||
|
if ticker and shares > 0:
|
||||||
|
import yfinance as yf
|
||||||
|
price = yf.Ticker(ticker).info.get("currentPrice") or yf.Ticker(ticker).info.get("regularMarketPrice", 0)
|
||||||
|
if price and price > 0:
|
||||||
|
if action == "BUY":
|
||||||
|
game_engine.buy(gid, username, ticker, shares, price, reason="Manual trade")
|
||||||
|
elif action == "SELL":
|
||||||
|
game_engine.sell(gid, username, ticker, shares, price, reason="Manual trade")
|
||||||
|
|
||||||
|
self.redirect(f"/game/{gid}/player/{username}")
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header("Content-type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
|
||||||
|
|
||||||
|
def serve_home(self):
|
||||||
|
games = game_engine.list_games(active_only=False)
|
||||||
|
rows = ""
|
||||||
|
for g in games:
|
||||||
|
players = len(g.get("players", []))
|
||||||
|
status_badge = '<span class="badge badge-ai">Active</span>' if g["status"] == "active" else '<span class="badge">Ended</span>'
|
||||||
|
rows += f"""<tr>
|
||||||
|
<td><a href="/game/{g['game_id']}" class="player-link">{g['name']}</a></td>
|
||||||
|
<td>{players}</td>
|
||||||
|
<td>${g['starting_cash']:,.0f}</td>
|
||||||
|
<td>{g['start_date']}</td>
|
||||||
|
<td>{g.get('end_date', '—') or '—'}</td>
|
||||||
|
<td>{status_badge}</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Market Watch</title><style>{CSS}</style></head><body>
|
||||||
|
{nav('home')}
|
||||||
|
<div class="container">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
|
||||||
|
<h2>🎮 Active Games</h2>
|
||||||
|
<a href="/create-game" class="btn">+ New Game</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table><thead><tr><th>Game</th><th>Players</th><th>Starting Cash</th><th>Started</th><th>Ends</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>{rows if rows else '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary)">No games yet — create one!</td></tr>'}</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div></body></html>"""
|
||||||
|
self.send_html(html)
|
||||||
|
|
||||||
|
def serve_create_game(self):
|
||||||
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Create Game - Market Watch</title><style>{CSS}</style></head><body>
|
||||||
|
{nav()}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h3>🎮 Create New Game</h3>
|
||||||
|
<form method="POST" action="/api/games">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Game Name</label><input type="text" name="name" placeholder="GARP Challenge" required></div>
|
||||||
|
<div class="form-group"><label>Starting Cash ($)</label><input type="number" name="starting_cash" value="100000" min="1000" step="1000"></div>
|
||||||
|
<div class="form-group"><label>End Date (optional)</label><input type="date" name="end_date"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Create Game</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div></body></html>"""
|
||||||
|
self.send_html(html)
|
||||||
|
|
||||||
|
def serve_game(self, game_id):
|
||||||
|
game = game_engine.get_game(game_id)
|
||||||
|
if not game:
|
||||||
|
return self.send_error(404)
|
||||||
|
|
||||||
|
board = game_engine.get_leaderboard(game_id)
|
||||||
|
|
||||||
|
rank_rows = ""
|
||||||
|
for i, entry in enumerate(board):
|
||||||
|
rank_class = f"rank-{i+1}" if i < 3 else ""
|
||||||
|
medal = ["🥇", "🥈", "🥉"][i] if i < 3 else f"#{i+1}"
|
||||||
|
pnl_class = "positive" if entry["pnl_pct"] >= 0 else "negative"
|
||||||
|
badge = ' <span class="badge badge-ai">AI</span>' if entry["username"] == "case" else ""
|
||||||
|
rank_rows += f"""<tr>
|
||||||
|
<td class="{rank_class}">{medal}</td>
|
||||||
|
<td><a href="/game/{game_id}/player/{entry['username']}" class="player-link">{entry['username']}</a>{badge}</td>
|
||||||
|
<td>${entry['total_value']:,.2f}</td>
|
||||||
|
<td class="{pnl_class}">{entry['pnl_pct']:+.2f}%</td>
|
||||||
|
<td class="{pnl_class}">${entry['total_pnl']:+,.2f}</td>
|
||||||
|
<td>{entry['num_positions']}</td>
|
||||||
|
<td>{entry['num_trades']}</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>{game['name']} - Market Watch</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
|
||||||
|
{nav()}
|
||||||
|
<div class="container">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
|
||||||
|
<h2>🏆 {game['name']}</h2>
|
||||||
|
<span class="badge badge-ai">{game['status'].upper()}</span>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-secondary);margin-bottom:1.5rem">Started {game['start_date']} · ${game['starting_cash']:,.0f} starting cash · {len(game['players'])} players</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Leaderboard</h3>
|
||||||
|
<table><thead><tr><th>Rank</th><th>Player</th><th>Portfolio</th><th>Return</th><th>P&L</th><th>Positions</th><th>Trades</th></tr></thead>
|
||||||
|
<tbody>{rank_rows if rank_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No players yet</td></tr>'}</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Join This Game</h3>
|
||||||
|
<form method="POST" action="/api/games/{game_id}/join">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Username</label><input type="text" name="username" placeholder="your name" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, dashes, underscores only"></div>
|
||||||
|
<button type="submit" class="btn">Join Game</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div></body></html>"""
|
||||||
|
self.send_html(html)
|
||||||
|
|
||||||
|
def serve_player(self, game_id, username):
|
||||||
|
game = game_engine.get_game(game_id)
|
||||||
|
p = game_engine.get_portfolio(game_id, username)
|
||||||
|
if not game or not p:
|
||||||
|
return self.send_error(404)
|
||||||
|
|
||||||
|
trades = game_engine.get_trades(game_id, username)
|
||||||
|
snapshots = game_engine.get_snapshots(game_id, username)
|
||||||
|
|
||||||
|
pnl_class = "positive" if p["total_pnl"] >= 0 else "negative"
|
||||||
|
is_ai = username == "case"
|
||||||
|
badge = '<span class="badge badge-ai">AI Player</span>' if is_ai else '<span class="badge badge-human">Human</span>'
|
||||||
|
|
||||||
|
# Positions table
|
||||||
|
pos_rows = ""
|
||||||
|
for ticker, pos in sorted(p["positions"].items()):
|
||||||
|
pc = "positive" if pos["unrealized_pnl"] >= 0 else "negative"
|
||||||
|
pos_rows += f"""<tr>
|
||||||
|
<td><strong>{ticker}</strong></td><td>{pos['shares']}</td>
|
||||||
|
<td>${pos['avg_cost']:.2f}</td><td>${pos['current_price']:.2f}</td>
|
||||||
|
<td>${pos['market_value']:,.2f}</td><td class="{pc}">${pos['unrealized_pnl']:+,.2f}</td>
|
||||||
|
<td>${pos.get('trailing_stop',0):.2f}</td>
|
||||||
|
</tr>"""
|
||||||
|
if not pos_rows:
|
||||||
|
pos_rows = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No positions</td></tr>'
|
||||||
|
|
||||||
|
# Trade log
|
||||||
|
trade_rows = ""
|
||||||
|
for t in reversed(trades[-30:]):
|
||||||
|
action_class = "positive" if t["action"] == "BUY" else "negative"
|
||||||
|
pnl_cell = ""
|
||||||
|
if t["action"] == "SELL":
|
||||||
|
rpnl = t.get("realized_pnl", 0)
|
||||||
|
rpnl_class = "positive" if rpnl >= 0 else "negative"
|
||||||
|
pnl_cell = f'<span class="{rpnl_class}">${rpnl:+,.2f}</span>'
|
||||||
|
trade_rows += f"""<tr>
|
||||||
|
<td class="{action_class}">{t['action']}</td><td>{t['ticker']}</td><td>{t['shares']}</td>
|
||||||
|
<td>${t['price']:.2f}</td><td>{pnl_cell}</td>
|
||||||
|
<td>{t.get('reason','')[:40]}</td><td>{t['timestamp'][:16]}</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
chart_labels = json.dumps([s["date"] for s in snapshots])
|
||||||
|
chart_values = json.dumps([s["total_value"] for s in snapshots])
|
||||||
|
starting = game.get("starting_cash", 100000)
|
||||||
|
|
||||||
|
# Trade form (only for humans)
|
||||||
|
trade_form = "" if is_ai else f"""
|
||||||
|
<div class="card">
|
||||||
|
<h3>📝 Place Trade</h3>
|
||||||
|
<form method="POST" action="/api/games/{game_id}/players/{username}/trade">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Action</label>
|
||||||
|
<select name="action"><option value="BUY">BUY</option><option value="SELL">SELL</option></select></div>
|
||||||
|
<div class="form-group"><label>Ticker</label><input type="text" name="ticker" placeholder="AAPL" required style="text-transform:uppercase"></div>
|
||||||
|
<div class="form-group"><label>Shares</label><input type="number" name="shares" min="1" value="10" required></div>
|
||||||
|
<button type="submit" class="btn btn-green">Execute</button>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-secondary);font-size:.8rem;margin-top:.5rem">Trades execute at current market price via Yahoo Finance</p>
|
||||||
|
</form>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>{username} - {game['name']}</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
|
||||||
|
{nav()}
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom:.5rem"><a href="/game/{game_id}" style="color:var(--text-secondary)">← {game['name']}</a></div>
|
||||||
|
<h2>{username} {badge}</h2>
|
||||||
|
<div class="cards" style="margin-top:1rem">
|
||||||
|
<div class="card"><h3>Portfolio Value</h3><div class="metric-large">${p['total_value']:,.2f}</div><div class="metric-small">Started at ${starting:,.0f}</div></div>
|
||||||
|
<div class="card"><h3>Cash</h3><div class="metric-large">${p['cash']:,.2f}</div><div class="metric-small">{p['cash']/max(p['total_value'],1)*100:.1f}% available</div></div>
|
||||||
|
<div class="card"><h3>Return</h3><div class="metric-large {pnl_class}">{p['pnl_pct']:+.2f}%</div><div class="metric-small {pnl_class}">${p['total_pnl']:+,.2f}</div></div>
|
||||||
|
<div class="card"><h3>Positions</h3><div class="metric-large">{p['num_positions']}</div><div class="metric-small">{len(trades)} total trades</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>Performance</h3><canvas id="chart" height="80"></canvas></div>
|
||||||
|
|
||||||
|
{trade_form}
|
||||||
|
|
||||||
|
<div class="card"><h3>Positions</h3>
|
||||||
|
<table><thead><tr><th>Ticker</th><th>Shares</th><th>Avg Cost</th><th>Price</th><th>Value</th><th>P&L</th><th>Stop</th></tr></thead>
|
||||||
|
<tbody>{pos_rows}</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><h3>Trade Log</h3>
|
||||||
|
<table><thead><tr><th>Action</th><th>Ticker</th><th>Shares</th><th>Price</th><th>P&L</th><th>Reason</th><th>Time</th></tr></thead>
|
||||||
|
<tbody>{trade_rows if trade_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No trades yet</td></tr>'}</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const ctx = document.getElementById('chart').getContext('2d');
|
||||||
|
const labels = {chart_labels}; const values = {chart_values};
|
||||||
|
if (labels.length > 0) {{
|
||||||
|
new Chart(ctx, {{type:'line',data:{{labels:labels,datasets:[
|
||||||
|
{{label:'Portfolio',data:values,borderColor:'#58a6ff',backgroundColor:'rgba(88,166,255,0.1)',fill:true,tension:0.3}},
|
||||||
|
{{label:'Starting',data:labels.map(()=>{starting}),borderColor:'#30363d',borderDash:[5,5],pointRadius:0}}
|
||||||
|
]}},options:{{responsive:true,plugins:{{legend:{{labels:{{color:'#f0f6fc'}}}}}},scales:{{x:{{ticks:{{color:'#8b949e'}},grid:{{color:'#21262d'}}}},y:{{ticks:{{color:'#8b949e',callback:v=>'$'+v.toLocaleString()}},grid:{{color:'#21262d'}}}}}}}}
|
||||||
|
}});
|
||||||
|
}} else {{ ctx.canvas.parentElement.innerHTML += '<div style="text-align:center;color:#8b949e;padding:2rem">Chart populates after first trading day</div>'; }}
|
||||||
|
</script></body></html>"""
|
||||||
|
self.send_html(html)
|
||||||
|
|
||||||
|
def serve_scans(self):
|
||||||
|
rows = ""
|
||||||
|
if os.path.exists(SCANS_DIR):
|
||||||
|
for sf in sorted(os.listdir(SCANS_DIR), reverse=True)[:30]:
|
||||||
|
if not sf.endswith(".json"): continue
|
||||||
|
data = {}
|
||||||
|
with open(os.path.join(SCANS_DIR, sf)) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
n = data.get("candidates_found", len(data.get("candidates", [])))
|
||||||
|
top = ", ".join(c.get("ticker","?") for c in data.get("candidates", [])[:8])
|
||||||
|
rows += f'<tr><td>{sf.replace(".json","")}</td><td>{data.get("total_scanned",0)}</td><td>{n}</td><td>{top or "—"}</td></tr>'
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Scans - Market Watch</title><style>{CSS}</style></head><body>
|
||||||
|
{nav('scans')}
|
||||||
|
<div class="container"><div class="card"><h3>📡 GARP Scan History</h3>
|
||||||
|
<table><thead><tr><th>Date</th><th>Scanned</th><th>Candidates</th><th>Top Picks</th></tr></thead>
|
||||||
|
<tbody>{rows if rows else '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary)">No scans yet</td></tr>'}</tbody></table>
|
||||||
|
</div></div></body></html>"""
|
||||||
|
self.send_html(html)
|
||||||
|
|
||||||
|
def redirect(self, url):
|
||||||
|
self.send_response(303)
|
||||||
|
self.send_header("Location", url)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def send_html(self, content):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(content.encode())
|
||||||
|
|
||||||
|
def send_json(self, data):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data, default=str).encode())
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_form(body):
|
||||||
|
"""Parse URL-encoded form data."""
|
||||||
|
result = {}
|
||||||
|
for pair in body.split("&"):
|
||||||
|
if "=" in pair:
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
result[unquote_plus(k)] = unquote_plus(v)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
game_engine.ensure_default_game()
|
||||||
|
print(f"📊 Market Watch Portal starting on localhost:{PORT}")
|
||||||
|
server = ThreadedHTTPServer(("0.0.0.0", PORT), MarketWatchHandler)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nPortal stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
51
projects/market-watch/portfolio.py
Executable file
51
projects/market-watch/portfolio.py
Executable file
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Portfolio module — backward-compatible wrapper around game_engine.
|
||||||
|
|
||||||
|
All operations now delegate to game_engine using the default game and 'case' player.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import game_engine
|
||||||
|
|
||||||
|
INITIAL_CASH = 100_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _default():
|
||||||
|
"""Get default game ID."""
|
||||||
|
return game_engine.get_default_game_id() or game_engine.ensure_default_game()
|
||||||
|
|
||||||
|
|
||||||
|
def buy(ticker, shares, price, reason="GARP signal"):
|
||||||
|
return game_engine.buy(_default(), "case", ticker, shares, price, reason)
|
||||||
|
|
||||||
|
|
||||||
|
def sell(ticker, shares=None, price=None, reason="GARP exit"):
|
||||||
|
return game_engine.sell(_default(), "case", ticker, shares, price, reason)
|
||||||
|
|
||||||
|
|
||||||
|
def update_price(ticker, price):
|
||||||
|
game_engine.update_price(_default(), "case", ticker, price)
|
||||||
|
|
||||||
|
|
||||||
|
def get_portfolio():
|
||||||
|
return game_engine.get_portfolio(_default(), "case")
|
||||||
|
|
||||||
|
|
||||||
|
def get_history():
|
||||||
|
return game_engine.get_trades(_default(), "case")
|
||||||
|
|
||||||
|
|
||||||
|
def daily_snapshot():
|
||||||
|
return game_engine.daily_snapshot(_default(), "case")
|
||||||
|
|
||||||
|
|
||||||
|
def get_snapshots():
|
||||||
|
return game_engine.get_snapshots(_default(), "case")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = get_portfolio()
|
||||||
|
if p:
|
||||||
|
print(json.dumps(p, indent=2))
|
||||||
|
else:
|
||||||
|
print("No default game found. Run: python3 game_engine.py")
|
||||||
99
projects/market-watch/run_daily.py
Executable file
99
projects/market-watch/run_daily.py
Executable file
@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Daily runner for Market Watch - scans, trades (as Case), snapshots, alerts."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import game_engine
|
||||||
|
from scanner import run_scan
|
||||||
|
from trader import run_trading_logic
|
||||||
|
|
||||||
|
CREDS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".credentials", "telegram-bot.env")
|
||||||
|
CHAT_ID = "6443752046"
|
||||||
|
|
||||||
|
|
||||||
|
def load_telegram_token():
|
||||||
|
if os.path.exists(CREDS_FILE):
|
||||||
|
with open(CREDS_FILE) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("TELEGRAM_BOT_TOKEN="):
|
||||||
|
return line.strip().split("=", 1)[1]
|
||||||
|
return os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram(message, token):
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
requests.post(url, json={"chat_id": CHAT_ID, "text": message, "parse_mode": "HTML"}, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Telegram error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"📊 Market Watch Daily Run — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
|
||||||
|
token = load_telegram_token()
|
||||||
|
game_id = game_engine.ensure_default_game()
|
||||||
|
game = game_engine.get_game(game_id)
|
||||||
|
|
||||||
|
# 1. Run GARP scan
|
||||||
|
print("\n[1/3] Running GARP scan...")
|
||||||
|
scan = run_scan()
|
||||||
|
candidates = scan.get("candidates", [])
|
||||||
|
print(f" Found {len(candidates)} candidates from {scan.get('total_scanned', 0)} stocks")
|
||||||
|
|
||||||
|
# 2. Run trading logic for Case
|
||||||
|
print("\n[2/3] Running trading logic for Case...")
|
||||||
|
result = run_trading_logic(game_id, "case", candidates)
|
||||||
|
|
||||||
|
# 3. Snapshots for all players
|
||||||
|
print("\n[3/3] Taking snapshots...")
|
||||||
|
for username in game["players"]:
|
||||||
|
snap = game_engine.daily_snapshot(game_id, username)
|
||||||
|
if snap:
|
||||||
|
print(f" {username}: ${snap['total_value']:,.2f} ({snap['pnl_pct']:+.2f}%)")
|
||||||
|
|
||||||
|
# Telegram summary
|
||||||
|
p = game_engine.get_portfolio(game_id, "case")
|
||||||
|
pnl_emoji = "📈" if p["total_pnl"] >= 0 else "📉"
|
||||||
|
|
||||||
|
summary = f"📊 <b>Market Watch Daily</b>\n"
|
||||||
|
summary += f"{pnl_emoji} Portfolio: ${p['total_value']:,.2f} ({p['pnl_pct']:+.2f}%)\n"
|
||||||
|
summary += f"💰 Cash: ${p['cash']:,.2f} | Positions: {p['num_positions']}\n"
|
||||||
|
|
||||||
|
num_trades = len(result.get("sells", [])) + len(result.get("buys", []))
|
||||||
|
if num_trades:
|
||||||
|
summary += f"\n<b>{num_trades} trades executed</b>\n"
|
||||||
|
for s in result.get("sells", []):
|
||||||
|
summary += f"🔴 SELL {s['ticker']} — {s['reason'][:50]}\n"
|
||||||
|
for b in result.get("buys", []):
|
||||||
|
summary += f"🟢 BUY {b['ticker']} — {b['reason'][:50]}\n"
|
||||||
|
else:
|
||||||
|
summary += "\nNo trades today."
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
top5 = ", ".join(c["ticker"] for c in candidates[:5])
|
||||||
|
summary += f"\n🔍 Top picks: {top5}"
|
||||||
|
|
||||||
|
# Leaderboard
|
||||||
|
board = game_engine.get_leaderboard(game_id)
|
||||||
|
if len(board) > 1:
|
||||||
|
summary += "\n\n<b>Leaderboard:</b>\n"
|
||||||
|
medals = ["🥇", "🥈", "🥉"]
|
||||||
|
for i, entry in enumerate(board[:5]):
|
||||||
|
medal = medals[i] if i < 3 else f"#{i+1}"
|
||||||
|
summary += f"{medal} {entry['username']}: {entry['pnl_pct']:+.2f}%\n"
|
||||||
|
|
||||||
|
send_telegram(summary, token)
|
||||||
|
print("\n✅ Daily run complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
233
projects/market-watch/scanner.py
Executable file
233
projects/market-watch/scanner.py
Executable file
@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GARP stock scanner - scans S&P 500 + S&P 400 MidCap for growth-at-reasonable-price candidates."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||||
|
SCANS_DIR = os.path.join(DATA_DIR, "scans")
|
||||||
|
TICKERS_CACHE = os.path.join(DATA_DIR, "tickers.json")
|
||||||
|
|
||||||
|
|
||||||
|
HEADERS = {"User-Agent": "MarketWatch/1.0 (paper trading bot; contact: case-lgn@protonmail.com)"}
|
||||||
|
|
||||||
|
|
||||||
|
def _scrape_tickers(url):
|
||||||
|
"""Scrape tickers from a Wikipedia S&P constituents page."""
|
||||||
|
import io
|
||||||
|
import pandas as pd
|
||||||
|
resp = requests.get(url, timeout=30, headers=HEADERS)
|
||||||
|
tables = pd.read_html(io.StringIO(resp.text))
|
||||||
|
if tables:
|
||||||
|
df = tables[0]
|
||||||
|
col = "Symbol" if "Symbol" in df.columns else df.columns[0]
|
||||||
|
tickers = df[col].astype(str).str.strip().tolist()
|
||||||
|
tickers = [t.replace(".", "-") for t in tickers if re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', t.replace("-", "."))]
|
||||||
|
return tickers
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_sp500_tickers():
|
||||||
|
return _scrape_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")
|
||||||
|
|
||||||
|
|
||||||
|
def get_sp400_tickers():
|
||||||
|
return _scrape_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_400_companies")
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_tickers(use_cache=True):
|
||||||
|
"""Get combined ticker list, with caching."""
|
||||||
|
if use_cache and os.path.exists(TICKERS_CACHE):
|
||||||
|
cache = json.loads(open(TICKERS_CACHE).read())
|
||||||
|
# Use cache if less than 7 days old
|
||||||
|
cached_date = cache.get("date", "")
|
||||||
|
if cached_date and (date.today() - date.fromisoformat(cached_date)).days < 7:
|
||||||
|
return cache["tickers"]
|
||||||
|
|
||||||
|
print("Fetching ticker lists from Wikipedia...")
|
||||||
|
sp500 = get_sp500_tickers()
|
||||||
|
print(f" S&P 500: {len(sp500)} tickers")
|
||||||
|
sp400 = get_sp400_tickers()
|
||||||
|
print(f" S&P 400: {len(sp400)} tickers")
|
||||||
|
|
||||||
|
all_tickers = sorted(set(sp500 + sp400))
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
with open(TICKERS_CACHE, "w") as f:
|
||||||
|
json.dump({"date": date.today().isoformat(), "tickers": all_tickers, "sp500": len(sp500), "sp400": len(sp400)}, f)
|
||||||
|
|
||||||
|
print(f" Combined: {len(all_tickers)} unique tickers")
|
||||||
|
return all_tickers
|
||||||
|
|
||||||
|
|
||||||
|
def compute_rsi(prices, period=14):
|
||||||
|
"""Compute RSI from a price series."""
|
||||||
|
if len(prices) < period + 1:
|
||||||
|
return None
|
||||||
|
deltas = np.diff(prices)
|
||||||
|
gains = np.where(deltas > 0, deltas, 0)
|
||||||
|
losses = np.where(deltas < 0, -deltas, 0)
|
||||||
|
avg_gain = np.mean(gains[-period:])
|
||||||
|
avg_loss = np.mean(losses[-period:])
|
||||||
|
if avg_loss == 0:
|
||||||
|
return 100.0
|
||||||
|
rs = avg_gain / avg_loss
|
||||||
|
return round(100 - (100 / (1 + rs)), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_ticker(ticker):
|
||||||
|
"""Evaluate a single ticker against GARP criteria. Returns dict or None."""
|
||||||
|
try:
|
||||||
|
stock = yf.Ticker(ticker)
|
||||||
|
info = stock.info
|
||||||
|
if not info or info.get("regularMarketPrice") is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Market cap filter
|
||||||
|
market_cap = info.get("marketCap", 0)
|
||||||
|
if not market_cap or market_cap < 5e9:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# P/E filters
|
||||||
|
trailing_pe = info.get("trailingPE")
|
||||||
|
forward_pe = info.get("forwardPE")
|
||||||
|
if trailing_pe is None or trailing_pe <= 0 or trailing_pe >= 25:
|
||||||
|
return None
|
||||||
|
if forward_pe is None or forward_pe <= 0 or forward_pe >= 15:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Revenue growth
|
||||||
|
revenue_growth = info.get("revenueGrowth")
|
||||||
|
if revenue_growth is None or revenue_growth < 0.10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# EPS growth (earnings growth)
|
||||||
|
earnings_growth = info.get("earningsGrowth")
|
||||||
|
if earnings_growth is None or earnings_growth < 0.15:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ROE
|
||||||
|
roe = info.get("returnOnEquity")
|
||||||
|
if roe is None or roe < 0.05:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Optional filters (don't disqualify if unavailable)
|
||||||
|
peg = info.get("pegRatio")
|
||||||
|
if peg is not None and peg > 1.2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
quick_ratio = info.get("quickRatio")
|
||||||
|
if quick_ratio is not None and quick_ratio < 1.5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
de_ratio = info.get("debtToEquity")
|
||||||
|
if de_ratio is not None and de_ratio > 35:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get price history for RSI and 52-week high
|
||||||
|
hist = stock.history(period="3mo")
|
||||||
|
if hist.empty or len(hist) < 20:
|
||||||
|
return None
|
||||||
|
|
||||||
|
closes = hist["Close"].values
|
||||||
|
current_price = closes[-1]
|
||||||
|
rsi = compute_rsi(closes)
|
||||||
|
|
||||||
|
# 52-week high
|
||||||
|
week52_high = info.get("fiftyTwoWeekHigh", current_price)
|
||||||
|
pct_from_high = ((week52_high - current_price) / week52_high) * 100 if week52_high else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"price": round(current_price, 2),
|
||||||
|
"market_cap": market_cap,
|
||||||
|
"market_cap_b": round(market_cap / 1e9, 1),
|
||||||
|
"trailing_pe": round(trailing_pe, 2),
|
||||||
|
"forward_pe": round(forward_pe, 2),
|
||||||
|
"peg_ratio": round(peg, 2) if peg else None,
|
||||||
|
"revenue_growth": round(revenue_growth * 100, 1),
|
||||||
|
"earnings_growth": round(earnings_growth * 100, 1),
|
||||||
|
"roe": round(roe * 100, 1),
|
||||||
|
"quick_ratio": round(quick_ratio, 2) if quick_ratio else None,
|
||||||
|
"debt_to_equity": round(de_ratio, 1) if de_ratio else None,
|
||||||
|
"rsi": rsi,
|
||||||
|
"week52_high": round(week52_high, 2) if week52_high else None,
|
||||||
|
"pct_from_52wk_high": round(pct_from_high, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan(batch_size=5, delay=1.0):
|
||||||
|
"""Run full GARP scan. Returns list of candidates sorted by score."""
|
||||||
|
tickers = get_all_tickers()
|
||||||
|
candidates = []
|
||||||
|
total = len(tickers)
|
||||||
|
|
||||||
|
print(f"\nScanning {total} tickers...")
|
||||||
|
for i in range(0, total, batch_size):
|
||||||
|
batch = tickers[i:i + batch_size]
|
||||||
|
for ticker in batch:
|
||||||
|
idx = i + batch.index(ticker) + 1
|
||||||
|
sys.stdout.write(f"\r [{idx}/{total}] Scanning {ticker}... ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
result = scan_ticker(ticker)
|
||||||
|
if result:
|
||||||
|
candidates.append(result)
|
||||||
|
print(f"\n ✓ {ticker} passed GARP filter (PE={result['trailing_pe']}, FwdPE={result['forward_pe']}, RevGr={result['revenue_growth']}%)")
|
||||||
|
if i + batch_size < total:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
print(f"\n\nScan complete: {len(candidates)} candidates from {total} tickers")
|
||||||
|
|
||||||
|
# Sort by a composite score: lower forward PE + higher earnings growth
|
||||||
|
for c in candidates:
|
||||||
|
# Simple ranking score: lower is better
|
||||||
|
c["score"] = c["forward_pe"] - (c["earnings_growth"] / 10) - (c["revenue_growth"] / 10)
|
||||||
|
candidates.sort(key=lambda x: x["score"])
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
os.makedirs(SCANS_DIR, exist_ok=True)
|
||||||
|
scan_file = os.path.join(SCANS_DIR, f"{date.today().isoformat()}.json")
|
||||||
|
scan_data = {
|
||||||
|
"date": date.today().isoformat(),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"total_scanned": total,
|
||||||
|
"candidates_found": len(candidates),
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
with open(scan_file, "w") as f:
|
||||||
|
json.dump(scan_data, f, indent=2)
|
||||||
|
print(f"Results saved to {scan_file}")
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def load_latest_scan():
|
||||||
|
"""Load the most recent scan results."""
|
||||||
|
if not os.path.exists(SCANS_DIR):
|
||||||
|
return None
|
||||||
|
files = sorted(f for f in os.listdir(SCANS_DIR) if f.endswith(".json"))
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
with open(os.path.join(SCANS_DIR, files[-1])) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
candidates = run_scan()
|
||||||
|
if candidates:
|
||||||
|
print(f"\nTop candidates:")
|
||||||
|
for c in candidates[:10]:
|
||||||
|
print(f" {c['ticker']:6s} Price=${c['price']:8.2f} PE={c['trailing_pe']:5.1f} FwdPE={c['forward_pe']:5.1f} "
|
||||||
|
f"RevGr={c['revenue_growth']:5.1f}% EPSGr={c['earnings_growth']:5.1f}% RSI={c['rsi']}")
|
||||||
|
else:
|
||||||
|
print("No candidates found matching GARP criteria.")
|
||||||
191
projects/market-watch/trader.py
Executable file
191
projects/market-watch/trader.py
Executable file
@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GARP trading decision engine — multiplayer aware."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
import game_engine
|
||||||
|
import scanner
|
||||||
|
|
||||||
|
MAX_POSITIONS = 15
|
||||||
|
MAX_POSITION_PCT = 0.10
|
||||||
|
RSI_BUY_LIMIT = 70
|
||||||
|
RSI_SELL_LIMIT = 80
|
||||||
|
NEAR_HIGH_PCT = 2.0
|
||||||
|
|
||||||
|
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "logs")
|
||||||
|
|
||||||
|
|
||||||
|
def log_decision(action, ticker, reason, details=None):
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"action": action,
|
||||||
|
"ticker": ticker,
|
||||||
|
"reason": reason,
|
||||||
|
"details": details or {},
|
||||||
|
}
|
||||||
|
log_file = os.path.join(LOG_DIR, f"{datetime.now().strftime('%Y-%m-%d')}.json")
|
||||||
|
logs = []
|
||||||
|
if os.path.exists(log_file):
|
||||||
|
with open(log_file) as f:
|
||||||
|
logs = json.load(f)
|
||||||
|
logs.append(entry)
|
||||||
|
with open(log_file, "w") as f:
|
||||||
|
json.dump(logs, f, indent=2, default=str)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def update_all_prices(game_id, username):
|
||||||
|
"""Update current prices for all held positions."""
|
||||||
|
p = game_engine.get_portfolio(game_id, username)
|
||||||
|
updated = []
|
||||||
|
for ticker in p["positions"]:
|
||||||
|
try:
|
||||||
|
stock = yf.Ticker(ticker)
|
||||||
|
hist = stock.history(period="5d")
|
||||||
|
if not hist.empty:
|
||||||
|
price = float(hist["Close"].iloc[-1])
|
||||||
|
game_engine.update_price(game_id, username, ticker, price)
|
||||||
|
updated.append((ticker, price))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: Could not update {ticker}: {e}")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def check_sell_signals(game_id, username):
|
||||||
|
"""Check existing positions for sell signals."""
|
||||||
|
p = game_engine.get_portfolio(game_id, username)
|
||||||
|
sells = []
|
||||||
|
|
||||||
|
if not p["positions"]:
|
||||||
|
return sells
|
||||||
|
|
||||||
|
latest_scan = scanner.load_latest_scan()
|
||||||
|
scan_tickers = set()
|
||||||
|
if latest_scan:
|
||||||
|
scan_tickers = {c["ticker"] for c in latest_scan.get("candidates", [])}
|
||||||
|
|
||||||
|
for ticker, pos in list(p["positions"].items()):
|
||||||
|
sell_reason = None
|
||||||
|
|
||||||
|
if pos["current_price"] <= pos.get("trailing_stop", 0):
|
||||||
|
sell_reason = f"Trailing stop hit (stop={pos.get('trailing_stop', 0):.2f}, price={pos['current_price']:.2f})"
|
||||||
|
|
||||||
|
if not sell_reason:
|
||||||
|
try:
|
||||||
|
stock = yf.Ticker(ticker)
|
||||||
|
hist = stock.history(period="3mo")
|
||||||
|
if not hist.empty and len(hist) >= 15:
|
||||||
|
rsi = scanner.compute_rsi(hist["Close"].values)
|
||||||
|
if rsi and rsi > RSI_SELL_LIMIT:
|
||||||
|
sell_reason = f"RSI overbought ({rsi:.1f} > {RSI_SELL_LIMIT})"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not sell_reason and latest_scan and ticker not in scan_tickers:
|
||||||
|
sell_reason = f"No longer passes GARP filter"
|
||||||
|
|
||||||
|
if sell_reason:
|
||||||
|
result = game_engine.sell(game_id, username, ticker, price=pos["current_price"], reason=sell_reason)
|
||||||
|
log_entry = log_decision("SELL", ticker, sell_reason, result)
|
||||||
|
sells.append(log_entry)
|
||||||
|
print(f" SELL {ticker}: {sell_reason}")
|
||||||
|
|
||||||
|
return sells
|
||||||
|
|
||||||
|
|
||||||
|
def check_buy_signals(game_id, username, candidates=None):
|
||||||
|
"""Check scan candidates for buy signals."""
|
||||||
|
p = game_engine.get_portfolio(game_id, username)
|
||||||
|
buys = []
|
||||||
|
|
||||||
|
if p["num_positions"] >= MAX_POSITIONS:
|
||||||
|
print(f" Max positions reached ({MAX_POSITIONS}), skipping buys")
|
||||||
|
return buys
|
||||||
|
|
||||||
|
if candidates is None:
|
||||||
|
latest_scan = scanner.load_latest_scan()
|
||||||
|
if not latest_scan:
|
||||||
|
print(" No scan data available")
|
||||||
|
return buys
|
||||||
|
candidates = latest_scan.get("candidates", [])
|
||||||
|
|
||||||
|
position_size = p["total_value"] / MAX_POSITIONS
|
||||||
|
max_per_position = p["total_value"] * MAX_POSITION_PCT
|
||||||
|
existing_tickers = set(p["positions"].keys())
|
||||||
|
|
||||||
|
for c in candidates:
|
||||||
|
if p["num_positions"] + len(buys) >= MAX_POSITIONS:
|
||||||
|
break
|
||||||
|
|
||||||
|
ticker = c["ticker"]
|
||||||
|
if ticker in existing_tickers:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rsi = c.get("rsi")
|
||||||
|
if rsi and rsi > RSI_BUY_LIMIT:
|
||||||
|
log_decision("SKIP", ticker, f"RSI too high ({rsi:.1f} > {RSI_BUY_LIMIT})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
pct_from_high = c.get("pct_from_52wk_high", 0)
|
||||||
|
if pct_from_high < NEAR_HIGH_PCT:
|
||||||
|
log_decision("SKIP", ticker, f"Too close to 52wk high ({pct_from_high:.1f}% away)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
price = c["price"]
|
||||||
|
# Refresh cash from current portfolio state
|
||||||
|
current_p = game_engine.get_portfolio(game_id, username)
|
||||||
|
amount = min(position_size, max_per_position, current_p["cash"])
|
||||||
|
if amount < price:
|
||||||
|
continue
|
||||||
|
|
||||||
|
shares = int(amount / price)
|
||||||
|
if shares < 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
reason = (f"GARP signal: PE={c['trailing_pe']}, FwdPE={c['forward_pe']}, "
|
||||||
|
f"RevGr={c['revenue_growth']}%, EPSGr={c['earnings_growth']}%, RSI={rsi}")
|
||||||
|
|
||||||
|
result = game_engine.buy(game_id, username, ticker, shares, price, reason=reason)
|
||||||
|
if result["success"]:
|
||||||
|
log_entry = log_decision("BUY", ticker, reason, result)
|
||||||
|
buys.append(log_entry)
|
||||||
|
print(f" BUY {ticker}: {shares} shares @ ${price:.2f} = ${shares * price:,.2f}")
|
||||||
|
else:
|
||||||
|
log_decision("SKIP", ticker, f"Buy failed: {result.get('error', 'unknown')}")
|
||||||
|
|
||||||
|
return buys
|
||||||
|
|
||||||
|
|
||||||
|
def run_trading_logic(game_id, username, candidates=None):
|
||||||
|
"""Run full trading cycle for a player."""
|
||||||
|
print(f"\n--- Trading Logic [{username}@{game_id}] ---")
|
||||||
|
|
||||||
|
print("\nUpdating prices...")
|
||||||
|
updated = update_all_prices(game_id, username)
|
||||||
|
for ticker, price in updated:
|
||||||
|
print(f" {ticker}: ${price:.2f}")
|
||||||
|
|
||||||
|
print("\nChecking sell signals...")
|
||||||
|
sells = check_sell_signals(game_id, username)
|
||||||
|
if not sells:
|
||||||
|
print(" No sell signals")
|
||||||
|
|
||||||
|
print("\nChecking buy signals...")
|
||||||
|
buys = check_buy_signals(game_id, username, candidates)
|
||||||
|
if not buys:
|
||||||
|
print(" No buy signals")
|
||||||
|
|
||||||
|
return {"sells": sells, "buys": buys, "price_updates": len(updated)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
gid = game_engine.get_default_game_id()
|
||||||
|
if gid:
|
||||||
|
run_trading_logic(gid, "case")
|
||||||
|
else:
|
||||||
|
print("No default game found. Run game_engine.py first.")
|
||||||
Reference in New Issue
Block a user