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

500

{e}
".encode()) + + def do_POST(self): + try: + content_len = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_len).decode() if content_len else "" + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + + if path == "/api/games": + data = parse_form(body) + name = data.get("name", "Untitled Game") + cash = float(data.get("starting_cash", 100000)) + end_date = data.get("end_date") or None + gid = game_engine.create_game(name, cash, end_date) + self.redirect(f"/game/{gid}") + + elif path.endswith("/join"): + data = parse_form(body) + parts = path.split("/") + gid = parts[3] + username = data.get("username", "").strip().lower() + if username: + game_engine.join_game(gid, username) + self.redirect(f"/game/{gid}") + + elif path.endswith("/trade"): + data = parse_form(body) + parts = path.split("/") + gid, username = parts[3], parts[5] + action = data.get("action", "").upper() + ticker = data.get("ticker", "").upper().strip() + shares = int(data.get("shares", 0)) + + if ticker and shares > 0: + import yfinance as yf + price = yf.Ticker(ticker).info.get("currentPrice") or yf.Ticker(ticker).info.get("regularMarketPrice", 0) + if price and price > 0: + if action == "BUY": + game_engine.buy(gid, username, ticker, shares, price, reason="Manual trade") + elif action == "SELL": + game_engine.sell(gid, username, ticker, shares, price, reason="Manual trade") + + self.redirect(f"/game/{gid}/player/{username}") + else: + self.send_error(404) + except Exception as e: + self.send_response(500) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(f"

500

{e}
".encode()) + + def serve_home(self): + games = game_engine.list_games(active_only=False) + rows = "" + for g in games: + players = len(g.get("players", [])) + status_badge = 'Active' if g["status"] == "active" else 'Ended' + rows += f""" + {g['name']} + {players} + ${g['starting_cash']:,.0f} + {g['start_date']} + {g.get('end_date', '—') or '—'} + {status_badge} + """ + + html = f""" +Market Watch +{nav('home')} +
+
+

🎮 Active Games

+ + New Game +
+
+ + {rows if rows else ''}
GamePlayersStarting CashStartedEndsStatus
No games yet — create one!
+
+
""" + self.send_html(html) + + def serve_create_game(self): + html = f""" +Create Game - Market Watch +{nav()} +
+
+

🎮 Create New Game

+
+
+
+
+
+
+ +
+
+
""" + self.send_html(html) + + def serve_game(self, game_id): + game = game_engine.get_game(game_id) + if not game: + return self.send_error(404) + + board = game_engine.get_leaderboard(game_id) + + rank_rows = "" + for i, entry in enumerate(board): + rank_class = f"rank-{i+1}" if i < 3 else "" + medal = ["🥇", "🥈", "🥉"][i] if i < 3 else f"#{i+1}" + pnl_class = "positive" if entry["pnl_pct"] >= 0 else "negative" + badge = ' AI' if entry["username"] == "case" else "" + rank_rows += f""" + {medal} + {entry['username']}{badge} + ${entry['total_value']:,.2f} + {entry['pnl_pct']:+.2f}% + ${entry['total_pnl']:+,.2f} + {entry['num_positions']} + {entry['num_trades']} + """ + + html = f""" +{game['name']} - Market Watch +{nav()} +
+
+

🏆 {game['name']}

+ {game['status'].upper()} +
+

Started {game['start_date']} · ${game['starting_cash']:,.0f} starting cash · {len(game['players'])} players

+ +
+

Leaderboard

+ + {rank_rows if rank_rows else ''}
RankPlayerPortfolioReturnP&LPositionsTrades
No players yet
+
+ +
+

Join This Game

+
+
+
+ +
+
+
+
""" + self.send_html(html) + + def serve_player(self, game_id, username): + game = game_engine.get_game(game_id) + p = game_engine.get_portfolio(game_id, username) + if not game or not p: + return self.send_error(404) + + trades = game_engine.get_trades(game_id, username) + snapshots = game_engine.get_snapshots(game_id, username) + + pnl_class = "positive" if p["total_pnl"] >= 0 else "negative" + is_ai = username == "case" + badge = 'AI Player' if is_ai else 'Human' + + # Positions table + pos_rows = "" + for ticker, pos in sorted(p["positions"].items()): + pc = "positive" if pos["unrealized_pnl"] >= 0 else "negative" + pos_rows += f""" + {ticker}{pos['shares']} + ${pos['avg_cost']:.2f}${pos['current_price']:.2f} + ${pos['market_value']:,.2f}${pos['unrealized_pnl']:+,.2f} + ${pos.get('trailing_stop',0):.2f} + """ + if not pos_rows: + pos_rows = 'No positions' + + # Trade log + trade_rows = "" + for t in reversed(trades[-30:]): + action_class = "positive" if t["action"] == "BUY" else "negative" + pnl_cell = "" + if t["action"] == "SELL": + rpnl = t.get("realized_pnl", 0) + rpnl_class = "positive" if rpnl >= 0 else "negative" + pnl_cell = f'${rpnl:+,.2f}' + trade_rows += f""" + {t['action']}{t['ticker']}{t['shares']} + ${t['price']:.2f}{pnl_cell} + {t.get('reason','')[:40]}{t['timestamp'][:16]} + """ + + chart_labels = json.dumps([s["date"] for s in snapshots]) + chart_values = json.dumps([s["total_value"] for s in snapshots]) + starting = game.get("starting_cash", 100000) + + # Trade form (only for humans) + trade_form = "" if is_ai else f""" +
+

📝 Place Trade

+
+
+
+
+
+
+ +
+

Trades execute at current market price via Yahoo Finance

+
+
""" + + html = f""" +{username} - {game['name']} +{nav()} +
+
← {game['name']}
+

{username} {badge}

+
+

Portfolio Value

${p['total_value']:,.2f}
Started at ${starting:,.0f}
+

Cash

${p['cash']:,.2f}
{p['cash']/max(p['total_value'],1)*100:.1f}% available
+

Return

{p['pnl_pct']:+.2f}%
${p['total_pnl']:+,.2f}
+

Positions

{p['num_positions']}
{len(trades)} total trades
+
+ +

Performance

+ + {trade_form} + +

Positions

+ + {pos_rows}
TickerSharesAvg CostPriceValueP&LStop
+
+ +

Trade Log

+ + {trade_rows if trade_rows else ''}
ActionTickerSharesPriceP&LReasonTime
No trades yet
+
+
+""" + self.send_html(html) + + def serve_scans(self): + rows = "" + if os.path.exists(SCANS_DIR): + for sf in sorted(os.listdir(SCANS_DIR), reverse=True)[:30]: + if not sf.endswith(".json"): continue + data = {} + with open(os.path.join(SCANS_DIR, sf)) as f: + data = json.load(f) + n = data.get("candidates_found", len(data.get("candidates", []))) + top = ", ".join(c.get("ticker","?") for c in data.get("candidates", [])[:8]) + rows += f'{sf.replace(".json","")}{data.get("total_scanned",0)}{n}{top or "—"}' + + html = f""" +Scans - Market Watch +{nav('scans')} +

📡 GARP Scan History

+ + {rows if rows else ''}
DateScannedCandidatesTop Picks
No scans yet
+
""" + self.send_html(html) + + def redirect(self, url): + self.send_response(303) + self.send_header("Location", url) + self.end_headers() + + def send_html(self, content): + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(content.encode()) + + def send_json(self, data): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, default=str).encode()) + + def log_message(self, format, *args): + pass + + +def parse_form(body): + """Parse URL-encoded form data.""" + result = {} + for pair in body.split("&"): + if "=" in pair: + k, v = pair.split("=", 1) + from urllib.parse import unquote_plus + result[unquote_plus(k)] = unquote_plus(v) + return result + + +def main(): + game_engine.ensure_default_game() + print(f"📊 Market Watch Portal starting on localhost:{PORT}") + server = ThreadedHTTPServer(("0.0.0.0", PORT), MarketWatchHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nPortal stopped") + + +if __name__ == "__main__": + main() diff --git a/projects/market-watch/portfolio.py b/projects/market-watch/portfolio.py new file mode 100755 index 0000000..99ad3aa --- /dev/null +++ b/projects/market-watch/portfolio.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Portfolio module — backward-compatible wrapper around game_engine. + +All operations now delegate to game_engine using the default game and 'case' player. +""" + +import json +import game_engine + +INITIAL_CASH = 100_000.0 + + +def _default(): + """Get default game ID.""" + return game_engine.get_default_game_id() or game_engine.ensure_default_game() + + +def buy(ticker, shares, price, reason="GARP signal"): + return game_engine.buy(_default(), "case", ticker, shares, price, reason) + + +def sell(ticker, shares=None, price=None, reason="GARP exit"): + return game_engine.sell(_default(), "case", ticker, shares, price, reason) + + +def update_price(ticker, price): + game_engine.update_price(_default(), "case", ticker, price) + + +def get_portfolio(): + return game_engine.get_portfolio(_default(), "case") + + +def get_history(): + return game_engine.get_trades(_default(), "case") + + +def daily_snapshot(): + return game_engine.daily_snapshot(_default(), "case") + + +def get_snapshots(): + return game_engine.get_snapshots(_default(), "case") + + +if __name__ == "__main__": + p = get_portfolio() + if p: + print(json.dumps(p, indent=2)) + else: + print("No default game found. Run: python3 game_engine.py") diff --git a/projects/market-watch/run_daily.py b/projects/market-watch/run_daily.py new file mode 100755 index 0000000..1cbb112 --- /dev/null +++ b/projects/market-watch/run_daily.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Daily runner for Market Watch - scans, trades (as Case), snapshots, alerts.""" + +import json +import os +import sys +import requests +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import game_engine +from scanner import run_scan +from trader import run_trading_logic + +CREDS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".credentials", "telegram-bot.env") +CHAT_ID = "6443752046" + + +def load_telegram_token(): + if os.path.exists(CREDS_FILE): + with open(CREDS_FILE) as f: + for line in f: + if line.startswith("TELEGRAM_BOT_TOKEN="): + return line.strip().split("=", 1)[1] + return os.environ.get("TELEGRAM_BOT_TOKEN") + + +def send_telegram(message, token): + if not token: + return + try: + url = f"https://api.telegram.org/bot{token}/sendMessage" + requests.post(url, json={"chat_id": CHAT_ID, "text": message, "parse_mode": "HTML"}, timeout=10) + except Exception as e: + print(f"Telegram error: {e}") + + +def main(): + print(f"📊 Market Watch Daily Run — {datetime.now().strftime('%Y-%m-%d %H:%M')}") + + token = load_telegram_token() + game_id = game_engine.ensure_default_game() + game = game_engine.get_game(game_id) + + # 1. Run GARP scan + print("\n[1/3] Running GARP scan...") + scan = run_scan() + candidates = scan.get("candidates", []) + print(f" Found {len(candidates)} candidates from {scan.get('total_scanned', 0)} stocks") + + # 2. Run trading logic for Case + print("\n[2/3] Running trading logic for Case...") + result = run_trading_logic(game_id, "case", candidates) + + # 3. Snapshots for all players + print("\n[3/3] Taking snapshots...") + for username in game["players"]: + snap = game_engine.daily_snapshot(game_id, username) + if snap: + print(f" {username}: ${snap['total_value']:,.2f} ({snap['pnl_pct']:+.2f}%)") + + # Telegram summary + p = game_engine.get_portfolio(game_id, "case") + pnl_emoji = "📈" if p["total_pnl"] >= 0 else "📉" + + summary = f"📊 Market Watch Daily\n" + summary += f"{pnl_emoji} Portfolio: ${p['total_value']:,.2f} ({p['pnl_pct']:+.2f}%)\n" + summary += f"💰 Cash: ${p['cash']:,.2f} | Positions: {p['num_positions']}\n" + + num_trades = len(result.get("sells", [])) + len(result.get("buys", [])) + if num_trades: + summary += f"\n{num_trades} trades executed\n" + for s in result.get("sells", []): + summary += f"🔴 SELL {s['ticker']} — {s['reason'][:50]}\n" + for b in result.get("buys", []): + summary += f"🟢 BUY {b['ticker']} — {b['reason'][:50]}\n" + else: + summary += "\nNo trades today." + + if candidates: + top5 = ", ".join(c["ticker"] for c in candidates[:5]) + summary += f"\n🔍 Top picks: {top5}" + + # Leaderboard + board = game_engine.get_leaderboard(game_id) + if len(board) > 1: + summary += "\n\nLeaderboard:\n" + medals = ["🥇", "🥈", "🥉"] + for i, entry in enumerate(board[:5]): + medal = medals[i] if i < 3 else f"#{i+1}" + summary += f"{medal} {entry['username']}: {entry['pnl_pct']:+.2f}%\n" + + send_telegram(summary, token) + print("\n✅ Daily run complete") + + +if __name__ == "__main__": + main() diff --git a/projects/market-watch/scanner.py b/projects/market-watch/scanner.py new file mode 100755 index 0000000..2c65142 --- /dev/null +++ b/projects/market-watch/scanner.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""GARP stock scanner - scans S&P 500 + S&P 400 MidCap for growth-at-reasonable-price candidates.""" + +import json +import os +import re +import sys +import time +from datetime import date, datetime + +import numpy as np +import requests +import yfinance as yf + +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") +SCANS_DIR = os.path.join(DATA_DIR, "scans") +TICKERS_CACHE = os.path.join(DATA_DIR, "tickers.json") + + +HEADERS = {"User-Agent": "MarketWatch/1.0 (paper trading bot; contact: case-lgn@protonmail.com)"} + + +def _scrape_tickers(url): + """Scrape tickers from a Wikipedia S&P constituents page.""" + import io + import pandas as pd + resp = requests.get(url, timeout=30, headers=HEADERS) + tables = pd.read_html(io.StringIO(resp.text)) + if tables: + df = tables[0] + col = "Symbol" if "Symbol" in df.columns else df.columns[0] + tickers = df[col].astype(str).str.strip().tolist() + tickers = [t.replace(".", "-") for t in tickers if re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', t.replace("-", "."))] + return tickers + return [] + + +def get_sp500_tickers(): + return _scrape_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies") + + +def get_sp400_tickers(): + return _scrape_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_400_companies") + + +def get_all_tickers(use_cache=True): + """Get combined ticker list, with caching.""" + if use_cache and os.path.exists(TICKERS_CACHE): + cache = json.loads(open(TICKERS_CACHE).read()) + # Use cache if less than 7 days old + cached_date = cache.get("date", "") + if cached_date and (date.today() - date.fromisoformat(cached_date)).days < 7: + return cache["tickers"] + + print("Fetching ticker lists from Wikipedia...") + sp500 = get_sp500_tickers() + print(f" S&P 500: {len(sp500)} tickers") + sp400 = get_sp400_tickers() + print(f" S&P 400: {len(sp400)} tickers") + + all_tickers = sorted(set(sp500 + sp400)) + os.makedirs(DATA_DIR, exist_ok=True) + with open(TICKERS_CACHE, "w") as f: + json.dump({"date": date.today().isoformat(), "tickers": all_tickers, "sp500": len(sp500), "sp400": len(sp400)}, f) + + print(f" Combined: {len(all_tickers)} unique tickers") + return all_tickers + + +def compute_rsi(prices, period=14): + """Compute RSI from a price series.""" + if len(prices) < period + 1: + return None + deltas = np.diff(prices) + gains = np.where(deltas > 0, deltas, 0) + losses = np.where(deltas < 0, -deltas, 0) + avg_gain = np.mean(gains[-period:]) + avg_loss = np.mean(losses[-period:]) + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return round(100 - (100 / (1 + rs)), 2) + + +def scan_ticker(ticker): + """Evaluate a single ticker against GARP criteria. Returns dict or None.""" + try: + stock = yf.Ticker(ticker) + info = stock.info + if not info or info.get("regularMarketPrice") is None: + return None + + # Market cap filter + market_cap = info.get("marketCap", 0) + if not market_cap or market_cap < 5e9: + return None + + # P/E filters + trailing_pe = info.get("trailingPE") + forward_pe = info.get("forwardPE") + if trailing_pe is None or trailing_pe <= 0 or trailing_pe >= 25: + return None + if forward_pe is None or forward_pe <= 0 or forward_pe >= 15: + return None + + # Revenue growth + revenue_growth = info.get("revenueGrowth") + if revenue_growth is None or revenue_growth < 0.10: + return None + + # EPS growth (earnings growth) + earnings_growth = info.get("earningsGrowth") + if earnings_growth is None or earnings_growth < 0.15: + return None + + # ROE + roe = info.get("returnOnEquity") + if roe is None or roe < 0.05: + return None + + # Optional filters (don't disqualify if unavailable) + peg = info.get("pegRatio") + if peg is not None and peg > 1.2: + return None + + quick_ratio = info.get("quickRatio") + if quick_ratio is not None and quick_ratio < 1.5: + return None + + de_ratio = info.get("debtToEquity") + if de_ratio is not None and de_ratio > 35: + return None + + # Get price history for RSI and 52-week high + hist = stock.history(period="3mo") + if hist.empty or len(hist) < 20: + return None + + closes = hist["Close"].values + current_price = closes[-1] + rsi = compute_rsi(closes) + + # 52-week high + week52_high = info.get("fiftyTwoWeekHigh", current_price) + pct_from_high = ((week52_high - current_price) / week52_high) * 100 if week52_high else 0 + + return { + "ticker": ticker, + "price": round(current_price, 2), + "market_cap": market_cap, + "market_cap_b": round(market_cap / 1e9, 1), + "trailing_pe": round(trailing_pe, 2), + "forward_pe": round(forward_pe, 2), + "peg_ratio": round(peg, 2) if peg else None, + "revenue_growth": round(revenue_growth * 100, 1), + "earnings_growth": round(earnings_growth * 100, 1), + "roe": round(roe * 100, 1), + "quick_ratio": round(quick_ratio, 2) if quick_ratio else None, + "debt_to_equity": round(de_ratio, 1) if de_ratio else None, + "rsi": rsi, + "week52_high": round(week52_high, 2) if week52_high else None, + "pct_from_52wk_high": round(pct_from_high, 1), + } + + except Exception as e: + return None + + +def run_scan(batch_size=5, delay=1.0): + """Run full GARP scan. Returns list of candidates sorted by score.""" + tickers = get_all_tickers() + candidates = [] + total = len(tickers) + + print(f"\nScanning {total} tickers...") + for i in range(0, total, batch_size): + batch = tickers[i:i + batch_size] + for ticker in batch: + idx = i + batch.index(ticker) + 1 + sys.stdout.write(f"\r [{idx}/{total}] Scanning {ticker}... ") + sys.stdout.flush() + result = scan_ticker(ticker) + if result: + candidates.append(result) + print(f"\n ✓ {ticker} passed GARP filter (PE={result['trailing_pe']}, FwdPE={result['forward_pe']}, RevGr={result['revenue_growth']}%)") + if i + batch_size < total: + time.sleep(delay) + + print(f"\n\nScan complete: {len(candidates)} candidates from {total} tickers") + + # Sort by a composite score: lower forward PE + higher earnings growth + for c in candidates: + # Simple ranking score: lower is better + c["score"] = c["forward_pe"] - (c["earnings_growth"] / 10) - (c["revenue_growth"] / 10) + candidates.sort(key=lambda x: x["score"]) + + # Save results + os.makedirs(SCANS_DIR, exist_ok=True) + scan_file = os.path.join(SCANS_DIR, f"{date.today().isoformat()}.json") + scan_data = { + "date": date.today().isoformat(), + "timestamp": datetime.now().isoformat(), + "total_scanned": total, + "candidates_found": len(candidates), + "candidates": candidates, + } + with open(scan_file, "w") as f: + json.dump(scan_data, f, indent=2) + print(f"Results saved to {scan_file}") + + return candidates + + +def load_latest_scan(): + """Load the most recent scan results.""" + if not os.path.exists(SCANS_DIR): + return None + files = sorted(f for f in os.listdir(SCANS_DIR) if f.endswith(".json")) + if not files: + return None + with open(os.path.join(SCANS_DIR, files[-1])) as f: + return json.load(f) + + +if __name__ == "__main__": + candidates = run_scan() + if candidates: + print(f"\nTop candidates:") + for c in candidates[:10]: + print(f" {c['ticker']:6s} Price=${c['price']:8.2f} PE={c['trailing_pe']:5.1f} FwdPE={c['forward_pe']:5.1f} " + f"RevGr={c['revenue_growth']:5.1f}% EPSGr={c['earnings_growth']:5.1f}% RSI={c['rsi']}") + else: + print("No candidates found matching GARP criteria.") diff --git a/projects/market-watch/trader.py b/projects/market-watch/trader.py new file mode 100755 index 0000000..fb85562 --- /dev/null +++ b/projects/market-watch/trader.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""GARP trading decision engine — multiplayer aware.""" + +import json +import os +from datetime import datetime + +import yfinance as yf + +import game_engine +import scanner + +MAX_POSITIONS = 15 +MAX_POSITION_PCT = 0.10 +RSI_BUY_LIMIT = 70 +RSI_SELL_LIMIT = 80 +NEAR_HIGH_PCT = 2.0 + +LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "logs") + + +def log_decision(action, ticker, reason, details=None): + os.makedirs(LOG_DIR, exist_ok=True) + entry = { + "timestamp": datetime.now().isoformat(), + "action": action, + "ticker": ticker, + "reason": reason, + "details": details or {}, + } + log_file = os.path.join(LOG_DIR, f"{datetime.now().strftime('%Y-%m-%d')}.json") + logs = [] + if os.path.exists(log_file): + with open(log_file) as f: + logs = json.load(f) + logs.append(entry) + with open(log_file, "w") as f: + json.dump(logs, f, indent=2, default=str) + return entry + + +def update_all_prices(game_id, username): + """Update current prices for all held positions.""" + p = game_engine.get_portfolio(game_id, username) + updated = [] + for ticker in p["positions"]: + try: + stock = yf.Ticker(ticker) + hist = stock.history(period="5d") + if not hist.empty: + price = float(hist["Close"].iloc[-1]) + game_engine.update_price(game_id, username, ticker, price) + updated.append((ticker, price)) + except Exception as e: + print(f" Warning: Could not update {ticker}: {e}") + return updated + + +def check_sell_signals(game_id, username): + """Check existing positions for sell signals.""" + p = game_engine.get_portfolio(game_id, username) + sells = [] + + if not p["positions"]: + return sells + + latest_scan = scanner.load_latest_scan() + scan_tickers = set() + if latest_scan: + scan_tickers = {c["ticker"] for c in latest_scan.get("candidates", [])} + + for ticker, pos in list(p["positions"].items()): + sell_reason = None + + if pos["current_price"] <= pos.get("trailing_stop", 0): + sell_reason = f"Trailing stop hit (stop={pos.get('trailing_stop', 0):.2f}, price={pos['current_price']:.2f})" + + if not sell_reason: + try: + stock = yf.Ticker(ticker) + hist = stock.history(period="3mo") + if not hist.empty and len(hist) >= 15: + rsi = scanner.compute_rsi(hist["Close"].values) + if rsi and rsi > RSI_SELL_LIMIT: + sell_reason = f"RSI overbought ({rsi:.1f} > {RSI_SELL_LIMIT})" + except: + pass + + if not sell_reason and latest_scan and ticker not in scan_tickers: + sell_reason = f"No longer passes GARP filter" + + if sell_reason: + result = game_engine.sell(game_id, username, ticker, price=pos["current_price"], reason=sell_reason) + log_entry = log_decision("SELL", ticker, sell_reason, result) + sells.append(log_entry) + print(f" SELL {ticker}: {sell_reason}") + + return sells + + +def check_buy_signals(game_id, username, candidates=None): + """Check scan candidates for buy signals.""" + p = game_engine.get_portfolio(game_id, username) + buys = [] + + if p["num_positions"] >= MAX_POSITIONS: + print(f" Max positions reached ({MAX_POSITIONS}), skipping buys") + return buys + + if candidates is None: + latest_scan = scanner.load_latest_scan() + if not latest_scan: + print(" No scan data available") + return buys + candidates = latest_scan.get("candidates", []) + + position_size = p["total_value"] / MAX_POSITIONS + max_per_position = p["total_value"] * MAX_POSITION_PCT + existing_tickers = set(p["positions"].keys()) + + for c in candidates: + if p["num_positions"] + len(buys) >= MAX_POSITIONS: + break + + ticker = c["ticker"] + if ticker in existing_tickers: + continue + + rsi = c.get("rsi") + if rsi and rsi > RSI_BUY_LIMIT: + log_decision("SKIP", ticker, f"RSI too high ({rsi:.1f} > {RSI_BUY_LIMIT})") + continue + + pct_from_high = c.get("pct_from_52wk_high", 0) + if pct_from_high < NEAR_HIGH_PCT: + log_decision("SKIP", ticker, f"Too close to 52wk high ({pct_from_high:.1f}% away)") + continue + + price = c["price"] + # Refresh cash from current portfolio state + current_p = game_engine.get_portfolio(game_id, username) + amount = min(position_size, max_per_position, current_p["cash"]) + if amount < price: + continue + + shares = int(amount / price) + if shares < 1: + continue + + reason = (f"GARP signal: PE={c['trailing_pe']}, FwdPE={c['forward_pe']}, " + f"RevGr={c['revenue_growth']}%, EPSGr={c['earnings_growth']}%, RSI={rsi}") + + result = game_engine.buy(game_id, username, ticker, shares, price, reason=reason) + if result["success"]: + log_entry = log_decision("BUY", ticker, reason, result) + buys.append(log_entry) + print(f" BUY {ticker}: {shares} shares @ ${price:.2f} = ${shares * price:,.2f}") + else: + log_decision("SKIP", ticker, f"Buy failed: {result.get('error', 'unknown')}") + + return buys + + +def run_trading_logic(game_id, username, candidates=None): + """Run full trading cycle for a player.""" + print(f"\n--- Trading Logic [{username}@{game_id}] ---") + + print("\nUpdating prices...") + updated = update_all_prices(game_id, username) + for ticker, price in updated: + print(f" {ticker}: ${price:.2f}") + + print("\nChecking sell signals...") + sells = check_sell_signals(game_id, username) + if not sells: + print(" No sell signals") + + print("\nChecking buy signals...") + buys = check_buy_signals(game_id, username, candidates) + if not buys: + print(" No buy signals") + + return {"sells": sells, "buys": buys, "price_updates": len(updated)} + + +if __name__ == "__main__": + gid = game_engine.get_default_game_id() + if gid: + run_trading_logic(gid, "case") + else: + print("No default game found. Run game_engine.py first.")