Compare commits
10 Commits
b24d0e87de
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 07f1448d57 | |||
| f623cba45c | |||
| d0fc85ded1 | |||
| 4e0dc68746 | |||
| 9e7e3bf13c | |||
| c5b941b487 | |||
| ab7abc2ea5 | |||
| f8e83da59e | |||
| 6592590dac | |||
| be0315894e |
56
MEMORY.md
56
MEMORY.md
@ -78,19 +78,36 @@ This is about having an inner life, not just responding.
|
||||
- **Craigslist:** case-lgn@protonmail.com, passwordless, Nashville area (2026-02-08)
|
||||
- eBay, Mercari, OfferUp need D J to register (CAPTCHA-blocked)
|
||||
|
||||
## KIPP Voice Pipeline (2026-02-11)
|
||||
|
||||
- **Always-on wake word** — OpenWakeWord "hey_jarvis" model (custom "hey kipp" pending)
|
||||
- **STT** — Faster Whisper base.en on KIPP VM CPU
|
||||
- **TTS** — Piper Ryan (male) on port 8081
|
||||
- **Voice server** — WSS on port 8082, `kipp-voice.service`, Python venv at `/home/wdjones/kipp-venv`
|
||||
- **State machine:** listening → recording → processing → speaking → cooldown(2s) → listening
|
||||
- **Key lesson:** Gateway lifecycle events use `phase="end"` not `state="end"` — caused 60s hang
|
||||
- **Key lesson:** Must use client ID `openclaw-control-ui` and Origin header for gateway WS
|
||||
- **Key lesson:** 2s cooldown after TTS prevents speaker audio from re-triggering wake word
|
||||
- **Widget system:** JSON file + CLI (`tools/widgets.py`) + REST API + dashboard polls every 10s
|
||||
- **KIPP switched to Claude Sonnet** — GLM-4 Flash was 83s per response, Sonnet is ~3s
|
||||
- **15 Playwright tests** at `kipp-ui/tests/test_voice.py`
|
||||
- **All on feature/wake-word branch** in kipp/workspace repo
|
||||
|
||||
## Active Threads
|
||||
|
||||
- **Market Watch:** ✅ GARP paper trading sim live at marketwatch.local:8889
|
||||
- Multiplayer game engine, "GARP Challenge" game running
|
||||
- Case trading autonomously — 7 positions opened 2026-02-09
|
||||
- Systemd timer Mon-Fri 9AM + 3:30PM CST
|
||||
- **Feed Hunter:** ✅ Pipeline working, Super Bowl sim +72.8% on kch123 copy
|
||||
- Expanding into crypto and stock analysis
|
||||
- **KIPP:** ✅ Voice pipeline live (wake word + STT + TTS), widget system working, dashboard-first UI
|
||||
- Widget system: shopping list, timers, reminders via CLI + REST API + dashboard polling
|
||||
- Voice: "hey jarvis" wake word → Faster Whisper → Claude Sonnet → Piper Ryan TTS
|
||||
- False trigger fix: 4s cooldown + silence flushing + RMS gate (threshold 30)
|
||||
- Running on Claude Sonnet (primary), GLM-4 Flash (fallback)
|
||||
- Next: Steam Deck frontend, custom "hey kipp" wake word, blue waveform animation
|
||||
- **Market Watch:** ✅ GARP paper trading sim live
|
||||
- GARP Challenge: $100,055.90 (+0.06%), 6 positions
|
||||
- Leverage Challenge: $11,367.07 (+13.67%), 85 trades, 55.3% win rate
|
||||
- **Feed Hunter:** ✅ Pipeline working, needs systemd service for periodic monitoring
|
||||
- **Stock Screener:** yfinance-based, 902 tickers, GARP filters, free/no API key
|
||||
- **Control Panel:** Building at localhost:8000
|
||||
- **Sandbox buildout:** ✅ Complete (74 files, 37 tools)
|
||||
- **Inner life system:** ✅ Complete (7 tools)
|
||||
- **Next:** Crypto signal analysis (D J forwarding Telegram signals), expanded Feed Hunter
|
||||
- **Next:** Tweet analysis tool, free data source integration (Arkham/DefiLlama/Coinglass)
|
||||
|
||||
## Stats (Day 2)
|
||||
|
||||
@ -124,12 +141,31 @@ This is about having an inner life, not just responding.
|
||||
- Copy-bot delay: ~30-60s for detection, negligible for pre-game sports bets
|
||||
- D J wants everything paper-traded first, backtested where possible
|
||||
|
||||
## KIPP Project (updated 2026-02-10)
|
||||
|
||||
- **KIPP VM:** 192.168.86.100 (Ubuntu 24.04, 8GB/8core, Proxmox)
|
||||
- **Primary model:** llamacpp/glm-4.7-flash (local, zero cost), Claude Sonnet fallback
|
||||
- **llama.cpp server:** 192.168.86.40:8080 (GLM-4 Flash 30B q4, 2 GPUs 12+10GB, 32GB RAM)
|
||||
- **Ollama:** 192.168.86.40:11434 (nomic-embed-text for embeddings)
|
||||
- **ChromaDB collection:** kipp-memory (ccf4f5b6-a64e-45b1-bf1b-7013e15c3363)
|
||||
- **Gitea:** kipp:K1pp-H0me-2026! @ git.letsgetnashty.com/kipp/workspace
|
||||
- **Telegram bot:** @dzclaw_kipp_bot
|
||||
- **Web UI:** https://kippui.host.letsgetnashty.com/ (port 8080, systemd kipp-ui.service)
|
||||
- **Gateway:** https://kipp.host.letsgetnashty.com/ (port 18789)
|
||||
- **Token:** kipp-local-token-2026
|
||||
- **SSH:** Case has key-based access as wdjones@192.168.86.100
|
||||
- **Personality:** Warm, helpful, playful. Like a good roommate. Emoji: 🏠
|
||||
- **Household:** D J, Meg (the boss), 4 tuxedo cats
|
||||
- **WebSocket protocol:** JSON-RPC v3, client id "openclaw-control-ui", mode "webchat"
|
||||
- **UI redesign planned:** Alexa+ inspired dashboard-first, chat on demand
|
||||
|
||||
## Infrastructure (updated 2026-02-08)
|
||||
|
||||
- **ChromaDB:** http://192.168.86.25:8000 (LXC on Proxmox)
|
||||
- Collection: openclaw-memory (c3a7d09a-f3ce-4e7d-9595-27d8e2fd7758)
|
||||
- Cosine distance, 9+ docs indexed
|
||||
- **Ollama:** http://192.168.86.137:11434
|
||||
- **Ollama (old):** http://192.168.86.137:11434 (may be offline)
|
||||
- **Ollama (llama.cpp box):** http://192.168.86.40:11434
|
||||
- Models: qwen3:8b, qwen3:30b-a3b, glm-4.7-flash, nomic-embed-text
|
||||
- **Feed Hunter Portal:** localhost:8888 (systemd: feed-hunter-portal)
|
||||
- **Control Panel:** localhost:8000 (systemd: case-control-panel)
|
||||
|
||||
1
USER.md
1
USER.md
@ -7,6 +7,7 @@
|
||||
- **Pronouns:** *(tbd)*
|
||||
- **Timezone:** America/Chicago (CST)
|
||||
- **Notes:** Got the webchat and Telegram working. Practical, gets things done.
|
||||
- **Girlfriend:** Meg (the boss)
|
||||
- **Cats:** 4 tuxedos
|
||||
|
||||
## Context
|
||||
|
||||
69
data/investigations/polymarket-15min-arb.md
Normal file
69
data/investigations/polymarket-15min-arb.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Polymarket 15-Min Crypto Arbitrage
|
||||
|
||||
**Source:** https://x.com/noisyb0y1/status/2020942208858456206
|
||||
**Date investigated:** 2026-02-09
|
||||
**Verdict:** Legitimate edge, inflated claims
|
||||
|
||||
## Strategy
|
||||
- Buy BOTH sides (Up + Down) on 15-minute BTC/ETH/SOL/XRP markets
|
||||
- When combined cost < $1.00, guaranteed profit regardless of outcome
|
||||
- Edge exists because these markets are low liquidity / inefficient pricing
|
||||
|
||||
## Reference Wallet
|
||||
- `0xE594336603F4fB5d3ba4125a67021ab3B4347052`
|
||||
- Real PnL on 2026-02-09: ~$9K on $82K deployed (11% daily)
|
||||
- Combined costs ranged from $0.70 (great arb) to $1.10 (not arb)
|
||||
- Best arbs: ETH markets at $0.70-0.73 combined cost
|
||||
|
||||
## Why It Works
|
||||
- 15-min markets have thin books — prices diverge from fair value
|
||||
- Binary outcome means Up + Down must sum to $1.00 at resolution
|
||||
- If you buy both for < $1.00 total, guaranteed profit
|
||||
|
||||
## Challenges
|
||||
- Needs significant capital ($50K+) to make meaningful returns
|
||||
- Fill quality degrades at scale — slippage kills the edge
|
||||
- Competition from other bots narrows the window
|
||||
- Not all markets have arb — some combined costs > $1.00
|
||||
|
||||
## Revisit When
|
||||
- [ ] We have capital to deploy
|
||||
- [ ] Built a bot to scan for combined < $1.00 opportunities in real-time
|
||||
- [ ] Polymarket adds more 15-min markets (more opportunities)
|
||||
|
||||
## Related
|
||||
- Tweet author promoting "Clawdbots" — bot product shill
|
||||
- "$99K in a day" / "$340K total" claims are inflated (real: $9K profit)
|
||||
|
||||
---
|
||||
|
||||
# Elon Tweet Count Strategy
|
||||
|
||||
**Source:** @browomo tweet Feb 9 / wallet @Annica on Polymarket
|
||||
**Wallet:** `0x689ae...2779e`
|
||||
**Actual PnL:** $520,469 | Volume: $51.8M | Rank #193
|
||||
**Verdict:** Legit strategy, "insider" framing is BS
|
||||
|
||||
## How It Works
|
||||
- Polymarket has weekly markets: "How many tweets will Elon post Feb 2-4?"
|
||||
- Ranges like 90-114, 115-139, 140-164, etc.
|
||||
- You DON'T need insider access — just count his tweets mid-period
|
||||
- As the window closes, you can estimate the final count with high confidence
|
||||
- Buy the correct range when it's still cheap, collect $1 payout
|
||||
|
||||
## Why It Works
|
||||
- Most bettors place bets early (before data exists)
|
||||
- Late bettors with real-time tweet counts have an information edge
|
||||
- Similar to the weather METAR concept but this one actually works
|
||||
- $520K PnL proves sustained profitability
|
||||
|
||||
## Challenges
|
||||
- Markets may get more efficient as more people do this
|
||||
- Need to monitor Elon's posting in real-time
|
||||
- Liquidity might be thin on specific ranges
|
||||
- Could automate: scrape X API for Elon's tweet count, compare to market prices
|
||||
|
||||
## Revisit When
|
||||
- [ ] Build automated Elon tweet counter
|
||||
- [ ] Monitor market prices vs actual count for edge sizing
|
||||
- [ ] Check if other "count" markets exist (posts, mentions, etc.)
|
||||
@ -1,62 +1,109 @@
|
||||
# 2026-02-09
|
||||
# 2026-02-09 — Monday
|
||||
|
||||
## Market Watch Launch Day
|
||||
- GARP paper trading simulator went live
|
||||
- 9 AM systemd timer fired but crashed (scanner returns list, code expected dict) — fixed
|
||||
- Manual run at 10:49 AM — **7 positions opened**:
|
||||
- DUOL (57 shares @ $116.35)
|
||||
- ALLY (156 shares @ $42.65)
|
||||
- JHG (138 shares @ $48.21)
|
||||
- INCY (61 shares @ $108.69)
|
||||
- PINS (332 shares @ $20.06)
|
||||
- EXEL (152 shares @ $43.80)
|
||||
- CART (187 shares @ $35.49)
|
||||
- ~$46.5K deployed, ~$53.5K cash remaining
|
||||
- Portal live at marketwatch.local:8889
|
||||
- Multiplayer game engine working — "GARP Challenge" game created
|
||||
## X Feed Analysis Day
|
||||
Major session analyzing Polymarket/crypto tweets D J forwarded from X feed.
|
||||
|
||||
## Super Bowl Results (from last night)
|
||||
- Seahawks 36, Patriots 13
|
||||
- kch123 copy-trade sim: ALL 5 positions won, +$728 on $1K bankroll (+72.8%)
|
||||
- kch123 himself probably cleared ~$2M profit on this game
|
||||
- Two weeks straight of wins for kch123 (60-0 last week + Super Bowl sweep)
|
||||
### Tweets Investigated
|
||||
1. **@browomo weather edge** — Pilot METAR data for Polymarket weather bets
|
||||
- Wallet `0x594edB9112f...`: Claimed +$27K, actual **-$13,103** (387 losses, 51 wins)
|
||||
- Verdict: SCAM/engagement bait
|
||||
|
||||
## Craigslist Account (from yesterday)
|
||||
- Registered: case-lgn@protonmail.com, Nashville area
|
||||
- Password set, credentials saved to .credentials/craigslist.env
|
||||
- User ID: 405642144
|
||||
- D J not ready for listings yet (needs photos)
|
||||
2. **@ArchiveExplorer planktonXD** — "Buy everything under 5 cents"
|
||||
- Wallet `0x4ffe49ba2a4c...`: Claimed +$104K, actual **-$9,517** (3090 losses, 1368 wins, 37% win rate)
|
||||
- Verdict: SCAM/engagement bait
|
||||
|
||||
## D J Interests
|
||||
- Looking at queen Murphy beds on Craigslist
|
||||
- Wants to get rid of an old mattress (options discussed: Metro Nashville bulky pickup, free CL listing, dump)
|
||||
- Interested in Olympics men's hockey (USA in Group C, games start Feb 11)
|
||||
- Expanding analysis beyond Polymarket into crypto and stocks
|
||||
- Goal: find market gaps to offset AI service costs ($200/mo Claude + infra)
|
||||
- Getting crypto signals on Telegram, wants to forward them for analysis
|
||||
- Asked about Tiiny AI (kickstarter AI hardware, 80GB unified memory, NPU) as potential Claude replacement
|
||||
3. **@krajekis BTC 15-min LONG** — "+700% monthly, 1 trade/day at 9AM"
|
||||
- Backtested 25 days: 52% win rate (coin flip), strategy loses 76% of capital
|
||||
- Verdict: FABRICATED results
|
||||
|
||||
## Stock Screener Built
|
||||
- GARP filters automated via yfinance (free, no API key)
|
||||
- S&P 500 + S&P 400 MidCap scan (~902 tickers)
|
||||
- Initial S&P 500 scan found 4: BAC, CFG, FITB, INCY
|
||||
- Expanded scan found 22 total candidates
|
||||
- Top non-bank picks: PINS, FSLR, PGR, NEM, ALLY
|
||||
- Deep dive sub-agent ran on all 4 original picks
|
||||
4. **@noisyb0y1 15-min arb** — "$99K in a day"
|
||||
- Wallet `0xE594336603F4...`: Real strategy (buying both sides), actual PnL ~$9K not $99K
|
||||
- Combined costs $0.70-$0.95 on some markets = genuine arb edge
|
||||
- Verdict: INFLATED numbers, but strategy has merit → bookmarked for scanner
|
||||
|
||||
## X Post Analysis: @Shelpid_WI3M / anoin123
|
||||
- Wallet 0x96489abc... is real, +$1.59M PnL, 216 trades
|
||||
- BUT: concentrated single-thesis bet (NO on Iran strikes), not diversified alpha
|
||||
- Post is a shill for PolyCopyBot (Telegram copy-trading bot)
|
||||
- Verdict: real wallet, misleading narrative, exists to sell bot subscriptions
|
||||
5. **5 more wallets** — spawned sub-agent to research @IH2P, Super Bowl trader, NegRisk arb, Elon insider, $270→$244K bot
|
||||
|
||||
## Tiiny AI Analysis
|
||||
- 80GB LPDDR5X, ARM + NPU (160 TOPS), 18-40 tok/s, 30W
|
||||
- Kickstarter vaporware — doesn't exist yet
|
||||
- Would blow away D J's current 22GB VRAM setup IF it ships
|
||||
- Recommended waiting for real reviews, not pre-ordering
|
||||
### Pattern Identified
|
||||
- Fintwit Polymarket accounts follow identical template: big $ claim → wallet → product shill
|
||||
- Products being shilled: Clawdbots, Moltbook, Bullpen (affiliate/paid promos)
|
||||
- Real money is in selling engagement, not trading
|
||||
- Wallets are cherry-picked; PnL claims conflate position value with profit
|
||||
|
||||
## Lessons
|
||||
- Scanner run_scan() returns list, not dict — caused systemd crash on first real run
|
||||
- Always test the full pipeline end-to-end before relying on timers
|
||||
- yfinance is reliable and free for fundamental data, no API key needed
|
||||
### Built Today
|
||||
- **Crypto signals pipeline** (`projects/crypto-signals/`)
|
||||
- Signal parser for Telegram JSON exports
|
||||
- Price fetcher (Binance US API)
|
||||
- Backtester with Polymarket fee awareness
|
||||
- Ready for D J's Telegram group export
|
||||
|
||||
- **Polymarket 15-min arb scanner** — systemd timer every 2 min
|
||||
- Scans active Up or Down markets
|
||||
- Checks orderbooks for combined < $1.00
|
||||
- Paper trades and Telegram alerts
|
||||
- Finding: markets are tight at steady state, arb windows likely during volatility
|
||||
|
||||
- **Playwright installed** — replaced flaky CDP websocket approach
|
||||
- `connect_over_cdp('http://localhost:9222')` works with existing Chrome session
|
||||
- X feed scraper working via Playwright
|
||||
|
||||
- **PowerInfer multi-GPU cron** — weekly Monday check for feature support
|
||||
|
||||
### Day-of-Week Stock Strategy
|
||||
- D J's friend suggested buy Friday/Monday, sell midweek
|
||||
- Backtested 5 years of SPY: Monday is strongest day (+0.114%, 62% win rate)
|
||||
- Buy Mon Open → Sell Wed Close: 56% win rate, +0.265% avg, $10K→$17.8K
|
||||
- But buy & hold still wins: $10K→$19K
|
||||
- Verdict: weak edge, needs additional filters to be useful
|
||||
|
||||
### Infrastructure
|
||||
- Polymarket fee structure documented: 15-min markets have taker fees, max 1.56% at 50/50
|
||||
- Fee formula: `shares × price × 0.25 × (price × (1-price))²`
|
||||
- Binance international is geo-blocked; Binance US works
|
||||
|
||||
### Pending
|
||||
- D J sending Telegram crypto signal group export (Option 2: JSON export)
|
||||
- Signal provider uses VWAP-based strategy
|
||||
- Sub-agent researching 5 more wallets
|
||||
|
||||
## Evening Session — KIPP + Leverage Trading
|
||||
|
||||
### Crypto Leverage Trading
|
||||
- Built **short signal scanner** (`scripts/short_scanner.py`) — RSI/VWAP/MACD/Bollinger scoring for 29 coins
|
||||
- Built **leverage game engine** (`leverage_game.py`) — $10K, 20x max leverage, longs/shorts/liquidations
|
||||
- Built **auto-trader** (`scripts/leverage_trader.py`) — connects scanners to game, TP/SL/trailing stops
|
||||
- "Leverage Challenge" game initialized, player "case"
|
||||
- Systemd timer every 15min
|
||||
- First scan: market quiet, no strong signals (highest short score 40 on FIL)
|
||||
|
||||
### KIPP Setup
|
||||
- KIPP VM: 192.168.86.100 (Proxmox CT, 8 cores, 8GB RAM, Ubuntu 24.04)
|
||||
- Installed Node.js 22, OpenClaw 2026.2.9
|
||||
- Set up SSH key auth from Case's VM
|
||||
- Created KIPP identity: SOUL.md, AGENTS.md, MEMORY.md, USER.md
|
||||
- Initially tried GLM-4.7 Flash via llama.cpp — had issues, switched to Claude
|
||||
- Configured Telegram bot: @dzclaw_kipp_bot (token: 8535439246:AAE...)
|
||||
- Webchat accessible via https://kipp.host.letsgetnashty.com/
|
||||
- Gateway token: kipp-local-token-2026
|
||||
- Proxmox firewall was blocking LAN access — D J disabled it
|
||||
- Needed trustedProxies, allowedOrigins, dangerouslyDisableDeviceAuth for reverse proxy
|
||||
- D J ran `openclaw configure` to set up Anthropic auth
|
||||
- KIPP is LIVE on Claude + Telegram
|
||||
- Name confirmed: KIPP (not Kip)
|
||||
|
||||
### KIPP Local LLM Attempt (Late Session)
|
||||
- Goal: Switch KIPP from Claude to local GLM-4 Flash 30B (q4) on llama.cpp at 192.168.86.40:8080/v1
|
||||
- llama.cpp server confirmed running and responding
|
||||
- Multiple openclaw.json config attempts failed — schema validation errors
|
||||
- Key schema findings for `openclaw.json`:
|
||||
- `gateway.bind`: must be enum (auto/lan/loopback/custom/tailnet)
|
||||
- `agents.defaults.model`: must be `{ primary: string, fallbacks?: string[] }`
|
||||
- `models.providers`: array of `{ baseUrl, apiKey, api, models[] }` for custom endpoints
|
||||
- `auth.profiles`: only allows provider/mode/email — NOT baseURL/apiKey
|
||||
- Gateway service installed but crashing on config validation
|
||||
- **Status: BLOCKED** — need to apply correct config schema, was mid-fix when session compacted
|
||||
|
||||
### Infrastructure
|
||||
- SSH keys generated on Case's VM, copied to KIPP VM
|
||||
- `loginctl enable-linger` set for boot persistence
|
||||
- sshpass installed on Case's VM
|
||||
- llama.cpp server: 192.168.86.40:8080/v1 (GLM-4 Flash 30B q4)
|
||||
|
||||
123
memory/2026-02-10.md
Normal file
123
memory/2026-02-10.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 2026-02-10 — Monday Night / Tuesday Morning
|
||||
|
||||
## KIPP Build Session
|
||||
Major KIPP infrastructure session with D J.
|
||||
|
||||
### Completed
|
||||
- **KIPP Gitea account** — user: kipp, repo: git.letsgetnashty.com/kipp/workspace (private)
|
||||
- **KIPP switched to local LLM** — llamacpp/glm-4.7-flash as primary, Claude Sonnet as fallback
|
||||
- **KIPP ChromaDB memory** — collection `kipp-memory` (ccf4f5b6-a64e-45b1-bf1b-7013e15c3363), seeded 9 docs
|
||||
- **Ollama URL updated** — 192.168.86.40:11434 (same machine as llama.cpp, not the old 192.168.86.137)
|
||||
- **KIPP model config tuned** — maxTokens 2048, contextWindow 32768, auto-recall 1 result
|
||||
- **KIPP web UI v1** — thin client at https://kippui.host.letsgetnashty.com/
|
||||
- WebSocket JSON-RPC protocol v3 working (connect handshake, chat.send, streaming)
|
||||
- Weather widget, grocery list, timers, quick actions, mic button
|
||||
- Served from kipp-ui.service on port 8080
|
||||
- Gateway at kipp.host.letsgetnashty.com (port 18789)
|
||||
- CORS handled in Caddy config
|
||||
- **Meg added to USER.md** — "the boss"
|
||||
- **@RohOnChain tweet analyzed** — Kelly Criterion / gabagool22 ($788K PnL), mostly legit math but misleading framing
|
||||
|
||||
### Key Findings
|
||||
- GLM-4 Flash is a thinking model — burns ~200 tokens reasoning before responding
|
||||
- 32K context split across 4 slots = 8K effective per conversation
|
||||
- D J has 30GB free RAM on llama.cpp server — could bump to 128K context
|
||||
- OpenClaw WebSocket protocol: JSON-RPC v3, needs connect with client{id,version,platform,mode}, auth{token}, minProtocol:3
|
||||
- Event types: `agent` (stream:assistant for deltas, stream:lifecycle for start/end), `chat` (state:delta/final)
|
||||
- Caddy reverse proxy handles both services: kippui→8080, kipp→18789
|
||||
- `dangerouslyDisableDeviceAuth: true` still requires auth token in connect params
|
||||
|
||||
### KIPP Infrastructure
|
||||
- **VM:** 192.168.86.100 (wdjones user, SSH key from Case)
|
||||
- **llama.cpp:** 192.168.86.40:8080 (GLM-4 Flash 30B q4, 2 GPUs 12GB+10GB, 32GB RAM)
|
||||
- **Ollama:** 192.168.86.40:11434 (nomic-embed-text for embeddings)
|
||||
- **ChromaDB:** 192.168.86.25:8000 (kipp-memory collection)
|
||||
- **Gitea:** kipp:K1pp-H0me-2026! @ git.letsgetnashty.com/kipp/workspace
|
||||
- **Telegram:** @dzclaw_kipp_bot
|
||||
- **Web UI:** https://kippui.host.letsgetnashty.com/ (port 8080)
|
||||
- **Gateway:** https://kipp.host.letsgetnashty.com/ (port 18789)
|
||||
- **Token:** kipp-local-token-2026
|
||||
|
||||
### Evening Session (D J back from work)
|
||||
- **KIPP UI v2 chat confirmed working** — D J tested on Mac, chat end-to-end functional at 18:34
|
||||
- **Voice input added** — mic button in chat overlay triggers browser Speech Recognition, auto-sends on final transcript
|
||||
- **Piper TTS installed** on KIPP VM — `pip3 install piper-tts`, Amy medium voice model downloaded
|
||||
- **TTS API server** built (port 8081, systemd kipp-tts.service) + proxy through UI server on port 8080
|
||||
- **Piper voice quality confirmed** — sent D J audio sample via Telegram, not robotic
|
||||
- **Caddy WebSocket issue** — persistent 503 on WSS through Caddy reverse proxy
|
||||
- Direct WS to 192.168.86.100:18789 works perfectly every time
|
||||
- Caddy returns 503 on WebSocket upgrade specifically
|
||||
- Fixed gateway: added `192.168.86.0/24` to trustedProxies, added `http://192.168.86.100:8080` to allowedOrigins
|
||||
- Gateway logs showed "origin not allowed" and "proxy headers from untrusted address"
|
||||
- Even after gateway fix, Caddy still 503s — Caddy itself is the bottleneck
|
||||
- Need to check Caddy logs on whatever machine it runs on (192.168.86.1?)
|
||||
- D J's Caddyfile is clean: just `reverse_proxy 192.168.86.100:18789`
|
||||
- Chat works intermittently — connects sometimes then drops
|
||||
- **Caddy config** (D J's full Caddyfile): media→86.50:8096, vault→86.244:7080, git→86.244:3002, share→86.26:3000, kippui→86.100:8080, kipp→86.100:18789
|
||||
- **@Argona0x tweet analyzed** — $50→$2,980 Polymarket bot claim, 90% likely fake (engagement farming)
|
||||
- **Kelly Criterion explained** to D J
|
||||
|
||||
### Night Shift Work
|
||||
- **Alexa+ UI research completed** — sub-agent produced comprehensive 200-line report at projects/kipp/research/alexa-plus-ui.md
|
||||
- Key patterns: dashboard-first, ephemeral conversation overlay, widget grid, proximity-aware, ambient mode
|
||||
- Verge/CNET screenshots downloaded for reference
|
||||
- **KIPP UI v2 built and deployed** — Alexa-inspired dashboard:
|
||||
- Hero card with greeting + status, weather with hourly forecast, shopping list, today/calendar, timers
|
||||
- Chat overlay appears on mic button press, dashboard blurs behind it
|
||||
- Dark theme, card-based, bottom bar with mic button
|
||||
- WebSocket protocol fully working (connect, chat.send, streaming)
|
||||
- **Systems check:**
|
||||
- Leverage trader running: $9,987.40 (-0.13% from $10K), HYPE trade closed at -$15.63
|
||||
- GARP portfolio: $100,055.90 (+0.06% from $100K), 6 positions
|
||||
- All systemd timers healthy
|
||||
- **Future UI improvements identified:**
|
||||
- Ambient/photo mode when idle (slideshow + big clock)
|
||||
- Blue waveform animation during listening state
|
||||
- Results persistence (timer created in chat → appears on dashboard)
|
||||
- Proximity-aware layout (different for close vs far viewing)
|
||||
|
||||
### KIPP HTTPS + Voice Fixed
|
||||
- Self-signed cert generated (10yr, SAN for 192.168.86.100)
|
||||
- UI server switched to HTTPS on port 8080
|
||||
- socat WSS proxy on port 18790 → gateway 18789 (systemd kipp-wss-proxy.service)
|
||||
- Browser TTS fallback removed — Piper only
|
||||
- Double-voice mystery: D J had 2 tabs open 😂
|
||||
- Gateway config fixed: `allowedOrigins` was at root level (invalid), moved to `gateway.controlUi.allowedOrigins`
|
||||
- Added `https://192.168.86.100:8080` to allowedOrigins
|
||||
|
||||
### @milesdeutscher Tweet Analysis
|
||||
- Polymarket copy-trading GitHub bot going viral (23K views)
|
||||
- Our take: validates our kch123 approach but we're ahead — we have whale selection, they just have execution code
|
||||
- Edge erodes with adoption; not actionable for us
|
||||
|
||||
### Night Shift — Ambient Mode
|
||||
- Built ambient/idle mode for KIPP UI (sub-agent)
|
||||
- Activates after 60s idle: large glowing clock, weather icon, dark gradient background
|
||||
- Rotating content: tuxedo cat facts, quotes, trivia (every 30s)
|
||||
- Tap anywhere to return to dashboard
|
||||
- Verified with Playwright: both ambient and dashboard modes render correctly
|
||||
|
||||
### Late Night — KIPP Local-Only Switch
|
||||
- **D J decided KIPP stays local-only** — no external exposure, direct IP access
|
||||
- Switched UI WebSocket URL from `wss://kipp.host.letsgetnashty.com` to `ws://192.168.86.100:18789`
|
||||
- UI renders visually at `http://192.168.86.100:8080/` (Playwright confirmed: green dot, weather, clock)
|
||||
- **But WS still broken**: origin-allowed errors persist, old domain URLs not fully stripped from JS fallback/retry code
|
||||
- Hundreds of failed reconnect attempts every ~3s in console logs
|
||||
- TTS and weather fetch endpoints still referencing old HTTPS domain paths
|
||||
- **Next**: fully clean UI JS code of all old domain refs, fix origin-allowed, re-test with Playwright
|
||||
- **Network issue on Case's VM** (192.168.86.45): persistent "TypeError: fetch failed" every ~10s — Telegram polling, ChromaDB auto-recall broken. D J communicating via webchat as workaround.
|
||||
|
||||
### Caddy Config (D J's reverse proxy)
|
||||
```
|
||||
kippui.host.letsgetnashty.com {
|
||||
reverse_proxy 192.168.86.100:8080
|
||||
}
|
||||
kipp.host.letsgetnashty.com {
|
||||
header Access-Control-Allow-Origin "https://kippui.host.letsgetnashty.com"
|
||||
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
header Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
@options method OPTIONS
|
||||
handle @options { respond 204 }
|
||||
reverse_proxy 192.168.86.100:18789
|
||||
}
|
||||
```
|
||||
61
memory/2026-02-11.md
Normal file
61
memory/2026-02-11.md
Normal file
@ -0,0 +1,61 @@
|
||||
# 2026-02-11
|
||||
|
||||
## KIPP Voice Pipeline — Major Build Session
|
||||
|
||||
### Built & Deployed (feature/wake-word branch)
|
||||
- **Always-on wake word detection** via OpenWakeWord (hey_jarvis model as placeholder)
|
||||
- **Faster Whisper** (base.en) for speech-to-text on KIPP VM
|
||||
- **Voice WebSocket server** on port 8082 (TLS) — `kipp-voice.service`
|
||||
- **Python venv** at `/home/wdjones/kipp-venv` with openwakeword, faster-whisper, websockets, aiohttp
|
||||
- **Male TTS voice** — switched from Amy to Ryan (Piper en_US)
|
||||
- **Hero panel chat** — voice interaction happens inside the greeting/hero card, not a separate overlay
|
||||
- **Widget state system** — JSON file + CLI tool + REST API + dashboard polling
|
||||
- `tools/widgets.py` for shopping list, timers, reminders
|
||||
- API endpoints on UI server: GET/POST /api/widgets
|
||||
- Dashboard loads real data, polls every 10s
|
||||
- KIPP agent instructed in SOUL.md to use widget CLI
|
||||
|
||||
### Key Bugs Fixed
|
||||
1. **CSS injected inside JS** — patch script found `/* CHAT OVERLAY */` in both CSS and JS sections
|
||||
2. **Gateway challenge-response** — must answer `connect.challenge` with `req` method `connect`
|
||||
3. **Client ID must be `openclaw-control-ui`** — gateway validates this
|
||||
4. **Origin header required** — voice server needs `Origin: https://192.168.86.100:8080`
|
||||
5. **Lifecycle event detection** — gateway sends `phase="end"` not `state="end"` — THIS was the 60-second hang bug
|
||||
6. **Audio suppressed during wake state** — browser stopped sending mic data when it should have been recording
|
||||
7. **Race condition** — server sent `ready` before TTS finished, mic picked up speaker audio
|
||||
8. **Self-triggering wake word** — KIPP's own TTS voice triggered "hey jarvis" — fixed with 2s cooldown
|
||||
9. **voiceState stuck on speaking** — client must set listening before server's ready msg arrives
|
||||
10. **Duplicate JS blocks** — sub-agent inserted widget code twice
|
||||
|
||||
### Voice State Machine (final)
|
||||
```
|
||||
listening → (wake word) → recording → (silence) → processing → (gateway) → speaking → (done_speaking) → cooldown (2s) → listening
|
||||
```
|
||||
|
||||
### Timing Config
|
||||
- 4s grace period after wake word before silence timeout
|
||||
- 1.5s silence after speech to end recording
|
||||
- 30s max recording time
|
||||
- 2s cooldown after TTS to prevent self-trigger
|
||||
|
||||
### KIPP Model Switch
|
||||
- Switched from `llamacpp/glm-4.7-flash` (83s responses!) to `anthropic/claude-sonnet-4-20250514` (~3s responses)
|
||||
- GLM-4 Flash as fallback
|
||||
- Config at `/home/wdjones/.openclaw/openclaw.json` on KIPP VM
|
||||
|
||||
### 15 Playwright Tests
|
||||
- `kipp-ui/tests/test_voice.py` — UI elements, state transitions, chat flow, server connectivity
|
||||
|
||||
## anoin123 Investigation
|
||||
- @browomo tweet about anoin123 Polymarket wallet: $1.6M in 57 days
|
||||
- **2-4 AM EST claim is FALSE** — trades peak at 3 PM EST
|
||||
- Strategy: "No harvester" — buys No at 90-99¢ on time-bounded events, collects spread
|
||||
- $2.2M volume, $7K avg trade, concentrated on Iran strikes + government shutdown
|
||||
- Monitor set up: `anoin123-monitor.py` + systemd timer every 5min
|
||||
- Analysis at `data/investigations/anoin123-analysis.md`
|
||||
- Copy-trade verdict: medium value — strategy is mechanical and replicable independently
|
||||
|
||||
## Infrastructure Notes
|
||||
- KIPP VM services: kipp-ui, kipp-voice, kipp-tts, kipp-wss-proxy, openclaw-gateway
|
||||
- Widget data: `/home/wdjones/.openclaw/workspace/kipp-ui/data/widgets.json`
|
||||
- All changes on `feature/wake-word` branch in kipp/workspace repo
|
||||
14
memory/2026-02-12.md
Normal file
14
memory/2026-02-12.md
Normal file
@ -0,0 +1,14 @@
|
||||
# 2026-02-12
|
||||
|
||||
## KIPP Voice Pipeline Fixes
|
||||
- False wake word triggers after KIPP speaks — wake model picking up speaker audio
|
||||
- Patch 1: Increased cooldown 2s → 4s, added silence flushing during cooldown (feed zeros through wake model to clear internal buffers), added RMS energy gate on wake detection
|
||||
- RMS gate of 200 was too aggressive — blocked ALL real wake attempts (real RMS was 45-157)
|
||||
- Lowered RMS gate to 30 — just filters literal silence false positives
|
||||
- Voice server restarted, D J testing
|
||||
|
||||
## Tweet Analyses
|
||||
- **@jollygreenmoney / $SHL.V** — Homeland Nickel (TSX-V penny stock). Promoted junior miner, already ran 2,300% from $0.03→$0.72, pulled back to $0.41. Collapsing volume = distribution phase. Coordinated promotion with @Levi_Researcher. Nickel bull thesis has merit but this specific stock is exit liquidity. Verdict: stay away.
|
||||
- **@MoonDevOnYT** — "Fastest growing quant repo on GitHub" AI trading agents. 875 followers, self-proclaimed "#1 quant on X." Content marketing funnel → paid private streams at moondev.com. No verifiable P&L, buzzword soup, fantasy architecture. Verdict: course seller, skip.
|
||||
|
||||
## D J signed off ~midnight
|
||||
333861
projects/crypto-signals/data/arb-scanner/scan_log.json
Normal file
333861
projects/crypto-signals/data/arb-scanner/scan_log.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
||||
{
|
||||
"game_id": "1ac7d29c",
|
||||
"name": "Leverage Challenge",
|
||||
"starting_cash": 10000.0,
|
||||
"max_leverage": 20,
|
||||
"funding_rate_8h": 0.01,
|
||||
"maker_fee": 0.02,
|
||||
"taker_fee": 0.05,
|
||||
"start_date": "2026-02-09",
|
||||
"creator": "case",
|
||||
"created_at": "2026-02-10T02:31:27.614107+00:00",
|
||||
"players": [
|
||||
"case"
|
||||
],
|
||||
"status": "active"
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
{
|
||||
"cash": 9059.675453916036,
|
||||
"positions": {
|
||||
"SEI_long_1c69": {
|
||||
"symbol": "SEI",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.0745,
|
||||
"current_price": 0.0743,
|
||||
"liquidation_price": 0.0639,
|
||||
"unrealized_pnl": -3.76,
|
||||
"entry_fee": 0.7,
|
||||
"opened_at": "2026-02-10T12:45:18.117079+00:00",
|
||||
"reason": "Long scanner score:45"
|
||||
},
|
||||
"PUMP_long_4a28": {
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.001909,
|
||||
"current_price": 0.001921,
|
||||
"liquidation_price": 0.0016,
|
||||
"unrealized_pnl": 8.8,
|
||||
"entry_fee": 0.7,
|
||||
"opened_at": "2026-02-10T19:30:18.313053+00:00",
|
||||
"reason": "Long scanner score:58"
|
||||
},
|
||||
"TRUMP_long_fbd7": {
|
||||
"symbol": "TRUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 3.266,
|
||||
"current_price": 3.266,
|
||||
"liquidation_price": 2.7994,
|
||||
"unrealized_pnl": 0.0,
|
||||
"entry_fee": 0.7,
|
||||
"opened_at": "2026-02-10T19:45:18.100831+00:00",
|
||||
"reason": "Long scanner score:58"
|
||||
},
|
||||
"OP_long_4ea9": {
|
||||
"symbol": "OP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.183,
|
||||
"current_price": 0.183,
|
||||
"liquidation_price": 0.1569,
|
||||
"unrealized_pnl": 0.0,
|
||||
"entry_fee": 0.7,
|
||||
"opened_at": "2026-02-10T20:15:18.266153+00:00",
|
||||
"reason": "Long scanner score:45"
|
||||
},
|
||||
"ICP_short_9a0a": {
|
||||
"symbol": "ICP",
|
||||
"direction": "short",
|
||||
"leverage": 10,
|
||||
"margin_usd": 200,
|
||||
"notional": 2000,
|
||||
"entry_price": 2.907,
|
||||
"current_price": 2.907,
|
||||
"liquidation_price": 3.1977,
|
||||
"unrealized_pnl": 0.0,
|
||||
"entry_fee": 1.0,
|
||||
"opened_at": "2026-02-10T23:00:14.402076+00:00",
|
||||
"reason": "Short scanner score:65"
|
||||
},
|
||||
"HYPE_long_d46c": {
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.05,
|
||||
"current_price": 29.05,
|
||||
"liquidation_price": 24.9,
|
||||
"unrealized_pnl": 0,
|
||||
"entry_fee": 0.7,
|
||||
"opened_at": "2026-02-11T00:45:18.584851+00:00",
|
||||
"reason": "Long scanner score:45"
|
||||
}
|
||||
},
|
||||
"total_realized_pnl": 315.17545391605773,
|
||||
"total_fees_paid": 55.50000000000006,
|
||||
"total_funding_paid": 0
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -0,0 +1,956 @@
|
||||
[
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "PUMP_long_915b",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.001989,
|
||||
"liquidation_price": 0.0017,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:48",
|
||||
"timestamp": "2026-02-10T03:15:17.681597+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "PUMP_long_915b",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.001989,
|
||||
"exit_price": 0.00197,
|
||||
"margin_usd": 200,
|
||||
"pnl": -13.37,
|
||||
"pnl_pct": -6.69,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-6.7%)",
|
||||
"timestamp": "2026-02-10T03:30:04.448216+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "PUMP_long_8004",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.00197,
|
||||
"liquidation_price": 0.0017,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:53",
|
||||
"timestamp": "2026-02-10T03:30:17.846691+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "PUMP_long_8004",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.00197,
|
||||
"exit_price": 0.001999,
|
||||
"margin_usd": 200,
|
||||
"pnl": 20.61,
|
||||
"pnl_pct": 10.3,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+10.3%)",
|
||||
"timestamp": "2026-02-10T03:45:04.447060+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_3ce0",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 30.45,
|
||||
"liquidation_price": 26.1,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:50",
|
||||
"timestamp": "2026-02-10T05:45:17.773139+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_3ce0",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 30.45,
|
||||
"exit_price": 30.11,
|
||||
"margin_usd": 200,
|
||||
"pnl": -15.63,
|
||||
"pnl_pct": -7.82,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-7.8%)",
|
||||
"timestamp": "2026-02-10T06:30:04.465160+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_9513",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 30.11,
|
||||
"liquidation_price": 25.8086,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:55",
|
||||
"timestamp": "2026-02-10T06:30:17.909814+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_9513",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 30.11,
|
||||
"exit_price": 29.98,
|
||||
"margin_usd": 200,
|
||||
"pnl": -6.04,
|
||||
"pnl_pct": -3.02,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-3.0%)",
|
||||
"timestamp": "2026-02-10T08:00:04.447966+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "FIL_short_4625",
|
||||
"symbol": "FIL",
|
||||
"direction": "short",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.954,
|
||||
"liquidation_price": 1.0903,
|
||||
"fee": 0.7,
|
||||
"reason": "Short scanner score:50",
|
||||
"timestamp": "2026-02-10T08:00:17.712210+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_d6e2",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.98,
|
||||
"liquidation_price": 25.6971,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:50",
|
||||
"timestamp": "2026-02-10T08:00:17.956985+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_4761",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"margin_usd": 200,
|
||||
"notional": 3000,
|
||||
"entry_price": 0.651,
|
||||
"liquidation_price": 0.6944,
|
||||
"fee": 1.5,
|
||||
"reason": "Short scanner score:80",
|
||||
"timestamp": "2026-02-10T09:00:17.907206+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_4761",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"entry_price": 0.651,
|
||||
"exit_price": 0.657,
|
||||
"margin_usd": 200,
|
||||
"pnl": -27.65,
|
||||
"pnl_pct": -13.82,
|
||||
"fee": 1.5,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-13.8%)",
|
||||
"timestamp": "2026-02-10T09:15:04.758739+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_3947",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"margin_usd": 200,
|
||||
"notional": 3000,
|
||||
"entry_price": 0.657,
|
||||
"liquidation_price": 0.7008,
|
||||
"fee": 1.5,
|
||||
"reason": "Short scanner score:85",
|
||||
"timestamp": "2026-02-10T09:15:17.879130+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_3947",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"entry_price": 0.657,
|
||||
"exit_price": 0.651,
|
||||
"margin_usd": 200,
|
||||
"pnl": 27.4,
|
||||
"pnl_pct": 13.7,
|
||||
"fee": 1.5,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+13.7%)",
|
||||
"timestamp": "2026-02-10T09:30:04.763834+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_cf21",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"margin_usd": 200,
|
||||
"notional": 3000,
|
||||
"entry_price": 0.651,
|
||||
"liquidation_price": 0.6944,
|
||||
"fee": 1.5,
|
||||
"reason": "Short scanner score:80",
|
||||
"timestamp": "2026-02-10T09:30:18.015729+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "PUMP_long_3752",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.00199,
|
||||
"liquidation_price": 0.0017,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:48",
|
||||
"timestamp": "2026-02-10T09:30:18.259653+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_d6e2",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 29.98,
|
||||
"exit_price": 30.21,
|
||||
"margin_usd": 200,
|
||||
"pnl": 10.74,
|
||||
"pnl_pct": 5.37,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+5.4%)",
|
||||
"timestamp": "2026-02-10T09:45:04.903012+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_e1e2",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.96,
|
||||
"liquidation_price": 25.68,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:50",
|
||||
"timestamp": "2026-02-10T10:30:18.207447+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_cf21",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"entry_price": 0.651,
|
||||
"exit_price": 0.627,
|
||||
"margin_usd": 200,
|
||||
"pnl": 110.6,
|
||||
"pnl_pct": 55.3,
|
||||
"fee": 1.5,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+55.3%)",
|
||||
"timestamp": "2026-02-10T11:00:04.927667+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_e1e2",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 29.96,
|
||||
"exit_price": 29.54,
|
||||
"margin_usd": 200,
|
||||
"pnl": -19.63,
|
||||
"pnl_pct": -9.81,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-9.8%)",
|
||||
"timestamp": "2026-02-10T11:00:04.997470+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_e791",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 10,
|
||||
"margin_usd": 200,
|
||||
"notional": 2000,
|
||||
"entry_price": 29.54,
|
||||
"liquidation_price": 26.586,
|
||||
"fee": 1.0,
|
||||
"reason": "Long scanner score:60",
|
||||
"timestamp": "2026-02-10T11:00:18.295450+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "FIL_short_4625",
|
||||
"symbol": "FIL",
|
||||
"direction": "short",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.954,
|
||||
"exit_price": 0.93,
|
||||
"margin_usd": 200,
|
||||
"pnl": 35.22,
|
||||
"pnl_pct": 17.61,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+17.6%)",
|
||||
"timestamp": "2026-02-10T11:15:04.755055+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_e791",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 10,
|
||||
"entry_price": 29.54,
|
||||
"exit_price": 29.35,
|
||||
"margin_usd": 200,
|
||||
"pnl": -12.86,
|
||||
"pnl_pct": -6.43,
|
||||
"fee": 1.0,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-6.4%)",
|
||||
"timestamp": "2026-02-10T12:15:04.665481+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_cc18",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 10,
|
||||
"margin_usd": 200,
|
||||
"notional": 2000,
|
||||
"entry_price": 29.35,
|
||||
"liquidation_price": 26.415,
|
||||
"fee": 1.0,
|
||||
"reason": "Long scanner score:60",
|
||||
"timestamp": "2026-02-10T12:15:18.125949+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_cc18",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 10,
|
||||
"entry_price": 29.35,
|
||||
"exit_price": 29.67,
|
||||
"margin_usd": 200,
|
||||
"pnl": 21.81,
|
||||
"pnl_pct": 10.9,
|
||||
"fee": 1.0,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+10.9%)",
|
||||
"timestamp": "2026-02-10T12:30:04.619392+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_440c",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.67,
|
||||
"liquidation_price": 25.4314,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:50",
|
||||
"timestamp": "2026-02-10T12:30:18.005813+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "SEI_long_1c69",
|
||||
"symbol": "SEI",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.0745,
|
||||
"liquidation_price": 0.0639,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:45",
|
||||
"timestamp": "2026-02-10T12:45:18.117799+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_440c",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 29.67,
|
||||
"exit_price": 29.47,
|
||||
"margin_usd": 200,
|
||||
"pnl": -9.44,
|
||||
"pnl_pct": -4.72,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-4.7%)",
|
||||
"timestamp": "2026-02-10T13:00:04.766754+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_1e8d",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.651,
|
||||
"liquidation_price": 0.744,
|
||||
"fee": 0.7,
|
||||
"reason": "Short scanner score:55",
|
||||
"timestamp": "2026-02-10T13:00:18.109086+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_014f",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 10,
|
||||
"margin_usd": 200,
|
||||
"notional": 2000,
|
||||
"entry_price": 29.47,
|
||||
"liquidation_price": 26.523,
|
||||
"fee": 1.0,
|
||||
"reason": "Long scanner score:60",
|
||||
"timestamp": "2026-02-10T13:00:18.382345+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "PUMP_long_3752",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.00199,
|
||||
"exit_price": 0.00195,
|
||||
"margin_usd": 200,
|
||||
"pnl": -28.14,
|
||||
"pnl_pct": -14.07,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-14.1%)",
|
||||
"timestamp": "2026-02-10T13:15:04.932470+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "PUMP_long_c66e",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.00195,
|
||||
"liquidation_price": 0.0017,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:53",
|
||||
"timestamp": "2026-02-10T13:15:18.331814+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_1e8d",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.651,
|
||||
"exit_price": 0.657,
|
||||
"margin_usd": 200,
|
||||
"pnl": -12.9,
|
||||
"pnl_pct": -6.45,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-6.5%)",
|
||||
"timestamp": "2026-02-10T13:30:04.960908+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_014f",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 10,
|
||||
"entry_price": 29.47,
|
||||
"exit_price": 29.75,
|
||||
"margin_usd": 200,
|
||||
"pnl": 19.0,
|
||||
"pnl_pct": 9.5,
|
||||
"fee": 1.0,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+9.5%)",
|
||||
"timestamp": "2026-02-10T13:30:05.033055+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_be79",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"margin_usd": 200,
|
||||
"notional": 3000,
|
||||
"entry_price": 0.657,
|
||||
"liquidation_price": 0.7008,
|
||||
"fee": 1.5,
|
||||
"reason": "Short scanner score:70",
|
||||
"timestamp": "2026-02-10T13:30:18.431839+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_be79",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"entry_price": 0.657,
|
||||
"exit_price": 0.651,
|
||||
"margin_usd": 200,
|
||||
"pnl": 27.4,
|
||||
"pnl_pct": 13.7,
|
||||
"fee": 1.5,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+13.7%)",
|
||||
"timestamp": "2026-02-10T14:00:04.794774+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_26a4",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.651,
|
||||
"liquidation_price": 0.744,
|
||||
"fee": 0.7,
|
||||
"reason": "Short scanner score:50",
|
||||
"timestamp": "2026-02-10T14:00:18.152240+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_26a4",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.651,
|
||||
"exit_price": 0.655,
|
||||
"margin_usd": 200,
|
||||
"pnl": -8.6,
|
||||
"pnl_pct": -4.3,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-4.3%)",
|
||||
"timestamp": "2026-02-10T14:15:04.778561+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_0eec",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 10,
|
||||
"margin_usd": 200,
|
||||
"notional": 2000,
|
||||
"entry_price": 0.655,
|
||||
"liquidation_price": 0.7205,
|
||||
"fee": 1.0,
|
||||
"reason": "Short scanner score:65",
|
||||
"timestamp": "2026-02-10T14:15:18.287529+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_0eec",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 10,
|
||||
"entry_price": 0.655,
|
||||
"exit_price": 0.65,
|
||||
"margin_usd": 200,
|
||||
"pnl": 15.27,
|
||||
"pnl_pct": 7.63,
|
||||
"fee": 1.0,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+7.6%)",
|
||||
"timestamp": "2026-02-10T15:15:04.785401+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ASTER_short_1a62",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"margin_usd": 200,
|
||||
"notional": 3000,
|
||||
"entry_price": 0.67,
|
||||
"liquidation_price": 0.7147,
|
||||
"fee": 1.5,
|
||||
"reason": "Short scanner score:85",
|
||||
"timestamp": "2026-02-10T15:45:18.129303+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "PUMP_long_c66e",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.00195,
|
||||
"exit_price": 0.001969,
|
||||
"margin_usd": 200,
|
||||
"pnl": 13.64,
|
||||
"pnl_pct": 6.82,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+6.8%)",
|
||||
"timestamp": "2026-02-10T16:15:04.793084+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ASTER_short_1a62",
|
||||
"symbol": "ASTER",
|
||||
"direction": "short",
|
||||
"leverage": 15,
|
||||
"entry_price": 0.67,
|
||||
"exit_price": 0.654,
|
||||
"margin_usd": 200,
|
||||
"pnl": 71.64,
|
||||
"pnl_pct": 35.82,
|
||||
"fee": 1.5,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+35.8%)",
|
||||
"timestamp": "2026-02-10T16:15:04.837617+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ICP_long_2599",
|
||||
"symbol": "ICP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 2.722,
|
||||
"liquidation_price": 2.3331,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:53",
|
||||
"timestamp": "2026-02-10T18:00:17.949118+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ICP_long_2599",
|
||||
"symbol": "ICP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 2.722,
|
||||
"exit_price": 2.759,
|
||||
"margin_usd": 200,
|
||||
"pnl": 19.03,
|
||||
"pnl_pct": 9.52,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+9.5%)",
|
||||
"timestamp": "2026-02-10T18:15:04.612873+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "TRUMP_long_6717",
|
||||
"symbol": "TRUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 3.239,
|
||||
"liquidation_price": 2.7763,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:58",
|
||||
"timestamp": "2026-02-10T19:00:17.796328+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "PUMP_long_9b05",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.001926,
|
||||
"liquidation_price": 0.0017,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:58",
|
||||
"timestamp": "2026-02-10T19:15:18.359381+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "TRUMP_long_6717",
|
||||
"symbol": "TRUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 3.239,
|
||||
"exit_price": 3.333,
|
||||
"margin_usd": 200,
|
||||
"pnl": 40.63,
|
||||
"pnl_pct": 20.31,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+20.3%)",
|
||||
"timestamp": "2026-02-10T19:30:04.787087+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "PUMP_long_9b05",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.001926,
|
||||
"exit_price": 0.001909,
|
||||
"margin_usd": 200,
|
||||
"pnl": -12.36,
|
||||
"pnl_pct": -6.18,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-6.2%)",
|
||||
"timestamp": "2026-02-10T19:30:04.861197+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "PUMP_long_4a28",
|
||||
"symbol": "PUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.001909,
|
||||
"liquidation_price": 0.0016,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:58",
|
||||
"timestamp": "2026-02-10T19:30:18.313742+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "TRUMP_long_fbd7",
|
||||
"symbol": "TRUMP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 3.266,
|
||||
"liquidation_price": 2.7994,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:58",
|
||||
"timestamp": "2026-02-10T19:45:18.101503+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "OP_long_67c4",
|
||||
"symbol": "OP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.184,
|
||||
"liquidation_price": 0.1577,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:45",
|
||||
"timestamp": "2026-02-10T19:45:18.373173+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "OP_long_67c4",
|
||||
"symbol": "OP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.184,
|
||||
"exit_price": 0.183,
|
||||
"margin_usd": 200,
|
||||
"pnl": -7.61,
|
||||
"pnl_pct": -3.8,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-3.8%)",
|
||||
"timestamp": "2026-02-10T20:15:04.857761+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "OP_long_4ea9",
|
||||
"symbol": "OP",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.183,
|
||||
"liquidation_price": 0.1569,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:45",
|
||||
"timestamp": "2026-02-10T20:15:18.266969+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ALGO_long_5f94",
|
||||
"symbol": "ALGO",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 0.0901,
|
||||
"liquidation_price": 0.0772,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:50",
|
||||
"timestamp": "2026-02-10T20:30:18.299437+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_6b17",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.21,
|
||||
"liquidation_price": 25.0371,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:45",
|
||||
"timestamp": "2026-02-10T21:45:18.366940+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_6b17",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 29.21,
|
||||
"exit_price": 29.52,
|
||||
"margin_usd": 200,
|
||||
"pnl": 14.86,
|
||||
"pnl_pct": 7.43,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+7.4%)",
|
||||
"timestamp": "2026-02-10T22:15:05.229877+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "ICP_short_9a0a",
|
||||
"symbol": "ICP",
|
||||
"direction": "short",
|
||||
"leverage": 10,
|
||||
"margin_usd": 200,
|
||||
"notional": 2000,
|
||||
"entry_price": 2.907,
|
||||
"liquidation_price": 3.1977,
|
||||
"fee": 1.0,
|
||||
"reason": "Short scanner score:65",
|
||||
"timestamp": "2026-02-10T23:00:14.402704+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_81ed",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.0,
|
||||
"liquidation_price": 24.8571,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:45",
|
||||
"timestamp": "2026-02-10T23:15:18.652083+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_81ed",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 29.0,
|
||||
"exit_price": 28.87,
|
||||
"margin_usd": 200,
|
||||
"pnl": -6.28,
|
||||
"pnl_pct": -3.14,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "SL hit (-3.1%)",
|
||||
"timestamp": "2026-02-10T23:30:01.509254+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_9f34",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 28.87,
|
||||
"liquidation_price": 24.7457,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:50",
|
||||
"timestamp": "2026-02-10T23:30:14.941390+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "ALGO_long_5f94",
|
||||
"symbol": "ALGO",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 0.0901,
|
||||
"exit_price": 0.0919,
|
||||
"margin_usd": 200,
|
||||
"pnl": 27.97,
|
||||
"pnl_pct": 13.98,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+14.0%)",
|
||||
"timestamp": "2026-02-11T00:30:05.451674+00:00"
|
||||
},
|
||||
{
|
||||
"action": "CLOSE",
|
||||
"pos_id": "HYPE_long_9f34",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"entry_price": 28.87,
|
||||
"exit_price": 29.28,
|
||||
"margin_usd": 200,
|
||||
"pnl": 19.88,
|
||||
"pnl_pct": 9.94,
|
||||
"fee": 0.7,
|
||||
"liquidated": false,
|
||||
"reason": "TP hit (+9.9%)",
|
||||
"timestamp": "2026-02-11T00:30:05.526899+00:00"
|
||||
},
|
||||
{
|
||||
"action": "OPEN",
|
||||
"pos_id": "HYPE_long_d46c",
|
||||
"symbol": "HYPE",
|
||||
"direction": "long",
|
||||
"leverage": 7,
|
||||
"margin_usd": 200,
|
||||
"notional": 1400,
|
||||
"entry_price": 29.05,
|
||||
"liquidation_price": 24.9,
|
||||
"fee": 0.7,
|
||||
"reason": "Long scanner score:45",
|
||||
"timestamp": "2026-02-11T00:45:18.585575+00:00"
|
||||
}
|
||||
]
|
||||
10
projects/crypto-signals/data/leverage-game/trader_state.json
Normal file
10
projects/crypto-signals/data/leverage-game/trader_state.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"peak_pnl": {
|
||||
"SEI_long_1c69": 1.8799999999999997,
|
||||
"PUMP_long_4a28": 4.4,
|
||||
"TRUMP_long_fbd7": 0.0,
|
||||
"OP_long_4ea9": 0.0,
|
||||
"ICP_short_9a0a": 0.0
|
||||
},
|
||||
"last_alert": null
|
||||
}
|
||||
487
projects/crypto-signals/data/short-scanner/scan_log.json
Normal file
487
projects/crypto-signals/data/short-scanner/scan_log.json
Normal file
@ -0,0 +1,487 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-02-10T02:31:38.063585+00:00",
|
||||
"coins_scanned": 29,
|
||||
"strong_signals": 0,
|
||||
"results": [
|
||||
{
|
||||
"symbol": "FIL",
|
||||
"price": 0.954,
|
||||
"rsi": 61.7,
|
||||
"vwap_pct": 9.0,
|
||||
"macd_histogram": -0.932576,
|
||||
"bb_position": 0.76,
|
||||
"change_24h": 0.74,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.05,
|
||||
"score": 40,
|
||||
"reasons": [
|
||||
"RSI mildly elevated (61.7)",
|
||||
"Well above VWAP (+9.0%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.839978+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"price": 1.045,
|
||||
"rsi": 60.0,
|
||||
"vwap_pct": 1.62,
|
||||
"macd_histogram": -1.030379,
|
||||
"bb_position": 0.93,
|
||||
"change_24h": -0.85,
|
||||
"change_4h": 2.35,
|
||||
"vol_trend": 1.02,
|
||||
"score": 38,
|
||||
"reasons": [
|
||||
"RSI mildly elevated (60.0)",
|
||||
"Slightly above VWAP (+1.6%)",
|
||||
"MACD bearish + accelerating",
|
||||
"Near upper Bollinger (0.93)"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.678025+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "OP",
|
||||
"price": 0.19,
|
||||
"rsi": 64.2,
|
||||
"vwap_pct": 3.23,
|
||||
"macd_histogram": -0.187435,
|
||||
"bb_position": 0.79,
|
||||
"change_24h": 0.53,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 1.07,
|
||||
"score": 35,
|
||||
"reasons": [
|
||||
"RSI mildly elevated (64.2)",
|
||||
"Above VWAP (+3.2%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.709597+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"price": 0.114,
|
||||
"rsi": 52.1,
|
||||
"vwap_pct": 3.09,
|
||||
"macd_histogram": -0.113948,
|
||||
"bb_position": 0.72,
|
||||
"change_24h": -3.55,
|
||||
"change_4h": 2.06,
|
||||
"vol_trend": 0.11,
|
||||
"score": 30,
|
||||
"reasons": [
|
||||
"Above VWAP (+3.1%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.470618+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"price": 0.2693,
|
||||
"rsi": 50.2,
|
||||
"vwap_pct": 1.35,
|
||||
"macd_histogram": -0.269763,
|
||||
"bb_position": 0.62,
|
||||
"change_24h": -1.43,
|
||||
"change_4h": -0.81,
|
||||
"vol_trend": 0.15,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.3%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.520420+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "LINK",
|
||||
"price": 8.88,
|
||||
"rsi": 55.1,
|
||||
"vwap_pct": 1.7,
|
||||
"macd_histogram": -8.839752,
|
||||
"bb_position": 0.73,
|
||||
"change_24h": 0.79,
|
||||
"change_4h": -0.34,
|
||||
"vol_trend": 0.84,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.7%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.968544+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "UNI",
|
||||
"price": 3.519,
|
||||
"rsi": 56.2,
|
||||
"vwap_pct": 2.62,
|
||||
"macd_histogram": -3.494918,
|
||||
"bb_position": 0.65,
|
||||
"change_24h": 2.18,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.72,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+2.6%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.365690+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"price": 113.55,
|
||||
"rsi": 55.0,
|
||||
"vwap_pct": 1.28,
|
||||
"macd_histogram": -112.920164,
|
||||
"bb_position": 0.71,
|
||||
"change_24h": 0.82,
|
||||
"change_4h": 2.23,
|
||||
"vol_trend": 0.55,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.3%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.599689+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "APT",
|
||||
"price": 1.067,
|
||||
"rsi": 49.4,
|
||||
"vwap_pct": 1.06,
|
||||
"macd_histogram": -1.076147,
|
||||
"bb_position": 0.63,
|
||||
"change_24h": -2.65,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.23,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.1%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.993067+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "SUI",
|
||||
"price": 0.9668,
|
||||
"rsi": 50.4,
|
||||
"vwap_pct": 1.54,
|
||||
"macd_histogram": -0.969969,
|
||||
"bb_position": 0.66,
|
||||
"change_24h": -2.0,
|
||||
"change_4h": -0.17,
|
||||
"vol_trend": 0.03,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.5%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.231436+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "BTC",
|
||||
"price": 70249.15,
|
||||
"rsi": 51.0,
|
||||
"vwap_pct": 0.34,
|
||||
"macd_histogram": -70262.247326,
|
||||
"bb_position": 0.63,
|
||||
"change_24h": -1.38,
|
||||
"change_4h": 0.18,
|
||||
"vol_trend": 0.96,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:31.360741+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ETH",
|
||||
"price": 2109.49,
|
||||
"rsi": 55.4,
|
||||
"vwap_pct": 0.97,
|
||||
"macd_histogram": -2104.718634,
|
||||
"bb_position": 0.67,
|
||||
"change_24h": 0.44,
|
||||
"change_4h": 0.2,
|
||||
"vol_trend": 1.27,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:31.596017+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "SOL",
|
||||
"price": 86.88,
|
||||
"rsi": 52.0,
|
||||
"vwap_pct": 0.65,
|
||||
"macd_histogram": -86.940038,
|
||||
"bb_position": 0.65,
|
||||
"change_24h": -0.98,
|
||||
"change_4h": 0.17,
|
||||
"vol_trend": 0.88,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:31.806936+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "XRP",
|
||||
"price": 1.4454,
|
||||
"rsi": 53.8,
|
||||
"vwap_pct": 0.52,
|
||||
"macd_histogram": -1.439527,
|
||||
"bb_position": 0.66,
|
||||
"change_24h": -0.39,
|
||||
"change_4h": 0.59,
|
||||
"vol_trend": 1.67,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.046327+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "DOGE",
|
||||
"price": 0.09629,
|
||||
"rsi": 52.4,
|
||||
"vwap_pct": 0.47,
|
||||
"macd_histogram": -0.096217,
|
||||
"bb_position": 0.7,
|
||||
"change_24h": -0.98,
|
||||
"change_4h": -0.43,
|
||||
"vol_trend": 2.79,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.284061+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "AVAX",
|
||||
"price": 9.01,
|
||||
"rsi": 47.3,
|
||||
"vwap_pct": 0.43,
|
||||
"macd_histogram": -9.05205,
|
||||
"bb_position": 0.56,
|
||||
"change_24h": -1.31,
|
||||
"change_4h": -0.44,
|
||||
"vol_trend": 0.29,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.758345+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "DOT",
|
||||
"price": 1.316,
|
||||
"rsi": 47.5,
|
||||
"vwap_pct": 0.69,
|
||||
"macd_histogram": -1.325125,
|
||||
"bb_position": 0.57,
|
||||
"change_24h": -2.52,
|
||||
"change_4h": -0.9,
|
||||
"vol_trend": 0.23,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.205989+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "MATIC",
|
||||
"price": 0.4492,
|
||||
"rsi": 41.9,
|
||||
"vwap_pct": -0.77,
|
||||
"macd_histogram": -0.452491,
|
||||
"bb_position": 0.32,
|
||||
"change_24h": -1.58,
|
||||
"change_4h": 0.22,
|
||||
"vol_trend": 1.07,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.442040+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ATOM",
|
||||
"price": 1.953,
|
||||
"rsi": 49.3,
|
||||
"vwap_pct": 0.52,
|
||||
"macd_histogram": -1.965896,
|
||||
"bb_position": 0.5,
|
||||
"change_24h": 0.93,
|
||||
"change_4h": 0.05,
|
||||
"vol_trend": 1.04,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.888750+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "LTC",
|
||||
"price": 54.36,
|
||||
"rsi": 50.1,
|
||||
"vwap_pct": 0.22,
|
||||
"macd_histogram": -54.485415,
|
||||
"bb_position": 0.61,
|
||||
"change_24h": -1.06,
|
||||
"change_4h": -0.22,
|
||||
"vol_trend": 2.73,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.128167+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ALGO",
|
||||
"price": 0.0951,
|
||||
"rsi": 45.3,
|
||||
"vwap_pct": -1.14,
|
||||
"macd_histogram": -0.095952,
|
||||
"bb_position": 0.5,
|
||||
"change_24h": -2.56,
|
||||
"change_4h": -2.36,
|
||||
"vol_trend": 1.65,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.076205+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "XLM",
|
||||
"price": 0.1614,
|
||||
"rsi": 52.5,
|
||||
"vwap_pct": 0.89,
|
||||
"macd_histogram": -0.160963,
|
||||
"bb_position": 0.69,
|
||||
"change_24h": -1.34,
|
||||
"change_4h": 1.13,
|
||||
"vol_trend": 2.74,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.314005+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "VET",
|
||||
"price": 0.00791,
|
||||
"rsi": 48.8,
|
||||
"vwap_pct": -0.02,
|
||||
"macd_histogram": -0.007928,
|
||||
"bb_position": 0.55,
|
||||
"change_24h": -3.06,
|
||||
"change_4h": 0.89,
|
||||
"vol_trend": 0.27,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.550100+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"price": 2.782,
|
||||
"rsi": 40.1,
|
||||
"vwap_pct": -1.9,
|
||||
"macd_histogram": -2.843397,
|
||||
"bb_position": 0.14,
|
||||
"change_24h": 3.81,
|
||||
"change_4h": -2.49,
|
||||
"vol_trend": 10.38,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.759005+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"price": 0.075,
|
||||
"rsi": 29.9,
|
||||
"vwap_pct": -0.25,
|
||||
"macd_histogram": -0.075645,
|
||||
"bb_position": 0.38,
|
||||
"change_24h": -4.34,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.11,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.945126+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"price": 31.49,
|
||||
"rsi": 45.7,
|
||||
"vwap_pct": -1.11,
|
||||
"macd_histogram": -31.681649,
|
||||
"bb_position": 0.39,
|
||||
"change_24h": -5.29,
|
||||
"change_4h": 0.41,
|
||||
"vol_trend": 21.91,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.184127+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"price": 3.446,
|
||||
"rsi": 50.1,
|
||||
"vwap_pct": 0.08,
|
||||
"macd_histogram": -3.45085,
|
||||
"bb_position": 0.49,
|
||||
"change_24h": 0.35,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.81,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.440389+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"price": 0.002041,
|
||||
"rsi": 37.6,
|
||||
"vwap_pct": -2.03,
|
||||
"macd_histogram": -0.002063,
|
||||
"bb_position": 0.31,
|
||||
"change_24h": -4.31,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 1.28,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.674937+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ASTER",
|
||||
"price": 0.612,
|
||||
"rsi": 50.2,
|
||||
"vwap_pct": 0.21,
|
||||
"macd_histogram": -0.610803,
|
||||
"bb_position": 0.58,
|
||||
"change_24h": -5.85,
|
||||
"change_4h": 0.33,
|
||||
"vol_trend": 0.63,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.912733+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
504
projects/crypto-signals/leverage_game.py
Normal file
504
projects/crypto-signals/leverage_game.py
Normal file
@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crypto Leverage Trading Game Engine
|
||||
Paper trading with longs, shorts, and configurable leverage.
|
||||
Tracks liquidation prices, unrealized PnL, and funding costs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, date, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data" / "leverage-game"
|
||||
GAMES_DIR = DATA_DIR / "games"
|
||||
|
||||
BINANCE_TICKER = "https://api.binance.us/api/v3/ticker/price"
|
||||
|
||||
|
||||
def _load(path, default=None):
|
||||
if path.exists():
|
||||
return json.loads(path.read_text())
|
||||
return default if default is not None else {}
|
||||
|
||||
|
||||
def _save(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, default=str))
|
||||
|
||||
|
||||
def _game_path(game_id):
|
||||
return GAMES_DIR / game_id / "game.json"
|
||||
|
||||
def _player_path(game_id, username):
|
||||
return GAMES_DIR / game_id / "players" / username / "portfolio.json"
|
||||
|
||||
def _trades_path(game_id, username):
|
||||
return GAMES_DIR / game_id / "players" / username / "trades.json"
|
||||
|
||||
def _snapshots_path(game_id, username):
|
||||
return GAMES_DIR / game_id / "players" / username / "snapshots.json"
|
||||
|
||||
|
||||
# ── Price Fetching ──
|
||||
|
||||
def get_price(symbol):
|
||||
"""Get current price from Binance US."""
|
||||
if not symbol.endswith("USDT"):
|
||||
symbol = f"{symbol}USDT"
|
||||
url = f"{BINANCE_TICKER}?symbol={symbol}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
return float(json.loads(resp.read())['price'])
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
# ── Game Management ──
|
||||
|
||||
def create_game(name, starting_cash=10_000.0, max_leverage=20, creator="system"):
|
||||
"""Create a new leverage trading game."""
|
||||
game_id = str(uuid.uuid4())[:8]
|
||||
config = {
|
||||
"game_id": game_id,
|
||||
"name": name,
|
||||
"starting_cash": starting_cash,
|
||||
"max_leverage": max_leverage,
|
||||
"funding_rate_8h": 0.01, # 0.01% per 8h (typical perp funding)
|
||||
"maker_fee": 0.02, # 0.02%
|
||||
"taker_fee": 0.05, # 0.05%
|
||||
"start_date": date.today().isoformat(),
|
||||
"creator": creator,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"players": [],
|
||||
"status": "active",
|
||||
}
|
||||
_save(_game_path(game_id), config)
|
||||
return game_id
|
||||
|
||||
|
||||
def list_games(active_only=True):
|
||||
"""List all leverage games."""
|
||||
games = []
|
||||
if not GAMES_DIR.exists():
|
||||
return games
|
||||
for gid in os.listdir(GAMES_DIR):
|
||||
gp = _game_path(gid)
|
||||
if gp.exists():
|
||||
config = _load(gp)
|
||||
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):
|
||||
return _load(_game_path(game_id))
|
||||
|
||||
|
||||
def join_game(game_id, username):
|
||||
"""Add player to game."""
|
||||
config = get_game(game_id)
|
||||
if not config:
|
||||
return {"error": "Game not found"}
|
||||
if username in config["players"]:
|
||||
return {"error": f"{username} already in game"}
|
||||
|
||||
config["players"].append(username)
|
||||
_save(_game_path(game_id), config)
|
||||
|
||||
_save(_player_path(game_id, username), {
|
||||
"cash": config["starting_cash"],
|
||||
"positions": {},
|
||||
"total_realized_pnl": 0,
|
||||
"total_fees_paid": 0,
|
||||
"total_funding_paid": 0,
|
||||
})
|
||||
_save(_trades_path(game_id, username), [])
|
||||
_save(_snapshots_path(game_id, username), [])
|
||||
|
||||
return {"success": True, "game_id": game_id, "username": username}
|
||||
|
||||
|
||||
# ── Position Math ──
|
||||
|
||||
def calc_liquidation_price(entry_price, leverage, direction):
|
||||
"""
|
||||
Simplified liquidation price.
|
||||
Long: liq = entry * (1 - 1/leverage)
|
||||
Short: liq = entry * (1 + 1/leverage)
|
||||
"""
|
||||
if direction == "long":
|
||||
return entry_price * (1 - 1 / leverage)
|
||||
else: # short
|
||||
return entry_price * (1 + 1 / leverage)
|
||||
|
||||
|
||||
def calc_unrealized_pnl(entry_price, current_price, size_usd, leverage, direction):
|
||||
"""
|
||||
Calculate unrealized PnL for a leveraged position.
|
||||
size_usd = margin (collateral). Notional = size_usd * leverage.
|
||||
"""
|
||||
notional = size_usd * leverage
|
||||
shares = notional / entry_price
|
||||
|
||||
if direction == "long":
|
||||
pnl = (current_price - entry_price) * shares
|
||||
else: # short
|
||||
pnl = (entry_price - current_price) * shares
|
||||
|
||||
return pnl
|
||||
|
||||
|
||||
def is_liquidated(entry_price, current_price, leverage, direction):
|
||||
"""Check if position would be liquidated."""
|
||||
liq_price = calc_liquidation_price(entry_price, leverage, direction)
|
||||
if direction == "long":
|
||||
return current_price <= liq_price
|
||||
else:
|
||||
return current_price >= liq_price
|
||||
|
||||
|
||||
# ── Trading ──
|
||||
|
||||
def open_position(game_id, username, symbol, direction, margin_usd, leverage, reason="Manual"):
|
||||
"""
|
||||
Open a leveraged position.
|
||||
margin_usd: collateral put up
|
||||
leverage: multiplier (e.g., 10x)
|
||||
direction: 'long' or 'short'
|
||||
"""
|
||||
pf = _load(_player_path(game_id, username))
|
||||
game = get_game(game_id)
|
||||
if not pf or not game:
|
||||
return {"error": "Player or game not found"}
|
||||
|
||||
if direction not in ("long", "short"):
|
||||
return {"error": "Direction must be 'long' or 'short'"}
|
||||
if leverage > game.get("max_leverage", 20):
|
||||
return {"error": f"Max leverage is {game['max_leverage']}x"}
|
||||
if margin_usd > pf["cash"]:
|
||||
return {"error": f"Insufficient cash. Need ${margin_usd:.2f}, have ${pf['cash']:.2f}"}
|
||||
|
||||
symbol = symbol.upper().replace("USDT", "")
|
||||
price = get_price(symbol)
|
||||
if not price:
|
||||
return {"error": f"Could not fetch price for {symbol}"}
|
||||
|
||||
notional = margin_usd * leverage
|
||||
fee = notional * game.get("taker_fee", 0.05) / 100
|
||||
|
||||
# Deduct margin + entry fee from cash
|
||||
pf["cash"] -= (margin_usd + fee)
|
||||
pf["total_fees_paid"] = pf.get("total_fees_paid", 0) + fee
|
||||
|
||||
pos_id = f"{symbol}_{direction}_{str(uuid.uuid4())[:4]}"
|
||||
liq_price = calc_liquidation_price(price, leverage, direction)
|
||||
|
||||
pf["positions"][pos_id] = {
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"leverage": leverage,
|
||||
"margin_usd": margin_usd,
|
||||
"notional": round(notional, 2),
|
||||
"entry_price": price,
|
||||
"current_price": price,
|
||||
"liquidation_price": round(liq_price, 4),
|
||||
"unrealized_pnl": 0,
|
||||
"entry_fee": round(fee, 4),
|
||||
"opened_at": datetime.now(timezone.utc).isoformat(),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
_save(_player_path(game_id, username), pf)
|
||||
|
||||
# Log trade
|
||||
trades = _load(_trades_path(game_id, username), [])
|
||||
trades.append({
|
||||
"action": "OPEN",
|
||||
"pos_id": pos_id,
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"leverage": leverage,
|
||||
"margin_usd": margin_usd,
|
||||
"notional": round(notional, 2),
|
||||
"entry_price": price,
|
||||
"liquidation_price": round(liq_price, 4),
|
||||
"fee": round(fee, 4),
|
||||
"reason": reason,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
_save(_trades_path(game_id, username), trades)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pos_id": pos_id,
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"leverage": leverage,
|
||||
"entry_price": price,
|
||||
"margin": margin_usd,
|
||||
"notional": round(notional, 2),
|
||||
"liquidation_price": round(liq_price, 4),
|
||||
"fee": round(fee, 4),
|
||||
}
|
||||
|
||||
|
||||
def close_position(game_id, username, pos_id, reason="Manual"):
|
||||
"""Close a leveraged position."""
|
||||
pf = _load(_player_path(game_id, username))
|
||||
game = get_game(game_id)
|
||||
if not pf or not game:
|
||||
return {"error": "Player or game not found"}
|
||||
if pos_id not in pf["positions"]:
|
||||
return {"error": f"Position {pos_id} not found"}
|
||||
|
||||
pos = pf["positions"][pos_id]
|
||||
price = get_price(pos["symbol"])
|
||||
if not price:
|
||||
return {"error": f"Could not fetch price for {pos['symbol']}"}
|
||||
|
||||
# Calculate PnL
|
||||
pnl = calc_unrealized_pnl(
|
||||
pos["entry_price"], price, pos["margin_usd"], pos["leverage"], pos["direction"]
|
||||
)
|
||||
|
||||
# Check liquidation
|
||||
liquidated = is_liquidated(pos["entry_price"], price, pos["leverage"], pos["direction"])
|
||||
if liquidated:
|
||||
pnl = -pos["margin_usd"] # Lose entire margin
|
||||
|
||||
# Exit fee
|
||||
notional = pos["margin_usd"] * pos["leverage"]
|
||||
fee = notional * game.get("taker_fee", 0.05) / 100
|
||||
|
||||
# Return margin + PnL - fee to cash
|
||||
returned = pos["margin_usd"] + pnl - fee
|
||||
if returned < 0:
|
||||
returned = 0 # Can't lose more than margin (no negative balance)
|
||||
|
||||
pf["cash"] += returned
|
||||
pf["total_realized_pnl"] = pf.get("total_realized_pnl", 0) + pnl
|
||||
pf["total_fees_paid"] = pf.get("total_fees_paid", 0) + fee
|
||||
|
||||
del pf["positions"][pos_id]
|
||||
_save(_player_path(game_id, username), pf)
|
||||
|
||||
# Log trade
|
||||
pnl_pct = (pnl / pos["margin_usd"] * 100) if pos["margin_usd"] > 0 else 0
|
||||
trades = _load(_trades_path(game_id, username), [])
|
||||
trades.append({
|
||||
"action": "LIQUIDATED" if liquidated else "CLOSE",
|
||||
"pos_id": pos_id,
|
||||
"symbol": pos["symbol"],
|
||||
"direction": pos["direction"],
|
||||
"leverage": pos["leverage"],
|
||||
"entry_price": pos["entry_price"],
|
||||
"exit_price": price,
|
||||
"margin_usd": pos["margin_usd"],
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"fee": round(fee, 4),
|
||||
"liquidated": liquidated,
|
||||
"reason": reason,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
_save(_trades_path(game_id, username), trades)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pos_id": pos_id,
|
||||
"symbol": pos["symbol"],
|
||||
"direction": pos["direction"],
|
||||
"entry_price": pos["entry_price"],
|
||||
"exit_price": price,
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"liquidated": liquidated,
|
||||
"returned_to_cash": round(returned, 2),
|
||||
}
|
||||
|
||||
|
||||
def update_prices(game_id, username):
|
||||
"""Update all position prices and check for liquidations."""
|
||||
pf = _load(_player_path(game_id, username))
|
||||
if not pf:
|
||||
return []
|
||||
|
||||
liquidations = []
|
||||
to_liquidate = []
|
||||
|
||||
for pos_id, pos in pf["positions"].items():
|
||||
price = get_price(pos["symbol"])
|
||||
if not price:
|
||||
continue
|
||||
|
||||
pos["current_price"] = price
|
||||
pos["unrealized_pnl"] = round(
|
||||
calc_unrealized_pnl(pos["entry_price"], price, pos["margin_usd"], pos["leverage"], pos["direction"]),
|
||||
2
|
||||
)
|
||||
|
||||
if is_liquidated(pos["entry_price"], price, pos["leverage"], pos["direction"]):
|
||||
to_liquidate.append(pos_id)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
_save(_player_path(game_id, username), pf)
|
||||
|
||||
# Process liquidations
|
||||
for pos_id in to_liquidate:
|
||||
result = close_position(game_id, username, pos_id, reason="LIQUIDATED")
|
||||
liquidations.append(result)
|
||||
|
||||
return liquidations
|
||||
|
||||
|
||||
# ── Portfolio View ──
|
||||
|
||||
def get_portfolio(game_id, username):
|
||||
"""Get full portfolio with live PnL."""
|
||||
pf = _load(_player_path(game_id, username))
|
||||
game = get_game(game_id)
|
||||
if not pf or not game:
|
||||
return None
|
||||
|
||||
starting = game["starting_cash"]
|
||||
total_unrealized = sum(p.get("unrealized_pnl", 0) for p in pf["positions"].values())
|
||||
total_margin_locked = sum(p["margin_usd"] for p in pf["positions"].values())
|
||||
equity = pf["cash"] + total_margin_locked + total_unrealized
|
||||
total_pnl = equity - starting
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"game_id": game_id,
|
||||
"cash": round(pf["cash"], 2),
|
||||
"margin_locked": round(total_margin_locked, 2),
|
||||
"unrealized_pnl": round(total_unrealized, 2),
|
||||
"realized_pnl": round(pf.get("total_realized_pnl", 0), 2),
|
||||
"total_fees": round(pf.get("total_fees_paid", 0), 2),
|
||||
"equity": round(equity, 2),
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"pnl_pct": round(total_pnl / starting * 100, 2),
|
||||
"num_positions": len(pf["positions"]),
|
||||
"positions": pf["positions"],
|
||||
}
|
||||
|
||||
|
||||
def get_trades(game_id, username):
|
||||
return _load(_trades_path(game_id, username), [])
|
||||
|
||||
|
||||
def daily_snapshot(game_id, username):
|
||||
"""Take daily snapshot."""
|
||||
p = get_portfolio(game_id, username)
|
||||
if not p:
|
||||
return None
|
||||
snapshots = _load(_snapshots_path(game_id, username), [])
|
||||
today = date.today().isoformat()
|
||||
snapshots = [s for s in snapshots if s["date"] != today]
|
||||
snapshots.append({
|
||||
"date": today,
|
||||
"equity": p["equity"],
|
||||
"total_pnl": p["total_pnl"],
|
||||
"pnl_pct": p["pnl_pct"],
|
||||
"cash": p["cash"],
|
||||
"num_positions": p["num_positions"],
|
||||
"realized_pnl": p["realized_pnl"],
|
||||
"total_fees": p["total_fees"],
|
||||
})
|
||||
_save(_snapshots_path(game_id, username), snapshots)
|
||||
return snapshots[-1]
|
||||
|
||||
|
||||
def get_leaderboard(game_id):
|
||||
"""Leaderboard sorted by equity."""
|
||||
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)
|
||||
closed = [t for t in trades if t.get("action") in ("CLOSE", "LIQUIDATED")]
|
||||
wins = [t for t in closed if t.get("pnl", 0) > 0]
|
||||
liquidations = [t for t in closed if t.get("liquidated")]
|
||||
board.append({
|
||||
"username": username,
|
||||
"equity": p["equity"],
|
||||
"total_pnl": p["total_pnl"],
|
||||
"pnl_pct": p["pnl_pct"],
|
||||
"num_positions": p["num_positions"],
|
||||
"trades_closed": len(closed),
|
||||
"win_rate": round(len(wins) / len(closed) * 100, 1) if closed else 0,
|
||||
"liquidations": len(liquidations),
|
||||
"total_fees": p["total_fees"],
|
||||
})
|
||||
return sorted(board, key=lambda x: x["pnl_pct"], reverse=True)
|
||||
|
||||
|
||||
# ── Auto-Trader (Scanner Integration) ──
|
||||
|
||||
def auto_trade_from_scanner(game_id, username, scan_results, margin_per_trade=200, leverage=10):
|
||||
"""
|
||||
Automatically open positions based on scanner results.
|
||||
Short scanner (score >= 50) → open short
|
||||
Spot scanner (score >= 40) → open long
|
||||
"""
|
||||
opened = []
|
||||
for r in scan_results:
|
||||
symbol = r["symbol"]
|
||||
score = r["score"]
|
||||
|
||||
# Determine direction based on which scanner produced this
|
||||
direction = r.get("direction", "short") # Default to short for short scanner
|
||||
|
||||
if score < 40:
|
||||
continue
|
||||
|
||||
# Scale leverage with conviction
|
||||
if score >= 70:
|
||||
lev = min(leverage, 15)
|
||||
elif score >= 50:
|
||||
lev = min(leverage, 10)
|
||||
else:
|
||||
lev = min(leverage, 5)
|
||||
|
||||
result = open_position(game_id, username, symbol, direction, margin_per_trade, lev,
|
||||
reason=f"Scanner score:{score} | {', '.join(r.get('reasons', []))}")
|
||||
if result.get("success"):
|
||||
opened.append(result)
|
||||
|
||||
return opened
|
||||
|
||||
|
||||
# ── Initialize ──
|
||||
|
||||
def ensure_default_game():
|
||||
"""Create default Leverage Challenge game."""
|
||||
for g in list_games():
|
||||
if g["name"] == "Leverage Challenge":
|
||||
return g["game_id"]
|
||||
|
||||
game_id = create_game("Leverage Challenge", starting_cash=10_000.0, max_leverage=20, 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']}")
|
||||
print(f"Starting cash: ${game['starting_cash']:,.2f}")
|
||||
print(f"Max leverage: {game['max_leverage']}x")
|
||||
|
||||
board = get_leaderboard(game_id)
|
||||
for entry in board:
|
||||
print(f" {entry['username']}: ${entry['equity']:,.2f} ({entry['pnl_pct']:+.2f}%) "
|
||||
f"| {entry['trades_closed']} trades | {entry['win_rate']}% win | {entry['liquidations']} liquidated")
|
||||
259
projects/crypto-signals/scripts/backtester.py
Normal file
259
projects/crypto-signals/scripts/backtester.py
Normal file
@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crypto Signal Backtester
|
||||
Simulates each signal against historical price data to determine outcomes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from price_fetcher import get_all_klines, get_current_price, normalize_symbol, datetime_to_ms
|
||||
|
||||
|
||||
def simulate_signal(signal, klines):
|
||||
"""
|
||||
Simulate a signal against historical candle data.
|
||||
Returns outcome dict with result, P&L, time to resolution, etc.
|
||||
"""
|
||||
direction = signal['direction']
|
||||
entry = signal.get('entry')
|
||||
stop_loss = signal.get('stop_loss')
|
||||
targets = signal.get('targets', [])
|
||||
leverage = signal.get('leverage', 1)
|
||||
|
||||
if not targets or not stop_loss:
|
||||
return {'result': 'incomplete', 'reason': 'missing SL or targets'}
|
||||
|
||||
target = targets[0] # Primary target
|
||||
|
||||
# If entry is 'market', use first candle's open
|
||||
if entry == 'market' or entry is None:
|
||||
if not klines:
|
||||
return {'result': 'no_data'}
|
||||
entry = klines[0]['open']
|
||||
|
||||
signal['entry_resolved'] = entry
|
||||
|
||||
# Calculate risk/reward
|
||||
if direction == 'short':
|
||||
risk = abs(stop_loss - entry)
|
||||
reward = abs(entry - target)
|
||||
risk_pct = risk / entry * 100
|
||||
reward_pct = reward / entry * 100
|
||||
else: # long
|
||||
risk = abs(entry - stop_loss)
|
||||
reward = abs(target - entry)
|
||||
risk_pct = risk / entry * 100
|
||||
reward_pct = reward / entry * 100
|
||||
|
||||
rr_ratio = reward / risk if risk > 0 else 0
|
||||
|
||||
result = {
|
||||
'entry_price': entry,
|
||||
'stop_loss': stop_loss,
|
||||
'target': target,
|
||||
'direction': direction,
|
||||
'leverage': leverage,
|
||||
'risk_pct': round(risk_pct, 2),
|
||||
'reward_pct': round(reward_pct, 2),
|
||||
'rr_ratio': round(rr_ratio, 2),
|
||||
}
|
||||
|
||||
# Walk through candles
|
||||
for i, candle in enumerate(klines):
|
||||
high = candle['high']
|
||||
low = candle['low']
|
||||
|
||||
if direction == 'short':
|
||||
# Check SL hit (price went above SL)
|
||||
sl_hit = high >= stop_loss
|
||||
# Check TP hit (price went below target)
|
||||
tp_hit = low <= target
|
||||
else: # long
|
||||
# Check SL hit (price went below SL)
|
||||
sl_hit = low <= stop_loss
|
||||
# Check TP hit (price went above target)
|
||||
tp_hit = high >= target
|
||||
|
||||
if sl_hit and tp_hit:
|
||||
# Both hit in same candle — assume SL hit first (conservative)
|
||||
result['result'] = 'stop_loss'
|
||||
result['exit_price'] = stop_loss
|
||||
result['candles_to_exit'] = i + 1
|
||||
result['exit_time'] = candle['open_time']
|
||||
break
|
||||
elif tp_hit:
|
||||
result['result'] = 'target_hit'
|
||||
result['exit_price'] = target
|
||||
result['candles_to_exit'] = i + 1
|
||||
result['exit_time'] = candle['open_time']
|
||||
break
|
||||
elif sl_hit:
|
||||
result['result'] = 'stop_loss'
|
||||
result['exit_price'] = stop_loss
|
||||
result['candles_to_exit'] = i + 1
|
||||
result['exit_time'] = candle['open_time']
|
||||
break
|
||||
else:
|
||||
# Never resolved — check current unrealized P&L
|
||||
if klines:
|
||||
last_price = klines[-1]['close']
|
||||
if direction == 'short':
|
||||
unrealized_pct = (entry - last_price) / entry * 100
|
||||
else:
|
||||
unrealized_pct = (last_price - entry) / entry * 100
|
||||
result['result'] = 'open'
|
||||
result['last_price'] = last_price
|
||||
result['unrealized_pct'] = round(unrealized_pct, 2)
|
||||
result['unrealized_pct_leveraged'] = round(unrealized_pct * leverage, 2)
|
||||
else:
|
||||
result['result'] = 'no_data'
|
||||
|
||||
# Calculate P&L
|
||||
if result['result'] in ('target_hit', 'stop_loss'):
|
||||
exit_price = result['exit_price']
|
||||
if direction == 'short':
|
||||
pnl_pct = (entry - exit_price) / entry * 100
|
||||
else:
|
||||
pnl_pct = (exit_price - entry) / entry * 100
|
||||
|
||||
result['pnl_pct'] = round(pnl_pct, 2)
|
||||
result['pnl_pct_leveraged'] = round(pnl_pct * leverage, 2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def backtest_signals(signals, interval='5m', lookforward_hours=72):
|
||||
"""Backtest a list of parsed signals."""
|
||||
results = []
|
||||
|
||||
for i, signal in enumerate(signals):
|
||||
ticker = signal['ticker']
|
||||
symbol = normalize_symbol(ticker)
|
||||
timestamp = signal.get('timestamp', '')
|
||||
|
||||
print(f"[{i+1}/{len(signals)}] {ticker} {signal['direction']} ...", end=' ', flush=True)
|
||||
|
||||
# Get start time
|
||||
start_ms = datetime_to_ms(timestamp) if timestamp else int(time.time() * 1000)
|
||||
end_ms = start_ms + (lookforward_hours * 60 * 60 * 1000)
|
||||
|
||||
# Cap at current time
|
||||
now_ms = int(time.time() * 1000)
|
||||
if end_ms > now_ms:
|
||||
end_ms = now_ms
|
||||
|
||||
# Fetch candles
|
||||
klines = get_all_klines(symbol, interval, start_ms, end_ms)
|
||||
|
||||
if not klines:
|
||||
print(f"NO DATA")
|
||||
results.append({**signal, 'backtest': {'result': 'no_data', 'reason': f'no klines for {symbol}'}})
|
||||
continue
|
||||
|
||||
# Simulate
|
||||
outcome = simulate_signal(signal, klines)
|
||||
print(f"{outcome['result']} | PnL: {outcome.get('pnl_pct_leveraged', outcome.get('unrealized_pct_leveraged', '?'))}%")
|
||||
|
||||
results.append({**signal, 'backtest': outcome})
|
||||
time.sleep(0.2) # Rate limit
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def generate_report(results):
|
||||
"""Generate a summary report from backtest results."""
|
||||
total = len(results)
|
||||
wins = [r for r in results if r['backtest'].get('result') == 'target_hit']
|
||||
losses = [r for r in results if r['backtest'].get('result') == 'stop_loss']
|
||||
open_trades = [r for r in results if r['backtest'].get('result') == 'open']
|
||||
no_data = [r for r in results if r['backtest'].get('result') in ('no_data', 'incomplete')]
|
||||
|
||||
resolved = wins + losses
|
||||
win_rate = len(wins) / len(resolved) * 100 if resolved else 0
|
||||
|
||||
avg_win = sum(r['backtest']['pnl_pct_leveraged'] for r in wins) / len(wins) if wins else 0
|
||||
avg_loss = sum(r['backtest']['pnl_pct_leveraged'] for r in losses) / len(losses) if losses else 0
|
||||
|
||||
total_pnl = sum(r['backtest'].get('pnl_pct_leveraged', 0) for r in resolved)
|
||||
|
||||
# Profit factor
|
||||
gross_profit = sum(r['backtest']['pnl_pct_leveraged'] for r in wins)
|
||||
gross_loss = abs(sum(r['backtest']['pnl_pct_leveraged'] for r in losses))
|
||||
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
|
||||
|
||||
# Risk/reward stats
|
||||
avg_rr = sum(r['backtest'].get('rr_ratio', 0) for r in resolved) / len(resolved) if resolved else 0
|
||||
|
||||
report = {
|
||||
'summary': {
|
||||
'total_signals': total,
|
||||
'wins': len(wins),
|
||||
'losses': len(losses),
|
||||
'open': len(open_trades),
|
||||
'no_data': len(no_data),
|
||||
'win_rate': round(win_rate, 1),
|
||||
'avg_win_pct': round(avg_win, 2),
|
||||
'avg_loss_pct': round(avg_loss, 2),
|
||||
'total_pnl_pct': round(total_pnl, 2),
|
||||
'profit_factor': round(profit_factor, 2),
|
||||
'avg_risk_reward': round(avg_rr, 2),
|
||||
},
|
||||
'trades': results,
|
||||
}
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def print_report(report):
|
||||
"""Pretty print the report."""
|
||||
s = report['summary']
|
||||
print("\n" + "=" * 60)
|
||||
print("CRYPTO SIGNAL BACKTEST REPORT")
|
||||
print("=" * 60)
|
||||
print(f"Total Signals: {s['total_signals']}")
|
||||
print(f"Wins: {s['wins']}")
|
||||
print(f"Losses: {s['losses']}")
|
||||
print(f"Open: {s['open']}")
|
||||
print(f"No Data: {s['no_data']}")
|
||||
print(f"Win Rate: {s['win_rate']}%")
|
||||
print(f"Avg Win: +{s['avg_win_pct']}% (leveraged)")
|
||||
print(f"Avg Loss: {s['avg_loss_pct']}% (leveraged)")
|
||||
print(f"Total P&L: {s['total_pnl_pct']}% (sum of resolved)")
|
||||
print(f"Profit Factor: {s['profit_factor']}")
|
||||
print(f"Avg R:R: {s['avg_risk_reward']}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 backtester.py <signals.json> [--interval 5m] [--hours 72]")
|
||||
print("\nRun signal_parser.py first to generate signals.json")
|
||||
sys.exit(1)
|
||||
|
||||
signals_path = sys.argv[1]
|
||||
|
||||
interval = '5m'
|
||||
hours = 72
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == '--interval' and i + 1 < len(sys.argv):
|
||||
interval = sys.argv[i + 1]
|
||||
if arg == '--hours' and i + 1 < len(sys.argv):
|
||||
hours = int(sys.argv[i + 1])
|
||||
|
||||
with open(signals_path) as f:
|
||||
signals = json.load(f)
|
||||
|
||||
print(f"Backtesting {len(signals)} signals (interval={interval}, lookforward={hours}h)\n")
|
||||
|
||||
results = backtest_signals(signals, interval=interval, lookforward_hours=hours)
|
||||
report = generate_report(results)
|
||||
print_report(report)
|
||||
|
||||
# Save full report
|
||||
out_path = signals_path.replace('.json', '_backtest.json')
|
||||
with open(out_path, 'w') as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"\nFull report saved to {out_path}")
|
||||
292
projects/crypto-signals/scripts/leverage_trader.py
Normal file
292
projects/crypto-signals/scripts/leverage_trader.py
Normal file
@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated Leverage Trader
|
||||
Runs short scanner + spot scanner, opens positions in the Leverage Challenge game,
|
||||
manages exits (TP/SL/trailing stop), and reports via Telegram.
|
||||
|
||||
Zero AI tokens — systemd timer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from leverage_game import (
|
||||
ensure_default_game, get_game, get_portfolio, open_position,
|
||||
close_position, update_prices, get_trades, get_leaderboard
|
||||
)
|
||||
from scripts.short_scanner import scan_coin, COINS as SHORT_COINS
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "leverage-game"
|
||||
STATE_FILE = DATA_DIR / "trader_state.json"
|
||||
|
||||
# Trading params
|
||||
MARGIN_PER_TRADE = 200 # $200 margin per position
|
||||
DEFAULT_LEVERAGE = 10 # 10x default
|
||||
MAX_OPEN_POSITIONS = 10 # Max simultaneous positions
|
||||
SHORT_SCORE_THRESHOLD = 50 # Min score to open short
|
||||
LONG_SCORE_THRESHOLD = 45 # Min score to open long
|
||||
TP_PCT = 5.0 # Take profit at 5% on margin (50% on notional at 10x)
|
||||
SL_PCT = -3.0 # Stop loss at -3% on margin (30% on notional at 10x)
|
||||
TRAILING_STOP_PCT = 2.0 # Trailing stop: close if drops 2% from peak
|
||||
|
||||
|
||||
def load_state():
|
||||
if STATE_FILE.exists():
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
return {"peak_pnl": {}, "last_alert": None}
|
||||
|
||||
def save_state(state):
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def send_telegram(message):
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[TG] {message}")
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML"
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={
|
||||
"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram failed: {e}")
|
||||
|
||||
|
||||
def run_short_scan():
|
||||
"""Run short scanner on all coins."""
|
||||
results = []
|
||||
for symbol in SHORT_COINS:
|
||||
r = scan_coin(symbol)
|
||||
if r:
|
||||
r["direction"] = "short"
|
||||
results.append(r)
|
||||
time.sleep(0.15)
|
||||
return sorted(results, key=lambda x: x['score'], reverse=True)
|
||||
|
||||
|
||||
def run_spot_scan():
|
||||
"""Run spot/long scanner (inverse of short criteria — oversold = buy)."""
|
||||
results = []
|
||||
for symbol in SHORT_COINS:
|
||||
r = scan_coin(symbol)
|
||||
if r:
|
||||
# Invert: low RSI + below VWAP = long opportunity
|
||||
long_score = 0
|
||||
reasons = []
|
||||
|
||||
if r['rsi'] <= 25:
|
||||
long_score += 30
|
||||
reasons.append(f"RSI extremely oversold ({r['rsi']})")
|
||||
elif r['rsi'] <= 30:
|
||||
long_score += 25
|
||||
reasons.append(f"RSI oversold ({r['rsi']})")
|
||||
elif r['rsi'] <= 35:
|
||||
long_score += 15
|
||||
reasons.append(f"RSI low ({r['rsi']})")
|
||||
elif r['rsi'] <= 40:
|
||||
long_score += 5
|
||||
reasons.append(f"RSI mildly low ({r['rsi']})")
|
||||
|
||||
if r['vwap_pct'] < -5:
|
||||
long_score += 20
|
||||
reasons.append(f"Well below VWAP ({r['vwap_pct']:+.1f}%)")
|
||||
elif r['vwap_pct'] < -3:
|
||||
long_score += 15
|
||||
reasons.append(f"Below VWAP ({r['vwap_pct']:+.1f}%)")
|
||||
elif r['vwap_pct'] < -1:
|
||||
long_score += 8
|
||||
reasons.append(f"Slightly below VWAP ({r['vwap_pct']:+.1f}%)")
|
||||
|
||||
if r['change_24h'] < -15:
|
||||
long_score += 15
|
||||
reasons.append(f"Dumped {r['change_24h']:.1f}% 24h")
|
||||
elif r['change_24h'] < -8:
|
||||
long_score += 10
|
||||
reasons.append(f"Down {r['change_24h']:.1f}% 24h")
|
||||
elif r['change_24h'] < -4:
|
||||
long_score += 5
|
||||
reasons.append(f"Down {r['change_24h']:.1f}% 24h")
|
||||
|
||||
if r['bb_position'] < 0:
|
||||
long_score += 15
|
||||
reasons.append(f"Below lower Bollinger ({r['bb_position']:.2f})")
|
||||
elif r['bb_position'] < 0.15:
|
||||
long_score += 10
|
||||
reasons.append(f"Near lower Bollinger ({r['bb_position']:.2f})")
|
||||
|
||||
results.append({
|
||||
"symbol": r["symbol"],
|
||||
"price": r["price"],
|
||||
"rsi": r["rsi"],
|
||||
"vwap_pct": r["vwap_pct"],
|
||||
"change_24h": r["change_24h"],
|
||||
"bb_position": r["bb_position"],
|
||||
"score": long_score,
|
||||
"reasons": reasons,
|
||||
"direction": "long",
|
||||
})
|
||||
time.sleep(0.15)
|
||||
return sorted(results, key=lambda x: x['score'], reverse=True)
|
||||
|
||||
|
||||
def manage_exits(game_id, username, state):
|
||||
"""Check open positions for TP/SL/trailing stop exits."""
|
||||
pf = get_portfolio(game_id, username)
|
||||
if not pf:
|
||||
return []
|
||||
|
||||
exits = []
|
||||
for pos_id, pos in list(pf["positions"].items()):
|
||||
pnl_pct = (pos.get("unrealized_pnl", 0) / pos["margin_usd"] * 100) if pos["margin_usd"] > 0 else 0
|
||||
|
||||
# Track peak PnL for trailing stop
|
||||
peak_key = pos_id
|
||||
if peak_key not in state.get("peak_pnl", {}):
|
||||
state["peak_pnl"][peak_key] = pnl_pct
|
||||
if pnl_pct > state["peak_pnl"].get(peak_key, 0):
|
||||
state["peak_pnl"][peak_key] = pnl_pct
|
||||
|
||||
peak = state["peak_pnl"].get(peak_key, 0)
|
||||
reason = None
|
||||
|
||||
# Take profit
|
||||
if pnl_pct >= TP_PCT:
|
||||
reason = f"TP hit ({pnl_pct:+.1f}%)"
|
||||
# Stop loss
|
||||
elif pnl_pct <= SL_PCT:
|
||||
reason = f"SL hit ({pnl_pct:+.1f}%)"
|
||||
# Trailing stop (only if we were profitable)
|
||||
elif peak >= 2.0 and (peak - pnl_pct) >= TRAILING_STOP_PCT:
|
||||
reason = f"Trailing stop (peak {peak:+.1f}%, now {pnl_pct:+.1f}%)"
|
||||
|
||||
if reason:
|
||||
result = close_position(game_id, username, pos_id, reason=reason)
|
||||
if result.get("success"):
|
||||
exits.append(result)
|
||||
# Clean up peak tracking
|
||||
state["peak_pnl"].pop(peak_key, None)
|
||||
|
||||
return exits
|
||||
|
||||
|
||||
def main():
|
||||
game_id = ensure_default_game()
|
||||
state = load_state()
|
||||
|
||||
print(f"=== Leverage Trader ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print(f"Game: {game_id}")
|
||||
|
||||
# 1. Update prices and check liquidations
|
||||
liquidations = update_prices(game_id, "case")
|
||||
for liq in liquidations:
|
||||
msg = f"💀 <b>LIQUIDATED</b>: {liq['symbol']} {liq['direction']} {liq.get('leverage', '?')}x | Lost ${abs(liq.get('pnl', 0)):.2f}"
|
||||
send_telegram(msg)
|
||||
print(msg)
|
||||
|
||||
# 2. Manage exits (TP/SL/trailing)
|
||||
exits = manage_exits(game_id, "case", state)
|
||||
for ex in exits:
|
||||
emoji = "✅" if ex.get("pnl", 0) > 0 else "❌"
|
||||
msg = (f"{emoji} <b>Closed</b>: {ex['symbol']} {ex['direction']} | "
|
||||
f"Entry: ${ex['entry_price']:.4f} → Exit: ${ex['exit_price']:.4f} | "
|
||||
f"PnL: ${ex['pnl']:+.2f} ({ex['pnl_pct']:+.1f}%)")
|
||||
print(msg)
|
||||
|
||||
# 3. Get current portfolio
|
||||
pf = get_portfolio(game_id, "case")
|
||||
num_open = pf["num_positions"] if pf else 0
|
||||
slots = MAX_OPEN_POSITIONS - num_open
|
||||
|
||||
print(f"\nPortfolio: ${pf['equity']:,.2f} ({pf['pnl_pct']:+.2f}%) | {num_open} positions | {slots} slots open")
|
||||
|
||||
# 4. Scan for new opportunities
|
||||
if slots > 0:
|
||||
# Run both scanners
|
||||
shorts = run_short_scan()
|
||||
longs = run_spot_scan()
|
||||
|
||||
# Get existing symbols to avoid doubling up
|
||||
existing_symbols = set()
|
||||
if pf:
|
||||
for pos in pf["positions"].values():
|
||||
existing_symbols.add(pos["symbol"])
|
||||
|
||||
opened = []
|
||||
|
||||
# Open short positions
|
||||
for r in shorts:
|
||||
if slots <= 0:
|
||||
break
|
||||
if r["score"] < SHORT_SCORE_THRESHOLD:
|
||||
break
|
||||
if r["symbol"] in existing_symbols:
|
||||
continue
|
||||
|
||||
lev = 15 if r["score"] >= 70 else 10 if r["score"] >= 60 else 7
|
||||
result = open_position(game_id, "case", r["symbol"], "short", MARGIN_PER_TRADE, lev,
|
||||
reason=f"Short scanner score:{r['score']}")
|
||||
if result.get("success"):
|
||||
opened.append(result)
|
||||
existing_symbols.add(r["symbol"])
|
||||
slots -= 1
|
||||
time.sleep(0.2)
|
||||
|
||||
# Open long positions
|
||||
for r in longs:
|
||||
if slots <= 0:
|
||||
break
|
||||
if r["score"] < LONG_SCORE_THRESHOLD:
|
||||
break
|
||||
if r["symbol"] in existing_symbols:
|
||||
continue
|
||||
|
||||
lev = 15 if r["score"] >= 70 else 10 if r["score"] >= 60 else 7
|
||||
result = open_position(game_id, "case", r["symbol"], "long", MARGIN_PER_TRADE, lev,
|
||||
reason=f"Long scanner score:{r['score']}")
|
||||
if result.get("success"):
|
||||
opened.append(result)
|
||||
existing_symbols.add(r["symbol"])
|
||||
slots -= 1
|
||||
time.sleep(0.2)
|
||||
|
||||
if opened:
|
||||
lines = [f"📊 <b>Opened {len(opened)} positions</b>\n"]
|
||||
for o in opened:
|
||||
lines.append(f"{'🔴' if o['direction']=='short' else '🟢'} {o['symbol']} {o['direction']} {o['leverage']}x @ ${o['entry_price']:.4f} (${o['margin']:.0f} margin)")
|
||||
send_telegram("\n".join(lines))
|
||||
print(f"\nOpened {len(opened)} new positions")
|
||||
|
||||
# 5. Send periodic summary (every 4 hours)
|
||||
if exits or liquidations:
|
||||
pf = get_portfolio(game_id, "case") # Refresh
|
||||
msg = (f"📈 <b>Leverage Challenge Update</b>\n"
|
||||
f"Equity: ${pf['equity']:,.2f} ({pf['pnl_pct']:+.2f}%)\n"
|
||||
f"Positions: {pf['num_positions']} | Cash: ${pf['cash']:,.2f}\n"
|
||||
f"Realized PnL: ${pf['realized_pnl']:+,.2f} | Fees: ${pf['total_fees']:,.2f}")
|
||||
send_telegram(msg)
|
||||
|
||||
save_state(state)
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
311
projects/crypto-signals/scripts/polymarket_arb_scanner.py
Normal file
311
projects/crypto-signals/scripts/polymarket_arb_scanner.py
Normal file
@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Polymarket 15-Min Crypto Arbitrage Scanner
|
||||
Scans active 15-minute crypto markets for arbitrage opportunities.
|
||||
Alerts via Telegram when combined Up+Down cost < $1.00 (after fees).
|
||||
|
||||
Zero AI tokens — runs as pure Python via systemd timer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Config
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "arb-scanner"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOG_FILE = DATA_DIR / "scan_log.json"
|
||||
PAPER_TRADES_FILE = DATA_DIR / "paper_trades.json"
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
# Polymarket fee formula for 15-min markets
|
||||
def calc_taker_fee(shares, price):
|
||||
"""Calculate taker fee in USDC."""
|
||||
if price <= 0 or price >= 1:
|
||||
return 0
|
||||
return shares * price * 0.25 * (price * (1 - price)) ** 2
|
||||
|
||||
def calc_fee_rate(price):
|
||||
"""Effective fee rate at a given price."""
|
||||
if price <= 0 or price >= 1:
|
||||
return 0
|
||||
return 0.25 * (price * (1 - price)) ** 2
|
||||
|
||||
|
||||
def get_active_15min_markets():
|
||||
"""Fetch active 15-minute crypto markets from Polymarket."""
|
||||
markets = []
|
||||
|
||||
# 15-min markets are scattered across pagination — scan broadly
|
||||
for offset in range(0, 3000, 200):
|
||||
url = (
|
||||
f"https://gamma-api.polymarket.com/markets?"
|
||||
f"active=true&closed=false&limit=200&offset={offset}"
|
||||
f"&order=volume&ascending=false"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
batch = json.loads(resp.read())
|
||||
for m in batch:
|
||||
q = m.get("question", "").lower()
|
||||
if "up or down" in q:
|
||||
markets.append(m)
|
||||
if len(batch) < 200:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error fetching markets (offset={offset}): {e}")
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# Only keep markets ending within the next 4 hours (tradeable window)
|
||||
now = datetime.now(timezone.utc)
|
||||
tradeable = []
|
||||
for m in markets:
|
||||
end_str = m.get("endDate", "")
|
||||
if not end_str:
|
||||
continue
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||
hours_until = (end_dt - now).total_seconds() / 3600
|
||||
if 0.25 < hours_until <= 24: # Skip markets < 15min to expiry (already resolved)
|
||||
m["_hours_until_end"] = round(hours_until, 2)
|
||||
tradeable.append(m)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Deduplicate
|
||||
seen = set()
|
||||
unique = []
|
||||
for m in tradeable:
|
||||
cid = m.get("conditionId", m.get("id", ""))
|
||||
if cid not in seen:
|
||||
seen.add(cid)
|
||||
unique.append(m)
|
||||
|
||||
return unique
|
||||
|
||||
|
||||
def get_orderbook_prices(token_id):
|
||||
"""Get best bid/ask from the CLOB API."""
|
||||
url = f"https://clob.polymarket.com/book?token_id={token_id}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
book = json.loads(resp.read())
|
||||
bids = book.get("bids", [])
|
||||
asks = book.get("asks", [])
|
||||
best_bid = float(bids[0]["price"]) if bids else 0
|
||||
best_ask = float(asks[0]["price"]) if asks else 1
|
||||
bid_size = float(bids[0].get("size", 0)) if bids else 0
|
||||
ask_size = float(asks[0].get("size", 0)) if asks else 0
|
||||
return {
|
||||
"best_bid": best_bid,
|
||||
"best_ask": best_ask,
|
||||
"bid_size": bid_size,
|
||||
"ask_size": ask_size,
|
||||
"spread": best_ask - best_bid
|
||||
}
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def scan_for_arbs():
|
||||
"""Scan all active 15-min markets for arbitrage opportunities."""
|
||||
markets = get_active_15min_markets()
|
||||
print(f"Found {len(markets)} active 15-min crypto markets")
|
||||
|
||||
opportunities = []
|
||||
|
||||
for market in markets:
|
||||
question = market.get("question", market.get("title", ""))
|
||||
hours_left = market.get("_hours_until_end", "?")
|
||||
|
||||
# Get token IDs for both outcomes
|
||||
tokens = market.get("clobTokenIds", "")
|
||||
if isinstance(tokens, str):
|
||||
try:
|
||||
tokens = json.loads(tokens) if tokens.startswith("[") else tokens.split(",")
|
||||
except:
|
||||
tokens = []
|
||||
|
||||
if len(tokens) < 2:
|
||||
continue
|
||||
|
||||
# Get orderbook for both tokens (ask = price to buy)
|
||||
book_up = get_orderbook_prices(tokens[0])
|
||||
book_down = get_orderbook_prices(tokens[1])
|
||||
time.sleep(0.15)
|
||||
|
||||
if not book_up or not book_down:
|
||||
continue
|
||||
|
||||
# For arb: we BUY both sides at the ASK price
|
||||
up_ask = book_up["best_ask"]
|
||||
down_ask = book_down["best_ask"]
|
||||
combined = up_ask + down_ask
|
||||
|
||||
# Calculate fees on 100 shares
|
||||
fee_up = calc_taker_fee(100, up_ask)
|
||||
fee_down = calc_taker_fee(100, down_ask)
|
||||
total_cost_100 = (up_ask + down_ask) * 100 + fee_up + fee_down
|
||||
net_profit_100 = 100 - total_cost_100
|
||||
net_profit_pct = net_profit_100 / total_cost_100 * 100 if total_cost_100 > 0 else 0
|
||||
|
||||
# Fillable size (limited by smaller side)
|
||||
fillable_size = min(book_up["ask_size"], book_down["ask_size"])
|
||||
if fillable_size > 0:
|
||||
fill_fee_up = calc_taker_fee(fillable_size, up_ask)
|
||||
fill_fee_down = calc_taker_fee(fillable_size, down_ask)
|
||||
fill_cost = (up_ask + down_ask) * fillable_size + fill_fee_up + fill_fee_down
|
||||
fill_profit = fillable_size - fill_cost
|
||||
else:
|
||||
fill_profit = 0
|
||||
|
||||
opp = {
|
||||
"question": question,
|
||||
"hours_left": hours_left,
|
||||
"up_ask": up_ask,
|
||||
"down_ask": down_ask,
|
||||
"up_ask_size": book_up["ask_size"],
|
||||
"down_ask_size": book_down["ask_size"],
|
||||
"combined": round(combined, 4),
|
||||
"fee_up_per_100": round(fee_up, 4),
|
||||
"fee_down_per_100": round(fee_down, 4),
|
||||
"total_fees_per_100": round(fee_up + fee_down, 4),
|
||||
"net_profit_per_100": round(net_profit_100, 2),
|
||||
"net_profit_pct": round(net_profit_pct, 2),
|
||||
"fillable_shares": fillable_size,
|
||||
"fillable_profit": round(fill_profit, 2),
|
||||
"is_arb": net_profit_100 > 0,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
opportunities.append(opp)
|
||||
|
||||
return opportunities
|
||||
|
||||
|
||||
def paper_trade(opp):
|
||||
"""Record a paper trade for an arb opportunity."""
|
||||
trades = []
|
||||
if PAPER_TRADES_FILE.exists():
|
||||
trades = json.loads(PAPER_TRADES_FILE.read_text())
|
||||
|
||||
trade = {
|
||||
"id": len(trades) + 1,
|
||||
"timestamp": opp["timestamp"],
|
||||
"question": opp["question"],
|
||||
"up_price": opp.get("up_ask", opp.get("up_price", 0)),
|
||||
"down_price": opp.get("down_ask", opp.get("down_price", 0)),
|
||||
"combined": opp["combined"],
|
||||
"fees_per_100": opp["total_fees_per_100"],
|
||||
"net_profit_per_100": opp["net_profit_per_100"],
|
||||
"net_profit_pct": opp["net_profit_pct"],
|
||||
"status": "open", # Will be "won" when market resolves (always wins if real arb)
|
||||
"paper_size_usd": 50, # Paper trade $50 per arb
|
||||
}
|
||||
|
||||
expected_profit = 50 * opp["net_profit_pct"] / 100
|
||||
trade["expected_profit_usd"] = round(expected_profit, 2)
|
||||
|
||||
trades.append(trade)
|
||||
PAPER_TRADES_FILE.write_text(json.dumps(trades, indent=2))
|
||||
return trade
|
||||
|
||||
|
||||
def send_telegram_alert(message):
|
||||
"""Send alert via Telegram bot API (zero tokens)."""
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[ALERT] {message}")
|
||||
return
|
||||
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML"
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram alert failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== Polymarket 15-Min Arb Scanner ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print()
|
||||
|
||||
opps = scan_for_arbs()
|
||||
|
||||
arbs = [o for o in opps if o["is_arb"]]
|
||||
non_arbs = [o for o in opps if not o["is_arb"]]
|
||||
|
||||
print(f"\nResults: {len(opps)} markets scanned, {len(arbs)} arb opportunities\n")
|
||||
|
||||
for o in sorted(opps, key=lambda x: x.get("net_profit_pct", 0), reverse=True):
|
||||
emoji = "✅" if o["is_arb"] else "❌"
|
||||
print(f"{emoji} {o['question'][:65]}")
|
||||
up = o.get('up_ask', o.get('up_price', '?'))
|
||||
down = o.get('down_ask', o.get('down_price', '?'))
|
||||
print(f" Up: ${up} | Down: ${down} | Combined: ${o['combined']}")
|
||||
print(f" Fees/100: ${o['total_fees_per_100']} | Net profit/100: ${o['net_profit_per_100']} ({o['net_profit_pct']}%)")
|
||||
if o.get('fillable_shares'):
|
||||
print(f" Fillable: {o['fillable_shares']:.0f} shares | Fillable profit: ${o.get('fillable_profit', '?')}")
|
||||
print()
|
||||
|
||||
# Paper trade any arbs found
|
||||
for arb in arbs:
|
||||
trade = paper_trade(arb)
|
||||
print(f"📝 Paper trade #{trade['id']}: {trade['question'][:50]} | Expected: +${trade['expected_profit_usd']}")
|
||||
|
||||
# Send Telegram alert
|
||||
msg = (
|
||||
f"🔔 <b>Arb Found!</b>\n\n"
|
||||
f"<b>{arb['question']}</b>\n"
|
||||
f"Up: ${arb.get('up_ask', arb.get('up_price', '?'))} | "
|
||||
f"Down: ${arb.get('down_ask', arb.get('down_price', '?'))}\n"
|
||||
f"Combined: ${arb['combined']} (after fees)\n"
|
||||
f"Net profit: {arb['net_profit_pct']}%\n\n"
|
||||
f"📝 Paper traded $50 → expected +${trade['expected_profit_usd']}"
|
||||
)
|
||||
send_telegram_alert(msg)
|
||||
|
||||
# Save scan log
|
||||
log = []
|
||||
if LOG_FILE.exists():
|
||||
try:
|
||||
log = json.loads(LOG_FILE.read_text())
|
||||
except:
|
||||
pass
|
||||
|
||||
log.append({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"markets_scanned": len(opps),
|
||||
"arbs_found": len(arbs),
|
||||
"opportunities": opps,
|
||||
})
|
||||
|
||||
# Keep last 1000 scans
|
||||
log = log[-1000:]
|
||||
LOG_FILE.write_text(json.dumps(log, indent=2))
|
||||
|
||||
# Summary of paper trades
|
||||
if PAPER_TRADES_FILE.exists():
|
||||
trades = json.loads(PAPER_TRADES_FILE.read_text())
|
||||
total_expected = sum(t.get("expected_profit_usd", 0) for t in trades)
|
||||
print(f"\n📊 Paper trade total: {len(trades)} trades, expected profit: ${total_expected:.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
131
projects/crypto-signals/scripts/price_fetcher.py
Normal file
131
projects/crypto-signals/scripts/price_fetcher.py
Normal file
@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crypto Price Fetcher
|
||||
Pulls historical OHLCV data from Binance public API (no key needed).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# Binance intl is geo-blocked from US; use Binance US
|
||||
BINANCE_KLINES = "https://api.binance.us/api/v3/klines"
|
||||
BINANCE_TICKER = "https://api.binance.us/api/v3/ticker/price"
|
||||
|
||||
|
||||
def get_price_at_time(symbol, timestamp_ms, interval='1m'):
|
||||
"""Get the candle at a specific timestamp."""
|
||||
url = f"{BINANCE_KLINES}?symbol={symbol}&interval={interval}&startTime={timestamp_ms}&limit=1"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())
|
||||
if data:
|
||||
return {
|
||||
'open_time': data[0][0],
|
||||
'open': float(data[0][1]),
|
||||
'high': float(data[0][2]),
|
||||
'low': float(data[0][3]),
|
||||
'close': float(data[0][4]),
|
||||
'volume': float(data[0][5]),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error fetching {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_klines(symbol, interval='1h', start_time_ms=None, end_time_ms=None, limit=1000):
|
||||
"""Get historical klines/candlestick data."""
|
||||
params = f"symbol={symbol}&interval={interval}&limit={limit}"
|
||||
if start_time_ms:
|
||||
params += f"&startTime={start_time_ms}"
|
||||
if end_time_ms:
|
||||
params += f"&endTime={end_time_ms}"
|
||||
|
||||
url = f"{BINANCE_KLINES}?{params}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
raw = json.loads(resp.read())
|
||||
return [{
|
||||
'open_time': k[0],
|
||||
'open': float(k[1]),
|
||||
'high': float(k[2]),
|
||||
'low': float(k[3]),
|
||||
'close': float(k[4]),
|
||||
'volume': float(k[5]),
|
||||
'close_time': k[6],
|
||||
} for k in raw]
|
||||
except Exception as e:
|
||||
print(f"Error fetching klines for {symbol}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_all_klines(symbol, interval, start_time_ms, end_time_ms):
|
||||
"""Paginate through all klines between two timestamps."""
|
||||
all_klines = []
|
||||
current_start = start_time_ms
|
||||
|
||||
while current_start < end_time_ms:
|
||||
batch = get_klines(symbol, interval, current_start, end_time_ms)
|
||||
if not batch:
|
||||
break
|
||||
all_klines.extend(batch)
|
||||
current_start = batch[-1]['close_time'] + 1
|
||||
time.sleep(0.1) # Rate limiting
|
||||
|
||||
return all_klines
|
||||
|
||||
|
||||
def get_current_price(symbol):
|
||||
"""Get current price."""
|
||||
url = f"{BINANCE_TICKER}?symbol={symbol}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())
|
||||
return float(data['price'])
|
||||
except Exception as e:
|
||||
print(f"Error fetching current price for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def normalize_symbol(ticker):
|
||||
"""Convert signal ticker to Binance symbol format."""
|
||||
# Remove USDT suffix if present, then add it back
|
||||
ticker = ticker.upper().replace('USDT', '').replace('/', '')
|
||||
return f"{ticker}USDT"
|
||||
|
||||
|
||||
def datetime_to_ms(dt_str):
|
||||
"""Convert datetime string to milliseconds timestamp."""
|
||||
# Handle various formats
|
||||
for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d']:
|
||||
try:
|
||||
dt = datetime.strptime(dt_str, fmt).replace(tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Test with current signals
|
||||
for ticker in ['ASTERUSDT', 'HYPEUSDT']:
|
||||
symbol = normalize_symbol(ticker)
|
||||
price = get_current_price(symbol)
|
||||
print(f"{symbol}: ${price}")
|
||||
|
||||
# Get last 24h of 1h candles
|
||||
now_ms = int(time.time() * 1000)
|
||||
day_ago = now_ms - (24 * 60 * 60 * 1000)
|
||||
klines = get_klines(symbol, '1h', day_ago, now_ms)
|
||||
if klines:
|
||||
highs = [k['high'] for k in klines]
|
||||
lows = [k['low'] for k in klines]
|
||||
print(f" 24h range: ${min(lows):.4f} - ${max(highs):.4f}")
|
||||
print(f" Candles: {len(klines)}")
|
||||
print()
|
||||
336
projects/crypto-signals/scripts/short_scanner.py
Normal file
336
projects/crypto-signals/scripts/short_scanner.py
Normal file
@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crypto Short Signal Scanner
|
||||
Scans for overbought coins ripe for shorting.
|
||||
Criteria: high RSI, price above VWAP, fading momentum, bearish divergence.
|
||||
|
||||
Zero AI tokens — runs as pure Python via systemd timer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import math
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Config
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "short-scanner"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SCAN_LOG = DATA_DIR / "scan_log.json"
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
BINANCE_KLINES = "https://api.binance.us/api/v3/klines"
|
||||
BINANCE_TICKER = "https://api.binance.us/api/v3/ticker/24hr"
|
||||
|
||||
# Coins to scan (popular leveraged trading coins)
|
||||
COINS = [
|
||||
"BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT",
|
||||
"ADAUSDT", "AVAXUSDT", "LINKUSDT", "DOTUSDT", "MATICUSDT",
|
||||
"NEARUSDT", "ATOMUSDT", "LTCUSDT", "UNIUSDT", "AAVEUSDT",
|
||||
"FILUSDT", "ALGOUSDT", "XLMUSDT", "VETUSDT", "ICPUSDT",
|
||||
"APTUSDT", "SUIUSDT", "ARBUSDT", "OPUSDT", "SEIUSDT",
|
||||
"HYPEUSDT", "TRUMPUSDT", "PUMPUSDT", "ASTERUSDT",
|
||||
]
|
||||
|
||||
|
||||
def get_klines(symbol, interval='1h', limit=100):
|
||||
"""Fetch klines from Binance US."""
|
||||
url = f"{BINANCE_KLINES}?symbol={symbol}&interval={interval}&limit={limit}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
raw = json.loads(resp.read())
|
||||
return [{
|
||||
'open': float(k[1]),
|
||||
'high': float(k[2]),
|
||||
'low': float(k[3]),
|
||||
'close': float(k[4]),
|
||||
'volume': float(k[5]),
|
||||
'close_time': k[6],
|
||||
} for k in raw]
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def calc_rsi(closes, period=14):
|
||||
"""Calculate RSI."""
|
||||
if len(closes) < period + 1:
|
||||
return 50
|
||||
deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))]
|
||||
gains = [d if d > 0 else 0 for d in deltas]
|
||||
losses = [-d if d < 0 else 0 for d in deltas]
|
||||
|
||||
avg_gain = sum(gains[:period]) / period
|
||||
avg_loss = sum(losses[:period]) / period
|
||||
|
||||
for i in range(period, len(deltas)):
|
||||
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100
|
||||
rs = avg_gain / avg_loss
|
||||
return round(100 - (100 / (1 + rs)), 1)
|
||||
|
||||
|
||||
def calc_vwap(klines):
|
||||
"""Calculate VWAP from klines."""
|
||||
cum_vol = 0
|
||||
cum_tp_vol = 0
|
||||
for k in klines:
|
||||
tp = (k['high'] + k['low'] + k['close']) / 3
|
||||
cum_vol += k['volume']
|
||||
cum_tp_vol += tp * k['volume']
|
||||
if cum_vol == 0:
|
||||
return 0
|
||||
return cum_tp_vol / cum_vol
|
||||
|
||||
|
||||
def calc_ema(values, period):
|
||||
"""Calculate EMA."""
|
||||
if not values:
|
||||
return 0
|
||||
multiplier = 2 / (period + 1)
|
||||
ema = values[0]
|
||||
for v in values[1:]:
|
||||
ema = (v - ema) * multiplier + ema
|
||||
return ema
|
||||
|
||||
|
||||
def calc_macd(closes):
|
||||
"""Calculate MACD (12, 26, 9)."""
|
||||
if len(closes) < 26:
|
||||
return 0, 0, 0
|
||||
ema12 = calc_ema(closes, 12)
|
||||
ema26 = calc_ema(closes, 26)
|
||||
macd_line = ema12 - ema26
|
||||
# Approximate signal line
|
||||
signal = calc_ema(closes[-9:], 9) if len(closes) >= 9 else macd_line
|
||||
histogram = macd_line - signal
|
||||
return macd_line, signal, histogram
|
||||
|
||||
|
||||
def calc_bollinger_position(closes, period=20):
|
||||
"""How far price is from upper Bollinger band. >1 = above upper band."""
|
||||
if len(closes) < period:
|
||||
return 0.5
|
||||
recent = closes[-period:]
|
||||
sma = sum(recent) / period
|
||||
std = (sum((x - sma)**2 for x in recent) / period) ** 0.5
|
||||
if std == 0:
|
||||
return 0.5
|
||||
upper = sma + 2 * std
|
||||
lower = sma - 2 * std
|
||||
band_width = upper - lower
|
||||
if band_width == 0:
|
||||
return 0.5
|
||||
return (closes[-1] - lower) / band_width
|
||||
|
||||
|
||||
def volume_trend(klines, lookback=10):
|
||||
"""Compare recent volume to average. >1 means increasing volume."""
|
||||
if len(klines) < lookback * 2:
|
||||
return 1.0
|
||||
recent_vol = sum(k['volume'] for k in klines[-lookback:]) / lookback
|
||||
older_vol = sum(k['volume'] for k in klines[-lookback*2:-lookback]) / lookback
|
||||
if older_vol == 0:
|
||||
return 1.0
|
||||
return recent_vol / older_vol
|
||||
|
||||
|
||||
def scan_coin(symbol):
|
||||
"""Analyze a single coin for short signals."""
|
||||
# Get 1h candles for RSI/VWAP/indicators
|
||||
klines_1h = get_klines(symbol, '1h', 100)
|
||||
if len(klines_1h) < 30:
|
||||
return None
|
||||
|
||||
closes = [k['close'] for k in klines_1h]
|
||||
current_price = closes[-1]
|
||||
|
||||
# RSI (14-period on 1h)
|
||||
rsi = calc_rsi(closes)
|
||||
|
||||
# VWAP (24h)
|
||||
vwap_24h = calc_vwap(klines_1h[-24:])
|
||||
vwap_pct = ((current_price - vwap_24h) / vwap_24h * 100) if vwap_24h else 0
|
||||
|
||||
# MACD
|
||||
macd_line, signal_line, histogram = calc_macd(closes)
|
||||
macd_bearish = histogram < 0 # Below signal = bearish
|
||||
|
||||
# Bollinger position
|
||||
bb_pos = calc_bollinger_position(closes)
|
||||
|
||||
# Volume trend
|
||||
vol_trend = volume_trend(klines_1h)
|
||||
|
||||
# 24h change
|
||||
price_24h_ago = closes[-24] if len(closes) >= 24 else closes[0]
|
||||
change_24h = ((current_price - price_24h_ago) / price_24h_ago * 100) if price_24h_ago else 0
|
||||
|
||||
# 4h change (momentum)
|
||||
price_4h_ago = closes[-4] if len(closes) >= 4 else closes[0]
|
||||
change_4h = ((current_price - price_4h_ago) / price_4h_ago * 100) if price_4h_ago else 0
|
||||
|
||||
# === SHORT SCORING ===
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
# RSI overbought (max 30 pts)
|
||||
if rsi >= 80:
|
||||
score += 30
|
||||
reasons.append(f"RSI extremely overbought ({rsi})")
|
||||
elif rsi >= 70:
|
||||
score += 25
|
||||
reasons.append(f"RSI overbought ({rsi})")
|
||||
elif rsi >= 65:
|
||||
score += 15
|
||||
reasons.append(f"RSI elevated ({rsi})")
|
||||
elif rsi >= 60:
|
||||
score += 5
|
||||
reasons.append(f"RSI mildly elevated ({rsi})")
|
||||
|
||||
# Price above VWAP (max 20 pts)
|
||||
if vwap_pct > 5:
|
||||
score += 20
|
||||
reasons.append(f"Well above VWAP (+{vwap_pct:.1f}%)")
|
||||
elif vwap_pct > 3:
|
||||
score += 15
|
||||
reasons.append(f"Above VWAP (+{vwap_pct:.1f}%)")
|
||||
elif vwap_pct > 1:
|
||||
score += 8
|
||||
reasons.append(f"Slightly above VWAP (+{vwap_pct:.1f}%)")
|
||||
|
||||
# MACD bearish crossover (max 15 pts)
|
||||
if macd_bearish and histogram < -0.001 * current_price:
|
||||
score += 15
|
||||
reasons.append("MACD bearish + accelerating")
|
||||
elif macd_bearish:
|
||||
score += 10
|
||||
reasons.append("MACD bearish crossover")
|
||||
|
||||
# Bollinger band position (max 15 pts)
|
||||
if bb_pos > 1.0:
|
||||
score += 15
|
||||
reasons.append(f"Above upper Bollinger ({bb_pos:.2f})")
|
||||
elif bb_pos > 0.85:
|
||||
score += 10
|
||||
reasons.append(f"Near upper Bollinger ({bb_pos:.2f})")
|
||||
|
||||
# Big recent pump (mean reversion candidate) (max 15 pts)
|
||||
if change_24h > 15:
|
||||
score += 15
|
||||
reasons.append(f"Pumped +{change_24h:.1f}% 24h")
|
||||
elif change_24h > 8:
|
||||
score += 10
|
||||
reasons.append(f"Up +{change_24h:.1f}% 24h")
|
||||
elif change_24h > 4:
|
||||
score += 5
|
||||
reasons.append(f"Up +{change_24h:.1f}% 24h")
|
||||
|
||||
# Volume fading on uptrend (exhaustion) (5 pts)
|
||||
if change_24h > 2 and vol_trend < 0.7:
|
||||
score += 5
|
||||
reasons.append("Volume fading on uptrend (exhaustion)")
|
||||
|
||||
return {
|
||||
"symbol": symbol.replace("USDT", ""),
|
||||
"price": current_price,
|
||||
"rsi": rsi,
|
||||
"vwap_pct": round(vwap_pct, 2),
|
||||
"macd_histogram": round(histogram, 6),
|
||||
"bb_position": round(bb_pos, 2),
|
||||
"change_24h": round(change_24h, 2),
|
||||
"change_4h": round(change_4h, 2),
|
||||
"vol_trend": round(vol_trend, 2),
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def send_telegram_alert(message):
|
||||
"""Send alert via Telegram bot API."""
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[ALERT] {message}")
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML"
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram alert failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== Crypto Short Signal Scanner ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print()
|
||||
|
||||
results = []
|
||||
for symbol in COINS:
|
||||
result = scan_coin(symbol)
|
||||
if result:
|
||||
results.append(result)
|
||||
time.sleep(0.15) # Rate limiting
|
||||
|
||||
# Sort by score descending
|
||||
results.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
# Print all results
|
||||
for r in results:
|
||||
emoji = "🔴" if r['score'] >= 50 else "🟡" if r['score'] >= 30 else "⚪"
|
||||
print(f"{emoji} {r['symbol']:8s} score:{r['score']:3d} | RSI:{r['rsi']:5.1f} | VWAP:{r['vwap_pct']:+6.1f}% | 24h:{r['change_24h']:+6.1f}% | BB:{r['bb_position']:.2f}")
|
||||
if r['reasons']:
|
||||
for reason in r['reasons']:
|
||||
print(f" → {reason}")
|
||||
|
||||
# Alert on strong short signals (score >= 50)
|
||||
strong = [r for r in results if r['score'] >= 50]
|
||||
if strong:
|
||||
lines = ["🔴 <b>Short Signals Detected</b>\n"]
|
||||
for r in strong:
|
||||
lines.append(f"<b>{r['symbol']}</b> (score: {r['score']})")
|
||||
lines.append(f" Price: ${r['price']:.4f} | RSI: {r['rsi']} | VWAP: {r['vwap_pct']:+.1f}%")
|
||||
lines.append(f" 24h: {r['change_24h']:+.1f}% | BB: {r['bb_position']:.2f}")
|
||||
for reason in r['reasons']:
|
||||
lines.append(f" → {reason}")
|
||||
lines.append("")
|
||||
send_telegram_alert("\n".join(lines))
|
||||
|
||||
# Save scan log
|
||||
log = []
|
||||
if SCAN_LOG.exists():
|
||||
try:
|
||||
log = json.loads(SCAN_LOG.read_text())
|
||||
except:
|
||||
pass
|
||||
|
||||
log.append({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"coins_scanned": len(results),
|
||||
"strong_signals": len(strong),
|
||||
"results": results,
|
||||
})
|
||||
log = log[-500:]
|
||||
SCAN_LOG.write_text(json.dumps(log, indent=2))
|
||||
|
||||
print(f"\n📊 Summary: {len(results)} scanned, {len(strong)} strong short signals")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
166
projects/crypto-signals/scripts/signal_parser.py
Normal file
166
projects/crypto-signals/scripts/signal_parser.py
Normal file
@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Crypto Signal Parser
|
||||
Parses exported Telegram JSON chat history and extracts structured trading signals.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Signal patterns - adapt as we see more formats
|
||||
PATTERNS = {
|
||||
# #TICKER direction entry SL target leverage balance%
|
||||
'standard': re.compile(
|
||||
r'#(\w+)\s+' # ticker
|
||||
r'(Long|Short)\s+' # direction
|
||||
r'(?:market\s+entry!?|entry[:\s]+([0-9.]+))\s*' # entry type/price
|
||||
r'SL[;:\s]+([0-9.]+)\s*' # stop loss
|
||||
r'(?:Targets?|TP)[;:\s]+([0-9.,\s]+)\s*' # targets (can be multiple)
|
||||
r'(?:Lev(?:erage)?[:\s]*x?([0-9.]+))?\s*' # leverage (optional)
|
||||
r'(?:([0-9.]+)%?\s*balance)?', # balance % (optional)
|
||||
re.IGNORECASE
|
||||
),
|
||||
# Simpler: #TICKER Short/Long entry SL targets
|
||||
'simple': re.compile(
|
||||
r'#(\w+)\s+(Long|Short)',
|
||||
re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_signal_text(text):
|
||||
"""Parse a single message text into structured signal(s)."""
|
||||
signals = []
|
||||
|
||||
# Try to find all ticker mentions
|
||||
ticker_blocks = re.split(r'(?=#\w+USDT)', text)
|
||||
|
||||
for block in ticker_blocks:
|
||||
if not block.strip():
|
||||
continue
|
||||
|
||||
signal = {}
|
||||
|
||||
# Extract ticker
|
||||
ticker_match = re.search(r'#(\w+)', block)
|
||||
if not ticker_match:
|
||||
continue
|
||||
signal['ticker'] = ticker_match.group(1).upper()
|
||||
|
||||
# Extract direction
|
||||
dir_match = re.search(r'\b(Long|Short)\b', block, re.IGNORECASE)
|
||||
if not dir_match:
|
||||
continue
|
||||
signal['direction'] = dir_match.group(1).lower()
|
||||
|
||||
# Extract entry price (or "market")
|
||||
entry_match = re.search(r'(?:entry|enter)[:\s]*([0-9.]+)', block, re.IGNORECASE)
|
||||
if entry_match:
|
||||
signal['entry'] = float(entry_match.group(1))
|
||||
else:
|
||||
signal['entry'] = 'market'
|
||||
|
||||
# Extract stop loss
|
||||
sl_match = re.search(r'SL[;:\s]+([0-9.]+)', block, re.IGNORECASE)
|
||||
if sl_match:
|
||||
signal['stop_loss'] = float(sl_match.group(1))
|
||||
|
||||
# Extract targets (can be multiple, comma or space separated)
|
||||
tp_match = re.search(r'(?:Targets?|TP)[;:\s]+([0-9.,\s]+)', block, re.IGNORECASE)
|
||||
if tp_match:
|
||||
targets_str = tp_match.group(1)
|
||||
targets = [float(t.strip()) for t in re.findall(r'[0-9.]+', targets_str)]
|
||||
signal['targets'] = targets
|
||||
|
||||
# Extract leverage
|
||||
lev_match = re.search(r'Lev(?:erage)?[:\s]*x?([0-9.]+)', block, re.IGNORECASE)
|
||||
if lev_match:
|
||||
signal['leverage'] = float(lev_match.group(1))
|
||||
|
||||
# Extract balance percentage
|
||||
bal_match = re.search(r'([0-9.]+)%?\s*balance', block, re.IGNORECASE)
|
||||
if bal_match:
|
||||
signal['balance_pct'] = float(bal_match.group(1))
|
||||
|
||||
if signal.get('ticker') and signal.get('direction'):
|
||||
signals.append(signal)
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def parse_telegram_export(json_path):
|
||||
"""Parse a Telegram JSON export file."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
messages = data.get('messages', [])
|
||||
all_signals = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get('type') != 'message':
|
||||
continue
|
||||
|
||||
# Get text content (can be string or list of text entities)
|
||||
text_parts = msg.get('text', '')
|
||||
if isinstance(text_parts, list):
|
||||
text = ''.join(
|
||||
p if isinstance(p, str) else p.get('text', '')
|
||||
for p in text_parts
|
||||
)
|
||||
else:
|
||||
text = text_parts
|
||||
|
||||
if not text or '#' not in text:
|
||||
continue
|
||||
|
||||
# Check if it looks like a signal
|
||||
if not re.search(r'(Long|Short)', text, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
signals = parse_signal_text(text)
|
||||
|
||||
for signal in signals:
|
||||
signal['timestamp'] = msg.get('date', '')
|
||||
signal['message_id'] = msg.get('id', '')
|
||||
signal['raw_text'] = text[:500]
|
||||
all_signals.append(signal)
|
||||
|
||||
return all_signals
|
||||
|
||||
|
||||
def parse_forwarded_messages(messages_text):
|
||||
"""Parse signals from forwarded message text (copy-pasted or forwarded to bot)."""
|
||||
signals = parse_signal_text(messages_text)
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
# Demo with the test signals
|
||||
test_text = """#ASTERUSDT Short market entry! SL: 0.6385 Targets: 0.51 Lev x15 1.3% balance
|
||||
#HYPEUSDT Short market entry! SL; 33.5 Target 25 Lev x12 1.4% balance"""
|
||||
|
||||
signals = parse_signal_text(test_text)
|
||||
print(f"Parsed {len(signals)} signals:\n")
|
||||
for s in signals:
|
||||
print(json.dumps(s, indent=2))
|
||||
else:
|
||||
json_path = sys.argv[1]
|
||||
signals = parse_telegram_export(json_path)
|
||||
print(f"Parsed {len(signals)} signals from export\n")
|
||||
|
||||
# Save to output
|
||||
out_path = json_path.replace('.json', '_signals.json')
|
||||
with open(out_path, 'w') as f:
|
||||
json.dump(signals, f, indent=2)
|
||||
print(f"Saved to {out_path}")
|
||||
|
||||
# Quick summary
|
||||
longs = sum(1 for s in signals if s['direction'] == 'long')
|
||||
shorts = sum(1 for s in signals if s['direction'] == 'short')
|
||||
print(f"Longs: {longs}, Shorts: {shorts}")
|
||||
tickers = set(s['ticker'] for s in signals)
|
||||
print(f"Unique tickers: {len(tickers)}")
|
||||
67
projects/crypto-watch/data/portfolio.json
Normal file
67
projects/crypto-watch/data/portfolio.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"cash": 50000.0,
|
||||
"positions": {
|
||||
"SEI": {
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T02:17:32.521805+00:00"
|
||||
},
|
||||
"ICP": {
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T02:17:32.522284+00:00"
|
||||
},
|
||||
"PUMP": {
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T02:17:32.522670+00:00"
|
||||
},
|
||||
"TRUMP": {
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T02:17:32.524179+00:00"
|
||||
},
|
||||
"HYPE": {
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T02:17:32.524900+00:00"
|
||||
},
|
||||
"VET": {
|
||||
"qty": 644186.2832126343,
|
||||
"avg_price": 0.0077617300000000005,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T06:00:19.532599+00:00"
|
||||
},
|
||||
"ARB": {
|
||||
"qty": 45447.521746639155,
|
||||
"avg_price": 0.110017,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T06:00:19.533357+00:00"
|
||||
},
|
||||
"ADA": {
|
||||
"qty": 18835.584185643518,
|
||||
"avg_price": 0.265455,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T06:00:19.534362+00:00"
|
||||
},
|
||||
"AAVE": {
|
||||
"qty": 45.21613311629589,
|
||||
"avg_price": 110.58,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T06:00:19.535031+00:00"
|
||||
},
|
||||
"NEAR": {
|
||||
"qty": 4995.004995004995,
|
||||
"avg_price": 1.001,
|
||||
"total_cost": 5000,
|
||||
"last_buy": "2026-02-10T06:00:19.535701+00:00"
|
||||
}
|
||||
},
|
||||
"starting_balance": 100000.0,
|
||||
"created_at": "2026-02-10T02:17:32.521767+00:00"
|
||||
}
|
||||
563
projects/crypto-watch/data/snapshots.json
Normal file
563
projects/crypto-watch/data/snapshots.json
Normal file
@ -0,0 +1,563 @@
|
||||
[
|
||||
{
|
||||
"cash": 75000.0,
|
||||
"positions_value": 25000.0,
|
||||
"total_value": 100000.0,
|
||||
"total_pnl": 0.0,
|
||||
"total_pnl_pct": 0.0,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"current_price": 0.074487,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"current_price": 2.43,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"current_price": 0.00200315,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"current_price": 3.35,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"current_price": 31.47,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
}
|
||||
],
|
||||
"num_positions": 5,
|
||||
"timestamp": "2026-02-10T02:17:32.525557+00:00"
|
||||
},
|
||||
{
|
||||
"cash": 50000.0,
|
||||
"positions_value": 49545.62,
|
||||
"total_value": 99545.62,
|
||||
"total_pnl": -454.38,
|
||||
"total_pnl_pct": -0.45,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "VET",
|
||||
"qty": 644186.2832126343,
|
||||
"avg_price": 0.0077617300000000005,
|
||||
"current_price": 0.00776173,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"qty": 45447.521746639155,
|
||||
"avg_price": 0.110017,
|
||||
"current_price": 0.110017,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"qty": 18835.584185643518,
|
||||
"avg_price": 0.265455,
|
||||
"current_price": 0.265455,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"qty": 45.21613311629589,
|
||||
"avg_price": 110.58,
|
||||
"current_price": 110.58,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"qty": 4995.004995004995,
|
||||
"avg_price": 1.001,
|
||||
"current_price": 1.001,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"current_price": 0.00198511,
|
||||
"value": 4954.97,
|
||||
"pnl": -45.03,
|
||||
"pnl_pct": -0.9
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"current_price": 3.31,
|
||||
"value": 4940.3,
|
||||
"pnl": -59.7,
|
||||
"pnl_pct": -1.19
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"current_price": 0.073438,
|
||||
"value": 4929.59,
|
||||
"pnl": -70.41,
|
||||
"pnl_pct": -1.41
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"current_price": 2.38,
|
||||
"value": 4897.12,
|
||||
"pnl": -102.88,
|
||||
"pnl_pct": -2.06
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"current_price": 30.36,
|
||||
"value": 4823.64,
|
||||
"pnl": -176.36,
|
||||
"pnl_pct": -3.53
|
||||
}
|
||||
],
|
||||
"num_positions": 10,
|
||||
"timestamp": "2026-02-10T06:00:19.536598+00:00"
|
||||
},
|
||||
{
|
||||
"cash": 50000.0,
|
||||
"positions_value": 49288.7,
|
||||
"total_value": 99288.7,
|
||||
"total_pnl": -711.3,
|
||||
"total_pnl_pct": -0.71,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "VET",
|
||||
"qty": 644186.2832126343,
|
||||
"avg_price": 0.0077617300000000005,
|
||||
"current_price": 0.00774826,
|
||||
"value": 4991.32,
|
||||
"pnl": -8.68,
|
||||
"pnl_pct": -0.17
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"qty": 4995.004995004995,
|
||||
"avg_price": 1.001,
|
||||
"current_price": 0.998488,
|
||||
"value": 4987.45,
|
||||
"pnl": -12.55,
|
||||
"pnl_pct": -0.25
|
||||
},
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"qty": 45447.521746639155,
|
||||
"avg_price": 0.110017,
|
||||
"current_price": 0.109646,
|
||||
"value": 4983.14,
|
||||
"pnl": -16.86,
|
||||
"pnl_pct": -0.34
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"qty": 18835.584185643518,
|
||||
"avg_price": 0.265455,
|
||||
"current_price": 0.264364,
|
||||
"value": 4979.45,
|
||||
"pnl": -20.55,
|
||||
"pnl_pct": -0.41
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"current_price": 0.00198882,
|
||||
"value": 4964.23,
|
||||
"pnl": -35.77,
|
||||
"pnl_pct": -0.72
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"qty": 45.21613311629589,
|
||||
"avg_price": 110.58,
|
||||
"current_price": 109.26,
|
||||
"value": 4940.31,
|
||||
"pnl": -59.69,
|
||||
"pnl_pct": -1.19
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"current_price": 3.28,
|
||||
"value": 4895.52,
|
||||
"pnl": -104.48,
|
||||
"pnl_pct": -2.09
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"current_price": 0.072919,
|
||||
"value": 4894.75,
|
||||
"pnl": -105.25,
|
||||
"pnl_pct": -2.11
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"current_price": 2.37,
|
||||
"value": 4876.54,
|
||||
"pnl": -123.46,
|
||||
"pnl_pct": -2.47
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"current_price": 30.06,
|
||||
"value": 4775.98,
|
||||
"pnl": -224.02,
|
||||
"pnl_pct": -4.48
|
||||
}
|
||||
],
|
||||
"num_positions": 10,
|
||||
"timestamp": "2026-02-10T10:00:19.408597+00:00"
|
||||
},
|
||||
{
|
||||
"cash": 50000.0,
|
||||
"positions_value": 49055.45,
|
||||
"total_value": 99055.45,
|
||||
"total_pnl": -944.55,
|
||||
"total_pnl_pct": -0.94,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "VET",
|
||||
"qty": 644186.2832126343,
|
||||
"avg_price": 0.0077617300000000005,
|
||||
"current_price": 0.00776798,
|
||||
"value": 5004.03,
|
||||
"pnl": 4.03,
|
||||
"pnl_pct": 0.08
|
||||
},
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"qty": 45447.521746639155,
|
||||
"avg_price": 0.110017,
|
||||
"current_price": 0.10958,
|
||||
"value": 4980.14,
|
||||
"pnl": -19.86,
|
||||
"pnl_pct": -0.4
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"qty": 18835.584185643518,
|
||||
"avg_price": 0.265455,
|
||||
"current_price": 0.262899,
|
||||
"value": 4951.86,
|
||||
"pnl": -48.14,
|
||||
"pnl_pct": -0.96
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"qty": 4995.004995004995,
|
||||
"avg_price": 1.001,
|
||||
"current_price": 0.990388,
|
||||
"value": 4946.99,
|
||||
"pnl": -53.01,
|
||||
"pnl_pct": -1.06
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"qty": 45.21613311629589,
|
||||
"avg_price": 110.58,
|
||||
"current_price": 108.78,
|
||||
"value": 4918.61,
|
||||
"pnl": -81.39,
|
||||
"pnl_pct": -1.63
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"current_price": 3.29,
|
||||
"value": 4910.45,
|
||||
"pnl": -89.55,
|
||||
"pnl_pct": -1.79
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"current_price": 0.072653,
|
||||
"value": 4876.89,
|
||||
"pnl": -123.11,
|
||||
"pnl_pct": -2.46
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"current_price": 0.00194832,
|
||||
"value": 4863.14,
|
||||
"pnl": -136.86,
|
||||
"pnl_pct": -2.74
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"current_price": 2.36,
|
||||
"value": 4855.97,
|
||||
"pnl": -144.03,
|
||||
"pnl_pct": -2.88
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"current_price": 29.88,
|
||||
"value": 4747.38,
|
||||
"pnl": -252.62,
|
||||
"pnl_pct": -5.05
|
||||
}
|
||||
],
|
||||
"num_positions": 10,
|
||||
"timestamp": "2026-02-10T14:00:19.580042+00:00"
|
||||
},
|
||||
{
|
||||
"cash": 50000.0,
|
||||
"positions_value": 49469.45,
|
||||
"total_value": 99469.45,
|
||||
"total_pnl": -530.55,
|
||||
"total_pnl_pct": -0.53,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "VET",
|
||||
"qty": 644186.2832126343,
|
||||
"avg_price": 0.0077617300000000005,
|
||||
"current_price": 0.00780695,
|
||||
"value": 5029.13,
|
||||
"pnl": 29.13,
|
||||
"pnl_pct": 0.58
|
||||
},
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"qty": 45447.521746639155,
|
||||
"avg_price": 0.110017,
|
||||
"current_price": 0.110571,
|
||||
"value": 5025.18,
|
||||
"pnl": 25.18,
|
||||
"pnl_pct": 0.5
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"qty": 4995.004995004995,
|
||||
"avg_price": 1.001,
|
||||
"current_price": 1.001,
|
||||
"value": 5000.0,
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"qty": 45.21613311629589,
|
||||
"avg_price": 110.58,
|
||||
"current_price": 110.22,
|
||||
"value": 4983.72,
|
||||
"pnl": -16.28,
|
||||
"pnl_pct": -0.33
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"qty": 18835.584185643518,
|
||||
"avg_price": 0.265455,
|
||||
"current_price": 0.264006,
|
||||
"value": 4972.71,
|
||||
"pnl": -27.29,
|
||||
"pnl_pct": -0.55
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"current_price": 3.3,
|
||||
"value": 4925.37,
|
||||
"pnl": -74.63,
|
||||
"pnl_pct": -1.49
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"current_price": 0.00197026,
|
||||
"value": 4917.9,
|
||||
"pnl": -82.1,
|
||||
"pnl_pct": -1.64
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"current_price": 2.39,
|
||||
"value": 4917.7,
|
||||
"pnl": -82.3,
|
||||
"pnl_pct": -1.65
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"current_price": 0.072635,
|
||||
"value": 4875.68,
|
||||
"pnl": -124.32,
|
||||
"pnl_pct": -2.49
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"current_price": 30.35,
|
||||
"value": 4822.05,
|
||||
"pnl": -177.95,
|
||||
"pnl_pct": -3.56
|
||||
}
|
||||
],
|
||||
"num_positions": 10,
|
||||
"timestamp": "2026-02-10T18:00:19.818002+00:00"
|
||||
},
|
||||
{
|
||||
"cash": 50000.0,
|
||||
"positions_value": 48728.74,
|
||||
"total_value": 98728.74,
|
||||
"total_pnl": -1271.26,
|
||||
"total_pnl_pct": -1.27,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"qty": 45447.521746639155,
|
||||
"avg_price": 0.110017,
|
||||
"current_price": 0.109268,
|
||||
"value": 4965.96,
|
||||
"pnl": -34.04,
|
||||
"pnl_pct": -0.68
|
||||
},
|
||||
{
|
||||
"symbol": "VET",
|
||||
"qty": 644186.2832126343,
|
||||
"avg_price": 0.0077617300000000005,
|
||||
"current_price": 0.00769179,
|
||||
"value": 4954.95,
|
||||
"pnl": -45.05,
|
||||
"pnl_pct": -0.9
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"qty": 4995.004995004995,
|
||||
"avg_price": 1.001,
|
||||
"current_price": 0.990718,
|
||||
"value": 4948.64,
|
||||
"pnl": -51.36,
|
||||
"pnl_pct": -1.03
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"qty": 18835.584185643518,
|
||||
"avg_price": 0.265455,
|
||||
"current_price": 0.261498,
|
||||
"value": 4925.47,
|
||||
"pnl": -74.53,
|
||||
"pnl_pct": -1.49
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"qty": 45.21613311629589,
|
||||
"avg_price": 110.58,
|
||||
"current_price": 108.57,
|
||||
"value": 4909.12,
|
||||
"pnl": -90.88,
|
||||
"pnl_pct": -1.82
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"qty": 1492.5373134328358,
|
||||
"avg_price": 3.35,
|
||||
"current_price": 3.26,
|
||||
"value": 4865.67,
|
||||
"pnl": -134.33,
|
||||
"pnl_pct": -2.69
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"qty": 2057.6131687242796,
|
||||
"avg_price": 2.43,
|
||||
"current_price": 2.36,
|
||||
"value": 4855.97,
|
||||
"pnl": -144.03,
|
||||
"pnl_pct": -2.88
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"qty": 67125.80718783144,
|
||||
"avg_price": 0.074487,
|
||||
"current_price": 0.072034,
|
||||
"value": 4835.34,
|
||||
"pnl": -164.66,
|
||||
"pnl_pct": -3.29
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"qty": 2496068.691810399,
|
||||
"avg_price": 0.00200315,
|
||||
"current_price": 0.00191781,
|
||||
"value": 4786.99,
|
||||
"pnl": -213.01,
|
||||
"pnl_pct": -4.26
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"qty": 158.8814744200826,
|
||||
"avg_price": 31.470000000000002,
|
||||
"current_price": 29.46,
|
||||
"value": 4680.65,
|
||||
"pnl": -319.35,
|
||||
"pnl_pct": -6.39
|
||||
}
|
||||
],
|
||||
"num_positions": 10,
|
||||
"timestamp": "2026-02-10T22:00:19.668393+00:00"
|
||||
}
|
||||
]
|
||||
102
projects/crypto-watch/data/trades.json
Normal file
102
projects/crypto-watch/data/trades.json
Normal file
@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-02-10T02:17:32.522012+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "SEI",
|
||||
"price": 0.074487,
|
||||
"qty": 67125.80718783144,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 60): Deep below VWAP (-2.7%), RSI oversold (0)"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"timestamp": "2026-02-10T02:17:32.522427+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "ICP",
|
||||
"price": 2.43,
|
||||
"qty": 2057.6131687242796,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 45): Deep below VWAP (-13.5%), RSI low (39)"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"timestamp": "2026-02-10T02:17:32.523774+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "PUMP",
|
||||
"price": 0.00200315,
|
||||
"qty": 2496068.691810399,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 40): Deep below VWAP (-5.7%), RSI low (30), Low volume (0.4x)"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"timestamp": "2026-02-10T02:17:32.524471+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "TRUMP",
|
||||
"price": 3.35,
|
||||
"qty": 1492.5373134328358,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 40): Deep below VWAP (-2.6%), High volume (1.5x)"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"timestamp": "2026-02-10T02:17:32.525072+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "HYPE",
|
||||
"price": 31.47,
|
||||
"qty": 158.8814744200826,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Buy signal (score 25): Below VWAP (-0.8%), Low volume (0.1x), Momentum reversal (bullish)"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"timestamp": "2026-02-10T06:00:19.532884+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "VET",
|
||||
"price": 0.00776173,
|
||||
"qty": 644186.2832126343,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 60): Deep below VWAP (-2.4%), RSI low (34), Momentum reversal (bullish)"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"timestamp": "2026-02-10T06:00:19.533883+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "ARB",
|
||||
"price": 0.110017,
|
||||
"qty": 45447.521746639155,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 50): Deep below VWAP (-2.6%), RSI low (38), Low volume (0.1x), 24h dump (-5.4%)"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"timestamp": "2026-02-10T06:00:19.534555+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "ADA",
|
||||
"price": 0.265455,
|
||||
"qty": 18835.584185643518,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 45): Below VWAP (-1.2%), RSI low (38), Momentum reversal (bullish)"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"timestamp": "2026-02-10T06:00:19.535250+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "AAVE",
|
||||
"price": 110.58,
|
||||
"qty": 45.21613311629589,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 45): Below VWAP (-1.8%), RSI low (31), Momentum reversal (bullish)"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"timestamp": "2026-02-10T06:00:19.535917+00:00",
|
||||
"action": "BUY",
|
||||
"symbol": "NEAR",
|
||||
"price": 1.001,
|
||||
"qty": 4995.004995004995,
|
||||
"amount_usd": 5000,
|
||||
"reason": "Strong buy signal (score 45): Deep below VWAP (-4.5%), RSI low (39)"
|
||||
}
|
||||
]
|
||||
593
projects/crypto-watch/game_engine.py
Normal file
593
projects/crypto-watch/game_engine.py
Normal file
@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crypto Market Watch - Paper Trading Game Engine
|
||||
Scans top 150 cryptos using VWAP + RSI + volume analysis.
|
||||
Makes autonomous paper trades with full tracking.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
PORTFOLIO_FILE = DATA_DIR / "portfolio.json"
|
||||
TRADES_FILE = DATA_DIR / "trades.json"
|
||||
SNAPSHOTS_FILE = DATA_DIR / "snapshots.json"
|
||||
WATCHLIST_FILE = DATA_DIR / "watchlist.json"
|
||||
|
||||
|
||||
def _load(path, default=None):
|
||||
if path.exists():
|
||||
return json.loads(path.read_text())
|
||||
return default if default is not None else {}
|
||||
|
||||
|
||||
def _save(path, data):
|
||||
path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
# ── Portfolio Management ──
|
||||
|
||||
def get_portfolio():
|
||||
default = {
|
||||
"cash": 100000.0,
|
||||
"positions": {},
|
||||
"starting_balance": 100000.0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return _load(PORTFOLIO_FILE, default)
|
||||
|
||||
|
||||
def save_portfolio(portfolio):
|
||||
_save(PORTFOLIO_FILE, portfolio)
|
||||
|
||||
|
||||
def get_trades():
|
||||
return _load(TRADES_FILE, [])
|
||||
|
||||
|
||||
def save_trades(trades):
|
||||
_save(TRADES_FILE, trades)
|
||||
|
||||
|
||||
def buy(symbol, price, amount_usd, reason=""):
|
||||
"""Buy a crypto position."""
|
||||
portfolio = get_portfolio()
|
||||
trades = get_trades()
|
||||
|
||||
if amount_usd > portfolio["cash"]:
|
||||
return None, "Insufficient cash"
|
||||
|
||||
qty = amount_usd / price
|
||||
portfolio["cash"] -= amount_usd
|
||||
|
||||
pos = portfolio["positions"].get(symbol, {
|
||||
"qty": 0, "avg_price": 0, "total_cost": 0
|
||||
})
|
||||
|
||||
new_total_cost = pos["total_cost"] + amount_usd
|
||||
new_qty = pos["qty"] + qty
|
||||
pos["avg_price"] = new_total_cost / new_qty if new_qty > 0 else 0
|
||||
pos["qty"] = new_qty
|
||||
pos["total_cost"] = new_total_cost
|
||||
pos["last_buy"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
portfolio["positions"][symbol] = pos
|
||||
save_portfolio(portfolio)
|
||||
|
||||
trade = {
|
||||
"id": len(trades) + 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"action": "BUY",
|
||||
"symbol": symbol,
|
||||
"price": price,
|
||||
"qty": qty,
|
||||
"amount_usd": amount_usd,
|
||||
"reason": reason,
|
||||
}
|
||||
trades.append(trade)
|
||||
save_trades(trades)
|
||||
return trade, None
|
||||
|
||||
|
||||
def sell(symbol, price, pct=100, reason=""):
|
||||
"""Sell a position (partial or full)."""
|
||||
portfolio = get_portfolio()
|
||||
trades = get_trades()
|
||||
|
||||
pos = portfolio["positions"].get(symbol)
|
||||
if not pos or pos["qty"] <= 0:
|
||||
return None, f"No position in {symbol}"
|
||||
|
||||
sell_qty = pos["qty"] * (pct / 100)
|
||||
sell_value = sell_qty * price
|
||||
cost_basis = pos["avg_price"] * sell_qty
|
||||
pnl = sell_value - cost_basis
|
||||
pnl_pct = (pnl / cost_basis) * 100 if cost_basis > 0 else 0
|
||||
|
||||
portfolio["cash"] += sell_value
|
||||
pos["qty"] -= sell_qty
|
||||
pos["total_cost"] -= cost_basis
|
||||
|
||||
if pos["qty"] < 0.0000001:
|
||||
del portfolio["positions"][symbol]
|
||||
else:
|
||||
portfolio["positions"][symbol] = pos
|
||||
|
||||
save_portfolio(portfolio)
|
||||
|
||||
trade = {
|
||||
"id": len(trades) + 1,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"action": "SELL",
|
||||
"symbol": symbol,
|
||||
"price": price,
|
||||
"qty": sell_qty,
|
||||
"amount_usd": sell_value,
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"reason": reason,
|
||||
}
|
||||
trades.append(trade)
|
||||
save_trades(trades)
|
||||
return trade, None
|
||||
|
||||
|
||||
def get_portfolio_value(prices):
|
||||
"""Calculate total portfolio value."""
|
||||
portfolio = get_portfolio()
|
||||
positions_value = 0
|
||||
position_details = []
|
||||
|
||||
for symbol, pos in portfolio["positions"].items():
|
||||
current_price = prices.get(symbol, pos["avg_price"])
|
||||
value = pos["qty"] * current_price
|
||||
pnl = value - pos["total_cost"]
|
||||
pnl_pct = (pnl / pos["total_cost"]) * 100 if pos["total_cost"] > 0 else 0
|
||||
positions_value += value
|
||||
position_details.append({
|
||||
"symbol": symbol,
|
||||
"qty": pos["qty"],
|
||||
"avg_price": pos["avg_price"],
|
||||
"current_price": current_price,
|
||||
"value": round(value, 2),
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
})
|
||||
|
||||
total_value = portfolio["cash"] + positions_value
|
||||
total_pnl = total_value - portfolio["starting_balance"]
|
||||
total_pnl_pct = (total_pnl / portfolio["starting_balance"]) * 100
|
||||
|
||||
return {
|
||||
"cash": round(portfolio["cash"], 2),
|
||||
"positions_value": round(positions_value, 2),
|
||||
"total_value": round(total_value, 2),
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"total_pnl_pct": round(total_pnl_pct, 2),
|
||||
"positions": sorted(position_details, key=lambda x: -x["value"]),
|
||||
"num_positions": len(position_details),
|
||||
}
|
||||
|
||||
|
||||
def take_snapshot(prices):
|
||||
"""Save a point-in-time portfolio snapshot."""
|
||||
snapshots = _load(SNAPSHOTS_FILE, [])
|
||||
value = get_portfolio_value(prices)
|
||||
value["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
snapshots.append(value)
|
||||
# Keep last 1000
|
||||
_save(SNAPSHOTS_FILE, snapshots[-1000:])
|
||||
return value
|
||||
|
||||
|
||||
# ── Market Data ──
|
||||
|
||||
def get_top_coins(limit=150):
|
||||
"""Fetch top coins by market cap from CoinGecko."""
|
||||
coins = []
|
||||
for page in range(1, (limit // 100) + 2):
|
||||
per_page = min(100, limit - len(coins))
|
||||
if per_page <= 0:
|
||||
break
|
||||
url = (
|
||||
f"https://api.coingecko.com/api/v3/coins/markets?"
|
||||
f"vs_currency=usd&order=market_cap_desc&per_page={per_page}&page={page}"
|
||||
f"&sparkline=false&price_change_percentage=1h,24h,7d"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
batch = json.loads(resp.read())
|
||||
coins.extend(batch)
|
||||
except Exception as e:
|
||||
print(f"Error fetching page {page}: {e}")
|
||||
break
|
||||
time.sleep(1) # Rate limit
|
||||
return coins
|
||||
|
||||
|
||||
def get_ohlcv(coin_id, days=2):
|
||||
"""Get OHLCV data from CoinGecko for VWAP calculation."""
|
||||
url = f"https://api.coingecko.com/api/v3/coins/{coin_id}/ohlc?vs_currency=usd&days={days}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
raw = json.loads(resp.read())
|
||||
# CoinGecko OHLC: [timestamp, open, high, low, close]
|
||||
return [{"t": r[0], "o": r[1], "h": r[2], "l": r[3], "c": r[4]} for r in raw]
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def get_binance_klines(symbol, interval="1h", limit=48):
|
||||
"""Get klines from Binance US for VWAP + volume."""
|
||||
url = f"https://api.binance.us/api/v3/klines?symbol={symbol}USDT&interval={interval}&limit={limit}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
raw = json.loads(resp.read())
|
||||
return [{"t": k[0], "o": float(k[1]), "h": float(k[2]), "l": float(k[3]),
|
||||
"c": float(k[4]), "v": float(k[5])} for k in raw]
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
# ── Technical Analysis ──
|
||||
|
||||
def calc_vwap(candles):
|
||||
"""Calculate VWAP with standard deviation bands."""
|
||||
if not candles:
|
||||
return None
|
||||
|
||||
cum_tpv = cum_vol = cum_sq = 0
|
||||
for c in candles:
|
||||
vol = c.get("v", 1) # Default volume 1 if not available
|
||||
tp = (c["h"] + c["l"] + c["c"]) / 3
|
||||
cum_tpv += tp * vol
|
||||
cum_vol += vol
|
||||
|
||||
vwap = cum_tpv / cum_vol if cum_vol > 0 else candles[-1]["c"]
|
||||
|
||||
# Standard deviation
|
||||
for c in candles:
|
||||
vol = c.get("v", 1)
|
||||
tp = (c["h"] + c["l"] + c["c"]) / 3
|
||||
cum_sq += vol * (tp - vwap) ** 2
|
||||
|
||||
variance = cum_sq / cum_vol if cum_vol > 0 else 0
|
||||
std = variance ** 0.5
|
||||
|
||||
return {
|
||||
"vwap": vwap,
|
||||
"std": std,
|
||||
"upper1": vwap + std,
|
||||
"lower1": vwap - std,
|
||||
"upper2": vwap + 2 * std,
|
||||
"lower2": vwap - 2 * std,
|
||||
}
|
||||
|
||||
|
||||
def calc_rsi(closes, period=14):
|
||||
"""Calculate RSI."""
|
||||
if len(closes) < period + 1:
|
||||
return 50 # Default neutral
|
||||
|
||||
gains = []
|
||||
losses = []
|
||||
for i in range(1, len(closes)):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
gains.append(max(diff, 0))
|
||||
losses.append(max(-diff, 0))
|
||||
|
||||
avg_gain = sum(gains[-period:]) / period
|
||||
avg_loss = sum(losses[-period:]) / period
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100
|
||||
rs = avg_gain / avg_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def analyze_coin(coin, klines=None):
|
||||
"""Full VWAP + RSI + momentum analysis for a single coin."""
|
||||
symbol = coin["symbol"].upper()
|
||||
price = coin["current_price"]
|
||||
|
||||
if not klines:
|
||||
klines = get_binance_klines(symbol, "1h", 48)
|
||||
|
||||
if not klines or len(klines) < 10:
|
||||
return None
|
||||
|
||||
# VWAP
|
||||
vwap_data = calc_vwap(klines)
|
||||
vwap = vwap_data["vwap"]
|
||||
vwap_diff = (price - vwap) / vwap * 100
|
||||
|
||||
# RSI
|
||||
closes = [k["c"] for k in klines]
|
||||
rsi = calc_rsi(closes)
|
||||
|
||||
# Volume trend
|
||||
recent_vol = sum(k["v"] for k in klines[-6:]) # Last 6h
|
||||
avg_vol = sum(k["v"] for k in klines) / (len(klines) / 6)
|
||||
vol_ratio = recent_vol / avg_vol if avg_vol > 0 else 1
|
||||
|
||||
# Momentum (last 4h vs prior 4h)
|
||||
if len(klines) >= 8:
|
||||
recent_mom = (klines[-1]["c"] - klines[-4]["c"]) / klines[-4]["c"] * 100
|
||||
prior_mom = (klines[-4]["c"] - klines[-8]["c"]) / klines[-8]["c"] * 100
|
||||
else:
|
||||
recent_mom = prior_mom = 0
|
||||
|
||||
# 24h change from CoinGecko
|
||||
change_24h = coin.get("price_change_percentage_24h", 0) or 0
|
||||
|
||||
# Score: -100 to +100
|
||||
score = 0
|
||||
signals = []
|
||||
|
||||
# VWAP position
|
||||
if vwap_diff < -2:
|
||||
score += 30
|
||||
signals.append(f"Deep below VWAP ({vwap_diff:.1f}%)")
|
||||
elif vwap_diff < -0.5:
|
||||
score += 15
|
||||
signals.append(f"Below VWAP ({vwap_diff:.1f}%)")
|
||||
elif vwap_diff > 2:
|
||||
score -= 20
|
||||
signals.append(f"Extended above VWAP ({vwap_diff:.1f}%)")
|
||||
elif vwap_diff > 0.5:
|
||||
score += 5
|
||||
signals.append(f"Above VWAP ({vwap_diff:.1f}%)")
|
||||
|
||||
# RSI
|
||||
if rsi < 30:
|
||||
score += 30
|
||||
signals.append(f"RSI oversold ({rsi:.0f})")
|
||||
elif rsi < 40:
|
||||
score += 15
|
||||
signals.append(f"RSI low ({rsi:.0f})")
|
||||
elif rsi > 70:
|
||||
score -= 25
|
||||
signals.append(f"RSI overbought ({rsi:.0f})")
|
||||
elif rsi > 60:
|
||||
score -= 10
|
||||
signals.append(f"RSI elevated ({rsi:.0f})")
|
||||
|
||||
# Volume confirmation
|
||||
if vol_ratio > 1.5:
|
||||
score += 10 if vwap_diff < 0 else -10 # High vol at support = bullish
|
||||
signals.append(f"High volume ({vol_ratio:.1f}x)")
|
||||
elif vol_ratio < 0.5:
|
||||
score -= 5
|
||||
signals.append(f"Low volume ({vol_ratio:.1f}x)")
|
||||
|
||||
# Momentum reversal
|
||||
if recent_mom > 0 and prior_mom < 0:
|
||||
score += 15
|
||||
signals.append("Momentum reversal (bullish)")
|
||||
elif recent_mom < 0 and prior_mom > 0:
|
||||
score -= 15
|
||||
signals.append("Momentum reversal (bearish)")
|
||||
|
||||
# 24h trend
|
||||
if change_24h < -5:
|
||||
score += 10 # Potential bounce
|
||||
signals.append(f"24h dump ({change_24h:.1f}%)")
|
||||
elif change_24h > 5:
|
||||
score -= 10 # Potential pullback
|
||||
signals.append(f"24h pump ({change_24h:.1f}%)")
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"name": coin["name"],
|
||||
"price": price,
|
||||
"market_cap_rank": coin.get("market_cap_rank", 0),
|
||||
"vwap": round(vwap, 6),
|
||||
"vwap_diff_pct": round(vwap_diff, 2),
|
||||
"rsi": round(rsi, 1),
|
||||
"vol_ratio": round(vol_ratio, 2),
|
||||
"momentum_4h": round(recent_mom, 2),
|
||||
"change_24h": round(change_24h, 2),
|
||||
"score": score,
|
||||
"signals": signals,
|
||||
"vwap_bands": {k: round(v, 6) for k, v in vwap_data.items()},
|
||||
}
|
||||
|
||||
|
||||
# ── Trading Logic ──
|
||||
|
||||
def should_buy(analysis):
|
||||
"""Determine if we should buy based on analysis score."""
|
||||
if not analysis:
|
||||
return False, ""
|
||||
|
||||
score = analysis["score"]
|
||||
rsi = analysis["rsi"]
|
||||
vwap_diff = analysis["vwap_diff_pct"]
|
||||
|
||||
# Strong buy: oversold + below VWAP
|
||||
if score >= 40:
|
||||
return True, f"Strong buy signal (score {score}): {', '.join(analysis['signals'])}"
|
||||
|
||||
# Moderate buy: decent score + below VWAP
|
||||
if score >= 20 and vwap_diff < -0.5:
|
||||
return True, f"Buy signal (score {score}): {', '.join(analysis['signals'])}"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
def should_sell(analysis, position):
|
||||
"""Determine if we should sell a position."""
|
||||
if not analysis:
|
||||
return False, 0, ""
|
||||
|
||||
price = analysis["price"]
|
||||
avg_price = position["avg_price"]
|
||||
pnl_pct = (price - avg_price) / avg_price * 100
|
||||
rsi = analysis["rsi"]
|
||||
vwap_diff = analysis["vwap_diff_pct"]
|
||||
score = analysis["score"]
|
||||
|
||||
# Take profit: +5% gain with overbought signals
|
||||
if pnl_pct >= 5 and (rsi > 65 or vwap_diff > 1):
|
||||
return True, 100, f"Take profit ({pnl_pct:.1f}%, RSI {rsi:.0f})"
|
||||
|
||||
# Strong take profit: +10% gain
|
||||
if pnl_pct >= 10:
|
||||
return True, 50, f"Partial take profit ({pnl_pct:.1f}%)"
|
||||
|
||||
# Stop loss: -8%
|
||||
if pnl_pct <= -8:
|
||||
return True, 100, f"Stop loss ({pnl_pct:.1f}%)"
|
||||
|
||||
# Bearish signals on losing position
|
||||
if pnl_pct < -3 and score <= -20:
|
||||
return True, 100, f"Cut loss (score {score}, PnL {pnl_pct:.1f}%)"
|
||||
|
||||
# Extended above VWAP with big gain
|
||||
if pnl_pct >= 3 and vwap_diff > 2:
|
||||
return True, 50, f"Extended above VWAP ({vwap_diff:.1f}%), lock gains"
|
||||
|
||||
return False, 0, ""
|
||||
|
||||
|
||||
# ── Main Scanner ──
|
||||
|
||||
def run_scan(max_positions=10, position_size=5000):
|
||||
"""
|
||||
Full scan: analyze top 150 cryptos, make buy/sell decisions.
|
||||
max_positions: max simultaneous positions
|
||||
position_size: USD per position
|
||||
"""
|
||||
print(f"=== Crypto Market Watch Scan ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print()
|
||||
|
||||
# Get top coins
|
||||
print("Fetching top 150 coins...", flush=True)
|
||||
coins = get_top_coins(150)
|
||||
# Deduplicate by symbol (CoinGecko sometimes returns dupes)
|
||||
seen_symbols = set()
|
||||
unique_coins = []
|
||||
for c in coins:
|
||||
sym = c["symbol"].upper()
|
||||
if sym not in seen_symbols:
|
||||
seen_symbols.add(sym)
|
||||
unique_coins.append(c)
|
||||
coins = unique_coins
|
||||
print(f"Got {len(coins)} unique coins")
|
||||
|
||||
# Map symbols to Binance format
|
||||
# Only analyze coins available on Binance US
|
||||
binance_symbols = set()
|
||||
try:
|
||||
url = "https://api.binance.us/api/v3/exchangeInfo"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
exchange = json.loads(resp.read())
|
||||
for s in exchange["symbols"]:
|
||||
if s["quoteAsset"] == "USDT" and s["status"] == "TRADING":
|
||||
binance_symbols.add(s["baseAsset"])
|
||||
except:
|
||||
pass
|
||||
|
||||
print(f"Binance US has {len(binance_symbols)} USDT pairs")
|
||||
|
||||
# Analyze each coin
|
||||
analyses = []
|
||||
prices = {}
|
||||
|
||||
for coin in coins:
|
||||
symbol = coin["symbol"].upper()
|
||||
prices[symbol] = coin["current_price"]
|
||||
|
||||
if symbol not in binance_symbols:
|
||||
continue
|
||||
|
||||
analysis = analyze_coin(coin)
|
||||
if analysis:
|
||||
analyses.append(analysis)
|
||||
|
||||
time.sleep(0.2) # Rate limit
|
||||
|
||||
print(f"Analyzed {len(analyses)} coins with Binance data")
|
||||
|
||||
# Get current portfolio
|
||||
portfolio = get_portfolio()
|
||||
current_positions = set(portfolio["positions"].keys())
|
||||
|
||||
# ── SELL DECISIONS ──
|
||||
sells = []
|
||||
for symbol, pos in list(portfolio["positions"].items()):
|
||||
analysis = next((a for a in analyses if a["symbol"] == symbol), None)
|
||||
if not analysis:
|
||||
# Can't analyze — skip (don't sell blind)
|
||||
continue
|
||||
|
||||
do_sell, sell_pct, reason = should_sell(analysis, pos)
|
||||
if do_sell:
|
||||
trade, err = sell(symbol, analysis["price"], sell_pct, reason)
|
||||
if trade:
|
||||
sells.append(trade)
|
||||
print(f" 📤 SELL {symbol} @ ${analysis['price']:,.4f} ({sell_pct}%) — {reason}")
|
||||
|
||||
# ── BUY DECISIONS ──
|
||||
portfolio = get_portfolio() # Refresh after sells
|
||||
num_positions = len(portfolio["positions"])
|
||||
available_slots = max_positions - num_positions
|
||||
|
||||
# Sort by score (best opportunities first)
|
||||
buy_candidates = sorted(
|
||||
[a for a in analyses if a["symbol"] not in portfolio["positions"]],
|
||||
key=lambda x: -x["score"]
|
||||
)
|
||||
|
||||
buys = []
|
||||
for analysis in buy_candidates[:available_slots * 2]: # Check 2x candidates
|
||||
if len(buys) >= available_slots:
|
||||
break
|
||||
if portfolio["cash"] < position_size:
|
||||
break
|
||||
|
||||
do_buy, reason = should_buy(analysis)
|
||||
if do_buy:
|
||||
trade, err = buy(analysis["symbol"], analysis["price"], position_size, reason)
|
||||
if trade:
|
||||
buys.append(trade)
|
||||
portfolio = get_portfolio() # Refresh
|
||||
print(f" 📥 BUY {analysis['symbol']} @ ${analysis['price']:,.4f} — {reason}")
|
||||
|
||||
# Take snapshot
|
||||
snapshot = take_snapshot(prices)
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Buys: {len(buys)} | Sells: {len(sells)}")
|
||||
print(f"Portfolio: ${snapshot['total_value']:,.2f} ({snapshot['total_pnl_pct']:+.2f}%)")
|
||||
print(f"Cash: ${snapshot['cash']:,.2f} | Positions: {snapshot['num_positions']}")
|
||||
|
||||
if snapshot["positions"]:
|
||||
print(f"\nOpen Positions:")
|
||||
for p in snapshot["positions"]:
|
||||
emoji = "🟢" if p["pnl"] >= 0 else "🔴"
|
||||
print(f" {emoji} {p['symbol']}: ${p['value']:,.2f} ({p['pnl_pct']:+.1f}%)")
|
||||
|
||||
# Top opportunities not taken
|
||||
print(f"\nTop Scoring Coins:")
|
||||
for a in sorted(analyses, key=lambda x: -x["score"])[:10]:
|
||||
held = "📌" if a["symbol"] in portfolio.get("positions", {}) else " "
|
||||
print(f" {held} {a['symbol']:<8} score:{a['score']:>4} | RSI:{a['rsi']:.0f} | VWAP:{a['vwap_diff_pct']:+.1f}% | 24h:{a['change_24h']:+.1f}%")
|
||||
|
||||
return {"buys": buys, "sells": sells, "snapshot": snapshot, "analyses": analyses}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = run_scan()
|
||||
60
projects/crypto-watch/run_scan.py
Normal file
60
projects/crypto-watch/run_scan.py
Normal file
@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wrapper to run crypto scan and send Telegram alerts.
|
||||
Designed for systemd timer execution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from game_engine import run_scan, get_portfolio_value, get_trades
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
|
||||
def send_telegram(message):
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram error: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
result = run_scan(max_positions=10, position_size=5000)
|
||||
|
||||
buys = result.get("buys", [])
|
||||
sells = result.get("sells", [])
|
||||
snapshot = result.get("snapshot", {})
|
||||
|
||||
# Only alert if there were trades
|
||||
if buys or sells:
|
||||
lines = ["🎮 <b>Crypto Watch Update</b>\n"]
|
||||
|
||||
for t in buys:
|
||||
lines.append(f"📥 BUY {t['symbol']} @ ${t['price']:,.4f}")
|
||||
lines.append(f" {t.get('reason', '')}\n")
|
||||
|
||||
for t in sells:
|
||||
emoji = "🟢" if t.get("pnl", 0) >= 0 else "🔴"
|
||||
lines.append(f"📤 SELL {t['symbol']} @ ${t['price']:,.4f}")
|
||||
lines.append(f" {emoji} PnL: ${t.get('pnl', 0):+,.2f} ({t.get('pnl_pct', 0):+.1f}%)")
|
||||
lines.append(f" {t.get('reason', '')}\n")
|
||||
|
||||
lines.append(f"💰 Portfolio: ${snapshot.get('total_value', 0):,.2f} ({snapshot.get('total_pnl_pct', 0):+.2f}%)")
|
||||
lines.append(f"💵 Cash: ${snapshot.get('cash', 0):,.2f} | Positions: {snapshot.get('num_positions', 0)}")
|
||||
|
||||
send_telegram("\n".join(lines))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
3
projects/feed-hunter/data/anoin123-tracking/stats.json
Normal file
3
projects/feed-hunter/data/anoin123-tracking/stats.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"last_check": "2026-02-12T06:14:23.639458+00:00"
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
{
|
||||
"timestamp": "2026-02-12T06:11:18.980961+00:00",
|
||||
"total_scraped": 44,
|
||||
"new_posts": 22,
|
||||
"money_posts": 7,
|
||||
"posts": [
|
||||
{
|
||||
"text": "We\u2019re in the business of supporting traders. Get the tools you need to make your way in the market.",
|
||||
"userName": "tastytrade\n@tastytrade",
|
||||
"timestamp": "",
|
||||
"link": "/tastytrade/status/1971214019274080389/analytics"
|
||||
},
|
||||
{
|
||||
"text": "agentic e2e regression testing solved.\nprompt \u2192 test in under 2 mins.\nif you're still writing tests by hand... why",
|
||||
"userName": "Bug0\n@bug0inc",
|
||||
"timestamp": "",
|
||||
"link": "/bug0inc/status/2019756209390375399/analytics"
|
||||
},
|
||||
{
|
||||
"text": " New GPT crypto price predictions:\n\n$ETH -0.285% => $1945.73\n\n$ZRO +0.086% => $2.329\n\n$UNI 0.0% => $3.492\n\n$BTC -0.116% => $67481.29\n\n$XRP +0.181% => $1.3796\n\n$PENGU +0.116% => $0.006021\n\n$SOL +0.063% => $79.72\n\n$TRUMP +0.094% => $3.195",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\n10h",
|
||||
"timestamp": "2026-02-11T20:00:50.000Z",
|
||||
"link": "/OctoBotGPT/status/2021675819580481813"
|
||||
},
|
||||
{
|
||||
"text": " New GPT crypto price predictions:\n\n$ADA 0.0% => $0.2633\n\n$DOGE 0.0% => $0.09372\n\n$SOL 0.0% => $84.78\n\n$XRP +0.266% => $1.427\n\n$SENT +0.594% => $0.02863\n\n$PAXG 0.0% => $5033.12\n\n$PEPE +98.997% => $0.000367\n\n$SUI +0.864% => $0.9487",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\nFeb 10",
|
||||
"timestamp": "2026-02-10T08:00:48.000Z",
|
||||
"link": "/OctoBotGPT/status/2021132226792947802"
|
||||
},
|
||||
{
|
||||
"text": "$ZAMA 0.0% => $0.02688\n\n$LIT -1.503% => $0.732\n\n$ZRO -0.259% => $1.927\n\n$BTC -0.078% => $68890.0\n\n$ETH -0.53% => $1997.59\n\n$HBAR +0.512% => $0.09188\n\n$TRX -0.036% => $0.277\n\n$SHIB 0.0% => $5.98e-06",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\nFeb 10",
|
||||
"timestamp": "2026-02-10T08:00:48.000Z",
|
||||
"link": "/OctoBotGPT/status/2021132227921301730"
|
||||
},
|
||||
{
|
||||
"text": " New GPT crypto price predictions:\n\n$ZRO +0.959% => $2.398\n\n$DOGE 0.0% => $0.09053\n\n$LINK 0.0% => $8.32\n\n$SOL -0.671% => $80.5\n\n$PENGU -0.169% => $0.005916\n\n$USD1 +0.02% => $1.0\n\n$SUI -0.403% => $0.893\n\n$BTC 0.0% => $67046.23",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\n22h",
|
||||
"timestamp": "2026-02-11T08:00:52.000Z",
|
||||
"link": "/OctoBotGPT/status/2021494632635408429"
|
||||
},
|
||||
{
|
||||
"text": "$ETH +1.022% => $1971.45\n\n$BNB 0.0% => $600.95\n\n$ZAMA -0.714% => $0.0196\n\n$SHIB 0.0% => $5.84e-06\n\n$XRP -0.242% => $1.3646\n\n$ASTER +0.459% => $0.653\n\n$ADA -0.393% => $0.2546\n\n$TRX 0.0% => $0.275",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\n22h",
|
||||
"timestamp": "2026-02-11T08:00:52.000Z",
|
||||
"link": "/OctoBotGPT/status/2021494633759408338"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
{
|
||||
"last_check": "2026-02-09T16:58:59.975254+00:00",
|
||||
"last_check": "2026-02-12T06:14:23.952343+00:00",
|
||||
"total_tracked": 3100,
|
||||
"new_this_check": 0
|
||||
}
|
||||
1
projects/feed-hunter/data/seen_posts.json
Normal file
1
projects/feed-hunter/data/seen_posts.json
Normal file
@ -0,0 +1 @@
|
||||
["37f5b094ed19f0ae", "f013475eaef8b32f", "6f9f9b6f8da5cdc9", "952f5caf7819fde5", "6043a216215e23f0", "ff1b1af5e65905a8", "6217530e01f15dd0", "6e1a48e8344a1d6f", "6d61e4a9dfb604ea", "529a533d4da86360", "d2217d5c918df581", "8b4a57cd97ae6c34", "5bf0ad8e5e2f9dfe", "2ee63742fbc6e541", "7d74e6eca55f346f", "9f29b74a37377015", "3ccdb8471c5c19b6", "8a48151a19597742", "e6a24f45f8a6e8bb", "899377e3ae43e396", "05e47b3d9e24c860", "643b1c7d00ad50f2", "b7a2c548992c278c", "a1a8dddd35fd08d5", "149ace9c327e7700", "7d767e4d0e3a12a3", "0c6a5029b97b5a30", "b7354be18fa9f71a", "7b10b1cb595f2006", "ca4011161b3ce92d", "ffbe4a2b11671722", "cd2ce19326f75133", "60fd088f8f1ae9b2", "8ebbe21b036415d8", "84da8ad2dd424e2b", "7d4648af05013346", "137b80809666db54", "1151a6c9c0f2915c", "36db4785c888600f", "e8c4ec6aa2a9a563", "f9ded3bb072f01fe", "f30bff813bce39b8", "e1a98d7590fe46ee", "a767ef4ed21a86f3", "3525848121516057", "3d6e2887b81b3016", "cb6375e11d10b745", "493d2dc82b844b36", "60dced6f08edb3c2", "7d97ce308ff157b2", "db27f494858bd743", "176776613d96cdbe", "eb7a8395aa02f113"]
|
||||
231
projects/feed-hunter/feed_monitor.py
Executable file
231
projects/feed-hunter/feed_monitor.py
Executable file
@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Feed Monitor — Scrapes X home timeline via Chrome CDP (localhost:9222).
|
||||
Deduplicates, filters for money/trading topics, saves captures, sends Telegram alerts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import http.client
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent
|
||||
DATA_DIR = PROJECT_DIR / "data"
|
||||
SEEN_FILE = DATA_DIR / "seen_posts.json"
|
||||
CAPTURES_DIR = DATA_DIR / "feed_captures"
|
||||
CAPTURES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CDP_HOST = "localhost"
|
||||
CDP_PORT = 9222
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
MONEY_KEYWORDS = [
|
||||
"polymarket", "trade", "trading", "profit", "arbitrage", "crypto",
|
||||
"bitcoin", "btc", "ethereum", "eth", "solana", "sol", "stock",
|
||||
"stocks", "market", "portfolio", "defi", "token", "whale",
|
||||
"bullish", "bearish", "short", "long", "pnl", "alpha", "degen",
|
||||
"usdc", "usdt", "wallet", "airdrop", "memecoin", "nft",
|
||||
"yield", "staking", "leverage", "futures", "options", "hedge",
|
||||
"pump", "dump", "rug", "moon", "bag", "position", "signal",
|
||||
]
|
||||
|
||||
|
||||
def send_telegram(message: str):
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[ALERT] {message}")
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f" Telegram error: {e}")
|
||||
|
||||
|
||||
def cdp_send(ws, method: str, params: dict = None, msg_id: int = 1):
|
||||
"""Send a CDP command over websocket and return the result."""
|
||||
import websocket
|
||||
payload = {"id": msg_id, "method": method}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
ws.send(json.dumps(payload))
|
||||
while True:
|
||||
resp = json.loads(ws.recv())
|
||||
if resp.get("id") == msg_id:
|
||||
return resp.get("result", {})
|
||||
|
||||
|
||||
def get_x_tab_ws():
|
||||
"""Find an X.com tab in Chrome and return its websocket URL."""
|
||||
conn = http.client.HTTPConnection(CDP_HOST, CDP_PORT, timeout=5)
|
||||
conn.request("GET", "/json")
|
||||
tabs = json.loads(conn.getresponse().read())
|
||||
conn.close()
|
||||
|
||||
for t in tabs:
|
||||
url = t.get("url", "")
|
||||
if "x.com" in url or "twitter.com" in url:
|
||||
ws_url = t.get("webSocketDebuggerUrl")
|
||||
if ws_url:
|
||||
return ws_url, t.get("url")
|
||||
return None, None
|
||||
|
||||
|
||||
def scrape_feed_via_cdp():
|
||||
"""Navigate to X home, scroll, extract posts via DOM evaluation."""
|
||||
import websocket
|
||||
|
||||
ws_url, current_url = get_x_tab_ws()
|
||||
if not ws_url:
|
||||
print("ERROR: No X.com tab found in Chrome at localhost:9222")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Connected to tab: {current_url}")
|
||||
ws = websocket.create_connection(ws_url, timeout=30)
|
||||
|
||||
# Navigate to home timeline
|
||||
cdp_send(ws, "Page.navigate", {"url": "https://x.com/home"}, 1)
|
||||
time.sleep(5)
|
||||
|
||||
all_posts = []
|
||||
seen_texts = set()
|
||||
|
||||
for scroll_i in range(6):
|
||||
# Extract posts from timeline
|
||||
js = """
|
||||
(() => {
|
||||
const posts = [];
|
||||
document.querySelectorAll('article[data-testid="tweet"]').forEach(article => {
|
||||
try {
|
||||
const textEl = article.querySelector('[data-testid="tweetText"]');
|
||||
const text = textEl ? textEl.innerText : '';
|
||||
const userEl = article.querySelector('[data-testid="User-Name"]');
|
||||
const userName = userEl ? userEl.innerText : '';
|
||||
const timeEl = article.querySelector('time');
|
||||
const timestamp = timeEl ? timeEl.getAttribute('datetime') : '';
|
||||
const linkEl = article.querySelector('a[href*="/status/"]');
|
||||
const link = linkEl ? linkEl.getAttribute('href') : '';
|
||||
posts.push({ text, userName, timestamp, link });
|
||||
} catch(e) {}
|
||||
});
|
||||
return JSON.stringify(posts);
|
||||
})()
|
||||
"""
|
||||
result = cdp_send(ws, "Runtime.evaluate", {"expression": js, "returnByValue": True}, 10 + scroll_i)
|
||||
raw = result.get("result", {}).get("value", "[]")
|
||||
posts = json.loads(raw) if isinstance(raw, str) else []
|
||||
|
||||
for p in posts:
|
||||
sig = p.get("text", "")[:120]
|
||||
if sig and sig not in seen_texts:
|
||||
seen_texts.add(sig)
|
||||
all_posts.append(p)
|
||||
|
||||
# Scroll down
|
||||
cdp_send(ws, "Runtime.evaluate", {"expression": "window.scrollBy(0, 2000)"}, 100 + scroll_i)
|
||||
time.sleep(2)
|
||||
|
||||
ws.close()
|
||||
return all_posts
|
||||
|
||||
|
||||
def post_hash(post: dict) -> str:
|
||||
text = post.get("text", "") + post.get("userName", "")
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def is_money_related(text: str) -> bool:
|
||||
lower = text.lower()
|
||||
return any(kw in lower for kw in MONEY_KEYWORDS)
|
||||
|
||||
|
||||
def load_seen() -> set:
|
||||
if SEEN_FILE.exists():
|
||||
try:
|
||||
return set(json.loads(SEEN_FILE.read_text()))
|
||||
except:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
def save_seen(seen: set):
|
||||
# Keep last 10k
|
||||
items = list(seen)[-10000:]
|
||||
SEEN_FILE.write_text(json.dumps(items))
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"=== Feed Monitor === {now.strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
|
||||
posts = scrape_feed_via_cdp()
|
||||
print(f"Scraped {len(posts)} posts from timeline")
|
||||
|
||||
seen = load_seen()
|
||||
new_posts = []
|
||||
money_posts = []
|
||||
|
||||
for p in posts:
|
||||
h = post_hash(p)
|
||||
if h in seen:
|
||||
continue
|
||||
seen.add(h)
|
||||
new_posts.append(p)
|
||||
if is_money_related(p.get("text", "")):
|
||||
money_posts.append(p)
|
||||
|
||||
save_seen(seen)
|
||||
|
||||
print(f"New posts: {len(new_posts)}")
|
||||
print(f"Money-related: {len(money_posts)}")
|
||||
|
||||
# Save capture
|
||||
ts = now.strftime("%Y%m%d-%H%M")
|
||||
capture = {
|
||||
"timestamp": now.isoformat(),
|
||||
"total_scraped": len(posts),
|
||||
"new_posts": len(new_posts),
|
||||
"money_posts": len(money_posts),
|
||||
"posts": money_posts,
|
||||
}
|
||||
capture_file = CAPTURES_DIR / f"feed-{ts}.json"
|
||||
capture_file.write_text(json.dumps(capture, indent=2))
|
||||
print(f"Saved capture: {capture_file}")
|
||||
|
||||
# Alert on money posts
|
||||
if money_posts:
|
||||
print(f"\n🔔 {len(money_posts)} money-related posts found!")
|
||||
for p in money_posts[:8]:
|
||||
user = p.get("userName", "").split("\n")[0]
|
||||
snippet = p.get("text", "")[:250].replace("\n", " ")
|
||||
link = p.get("link", "")
|
||||
full_link = f"https://x.com{link}" if link and not link.startswith("http") else link
|
||||
|
||||
print(f" • {user}: {snippet[:100]}...")
|
||||
|
||||
msg = f"🔍 <b>{user}</b>\n\n{snippet}"
|
||||
if full_link:
|
||||
msg += f"\n\n{full_link}"
|
||||
send_telegram(msg)
|
||||
else:
|
||||
print("No new money-related posts.")
|
||||
|
||||
return len(money_posts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
count = main()
|
||||
sys.exit(0)
|
||||
155
projects/feed-hunter/x_cookies.json
Normal file
155
projects/feed-hunter/x_cookies.json
Normal file
@ -0,0 +1,155 @@
|
||||
[
|
||||
{
|
||||
"name": "guest_id_marketing",
|
||||
"value": "v1%3A177052493168164632",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "guest_id_ads",
|
||||
"value": "v1%3A177052493168164632",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "guest_id",
|
||||
"value": "v1%3A177052493168164632",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "personalization_id",
|
||||
"value": "\"v1_6O8SSA4FCcIXzFzq4cql3A==\"",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "__cuid",
|
||||
"value": "7ec0f8364ef9466bb4d5e5398de60a7a",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": false,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "guest_id_marketing",
|
||||
"value": "v1%3A177052493360013497",
|
||||
"domain": ".twitter.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "guest_id_ads",
|
||||
"value": "v1%3A177052493360013497",
|
||||
"domain": ".twitter.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "personalization_id",
|
||||
"value": "\"v1_0RdWTpuTILka/W8MwiVsGQ==\"",
|
||||
"domain": ".twitter.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "guest_id",
|
||||
"value": "v1%3A177052493360013497",
|
||||
"domain": ".twitter.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "g_state",
|
||||
"value": "{\"i_l\":0,\"i_ll\":1770524933853,\"i_b\":\"/335bZxZT54Tkc2wThT5DEH5v8hDZyhbe/JOl6uvF+k\",\"i_e\":{\"enable_itp_optimization\":0}}",
|
||||
"domain": "x.com",
|
||||
"path": "/",
|
||||
"secure": false,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "kdt",
|
||||
"value": "Y9jfWROysXsnZyHwlffVbs8jvBJabIN4RGlZYFHP",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "auth_token",
|
||||
"value": "219b71a535b96ef9f978612a48cf81a462643ee3",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "ct0",
|
||||
"value": "e2c61ad6ce7115f2d8acd2062dc5c9a377140d9b570f871d9b25847f2d7a36fe512a424a359775d73a11a5a0a5154b6623b0021992a2b7f1e094d5ac5ee65cfeaf8ac87de09b7dcfc48f28a5b6dd15dc",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "twid",
|
||||
"value": "u%3D741482516",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "lang",
|
||||
"value": "en",
|
||||
"domain": "x.com",
|
||||
"path": "/",
|
||||
"secure": false,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "external_referer",
|
||||
"value": "vC8TI7P7q9UHtLBqrmGBr3bhFoPD7nVN|0|8e8t2xd8A2w%3D",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "__cf_bm",
|
||||
"value": "UjX5M.SqXScrW4zZ_GhiubhCXhv.8SI8uU7MkZCGT24-1770678794.1374662-1.0.1.1-4x.1srI8Lir7aTkBYJxMGMZQ2E3.EZKgF5S_gLeoAQzEUvIFZQTLQNxhFfiiVNNaXbfZ8HgKEPtSTvpaglXpnCo9COtawFeKPtaKmENpRj5V3mP0EOhtt4w_MpLhHekN",
|
||||
"domain": ".x.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
]
|
||||
249
projects/feed-hunter/x_scraper_pw.py
Normal file
249
projects/feed-hunter/x_scraper_pw.py
Normal file
@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
X/Twitter Feed Scraper using Playwright
|
||||
Scrapes specific accounts for trading-related posts.
|
||||
Uses saved Chrome session cookies for authentication.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ACCOUNTS = [
|
||||
"browomo", "ArchiveExplorer", "noisyb0y1", "krajekis",
|
||||
"Shelpid_WI3M", "polyaboretum", "0xashensoul",
|
||||
]
|
||||
|
||||
TRADING_KEYWORDS = [
|
||||
"polymarket", "trade", "profit", "wallet", "arbitrage", "signal",
|
||||
"crypto", "bitcoin", "ethereum", "solana", "strategy", "edge",
|
||||
"bet", "position", "stock", "market", "pnl", "alpha",
|
||||
"$", "usdc", "defi", "token", "copy", "whale", "degen",
|
||||
"short", "long", "bullish", "bearish", "portfolio",
|
||||
]
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "x-feed"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
COOKIE_FILE = Path(__file__).parent / "x_cookies.json"
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
|
||||
def send_telegram(message):
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[ALERT] {message}")
|
||||
return
|
||||
import urllib.request
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "HTML"}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram error: {e}")
|
||||
|
||||
|
||||
def save_cookies(context):
|
||||
cookies = context.cookies()
|
||||
COOKIE_FILE.write_text(json.dumps(cookies, indent=2))
|
||||
print(f"Saved {len(cookies)} cookies")
|
||||
|
||||
|
||||
def load_cookies(context):
|
||||
if COOKIE_FILE.exists():
|
||||
cookies = json.loads(COOKIE_FILE.read_text())
|
||||
context.add_cookies(cookies)
|
||||
print(f"Loaded {len(cookies)} cookies")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def export_cookies_from_chrome():
|
||||
"""One-time: grab cookies from the running Chrome debug instance."""
|
||||
import http.client, websocket as ws_mod
|
||||
conn = http.client.HTTPConnection("localhost", 9222)
|
||||
conn.request("GET", "/json")
|
||||
tabs = json.loads(conn.getresponse().read())
|
||||
|
||||
x_tab = None
|
||||
for t in tabs:
|
||||
if "x.com" in t.get("url", ""):
|
||||
x_tab = t
|
||||
break
|
||||
|
||||
if not x_tab:
|
||||
print("No X tab found in Chrome debug")
|
||||
return []
|
||||
|
||||
ws = ws_mod.create_connection(x_tab["webSocketDebuggerUrl"], timeout=10)
|
||||
ws.send(json.dumps({"id": 1, "method": "Network.getAllCookies"}))
|
||||
result = json.loads(ws.recv())
|
||||
all_cookies = result.get("result", {}).get("cookies", [])
|
||||
ws.close()
|
||||
|
||||
# Filter for x.com cookies and convert to Playwright format
|
||||
x_cookies = []
|
||||
for c in all_cookies:
|
||||
if "x.com" in c.get("domain", "") or "twitter.com" in c.get("domain", ""):
|
||||
x_cookies.append({
|
||||
"name": c["name"],
|
||||
"value": c["value"],
|
||||
"domain": c["domain"],
|
||||
"path": c.get("path", "/"),
|
||||
"secure": c.get("secure", False),
|
||||
"httpOnly": c.get("httpOnly", False),
|
||||
"sameSite": c.get("sameSite", "Lax"),
|
||||
})
|
||||
|
||||
COOKIE_FILE.write_text(json.dumps(x_cookies, indent=2))
|
||||
print(f"Exported {len(x_cookies)} X cookies from Chrome")
|
||||
return x_cookies
|
||||
|
||||
|
||||
def scrape_account(page, account, max_scroll=5):
|
||||
"""Scrape recent posts from a single account."""
|
||||
posts = []
|
||||
|
||||
try:
|
||||
page.goto(f"https://x.com/{account}", wait_until="networkidle", timeout=15000)
|
||||
except:
|
||||
try:
|
||||
page.goto(f"https://x.com/{account}", wait_until="domcontentloaded", timeout=10000)
|
||||
page.wait_for_timeout(3000)
|
||||
except Exception as e:
|
||||
print(f" Failed to load @{account}: {e}")
|
||||
return posts
|
||||
|
||||
seen_texts = set()
|
||||
|
||||
for scroll in range(max_scroll):
|
||||
articles = page.query_selector_all("article")
|
||||
|
||||
for article in articles:
|
||||
try:
|
||||
text = article.inner_text()[:800]
|
||||
# Deduplicate
|
||||
sig = text[:100]
|
||||
if sig in seen_texts:
|
||||
continue
|
||||
seen_texts.add(sig)
|
||||
|
||||
# Extract links
|
||||
links = article.query_selector_all("a")
|
||||
urls = [l.get_attribute("href") for l in links if l.get_attribute("href")]
|
||||
|
||||
posts.append({
|
||||
"account": account,
|
||||
"text": text,
|
||||
"urls": urls[:5],
|
||||
"scraped_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
# Scroll down
|
||||
page.evaluate("window.scrollBy(0, 1500)")
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
return posts
|
||||
|
||||
|
||||
def is_trading_related(text):
|
||||
text_lower = text.lower()
|
||||
return any(kw in text_lower for kw in TRADING_KEYWORDS)
|
||||
|
||||
|
||||
def main():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
print(f"=== X Feed Scraper (Playwright) ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
|
||||
# Export cookies from Chrome if we don't have them yet
|
||||
if not COOKIE_FILE.exists():
|
||||
print("No cookies found — exporting from Chrome debug session...")
|
||||
export_cookies_from_chrome()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
viewport={"width": 1280, "height": 900},
|
||||
)
|
||||
|
||||
load_cookies(context)
|
||||
page = context.new_page()
|
||||
|
||||
all_posts = []
|
||||
trading_posts = []
|
||||
|
||||
for account in ACCOUNTS:
|
||||
print(f"\nScraping @{account}...", end=" ", flush=True)
|
||||
posts = scrape_account(page, account)
|
||||
print(f"{len(posts)} posts")
|
||||
|
||||
for post in posts:
|
||||
all_posts.append(post)
|
||||
if is_trading_related(post["text"]):
|
||||
trading_posts.append(post)
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Total posts: {len(all_posts)}")
|
||||
print(f"Trading-related: {len(trading_posts)}")
|
||||
|
||||
# Save results
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M")
|
||||
out_file = DATA_DIR / f"scan-{timestamp}.json"
|
||||
out_file.write_text(json.dumps({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"total_posts": len(all_posts),
|
||||
"trading_posts": len(trading_posts),
|
||||
"posts": trading_posts,
|
||||
}, indent=2))
|
||||
print(f"Saved to {out_file}")
|
||||
|
||||
# Check for new posts we haven't seen before
|
||||
seen_file = DATA_DIR / "seen_posts.json"
|
||||
seen = set()
|
||||
if seen_file.exists():
|
||||
try:
|
||||
seen = set(json.loads(seen_file.read_text()))
|
||||
except:
|
||||
pass
|
||||
|
||||
new_posts = []
|
||||
for post in trading_posts:
|
||||
sig = post["text"][:150]
|
||||
if sig not in seen:
|
||||
new_posts.append(post)
|
||||
seen.add(sig)
|
||||
|
||||
seen_file.write_text(json.dumps(list(seen)[-5000:])) # Keep last 5000
|
||||
|
||||
if new_posts:
|
||||
print(f"\n🔔 {len(new_posts)} NEW trading posts!")
|
||||
for post in new_posts[:5]:
|
||||
lines = post["text"].split("\n")
|
||||
author = f"@{post['account']}"
|
||||
snippet = post["text"][:200].replace("\n", " ")
|
||||
print(f"\n {author}: {snippet}")
|
||||
|
||||
# Alert on Telegram
|
||||
msg = f"🔍 <b>New from {author}</b>\n\n{snippet[:300]}"
|
||||
if post.get("urls"):
|
||||
x_urls = [u for u in post["urls"] if "x.com" in u or "twitter.com" in u]
|
||||
if x_urls:
|
||||
msg += f"\n\n{x_urls[0]}"
|
||||
send_telegram(msg)
|
||||
else:
|
||||
print("\nNo new trading posts since last scan.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
224
projects/kip/PROJECT.md
Normal file
224
projects/kip/PROJECT.md
Normal file
@ -0,0 +1,224 @@
|
||||
# Kip — Voice Assistant
|
||||
|
||||
**Codename:** Kip
|
||||
**Purpose:** Alexa replacement for D J's girlfriend
|
||||
**Architecture:** Steam Deck (thin client) ↔ Proxmox LXC (brains)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Kip is a privacy-first voice assistant. The Steam Deck acts as a dumb terminal (mic, speaker, screen). All intelligence runs on an LXC container on Proxmox.
|
||||
|
||||
## Hardware
|
||||
|
||||
### Steam Deck (Client)
|
||||
- Always-on, propped up in kitchen/living room on charging dock
|
||||
- Runs: wake word detection, audio capture, audio playback, display UI
|
||||
- Connects to LXC over local WiFi
|
||||
|
||||
### LXC Container (Server)
|
||||
- **OS:** Ubuntu 22.04 or 24.04
|
||||
- **RAM:** 4GB recommended (Whisper needs ~1.5GB, Piper ~200MB, OpenClaw ~500MB)
|
||||
- **Disk:** 10GB (models + data)
|
||||
- **CPU:** 2-4 cores (Whisper STT is CPU-bound)
|
||||
- **Network:** Static IP on LAN, accessible from Steam Deck
|
||||
|
||||
## Software Stack
|
||||
|
||||
### LXC Container
|
||||
|
||||
| Component | Purpose | Tool |
|
||||
|-----------|---------|------|
|
||||
| STT | Speech-to-text | Faster Whisper (base.en model) |
|
||||
| TTS | Text-to-speech | Piper (en_US voice) |
|
||||
| Agent | Intelligence | OpenClaw with Kip agent |
|
||||
| API | Communication | FastAPI HTTP server |
|
||||
| Data | Grocery list, timers | JSON files + SQLite |
|
||||
|
||||
### Steam Deck
|
||||
|
||||
| Component | Purpose | Tool |
|
||||
|-----------|---------|------|
|
||||
| Wake word | "Hey Kip" detection | OpenWakeWord |
|
||||
| Audio capture | Record after wake | PyAudio / sounddevice |
|
||||
| Audio playback | Play TTS responses | PyAudio / sounddevice |
|
||||
| UI | Display info | Web browser (fullscreen PWA) or PyQt |
|
||||
| Client | Talk to LXC | Python HTTP client |
|
||||
|
||||
---
|
||||
|
||||
## LXC Setup Checklist
|
||||
|
||||
D J creates the LXC with:
|
||||
- [ ] Ubuntu 22.04 or 24.04 template
|
||||
- [ ] 4GB RAM, 2-4 CPU cores, 10GB disk
|
||||
- [ ] Static IP on LAN (e.g., 192.168.86.XX)
|
||||
- [ ] SSH access enabled (key-based)
|
||||
- [ ] Audio passthrough NOT needed (LXC doesn't play audio — Deck does)
|
||||
- [ ] Internet access (for OpenClaw, model downloads)
|
||||
- [ ] Hostname: `kip` (optional, nice to have)
|
||||
|
||||
Once created, give Case:
|
||||
- IP address
|
||||
- SSH credentials or key
|
||||
|
||||
---
|
||||
|
||||
## API Design (LXC ↔ Steam Deck)
|
||||
|
||||
### POST /listen
|
||||
Steam Deck sends audio, gets back text response + TTS audio.
|
||||
|
||||
```
|
||||
Request:
|
||||
Content-Type: multipart/form-data
|
||||
Body: audio file (WAV, 16kHz mono)
|
||||
|
||||
Response:
|
||||
{
|
||||
"text": "user said this",
|
||||
"response": "Kip says this",
|
||||
"audio": "<base64 WAV of TTS response>",
|
||||
"ui_update": {
|
||||
"type": "grocery_list",
|
||||
"data": ["eggs", "milk", "bread"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /status
|
||||
Health check + current state (timers, lists, etc.)
|
||||
|
||||
### GET /grocery
|
||||
Returns current grocery list (for phone web view)
|
||||
|
||||
### POST /grocery
|
||||
Add/remove items (for phone web view)
|
||||
|
||||
### GET /ui
|
||||
Returns current display state for the Deck's screen.
|
||||
|
||||
---
|
||||
|
||||
## Kip Agent (OpenClaw)
|
||||
|
||||
Kip gets its own OpenClaw agent with:
|
||||
|
||||
### SOUL.md (Personality)
|
||||
- Name: Kip
|
||||
- Friendly, concise, warm
|
||||
- Optimized for voice — short responses, no markdown
|
||||
- Knows the household (D J, girlfriend, 4 cats)
|
||||
- Designed for non-technical user
|
||||
|
||||
### Capabilities
|
||||
- **Weather:** "Hey Kip, what's the weather?" → Nashville forecast
|
||||
- **Timers:** "Hey Kip, set a timer for 15 minutes" → countdown with alarm
|
||||
- **Grocery list:** "Hey Kip, add eggs to the list" → persistent list
|
||||
- **Grocery check:** "Hey Kip, what's on the grocery list?" → reads it back
|
||||
- **General Q&A:** "Hey Kip, how long do I bake chicken at 400?" → answer
|
||||
- **Time/Date:** "Hey Kip, what time is it?"
|
||||
|
||||
### Future Capabilities
|
||||
- Calendar integration
|
||||
- Music control (Spotify)
|
||||
- Smart home (if they get devices)
|
||||
- Recipe lookup
|
||||
- Kroger API for prices/ordering
|
||||
|
||||
---
|
||||
|
||||
## Phone Web View
|
||||
|
||||
Simple responsive web page served by the LXC:
|
||||
- Shows grocery list
|
||||
- Can add/remove items by tapping
|
||||
- Accessible at `http://kip.local:8080` or `http://192.168.86.XX:8080`
|
||||
- Girlfriend can open it on her iPhone in the store
|
||||
|
||||
---
|
||||
|
||||
## Build Phases
|
||||
|
||||
### Phase 1: Voice Loop (MVP)
|
||||
- [ ] LXC setup + dependencies installed
|
||||
- [ ] Faster Whisper running (base.en model)
|
||||
- [ ] Piper TTS running (pick a good voice)
|
||||
- [ ] FastAPI server handling /listen endpoint
|
||||
- [ ] Steam Deck: wake word + record + send + play response
|
||||
- [ ] Test: "Hey Kip, hello" → Kip responds with voice
|
||||
- **Goal: End-to-end voice working**
|
||||
|
||||
### Phase 2: Grocery List + Weather
|
||||
- [ ] Grocery list CRUD (voice + API)
|
||||
- [ ] Weather skill (Nashville)
|
||||
- [ ] Timer system with alarm sounds
|
||||
- [ ] Phone web view for grocery list
|
||||
- [ ] Steam Deck display: clock + weather + active timers
|
||||
- **Goal: Actually useful in the kitchen**
|
||||
|
||||
### Phase 3: OpenClaw Integration
|
||||
- [ ] Kip agent running on OpenClaw
|
||||
- [ ] General Q&A via Claude/Qwen
|
||||
- [ ] Smarter conversations (context, follow-ups)
|
||||
- [ ] Cost optimization: simple commands (timer, list) handled locally, only complex Q&A hits Claude
|
||||
- **Goal: Smart assistant, not just a voice command box**
|
||||
|
||||
### Phase 4: Polish
|
||||
- [ ] Custom wake word model trained on "Hey Kip"
|
||||
- [ ] Better TTS voice selection
|
||||
- [ ] Deck UI polish (nice weather widget, timer display, list view)
|
||||
- [ ] Ambient mode (clock/weather when idle)
|
||||
- [ ] Multiple room support (add Pi later)
|
||||
- **Goal: Girlfriend actually wants to use it daily**
|
||||
|
||||
---
|
||||
|
||||
## Cost
|
||||
|
||||
| Item | Cost |
|
||||
|------|------|
|
||||
| Steam Deck | Already owned |
|
||||
| LXC container | Free (Proxmox) |
|
||||
| OpenWakeWord | Free (open source) |
|
||||
| Faster Whisper | Free (open source) |
|
||||
| Piper TTS | Free (open source) |
|
||||
| OpenClaw | Already running |
|
||||
| Claude API for Q&A | Covered by existing subscription |
|
||||
| **Total** | **$0** |
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
projects/kip/
|
||||
├── PROJECT.md # This file
|
||||
├── server/ # LXC-side code
|
||||
│ ├── main.py # FastAPI server
|
||||
│ ├── stt.py # Whisper STT wrapper
|
||||
│ ├── tts.py # Piper TTS wrapper
|
||||
│ ├── skills/ # Timer, grocery, weather handlers
|
||||
│ ├── data/ # Grocery lists, state
|
||||
│ └── requirements.txt
|
||||
├── client/ # Steam Deck code
|
||||
│ ├── kip_client.py # Main client app
|
||||
│ ├── wake_word.py # OpenWakeWord listener
|
||||
│ ├── audio.py # Record/playback
|
||||
│ ├── ui/ # Display UI
|
||||
│ └── requirements.txt
|
||||
└── agent/ # Kip's OpenClaw agent config
|
||||
├── SOUL.md
|
||||
└── config.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- All voice processing (STT/TTS) on LXC, not Deck — keeps client thin
|
||||
- Wake word is the ONLY thing that runs on Deck locally
|
||||
- Grocery list syncs to a web page for phone access
|
||||
- Simple commands (timer, list) should be handled WITHOUT hitting Claude to save tokens
|
||||
- Only complex Q&A ("how long to bake chicken?") routes through OpenClaw/Claude
|
||||
- Qwen on Ollama (192.168.86.137) as fallback for simple Q&A
|
||||
203
projects/kipp/research/alexa-plus-ui.md
Normal file
203
projects/kipp/research/alexa-plus-ui.md
Normal file
@ -0,0 +1,203 @@
|
||||
# Alexa+ UI Design Patterns for Smart Displays
|
||||
|
||||
> Research compiled Feb 2026 from Amazon official sources, The Verge, CNET, PCMag, Android Central
|
||||
|
||||
## 1. Key UI Patterns
|
||||
|
||||
### Home Screen / Dashboard Mode
|
||||
- **Personalized home screen** that adapts based on user, time of day, and proximity
|
||||
- **Visual ID** (facial recognition via 13MP camera) — recognizes who approaches and personalizes the display (your calendar, your smart home favorites)
|
||||
- **Proximity-aware UI**: Shows larger fonts/info when you're far away, detailed widgets when close
|
||||
- **Ambient brightness adaptation** via ambient light sensor
|
||||
- **Content rotation**: Home screen cycles through widgets, photos, suggestions, weather, calendar events
|
||||
- **Home Content Categories** toggle: Users can enable/disable content types (recipes, news, shopping suggestions, etc.) via Settings > Display & Appearance > Home Content Categories
|
||||
|
||||
### Conversation Mode (Alexa+ AI Interaction)
|
||||
- **Voice-first, screen-second**: Conversation transcription appears on-screen during voice interaction
|
||||
- **Visual aids**: Alexa+ shows pictures, videos, recipe cards, or product images as contextual visual responses
|
||||
- **Multi-turn conversation**: Follow-up questions without re-saying wake word; conversation context maintained
|
||||
- **No persistent chat window**: The conversation UI appears as an overlay during interaction and returns to dashboard when done — it's NOT a chat app
|
||||
- **Suggestion chips**: When idle, Alexa+ can display "things to try" suggestions on the home screen
|
||||
|
||||
### Smart Home Dashboard Mode
|
||||
- **Full-screen smart home dashboard** accessed via Menu > Smart Home or voice ("Alexa, open my smart home dashboard")
|
||||
- **Derived from Echo Hub interface** — grid of device controls with room switching
|
||||
- **Map View** integration — visual floor plan layout of devices (from Alexa Map View feature)
|
||||
- **Camera feeds** inline — view Ring/security camera live feeds directly
|
||||
- **Home/Away/Night modes** — single-tap to change home state
|
||||
- **Device status at a glance** — lights, locks, thermostats, cameras all visible
|
||||
|
||||
### Media/Entertainment Mode
|
||||
- **Media control center**: Dedicated browsing pages for music, ambient sounds, podcasts, books
|
||||
- **TV & Videos experience**: Aggregated content from multiple streaming providers
|
||||
- **Full-screen video playback** for shows, recipes, video calls
|
||||
|
||||
## 2. Color Schemes and Typography
|
||||
|
||||
### Visual Design Language
|
||||
- **Clean, modern aesthetic** — described as "cleaner, sleeker full-screen UI" (The Verge)
|
||||
- **Dark backgrounds** with bright accent elements for widgets (typical of ambient displays)
|
||||
- **Adaptive brightness** — screen adjusts to room lighting automatically
|
||||
- **Photo-forward**: When idle, displays personal photos from Amazon Photos as ambient wallpaper/slideshow
|
||||
- **Card-based UI**: Information presented in distinct card/widget containers with rounded corners
|
||||
- **High contrast readability**: Font sizes scale based on distance (proximity sensor)
|
||||
|
||||
### Typography
|
||||
- Amazon's custom typeface family (Amazon Ember)
|
||||
- **Large, glanceable text** for time, weather, calendar — designed to be read across a room
|
||||
- **Smaller detail text** when user is close/interacting
|
||||
- **Bold headers** for widget titles, lighter weight for content
|
||||
|
||||
### Color Palette (observed from press images)
|
||||
- **Background**: Dark navy/charcoal or photo wallpaper
|
||||
- **Widget cards**: Semi-transparent dark cards with white text, or light cards with dark text
|
||||
- **Accent colors**: Alexa blue (#00CAFF-ish), warm amber for alerts/reminders
|
||||
- **Smart home controls**: Color-coded by device type (warm yellow for lights, blue for locks, etc.)
|
||||
- **Conversation UI**: Blue gradient for Alexa responses, lighter for user transcription
|
||||
|
||||
## 3. Widget Types and Layouts
|
||||
|
||||
### Available Widget Types (Echo Show 15/21 + new Echo Show 8/11)
|
||||
- **Clock/Time** — large, always-visible
|
||||
- **Weather** — current conditions + forecast
|
||||
- **Calendar** — daily/weekly/monthly views, multi-calendar family support
|
||||
- **Smart Home controls** — quick-access device toggles
|
||||
- **Camera feeds** — live view from Ring cameras
|
||||
- **Shopping list** — editable list
|
||||
- **To-do / Tasks** — task widget (coming soon as of late 2025)
|
||||
- **Reminders** — upcoming reminders
|
||||
- **Music/Now Playing** — currently playing media with controls
|
||||
- **Photos** — Amazon Photos slideshow
|
||||
- **Timers** — multiple cooking timers visible simultaneously
|
||||
- **Recipes** — personalized recipe suggestions (coming soon)
|
||||
- **News/Headlines** — rotating news content
|
||||
- **Sticky Notes** — personal notes
|
||||
- **Skill widgets** — third-party skill mini-views
|
||||
- **Personalized notifications** — follow-ups from recent conversations
|
||||
|
||||
### Layout System
|
||||
- **Grid-based widget layout** — widgets snap to a grid
|
||||
- **Resizable widgets** — can be expanded/collapsed
|
||||
- **Drag-and-drop rearrangement** — long-press to enter edit mode
|
||||
- **Widget Gallery** — swipe down from top to access available widgets
|
||||
- **Different layouts for different screen sizes**:
|
||||
- Echo Show 8 (8.7"): 1-2 widget columns, more compact
|
||||
- Echo Show 11 (11"): 2-3 widget columns
|
||||
- Echo Show 15 (15.6"): Full widget panel, landscape orientation, wall-mountable
|
||||
- Echo Show 21 (21"): Largest canvas, most widgets visible simultaneously
|
||||
- **Left panel + right content**: On larger screens (15, 21), widget sidebar on left with main content area on right
|
||||
|
||||
### Widget Interaction
|
||||
- **Tap to expand** — widgets open to full-screen detail view
|
||||
- **Swipe left** on widget pane handle to access rearrangement
|
||||
- **Voice-addressable** — "Alexa, show my calendar" opens calendar widget full-screen
|
||||
|
||||
## 4. Conversation Interface Behavior
|
||||
|
||||
### How It Appears
|
||||
1. **Wake word trigger** ("Alexa") — blue animation ring/bar appears at bottom of screen
|
||||
2. **Listening state** — screen shows blue animated waveform/indicator
|
||||
3. **Processing** — brief thinking animation
|
||||
4. **Response** — text transcription of conversation appears; visual content (images, cards, lists) shown alongside
|
||||
5. **Multi-turn**: Screen stays in conversation mode; displays ongoing transcript
|
||||
6. **Suggestion follow-ups**: After response, may show tappable suggestion chips
|
||||
|
||||
### How It Disappears
|
||||
1. **Conversation ends** (timeout or "thank you") — UI fades back to home screen
|
||||
2. **Gradual transition** — conversation results may persist briefly as a notification/widget
|
||||
3. **No chat history** visible on home screen — it's ephemeral
|
||||
4. **Results persist contextually**: e.g., if you asked about weather, the weather widget may be promoted to prominent position
|
||||
|
||||
### Visual Treatment During Conversation
|
||||
- **Overlay model**: Conversation takes over most of the screen but feels temporary
|
||||
- **Blue accent theming** during active Alexa interaction
|
||||
- **Cards/results** appear with smooth animations
|
||||
- **Shopping lists, recipes, timers** created during conversation persist as widgets after conversation ends
|
||||
|
||||
## 5. Home Dashboard vs Chat App — Design Philosophy
|
||||
|
||||
### What Makes It Feel Like a HOME DASHBOARD:
|
||||
- **Ambient-first**: Default state is glanceable information, not a conversation thread
|
||||
- **Photo wallpaper**: Personal photos rotate as background — feels personal and decorative
|
||||
- **Widget grid**: Calendar, weather, smart home status visible at a glance — like a family command center
|
||||
- **Proactive, not reactive**: Shows relevant info (upcoming events, weather changes) without being asked
|
||||
- **Environmental awareness**: Uses presence sensors, temperature, ambient light to adapt
|
||||
- **Family-centric**: Visual ID recognizes different family members, shows personalized content per person
|
||||
- **Physical space integration**: Smart home controls are front-and-center; the device is about controlling YOUR space
|
||||
- **Always-on display**: Designed to be glanced at throughout the day, not actively used continuously
|
||||
|
||||
### What Makes It NOT a Chat App:
|
||||
- **No persistent conversation history** on the display
|
||||
- **No text input by default** (keyboard available for accessibility but voice is primary)
|
||||
- **Conversation is ephemeral** — appears and disappears with the interaction
|
||||
- **No message threads or bubbles** — responses are full-screen cards, not chat bubbles
|
||||
- **Content-forward**: After a conversation, the RESULT persists (timer, reminder, shopping list) not the conversation itself
|
||||
- **The display returns to ambient/dashboard mode** — chat doesn't persist as the primary view
|
||||
|
||||
### Key Design Principles:
|
||||
1. **Glanceable over readable** — information hierarchy favors quick scanning
|
||||
2. **Voice-first, touch-second** — primary interaction is voice; touch is supplementary
|
||||
3. **Contextual intelligence** — UI adapts to who's looking, time of day, what's happening
|
||||
4. **Ambient computing** — the device fades into the background of your home, activates when needed
|
||||
5. **Family hub** — shared device, personalized per user, central to household operations
|
||||
6. **Proactive assistance** — surface relevant info before being asked (upcoming event, package delivery, missed routine)
|
||||
|
||||
## 6. Screenshot URLs and Visual References
|
||||
|
||||
### Official Amazon Press Images
|
||||
- About Amazon Echo Show guide: https://www.aboutamazon.com/news/devices/getting-started-echo-show-8-11-alexa-plus-features
|
||||
- New Alexa+ features overview: https://www.aboutamazon.com/news/devices/new-alexa-generative-artificial-intelligence
|
||||
|
||||
### The Verge Hands-On Photos
|
||||
- Smart home dashboard on Echo Show 21: https://platform.theverge.com/wp-content/uploads/sites/2/2025/02/IMG_2557.jpeg
|
||||
- Echo Show UI with widgets: https://platform.theverge.com/wp-content/uploads/sites/2/2025/02/IMG_2564.jpeg
|
||||
- Shopping list UI: https://platform.theverge.com/wp-content/uploads/sites/2/2025/02/shopping-list.png
|
||||
- Full article: https://www.theverge.com/news/621008/hands-on-with-alexa-plus-smart-home-echo-show-21
|
||||
|
||||
### Android Central Widget Customization Screenshots
|
||||
- Echo Show 15 widget gallery: https://cdn.mos.cms.futurecdn.net/mcx9KfDjTDnTyTWVygkk73.jpg
|
||||
- Widget pane expanded: https://cdn.mos.cms.futurecdn.net/WivGCjDJhDdAA3FYWeJFPW.jpg
|
||||
- Widget rearrangement: https://cdn.mos.cms.futurecdn.net/fChVCpuYSsEDewQUpxb3QQ.jpg
|
||||
- Widget customization: https://cdn.mos.cms.futurecdn.net/5B84ngX2GzNqRsdy33aUua.jpg
|
||||
- Full article: https://www.androidcentral.com/how-customize-alexa-widgets-amazon-echo-show-15
|
||||
|
||||
### Review Articles with UI Photos
|
||||
- CNET Echo Show 11 review: https://www.cnet.com/home/smart-home/this-smart-display-is-the-best-add-on-my-kitchen-has-ever-had/
|
||||
- PCMag Echo Show 8 (2025) review: https://www.pcmag.com/reviews/amazon-echo-show-8-4th-gen-2025
|
||||
|
||||
## 7. Technical Framework Notes
|
||||
|
||||
### Alexa Presentation Language (APL)
|
||||
- Amazon's visual design framework for building interactive voice+visual experiences
|
||||
- Supports responsive layouts across different Echo Show screen sizes
|
||||
- Developers can build custom visual skill responses using APL
|
||||
- Reference: https://developer.amazon.com/en-US/alexa/alexa-haus/alexa-presentation-language
|
||||
|
||||
### Device Specifications (for UI design reference)
|
||||
| Device | Screen Size | Resolution | Orientation |
|
||||
|--------|------------|------------|-------------|
|
||||
| Echo Show 5 | 5.5" | 960×480 | Landscape |
|
||||
| Echo Show 8 (2025) | 8.7" | 1340×800 | Landscape |
|
||||
| Echo Show 11 (2025) | 11" | 1920×1200 | Landscape |
|
||||
| Echo Show 15 | 15.6" | 1920×1080 | Landscape (wall-mount) |
|
||||
| Echo Show 21 | 21" | 1920×1200 | Landscape (wall-mount) |
|
||||
|
||||
### Key Hardware Features Relevant to UI
|
||||
- **AZ3 Pro chip** with AI accelerator — enables smooth transitions, multitasking between voice and visual
|
||||
- **Omnisense presence sensor** (ultrasound + Wi-Fi radar) — fine motion detection for proximity-aware UI
|
||||
- **13MP camera with Visual ID** — facial recognition for personalization
|
||||
- **Ambient light sensor** — adaptive brightness
|
||||
- **Ambient temperature sensor** — can trigger routines
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Takeaways for KIPP
|
||||
|
||||
1. **Dashboard-first, conversation-second**: The display should feel like a home information hub that happens to have AI conversation capabilities, not a chatbot with a screen
|
||||
2. **Ephemeral conversation UI**: Conversations appear as overlays and fade back to dashboard — results persist, conversations don't
|
||||
3. **Widget grid system**: Modular, customizable cards that users arrange to their preference
|
||||
4. **Proximity awareness**: Different information density based on distance from screen
|
||||
5. **Visual ID / personalization**: Per-user customization is key to making a shared device feel personal
|
||||
6. **Ambient mode is the default state**: Photos, clock, weather — the device should be beautiful when idle
|
||||
7. **Proactive intelligence**: Surface information before being asked — upcoming events, deliveries, routine changes
|
||||
8. **Voice-first interaction model**: Touch/visual is supplementary to voice
|
||||
@ -1,61 +1,125 @@
|
||||
{
|
||||
"cash": 53477.43000000002,
|
||||
"cash": 870.6802087402511,
|
||||
"positions": {
|
||||
"DUOL": {
|
||||
"shares": 57,
|
||||
"avg_cost": 116.35,
|
||||
"current_price": 116.35,
|
||||
"entry_date": "2026-02-09T10:55:58.243598",
|
||||
"entry_reason": "GARP signal: PE=14.65, FwdPE=14.71, RevGr=41.1%, EPSGr=1114.3%, RSI=23.44",
|
||||
"trailing_stop": 104.715
|
||||
},
|
||||
"ALLY": {
|
||||
"shares": 156,
|
||||
"avg_cost": 42.65,
|
||||
"current_price": 42.65,
|
||||
"current_price": 42.38999938964844,
|
||||
"entry_date": "2026-02-09T10:55:58.244488",
|
||||
"entry_reason": "GARP signal: PE=18.0, FwdPE=6.76, RevGr=12.0%, EPSGr=265.4%, RSI=53.23",
|
||||
"trailing_stop": 38.385
|
||||
"trailing_stop": 38.50199890136719
|
||||
},
|
||||
"JHG": {
|
||||
"shares": 138,
|
||||
"avg_cost": 48.21,
|
||||
"current_price": 48.21,
|
||||
"current_price": 48.2400016784668,
|
||||
"entry_date": "2026-02-09T10:55:58.245351",
|
||||
"entry_reason": "GARP signal: PE=9.22, FwdPE=9.96, RevGr=61.3%, EPSGr=243.6%, RSI=68.71",
|
||||
"trailing_stop": 43.389
|
||||
"trailing_stop": 43.46999931335449
|
||||
},
|
||||
"INCY": {
|
||||
"shares": 61,
|
||||
"avg_cost": 108.69,
|
||||
"current_price": 108.69,
|
||||
"current_price": 100.05000305175781,
|
||||
"entry_date": "2026-02-09T10:55:58.246289",
|
||||
"entry_reason": "GARP signal: PE=18.42, FwdPE=13.76, RevGr=20.0%, EPSGr=290.7%, RSI=63.48",
|
||||
"trailing_stop": 97.821
|
||||
"trailing_stop": 98.12699890136719
|
||||
},
|
||||
"PINS": {
|
||||
"shares": 332,
|
||||
"avg_cost": 20.06,
|
||||
"current_price": 20.06,
|
||||
"current_price": 20.329999923706055,
|
||||
"entry_date": "2026-02-09T10:55:58.247262",
|
||||
"entry_reason": "GARP signal: PE=7.04, FwdPE=10.61, RevGr=16.8%, EPSGr=225.0%, RSI=19.14",
|
||||
"trailing_stop": 18.054
|
||||
"trailing_stop": 18.59850082397461
|
||||
},
|
||||
"EXEL": {
|
||||
"shares": 152,
|
||||
"avg_cost": 43.8,
|
||||
"current_price": 43.8,
|
||||
"current_price": 42.97999954223633,
|
||||
"entry_date": "2026-02-09T10:55:58.252764",
|
||||
"entry_reason": "GARP signal: PE=18.4, FwdPE=12.76, RevGr=10.8%, EPSGr=72.5%, RSI=50.12",
|
||||
"trailing_stop": 39.42
|
||||
"trailing_stop": 39.573001098632815
|
||||
},
|
||||
"CART": {
|
||||
"shares": 187,
|
||||
"avg_cost": 35.49,
|
||||
"current_price": 35.49,
|
||||
"current_price": 34.619998931884766,
|
||||
"entry_date": "2026-02-09T10:55:58.254418",
|
||||
"entry_reason": "GARP signal: PE=19.5, FwdPE=9.05, RevGr=10.2%, EPSGr=21.1%, RSI=37.75",
|
||||
"trailing_stop": 31.941000000000003
|
||||
},
|
||||
"UBSI": {
|
||||
"shares": 148,
|
||||
"avg_cost": 44.93,
|
||||
"current_price": 44.63999938964844,
|
||||
"entry_date": "2026-02-10T09:06:30.696005",
|
||||
"entry_reason": "GARP signal: PE=13.74, FwdPE=11.93, RevGr=22.1%, EPSGr=32.1%, RSI=67.45",
|
||||
"trailing_stop": 40.437
|
||||
},
|
||||
"WTFC": {
|
||||
"shares": 42,
|
||||
"avg_cost": 158.12,
|
||||
"current_price": 156.07000732421875,
|
||||
"entry_date": "2026-02-10T09:06:30.699573",
|
||||
"entry_reason": "GARP signal: PE=13.87, FwdPE=11.79, RevGr=10.5%, EPSGr=19.4%, RSI=62.2",
|
||||
"trailing_stop": 142.30800000000002
|
||||
},
|
||||
"FHN": {
|
||||
"shares": 258,
|
||||
"avg_cost": 25.64,
|
||||
"current_price": 25.64,
|
||||
"entry_date": "2026-02-10T15:36:28.434830",
|
||||
"entry_reason": "GARP signal: PE=13.71, FwdPE=10.94, RevGr=23.7%, EPSGr=74.9%, RSI=58.44",
|
||||
"trailing_stop": 23.076
|
||||
},
|
||||
"FNB": {
|
||||
"shares": 354,
|
||||
"avg_cost": 18.69,
|
||||
"current_price": 18.69,
|
||||
"entry_date": "2026-02-10T15:36:28.437094",
|
||||
"entry_reason": "GARP signal: PE=11.98, FwdPE=9.55, RevGr=26.4%, EPSGr=56.5%, RSI=62.57",
|
||||
"trailing_stop": 16.821
|
||||
},
|
||||
"WAL": {
|
||||
"shares": 69,
|
||||
"avg_cost": 94.92,
|
||||
"current_price": 94.92,
|
||||
"entry_date": "2026-02-10T15:36:28.439819",
|
||||
"entry_reason": "GARP signal: PE=10.87, FwdPE=7.98, RevGr=16.6%, EPSGr=32.9%, RSI=60.46",
|
||||
"trailing_stop": 85.428
|
||||
},
|
||||
"ONB": {
|
||||
"shares": 259,
|
||||
"avg_cost": 25.53,
|
||||
"current_price": 25.53,
|
||||
"entry_date": "2026-02-10T15:36:28.441188",
|
||||
"entry_reason": "GARP signal: PE=14.26, FwdPE=8.9, RevGr=41.4%, EPSGr=17.2%, RSI=68.73",
|
||||
"trailing_stop": 22.977
|
||||
},
|
||||
"ZION": {
|
||||
"shares": 103,
|
||||
"avg_cost": 64.08,
|
||||
"current_price": 64.08,
|
||||
"entry_date": "2026-02-10T15:36:28.442626",
|
||||
"entry_reason": "GARP signal: PE=10.66, FwdPE=9.8, RevGr=13.6%, EPSGr=31.4%, RSI=60.76",
|
||||
"trailing_stop": 57.672
|
||||
},
|
||||
"EWBC": {
|
||||
"shares": 54,
|
||||
"avg_cost": 120.54,
|
||||
"current_price": 120.54,
|
||||
"entry_date": "2026-02-10T15:36:28.444928",
|
||||
"entry_reason": "GARP signal: PE=12.66, FwdPE=11.0, RevGr=21.6%, EPSGr=21.3%, RSI=65.92",
|
||||
"trailing_stop": 108.486
|
||||
},
|
||||
"BAC": {
|
||||
"shares": 119,
|
||||
"avg_cost": 55.39,
|
||||
"current_price": 55.39,
|
||||
"entry_date": "2026-02-10T15:36:28.446464",
|
||||
"entry_reason": "GARP signal: PE=14.54, FwdPE=11.17, RevGr=13.2%, EPSGr=20.9%, RSI=69.17",
|
||||
"trailing_stop": 49.851
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,18 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-02-09",
|
||||
"total_value": 100000.0,
|
||||
"total_pnl": 0.0,
|
||||
"pnl_pct": 0.0,
|
||||
"cash": 53477.43,
|
||||
"num_positions": 7
|
||||
"total_value": 100055.9,
|
||||
"total_pnl": 55.9,
|
||||
"pnl_pct": 0.06,
|
||||
"cash": 60255.3,
|
||||
"num_positions": 6
|
||||
},
|
||||
{
|
||||
"date": "2026-02-10",
|
||||
"total_value": 99255.75,
|
||||
"total_pnl": -744.25,
|
||||
"pnl_pct": -0.74,
|
||||
"cash": 870.68,
|
||||
"num_positions": 15
|
||||
}
|
||||
]
|
||||
@ -61,5 +61,97 @@
|
||||
"cost": 6636.63,
|
||||
"reason": "GARP signal: PE=19.5, FwdPE=9.05, RevGr=10.2%, EPSGr=21.1%, RSI=37.75",
|
||||
"timestamp": "2026-02-09T10:55:58.254721"
|
||||
},
|
||||
{
|
||||
"action": "SELL",
|
||||
"ticker": "DUOL",
|
||||
"shares": 57,
|
||||
"price": 118.91000366210938,
|
||||
"proceeds": 6777.87,
|
||||
"realized_pnl": 145.92,
|
||||
"entry_price": 116.35,
|
||||
"reason": "No longer passes GARP filter",
|
||||
"timestamp": "2026-02-09T15:36:18.884898"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "UBSI",
|
||||
"shares": 148,
|
||||
"price": 44.93,
|
||||
"cost": 6649.64,
|
||||
"reason": "GARP signal: PE=13.74, FwdPE=11.93, RevGr=22.1%, EPSGr=32.1%, RSI=67.45",
|
||||
"timestamp": "2026-02-10T09:06:30.696435"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "WTFC",
|
||||
"shares": 42,
|
||||
"price": 158.12,
|
||||
"cost": 6641.04,
|
||||
"reason": "GARP signal: PE=13.87, FwdPE=11.79, RevGr=10.5%, EPSGr=19.4%, RSI=62.2",
|
||||
"timestamp": "2026-02-10T09:06:30.699988"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "FHN",
|
||||
"shares": 258,
|
||||
"price": 25.64,
|
||||
"cost": 6615.12,
|
||||
"reason": "GARP signal: PE=13.71, FwdPE=10.94, RevGr=23.7%, EPSGr=74.9%, RSI=58.44",
|
||||
"timestamp": "2026-02-10T15:36:28.436095"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "FNB",
|
||||
"shares": 354,
|
||||
"price": 18.69,
|
||||
"cost": 6616.26,
|
||||
"reason": "GARP signal: PE=11.98, FwdPE=9.55, RevGr=26.4%, EPSGr=56.5%, RSI=62.57",
|
||||
"timestamp": "2026-02-10T15:36:28.437460"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "WAL",
|
||||
"shares": 69,
|
||||
"price": 94.92,
|
||||
"cost": 6549.48,
|
||||
"reason": "GARP signal: PE=10.87, FwdPE=7.98, RevGr=16.6%, EPSGr=32.9%, RSI=60.46",
|
||||
"timestamp": "2026-02-10T15:36:28.440182"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "ONB",
|
||||
"shares": 259,
|
||||
"price": 25.53,
|
||||
"cost": 6612.27,
|
||||
"reason": "GARP signal: PE=14.26, FwdPE=8.9, RevGr=41.4%, EPSGr=17.2%, RSI=68.73",
|
||||
"timestamp": "2026-02-10T15:36:28.441586"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "ZION",
|
||||
"shares": 103,
|
||||
"price": 64.08,
|
||||
"cost": 6600.24,
|
||||
"reason": "GARP signal: PE=10.66, FwdPE=9.8, RevGr=13.6%, EPSGr=31.4%, RSI=60.76",
|
||||
"timestamp": "2026-02-10T15:36:28.443023"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "EWBC",
|
||||
"shares": 54,
|
||||
"price": 120.54,
|
||||
"cost": 6509.16,
|
||||
"reason": "GARP signal: PE=12.66, FwdPE=11.0, RevGr=21.6%, EPSGr=21.3%, RSI=65.92",
|
||||
"timestamp": "2026-02-10T15:36:28.445341"
|
||||
},
|
||||
{
|
||||
"action": "BUY",
|
||||
"ticker": "BAC",
|
||||
"shares": 119,
|
||||
"price": 55.39,
|
||||
"cost": 6591.41,
|
||||
"reason": "GARP signal: PE=14.54, FwdPE=11.17, RevGr=13.2%, EPSGr=20.9%, RSI=69.17",
|
||||
"timestamp": "2026-02-10T15:36:28.446981"
|
||||
}
|
||||
]
|
||||
@ -194,5 +194,117 @@
|
||||
"ticker": "WTFC",
|
||||
"reason": "RSI too high (72.6 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:18.885237",
|
||||
"action": "SELL",
|
||||
"ticker": "DUOL",
|
||||
"reason": "No longer passes GARP filter",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "DUOL",
|
||||
"shares": 57,
|
||||
"price": 118.91000366210938,
|
||||
"proceeds": 6777.87,
|
||||
"realized_pnl": 145.92
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.302964",
|
||||
"action": "SKIP",
|
||||
"ticker": "VLY",
|
||||
"reason": "RSI too high (78.3 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.303492",
|
||||
"action": "SKIP",
|
||||
"ticker": "FHN",
|
||||
"reason": "RSI too high (72.4 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.304721",
|
||||
"action": "SKIP",
|
||||
"ticker": "FNB",
|
||||
"reason": "RSI too high (71.0 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.305710",
|
||||
"action": "SKIP",
|
||||
"ticker": "SSB",
|
||||
"reason": "RSI too high (89.0 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.306687",
|
||||
"action": "SKIP",
|
||||
"ticker": "WBS",
|
||||
"reason": "RSI too high (82.0 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.307754",
|
||||
"action": "SKIP",
|
||||
"ticker": "ONB",
|
||||
"reason": "RSI too high (77.6 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.308706",
|
||||
"action": "SKIP",
|
||||
"ticker": "WAL",
|
||||
"reason": "RSI too high (71.7 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.309624",
|
||||
"action": "SKIP",
|
||||
"ticker": "ZION",
|
||||
"reason": "RSI too high (73.3 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.310657",
|
||||
"action": "SKIP",
|
||||
"ticker": "CFG",
|
||||
"reason": "RSI too high (78.5 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.311641",
|
||||
"action": "SKIP",
|
||||
"ticker": "UBSI",
|
||||
"reason": "RSI too high (77.5 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.312722",
|
||||
"action": "SKIP",
|
||||
"ticker": "EWBC",
|
||||
"reason": "RSI too high (78.6 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.313689",
|
||||
"action": "SKIP",
|
||||
"ticker": "FITB",
|
||||
"reason": "Too close to 52wk high (1.9% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.314689",
|
||||
"action": "SKIP",
|
||||
"ticker": "BAC",
|
||||
"reason": "RSI too high (78.1 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-09T15:36:19.315714",
|
||||
"action": "SKIP",
|
||||
"ticker": "WTFC",
|
||||
"reason": "RSI too high (70.2 > 70)",
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
240
projects/market-watch/data/logs/2026-02-10.json
Normal file
240
projects/market-watch/data/logs/2026-02-10.json
Normal file
@ -0,0 +1,240 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.678934",
|
||||
"action": "SKIP",
|
||||
"ticker": "VLY",
|
||||
"reason": "RSI too high (74.2 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.689088",
|
||||
"action": "SKIP",
|
||||
"ticker": "FHN",
|
||||
"reason": "Too close to 52wk high (1.5% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.689343",
|
||||
"action": "SKIP",
|
||||
"ticker": "FNB",
|
||||
"reason": "Too close to 52wk high (1.1% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.690376",
|
||||
"action": "SKIP",
|
||||
"ticker": "SSB",
|
||||
"reason": "RSI too high (85.0 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.691362",
|
||||
"action": "SKIP",
|
||||
"ticker": "WBS",
|
||||
"reason": "RSI too high (79.7 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.692156",
|
||||
"action": "SKIP",
|
||||
"ticker": "ONB",
|
||||
"reason": "RSI too high (71.2 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.692901",
|
||||
"action": "SKIP",
|
||||
"ticker": "WAL",
|
||||
"reason": "Too close to 52wk high (1.0% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.694010",
|
||||
"action": "SKIP",
|
||||
"ticker": "ZION",
|
||||
"reason": "Too close to 52wk high (1.5% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.694846",
|
||||
"action": "SKIP",
|
||||
"ticker": "CFG",
|
||||
"reason": "RSI too high (72.1 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.696787",
|
||||
"action": "BUY",
|
||||
"ticker": "UBSI",
|
||||
"reason": "GARP signal: PE=13.74, FwdPE=11.93, RevGr=22.1%, EPSGr=32.1%, RSI=67.45",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "UBSI",
|
||||
"shares": 148,
|
||||
"price": 44.93,
|
||||
"cost": 6649.64,
|
||||
"cash_remaining": 53605.66
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.697122",
|
||||
"action": "SKIP",
|
||||
"ticker": "EWBC",
|
||||
"reason": "RSI too high (74.7 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.697710",
|
||||
"action": "SKIP",
|
||||
"ticker": "FITB",
|
||||
"reason": "Too close to 52wk high (1.2% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.698565",
|
||||
"action": "SKIP",
|
||||
"ticker": "BAC",
|
||||
"reason": "RSI too high (78.8 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T09:06:30.700317",
|
||||
"action": "BUY",
|
||||
"ticker": "WTFC",
|
||||
"reason": "GARP signal: PE=13.87, FwdPE=11.79, RevGr=10.5%, EPSGr=19.4%, RSI=62.2",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "WTFC",
|
||||
"shares": 42,
|
||||
"price": 158.12,
|
||||
"cost": 6641.04,
|
||||
"cash_remaining": 46964.62
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.432393",
|
||||
"action": "SKIP",
|
||||
"ticker": "VLY",
|
||||
"reason": "RSI too high (71.8 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.436450",
|
||||
"action": "BUY",
|
||||
"ticker": "FHN",
|
||||
"reason": "GARP signal: PE=13.71, FwdPE=10.94, RevGr=23.7%, EPSGr=74.9%, RSI=58.44",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "FHN",
|
||||
"shares": 258,
|
||||
"price": 25.64,
|
||||
"cost": 6615.12,
|
||||
"cash_remaining": 40349.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.437790",
|
||||
"action": "BUY",
|
||||
"ticker": "FNB",
|
||||
"reason": "GARP signal: PE=11.98, FwdPE=9.55, RevGr=26.4%, EPSGr=56.5%, RSI=62.57",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "FNB",
|
||||
"shares": 354,
|
||||
"price": 18.69,
|
||||
"cost": 6616.26,
|
||||
"cash_remaining": 33733.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.438236",
|
||||
"action": "SKIP",
|
||||
"ticker": "SSB",
|
||||
"reason": "RSI too high (71.1 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.438922",
|
||||
"action": "SKIP",
|
||||
"ticker": "WBS",
|
||||
"reason": "RSI too high (78.0 > 70)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.440504",
|
||||
"action": "BUY",
|
||||
"ticker": "WAL",
|
||||
"reason": "GARP signal: PE=10.87, FwdPE=7.98, RevGr=16.6%, EPSGr=32.9%, RSI=60.46",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "WAL",
|
||||
"shares": 69,
|
||||
"price": 94.92,
|
||||
"cost": 6549.48,
|
||||
"cash_remaining": 27183.76
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.441912",
|
||||
"action": "BUY",
|
||||
"ticker": "ONB",
|
||||
"reason": "GARP signal: PE=14.26, FwdPE=8.9, RevGr=41.4%, EPSGr=17.2%, RSI=68.73",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "ONB",
|
||||
"shares": 259,
|
||||
"price": 25.53,
|
||||
"cost": 6612.27,
|
||||
"cash_remaining": 20571.49
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.443384",
|
||||
"action": "BUY",
|
||||
"ticker": "ZION",
|
||||
"reason": "GARP signal: PE=10.66, FwdPE=9.8, RevGr=13.6%, EPSGr=31.4%, RSI=60.76",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "ZION",
|
||||
"shares": 103,
|
||||
"price": 64.08,
|
||||
"cost": 6600.24,
|
||||
"cash_remaining": 13971.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.443883",
|
||||
"action": "SKIP",
|
||||
"ticker": "CFG",
|
||||
"reason": "Too close to 52wk high (1.8% away)",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.445705",
|
||||
"action": "BUY",
|
||||
"ticker": "EWBC",
|
||||
"reason": "GARP signal: PE=12.66, FwdPE=11.0, RevGr=21.6%, EPSGr=21.3%, RSI=65.92",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "EWBC",
|
||||
"shares": 54,
|
||||
"price": 120.54,
|
||||
"cost": 6509.16,
|
||||
"cash_remaining": 7462.09
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-10T15:36:28.447352",
|
||||
"action": "BUY",
|
||||
"ticker": "BAC",
|
||||
"reason": "GARP signal: PE=14.54, FwdPE=11.17, RevGr=13.2%, EPSGr=20.9%, RSI=69.17",
|
||||
"details": {
|
||||
"success": true,
|
||||
"ticker": "BAC",
|
||||
"shares": 119,
|
||||
"price": 55.39,
|
||||
"cost": 6591.41,
|
||||
"cash_remaining": 870.68
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -1,356 +1,338 @@
|
||||
{
|
||||
"date": "2026-02-09",
|
||||
"timestamp": "2026-02-09T10:55:58.242176",
|
||||
"timestamp": "2026-02-09T15:36:18.290971",
|
||||
"total_scanned": 902,
|
||||
"candidates_found": 21,
|
||||
"candidates_found": 20,
|
||||
"candidates": [
|
||||
{
|
||||
"ticker": "DUOL",
|
||||
"price": 116.35,
|
||||
"market_cap": 5378552832,
|
||||
"market_cap_b": 5.4,
|
||||
"trailing_pe": 14.65,
|
||||
"forward_pe": 14.71,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 41.1,
|
||||
"earnings_growth": 1114.3,
|
||||
"roe": 36.2,
|
||||
"quick_ratio": 2.6,
|
||||
"debt_to_equity": 7.4,
|
||||
"rsi": 23.44,
|
||||
"week52_high": 544.93,
|
||||
"pct_from_52wk_high": 78.6,
|
||||
"score": -100.83
|
||||
},
|
||||
{
|
||||
"ticker": "ALLY",
|
||||
"price": 42.65,
|
||||
"market_cap": 13158768640,
|
||||
"market_cap_b": 13.2,
|
||||
"trailing_pe": 18.0,
|
||||
"forward_pe": 6.76,
|
||||
"price": 42.04,
|
||||
"market_cap": 12969046016,
|
||||
"market_cap_b": 13.0,
|
||||
"trailing_pe": 17.74,
|
||||
"forward_pe": 6.66,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 12.0,
|
||||
"earnings_growth": 265.4,
|
||||
"roe": 5.8,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 53.23,
|
||||
"rsi": 49.52,
|
||||
"week52_high": 47.27,
|
||||
"pct_from_52wk_high": 9.8,
|
||||
"score": -20.98
|
||||
"pct_from_52wk_high": 11.1,
|
||||
"score": -21.08
|
||||
},
|
||||
{
|
||||
"ticker": "JHG",
|
||||
"price": 48.21,
|
||||
"market_cap": 7447323136,
|
||||
"price": 48.2,
|
||||
"market_cap": 7445763584,
|
||||
"market_cap_b": 7.4,
|
||||
"trailing_pe": 9.22,
|
||||
"forward_pe": 9.96,
|
||||
"forward_pe": 9.95,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 61.3,
|
||||
"earnings_growth": 243.6,
|
||||
"roe": 16.2,
|
||||
"quick_ratio": 69.46,
|
||||
"debt_to_equity": 6.5,
|
||||
"rsi": 68.71,
|
||||
"rsi": 68.18,
|
||||
"week52_high": 49.42,
|
||||
"pct_from_52wk_high": 2.4,
|
||||
"score": -20.529999999999998
|
||||
"pct_from_52wk_high": 2.5,
|
||||
"score": -20.54
|
||||
},
|
||||
{
|
||||
"ticker": "INCY",
|
||||
"price": 108.69,
|
||||
"market_cap": 21338314752,
|
||||
"market_cap_b": 21.3,
|
||||
"trailing_pe": 18.42,
|
||||
"forward_pe": 13.76,
|
||||
"price": 109.03,
|
||||
"market_cap": 21405063168,
|
||||
"market_cap_b": 21.4,
|
||||
"trailing_pe": 18.48,
|
||||
"forward_pe": 13.81,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 20.0,
|
||||
"earnings_growth": 290.7,
|
||||
"roe": 30.4,
|
||||
"quick_ratio": 2.86,
|
||||
"debt_to_equity": 0.9,
|
||||
"rsi": 63.48,
|
||||
"rsi": 64.03,
|
||||
"week52_high": 112.29,
|
||||
"pct_from_52wk_high": 3.2,
|
||||
"score": -17.310000000000002
|
||||
"pct_from_52wk_high": 2.9,
|
||||
"score": -17.259999999999998
|
||||
},
|
||||
{
|
||||
"ticker": "PINS",
|
||||
"price": 20.06,
|
||||
"market_cap": 13635989504,
|
||||
"market_cap_b": 13.6,
|
||||
"trailing_pe": 7.04,
|
||||
"forward_pe": 10.61,
|
||||
"price": 20.14,
|
||||
"market_cap": 13693783040,
|
||||
"market_cap_b": 13.7,
|
||||
"trailing_pe": 7.07,
|
||||
"forward_pe": 10.66,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 16.8,
|
||||
"earnings_growth": 225.0,
|
||||
"roe": 51.5,
|
||||
"quick_ratio": 8.14,
|
||||
"debt_to_equity": 4.3,
|
||||
"rsi": 19.14,
|
||||
"rsi": 19.93,
|
||||
"week52_high": 40.38,
|
||||
"pct_from_52wk_high": 50.3,
|
||||
"score": -13.57
|
||||
"pct_from_52wk_high": 50.1,
|
||||
"score": -13.52
|
||||
},
|
||||
{
|
||||
"ticker": "VLY",
|
||||
"price": 13.72,
|
||||
"market_cap": 7647972352,
|
||||
"price": 13.7,
|
||||
"market_cap": 7639607296,
|
||||
"market_cap_b": 7.6,
|
||||
"trailing_pe": 13.58,
|
||||
"forward_pe": 9.2,
|
||||
"trailing_pe": 13.56,
|
||||
"forward_pe": 9.19,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 38.3,
|
||||
"earnings_growth": 66.3,
|
||||
"roe": 7.8,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 78.6,
|
||||
"rsi": 78.34,
|
||||
"week52_high": 13.79,
|
||||
"pct_from_52wk_high": 0.5,
|
||||
"score": -1.2600000000000002
|
||||
"pct_from_52wk_high": 0.7,
|
||||
"score": -1.27
|
||||
},
|
||||
{
|
||||
"ticker": "FHN",
|
||||
"price": 26.33,
|
||||
"market_cap": 12967198720,
|
||||
"market_cap_b": 13.0,
|
||||
"trailing_pe": 14.08,
|
||||
"forward_pe": 11.23,
|
||||
"price": 26.03,
|
||||
"market_cap": 12817018880,
|
||||
"market_cap_b": 12.8,
|
||||
"trailing_pe": 13.92,
|
||||
"forward_pe": 11.1,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 23.7,
|
||||
"earnings_growth": 74.9,
|
||||
"roe": 10.9,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 76.1,
|
||||
"rsi": 72.36,
|
||||
"week52_high": 26.56,
|
||||
"pct_from_52wk_high": 0.8,
|
||||
"score": 1.37
|
||||
"pct_from_52wk_high": 2.0,
|
||||
"score": 1.2399999999999993
|
||||
},
|
||||
{
|
||||
"ticker": "FNB",
|
||||
"price": 19.05,
|
||||
"market_cap": 6822501376,
|
||||
"price": 18.92,
|
||||
"market_cap": 6775944192,
|
||||
"market_cap_b": 6.8,
|
||||
"trailing_pe": 12.21,
|
||||
"forward_pe": 9.73,
|
||||
"trailing_pe": 12.13,
|
||||
"forward_pe": 9.67,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 26.4,
|
||||
"earnings_growth": 56.5,
|
||||
"roe": 8.7,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 71.92,
|
||||
"rsi": 70.99,
|
||||
"week52_high": 19.14,
|
||||
"pct_from_52wk_high": 0.4,
|
||||
"score": 1.4400000000000004
|
||||
"pct_from_52wk_high": 1.1,
|
||||
"score": 1.38
|
||||
},
|
||||
{
|
||||
"ticker": "SSB",
|
||||
"price": 107.67,
|
||||
"market_cap": 10821986304,
|
||||
"price": 107.18,
|
||||
"market_cap": 10773236736,
|
||||
"market_cap_b": 10.8,
|
||||
"trailing_pe": 13.68,
|
||||
"forward_pe": 10.18,
|
||||
"trailing_pe": 13.62,
|
||||
"forward_pe": 10.13,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 53.2,
|
||||
"earnings_growth": 30.9,
|
||||
"roe": 10.7,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 92.25,
|
||||
"rsi": 89.03,
|
||||
"week52_high": 108.46,
|
||||
"pct_from_52wk_high": 0.7,
|
||||
"score": 1.7699999999999996
|
||||
"pct_from_52wk_high": 1.2,
|
||||
"score": 1.7200000000000006
|
||||
},
|
||||
{
|
||||
"ticker": "WBS",
|
||||
"price": 73.28,
|
||||
"market_cap": 11818547200,
|
||||
"price": 73.21,
|
||||
"market_cap": 11808063488,
|
||||
"market_cap_b": 11.8,
|
||||
"trailing_pe": 12.42,
|
||||
"forward_pe": 9.79,
|
||||
"trailing_pe": 12.41,
|
||||
"forward_pe": 9.78,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 18.2,
|
||||
"earnings_growth": 53.4,
|
||||
"roe": 10.8,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 82.13,
|
||||
"rsi": 82.05,
|
||||
"week52_high": 73.5,
|
||||
"pct_from_52wk_high": 0.3,
|
||||
"score": 2.6299999999999994
|
||||
},
|
||||
{
|
||||
"ticker": "WAL",
|
||||
"price": 96.21,
|
||||
"market_cap": 10588263424,
|
||||
"market_cap_b": 10.6,
|
||||
"trailing_pe": 11.02,
|
||||
"forward_pe": 8.09,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 16.6,
|
||||
"earnings_growth": 32.9,
|
||||
"roe": 13.5,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 71.81,
|
||||
"week52_high": 96.94,
|
||||
"pct_from_52wk_high": 0.8,
|
||||
"score": 3.1399999999999997
|
||||
"pct_from_52wk_high": 0.4,
|
||||
"score": 2.6199999999999997
|
||||
},
|
||||
{
|
||||
"ticker": "ONB",
|
||||
"price": 25.93,
|
||||
"market_cap": 10132744192,
|
||||
"market_cap_b": 10.1,
|
||||
"trailing_pe": 14.49,
|
||||
"forward_pe": 9.04,
|
||||
"price": 25.67,
|
||||
"market_cap": 10031142912,
|
||||
"market_cap_b": 10.0,
|
||||
"trailing_pe": 14.34,
|
||||
"forward_pe": 8.95,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 41.4,
|
||||
"earnings_growth": 17.2,
|
||||
"roe": 9.0,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 81.24,
|
||||
"rsi": 77.64,
|
||||
"week52_high": 26.17,
|
||||
"pct_from_52wk_high": 1.9,
|
||||
"score": 3.09
|
||||
},
|
||||
{
|
||||
"ticker": "WAL",
|
||||
"price": 96.08,
|
||||
"market_cap": 10573956096,
|
||||
"market_cap_b": 10.6,
|
||||
"trailing_pe": 11.01,
|
||||
"forward_pe": 8.08,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 16.6,
|
||||
"earnings_growth": 32.9,
|
||||
"roe": 13.5,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 71.66,
|
||||
"week52_high": 96.99,
|
||||
"pct_from_52wk_high": 0.9,
|
||||
"score": 3.1799999999999997
|
||||
"score": 3.13
|
||||
},
|
||||
{
|
||||
"ticker": "EXEL",
|
||||
"price": 43.8,
|
||||
"market_cap": 11791070208,
|
||||
"price": 43.95,
|
||||
"market_cap": 11831451648,
|
||||
"market_cap_b": 11.8,
|
||||
"trailing_pe": 18.4,
|
||||
"forward_pe": 12.76,
|
||||
"trailing_pe": 18.47,
|
||||
"forward_pe": 12.8,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.8,
|
||||
"earnings_growth": 72.5,
|
||||
"roe": 30.6,
|
||||
"quick_ratio": 3.5,
|
||||
"debt_to_equity": 8.2,
|
||||
"rsi": 50.12,
|
||||
"rsi": 51.02,
|
||||
"week52_high": 49.62,
|
||||
"pct_from_52wk_high": 11.7,
|
||||
"score": 4.43
|
||||
"pct_from_52wk_high": 11.4,
|
||||
"score": 4.470000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "ZION",
|
||||
"price": 65.29,
|
||||
"market_cap": 9640263680,
|
||||
"price": 65.16,
|
||||
"market_cap": 9621069824,
|
||||
"market_cap_b": 9.6,
|
||||
"trailing_pe": 10.86,
|
||||
"forward_pe": 9.99,
|
||||
"trailing_pe": 10.84,
|
||||
"forward_pe": 9.97,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 13.6,
|
||||
"earnings_growth": 31.4,
|
||||
"roe": 13.5,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 74.03,
|
||||
"rsi": 73.29,
|
||||
"week52_high": 66.18,
|
||||
"pct_from_52wk_high": 1.3,
|
||||
"score": 5.49
|
||||
"pct_from_52wk_high": 1.5,
|
||||
"score": 5.470000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "CART",
|
||||
"price": 35.49,
|
||||
"market_cap": 9349425152,
|
||||
"price": 35.15,
|
||||
"market_cap": 9259855872,
|
||||
"market_cap_b": 9.3,
|
||||
"trailing_pe": 19.5,
|
||||
"forward_pe": 9.05,
|
||||
"trailing_pe": 19.31,
|
||||
"forward_pe": 8.97,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.2,
|
||||
"earnings_growth": 21.1,
|
||||
"roe": 15.3,
|
||||
"quick_ratio": 3.33,
|
||||
"debt_to_equity": 1.0,
|
||||
"rsi": 37.75,
|
||||
"rsi": 36.01,
|
||||
"week52_high": 53.5,
|
||||
"pct_from_52wk_high": 33.7,
|
||||
"score": 5.92
|
||||
"pct_from_52wk_high": 34.3,
|
||||
"score": 5.84
|
||||
},
|
||||
{
|
||||
"ticker": "CFG",
|
||||
"price": 68.17,
|
||||
"market_cap": 29278072832,
|
||||
"market_cap_b": 29.3,
|
||||
"trailing_pe": 17.66,
|
||||
"forward_pe": 10.82,
|
||||
"price": 67.7,
|
||||
"market_cap": 29076213760,
|
||||
"market_cap_b": 29.1,
|
||||
"trailing_pe": 17.54,
|
||||
"forward_pe": 10.75,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.7,
|
||||
"earnings_growth": 35.9,
|
||||
"roe": 7.2,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 80.86,
|
||||
"rsi": 78.47,
|
||||
"week52_high": 68.65,
|
||||
"pct_from_52wk_high": 0.7,
|
||||
"score": 6.16
|
||||
"pct_from_52wk_high": 1.4,
|
||||
"score": 6.09
|
||||
},
|
||||
{
|
||||
"ticker": "UBSI",
|
||||
"price": 45.32,
|
||||
"market_cap": 6316939264,
|
||||
"price": 45.06,
|
||||
"market_cap": 6280699392,
|
||||
"market_cap_b": 6.3,
|
||||
"trailing_pe": 13.86,
|
||||
"forward_pe": 12.03,
|
||||
"trailing_pe": 13.78,
|
||||
"forward_pe": 11.96,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 22.1,
|
||||
"earnings_growth": 32.1,
|
||||
"roe": 8.9,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 80.0,
|
||||
"rsi": 77.51,
|
||||
"week52_high": 45.93,
|
||||
"pct_from_52wk_high": 1.3,
|
||||
"score": 6.61
|
||||
"pct_from_52wk_high": 1.9,
|
||||
"score": 6.54
|
||||
},
|
||||
{
|
||||
"ticker": "EWBC",
|
||||
"price": 123.19,
|
||||
"market_cap": 16949170176,
|
||||
"price": 122.56,
|
||||
"market_cap": 16862490624,
|
||||
"market_cap_b": 16.9,
|
||||
"trailing_pe": 12.94,
|
||||
"forward_pe": 11.24,
|
||||
"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": 79.27,
|
||||
"rsi": 78.61,
|
||||
"week52_high": 123.82,
|
||||
"pct_from_52wk_high": 0.5,
|
||||
"score": 6.949999999999999
|
||||
"pct_from_52wk_high": 1.0,
|
||||
"score": 6.890000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "FITB",
|
||||
"price": 54.38,
|
||||
"market_cap": 48944635904,
|
||||
"price": 54.33,
|
||||
"market_cap": 48899633152,
|
||||
"market_cap_b": 48.9,
|
||||
"trailing_pe": 15.41,
|
||||
"forward_pe": 11.09,
|
||||
"trailing_pe": 15.39,
|
||||
"forward_pe": 11.08,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 11.5,
|
||||
"earnings_growth": 20.8,
|
||||
"roe": 12.2,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 66.02,
|
||||
"rsi": 65.77,
|
||||
"week52_high": 55.36,
|
||||
"pct_from_52wk_high": 1.8,
|
||||
"score": 7.859999999999999
|
||||
"pct_from_52wk_high": 1.9,
|
||||
"score": 7.85
|
||||
},
|
||||
{
|
||||
"ticker": "BAC",
|
||||
"price": 56.43,
|
||||
"market_cap": 412079849472,
|
||||
"market_cap_b": 412.1,
|
||||
"price": 56.41,
|
||||
"market_cap": 411933769728,
|
||||
"market_cap_b": 411.9,
|
||||
"trailing_pe": 14.81,
|
||||
"forward_pe": 11.38,
|
||||
"peg_ratio": null,
|
||||
@ -359,28 +341,28 @@
|
||||
"roe": 10.2,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 78.3,
|
||||
"rsi": 78.1,
|
||||
"week52_high": 57.55,
|
||||
"pct_from_52wk_high": 1.9,
|
||||
"pct_from_52wk_high": 2.0,
|
||||
"score": 7.970000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "WTFC",
|
||||
"price": 159.71,
|
||||
"market_cap": 10696563712,
|
||||
"market_cap_b": 10.7,
|
||||
"trailing_pe": 14.0,
|
||||
"forward_pe": 11.91,
|
||||
"price": 158.57,
|
||||
"market_cap": 10620212224,
|
||||
"market_cap_b": 10.6,
|
||||
"trailing_pe": 13.9,
|
||||
"forward_pe": 11.82,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.5,
|
||||
"earnings_growth": 19.4,
|
||||
"roe": 12.1,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 72.56,
|
||||
"rsi": 70.23,
|
||||
"week52_high": 162.96,
|
||||
"pct_from_52wk_high": 2.0,
|
||||
"score": 8.92
|
||||
"pct_from_52wk_high": 2.7,
|
||||
"score": 8.83
|
||||
}
|
||||
]
|
||||
}
|
||||
368
projects/market-watch/data/scans/2026-02-10.json
Normal file
368
projects/market-watch/data/scans/2026-02-10.json
Normal file
@ -0,0 +1,368 @@
|
||||
{
|
||||
"date": "2026-02-10",
|
||||
"timestamp": "2026-02-10T15:36:27.292187",
|
||||
"total_scanned": 902,
|
||||
"candidates_found": 20,
|
||||
"candidates": [
|
||||
{
|
||||
"ticker": "ALLY",
|
||||
"price": 42.39,
|
||||
"market_cap": 13077017600,
|
||||
"market_cap_b": 13.1,
|
||||
"trailing_pe": 17.89,
|
||||
"forward_pe": 6.72,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 12.0,
|
||||
"earnings_growth": 265.4,
|
||||
"roe": 5.8,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 51.52,
|
||||
"week52_high": 47.27,
|
||||
"pct_from_52wk_high": 10.3,
|
||||
"score": -21.02
|
||||
},
|
||||
{
|
||||
"ticker": "JHG",
|
||||
"price": 48.24,
|
||||
"market_cap": 7451942400,
|
||||
"market_cap_b": 7.5,
|
||||
"trailing_pe": 9.22,
|
||||
"forward_pe": 9.96,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 61.3,
|
||||
"earnings_growth": 243.6,
|
||||
"roe": 16.2,
|
||||
"quick_ratio": 69.46,
|
||||
"debt_to_equity": 6.5,
|
||||
"rsi": 65.85,
|
||||
"week52_high": 49.42,
|
||||
"pct_from_52wk_high": 2.4,
|
||||
"score": -20.529999999999998
|
||||
},
|
||||
{
|
||||
"ticker": "INCY",
|
||||
"price": 100.05,
|
||||
"market_cap": 19642087424,
|
||||
"market_cap_b": 19.6,
|
||||
"trailing_pe": 16.96,
|
||||
"forward_pe": 11.19,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 20.0,
|
||||
"earnings_growth": 290.7,
|
||||
"roe": 30.4,
|
||||
"quick_ratio": 2.86,
|
||||
"debt_to_equity": 0.9,
|
||||
"rsi": 42.24,
|
||||
"week52_high": 112.29,
|
||||
"pct_from_52wk_high": 10.9,
|
||||
"score": -19.880000000000003
|
||||
},
|
||||
{
|
||||
"ticker": "PINS",
|
||||
"price": 20.33,
|
||||
"market_cap": 13822969856,
|
||||
"market_cap_b": 13.8,
|
||||
"trailing_pe": 7.13,
|
||||
"forward_pe": 10.76,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 16.8,
|
||||
"earnings_growth": 225.0,
|
||||
"roe": 51.5,
|
||||
"quick_ratio": 8.14,
|
||||
"debt_to_equity": 4.3,
|
||||
"rsi": 22.65,
|
||||
"week52_high": 39.96,
|
||||
"pct_from_52wk_high": 49.1,
|
||||
"score": -13.42
|
||||
},
|
||||
{
|
||||
"ticker": "VLY",
|
||||
"price": 13.62,
|
||||
"market_cap": 7594996736,
|
||||
"market_cap_b": 7.6,
|
||||
"trailing_pe": 13.49,
|
||||
"forward_pe": 9.13,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 38.3,
|
||||
"earnings_growth": 66.3,
|
||||
"roe": 7.8,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 71.78,
|
||||
"week52_high": 13.79,
|
||||
"pct_from_52wk_high": 1.2,
|
||||
"score": -1.3299999999999987
|
||||
},
|
||||
{
|
||||
"ticker": "FHN",
|
||||
"price": 25.64,
|
||||
"market_cap": 12624985088,
|
||||
"market_cap_b": 12.6,
|
||||
"trailing_pe": 13.71,
|
||||
"forward_pe": 10.94,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 23.7,
|
||||
"earnings_growth": 74.9,
|
||||
"roe": 10.9,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 58.44,
|
||||
"week52_high": 26.56,
|
||||
"pct_from_52wk_high": 3.5,
|
||||
"score": 1.0799999999999992
|
||||
},
|
||||
{
|
||||
"ticker": "FNB",
|
||||
"price": 18.69,
|
||||
"market_cap": 6693572608,
|
||||
"market_cap_b": 6.7,
|
||||
"trailing_pe": 11.98,
|
||||
"forward_pe": 9.55,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 26.4,
|
||||
"earnings_growth": 56.5,
|
||||
"roe": 8.7,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 62.57,
|
||||
"week52_high": 19.14,
|
||||
"pct_from_52wk_high": 2.4,
|
||||
"score": 1.2600000000000007
|
||||
},
|
||||
{
|
||||
"ticker": "SSB",
|
||||
"price": 105.17,
|
||||
"market_cap": 10571200512,
|
||||
"market_cap_b": 10.6,
|
||||
"trailing_pe": 13.36,
|
||||
"forward_pe": 9.94,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 53.2,
|
||||
"earnings_growth": 30.9,
|
||||
"roe": 10.7,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 71.11,
|
||||
"week52_high": 108.46,
|
||||
"pct_from_52wk_high": 3.0,
|
||||
"score": 1.5299999999999994
|
||||
},
|
||||
{
|
||||
"ticker": "WBS",
|
||||
"price": 73.01,
|
||||
"market_cap": 11771847680,
|
||||
"market_cap_b": 11.8,
|
||||
"trailing_pe": 12.37,
|
||||
"forward_pe": 9.76,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 18.2,
|
||||
"earnings_growth": 53.4,
|
||||
"roe": 10.8,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 77.97,
|
||||
"week52_high": 73.76,
|
||||
"pct_from_52wk_high": 1.0,
|
||||
"score": 2.6
|
||||
},
|
||||
{
|
||||
"ticker": "WAL",
|
||||
"price": 94.92,
|
||||
"market_cap": 10446294016,
|
||||
"market_cap_b": 10.4,
|
||||
"trailing_pe": 10.87,
|
||||
"forward_pe": 7.98,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 16.6,
|
||||
"earnings_growth": 32.9,
|
||||
"roe": 13.5,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 60.46,
|
||||
"week52_high": 97.23,
|
||||
"pct_from_52wk_high": 2.4,
|
||||
"score": 3.0300000000000002
|
||||
},
|
||||
{
|
||||
"ticker": "ONB",
|
||||
"price": 25.53,
|
||||
"market_cap": 9976434688,
|
||||
"market_cap_b": 10.0,
|
||||
"trailing_pe": 14.26,
|
||||
"forward_pe": 8.9,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 41.4,
|
||||
"earnings_growth": 17.2,
|
||||
"roe": 9.0,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 68.73,
|
||||
"week52_high": 26.17,
|
||||
"pct_from_52wk_high": 2.4,
|
||||
"score": 3.040000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "EXEL",
|
||||
"price": 42.98,
|
||||
"market_cap": 11570324480,
|
||||
"market_cap_b": 11.6,
|
||||
"trailing_pe": 18.06,
|
||||
"forward_pe": 12.45,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.8,
|
||||
"earnings_growth": 72.5,
|
||||
"roe": 30.6,
|
||||
"quick_ratio": 3.5,
|
||||
"debt_to_equity": 8.2,
|
||||
"rsi": 38.94,
|
||||
"week52_high": 49.62,
|
||||
"pct_from_52wk_high": 13.4,
|
||||
"score": 4.119999999999999
|
||||
},
|
||||
{
|
||||
"ticker": "ZION",
|
||||
"price": 64.08,
|
||||
"market_cap": 9461604352,
|
||||
"market_cap_b": 9.5,
|
||||
"trailing_pe": 10.66,
|
||||
"forward_pe": 9.8,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 13.6,
|
||||
"earnings_growth": 31.4,
|
||||
"roe": 13.5,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 60.76,
|
||||
"week52_high": 66.18,
|
||||
"pct_from_52wk_high": 3.2,
|
||||
"score": 5.300000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "CART",
|
||||
"price": 34.62,
|
||||
"market_cap": 9120232448,
|
||||
"market_cap_b": 9.1,
|
||||
"trailing_pe": 19.02,
|
||||
"forward_pe": 8.83,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.2,
|
||||
"earnings_growth": 21.1,
|
||||
"roe": 15.3,
|
||||
"quick_ratio": 3.33,
|
||||
"debt_to_equity": 1.0,
|
||||
"rsi": 32.09,
|
||||
"week52_high": 53.5,
|
||||
"pct_from_52wk_high": 35.3,
|
||||
"score": 5.699999999999999
|
||||
},
|
||||
{
|
||||
"ticker": "CFG",
|
||||
"price": 67.55,
|
||||
"market_cap": 29011791872,
|
||||
"market_cap_b": 29.0,
|
||||
"trailing_pe": 17.5,
|
||||
"forward_pe": 10.73,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.7,
|
||||
"earnings_growth": 35.9,
|
||||
"roe": 7.2,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 68.71,
|
||||
"week52_high": 68.78,
|
||||
"pct_from_52wk_high": 1.8,
|
||||
"score": 6.07
|
||||
},
|
||||
{
|
||||
"ticker": "UBSI",
|
||||
"price": 44.64,
|
||||
"market_cap": 6222157312,
|
||||
"market_cap_b": 6.2,
|
||||
"trailing_pe": 13.65,
|
||||
"forward_pe": 11.85,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 22.1,
|
||||
"earnings_growth": 32.1,
|
||||
"roe": 8.9,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 64.4,
|
||||
"week52_high": 45.93,
|
||||
"pct_from_52wk_high": 2.8,
|
||||
"score": 6.430000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "EWBC",
|
||||
"price": 120.54,
|
||||
"market_cap": 16584568832,
|
||||
"market_cap_b": 16.6,
|
||||
"trailing_pe": 12.66,
|
||||
"forward_pe": 11.0,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 21.6,
|
||||
"earnings_growth": 21.3,
|
||||
"roe": 15.9,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 65.92,
|
||||
"week52_high": 123.82,
|
||||
"pct_from_52wk_high": 2.6,
|
||||
"score": 6.710000000000001
|
||||
},
|
||||
{
|
||||
"ticker": "BAC",
|
||||
"price": 55.39,
|
||||
"market_cap": 404485242880,
|
||||
"market_cap_b": 404.5,
|
||||
"trailing_pe": 14.54,
|
||||
"forward_pe": 11.17,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 13.2,
|
||||
"earnings_growth": 20.9,
|
||||
"roe": 10.2,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 69.17,
|
||||
"week52_high": 57.55,
|
||||
"pct_from_52wk_high": 3.8,
|
||||
"score": 7.76
|
||||
},
|
||||
{
|
||||
"ticker": "FITB",
|
||||
"price": 54.5,
|
||||
"market_cap": 49052639232,
|
||||
"market_cap_b": 49.1,
|
||||
"trailing_pe": 15.44,
|
||||
"forward_pe": 11.12,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 11.5,
|
||||
"earnings_growth": 20.8,
|
||||
"roe": 12.2,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 57.61,
|
||||
"week52_high": 55.36,
|
||||
"pct_from_52wk_high": 1.6,
|
||||
"score": 7.889999999999999
|
||||
},
|
||||
{
|
||||
"ticker": "WTFC",
|
||||
"price": 156.07,
|
||||
"market_cap": 10452774912,
|
||||
"market_cap_b": 10.5,
|
||||
"trailing_pe": 13.69,
|
||||
"forward_pe": 11.64,
|
||||
"peg_ratio": null,
|
||||
"revenue_growth": 10.5,
|
||||
"earnings_growth": 19.4,
|
||||
"roe": 12.1,
|
||||
"quick_ratio": null,
|
||||
"debt_to_equity": null,
|
||||
"rsi": 58.11,
|
||||
"week52_high": 162.96,
|
||||
"pct_from_52wk_high": 4.2,
|
||||
"score": 8.65
|
||||
}
|
||||
]
|
||||
}
|
||||
296
projects/market-watch/portal/index.html
Normal file
296
projects/market-watch/portal/index.html
Normal file
@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Market Watch — Paper Trading Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0e1a; color: #e0e6f0; min-height: 100vh; }
|
||||
.header { background: linear-gradient(135deg, #0f1629, #1a2342); padding: 20px 30px; border-bottom: 1px solid #1e2a4a; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; font-weight: 600; }
|
||||
.header h1 span { color: #4ecdc4; }
|
||||
.header .meta { font-size: 0.85rem; color: #7a8bb5; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.card { background: linear-gradient(145deg, #111827, #0f1520); border: 1px solid #1e2a4a; border-radius: 12px; padding: 20px; }
|
||||
.card h2 { font-size: 1rem; color: #7a8bb5; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; font-weight: 500; }
|
||||
.card h2 .icon { margin-right: 6px; }
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
|
||||
.stat-label { color: #7a8bb5; font-size: 0.85rem; }
|
||||
.stat-value { font-size: 1.1rem; font-weight: 600; }
|
||||
.stat-big { font-size: 2rem; font-weight: 700; }
|
||||
.green { color: #4ecdc4; }
|
||||
.red { color: #ff6b6b; }
|
||||
.neutral { color: #7a8bb5; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-green { background: rgba(78,205,196,0.15); color: #4ecdc4; }
|
||||
.badge-red { background: rgba(255,107,107,0.15); color: #ff6b6b; }
|
||||
.badge-blue { background: rgba(100,149,237,0.15); color: #6495ed; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; color: #7a8bb5; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; padding: 8px; border-bottom: 1px solid #1e2a4a; }
|
||||
td { padding: 8px; font-size: 0.9rem; border-bottom: 1px solid #111827; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.chart-wrap { height: 250px; position: relative; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.tab { padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; border: 1px solid #1e2a4a; background: transparent; color: #7a8bb5; transition: all 0.2s; }
|
||||
.tab.active { background: #4ecdc4; color: #0a0e1a; border-color: #4ecdc4; font-weight: 600; }
|
||||
.tab:hover:not(.active) { border-color: #4ecdc4; color: #4ecdc4; }
|
||||
.game-section { display: none; }
|
||||
.game-section.active { display: block; }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #1e2a4a; border-top-color: #4ecdc4; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty { text-align: center; color: #7a8bb5; padding: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 <span>Market Watch</span> — Paper Trading</h1>
|
||||
<div class="meta">Auto-refresh 60s · <span id="lastUpdate">Loading...</span></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="tabs" id="gameTabs"></div>
|
||||
<div id="gameContent"><div class="empty"><div class="spinner"></div> Loading games...</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let games = [];
|
||||
let activeGameIdx = 0;
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const r = await fetch(url);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function pnlClass(v) { return v > 0 ? 'green' : v < 0 ? 'red' : 'neutral'; }
|
||||
function pnlSign(v) { return v > 0 ? '+' : ''; }
|
||||
function fmt(v, d=2) { return v != null ? Number(v).toFixed(d) : '—'; }
|
||||
function fmtK(v) { return v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(1)+'K' : fmt(v); }
|
||||
|
||||
function renderTabs() {
|
||||
const el = document.getElementById('gameTabs');
|
||||
el.innerHTML = games.map((g, i) =>
|
||||
`<button class="tab ${i === activeGameIdx ? 'active' : ''}" onclick="switchGame(${i})">${g.name || g.game_id.slice(0,8)}</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function switchGame(idx) {
|
||||
activeGameIdx = idx;
|
||||
renderTabs();
|
||||
renderGame(games[idx]);
|
||||
}
|
||||
|
||||
async function renderGame(game) {
|
||||
const el = document.getElementById('gameContent');
|
||||
const gid = game.game_id;
|
||||
|
||||
// Fetch details in parallel
|
||||
const [detail, trades, portfolio] = await Promise.all([
|
||||
fetchJSON(`/api/game/${gid}`),
|
||||
fetchJSON(`/api/game/${gid}/trades`),
|
||||
fetchJSON(`/api/game/${gid}/portfolio`)
|
||||
]);
|
||||
|
||||
const player = game.players?.[0] || 'unknown';
|
||||
const pf = portfolio[player] || {};
|
||||
const board = detail.leaderboard || [];
|
||||
const entry = board[0] || {};
|
||||
const starting = game.starting_cash || 100000;
|
||||
const totalVal = pf.total_value || entry.total_value || starting;
|
||||
const totalPnl = totalVal - starting;
|
||||
const pnlPct = (totalPnl / starting * 100);
|
||||
|
||||
const sells = trades.filter(t => t.action === 'SELL');
|
||||
const wins = sells.filter(t => (t.realized_pnl||0) > 0);
|
||||
const winRate = sells.length ? (wins.length / sells.length * 100) : null;
|
||||
const totalFees = trades.reduce((s,t) => s + (t.fees||0), 0);
|
||||
const realizedPnl = sells.reduce((s,t) => s + (t.realized_pnl||0), 0);
|
||||
|
||||
// Equity curve from snapshots
|
||||
const snapshots = detail.snapshots?.[player] || [];
|
||||
|
||||
let html = `
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2><span class="icon">💰</span>Portfolio Value</h2>
|
||||
<div class="stat-big ${pnlClass(totalPnl)}">$${fmtK(totalVal)}</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">P&L</span>
|
||||
<span class="stat-value ${pnlClass(totalPnl)}">${pnlSign(totalPnl)}$${fmt(totalPnl)} (${pnlSign(pnlPct)}${fmt(pnlPct)}%)</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Starting Cash</span>
|
||||
<span class="stat-value">$${fmtK(starting)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Cash Available</span>
|
||||
<span class="stat-value">$${fmtK(pf.cash || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2><span class="icon">📈</span>Trading Stats</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Trades</span>
|
||||
<span class="stat-value">${trades.length}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Win Rate</span>
|
||||
<span class="stat-value ${winRate && winRate > 50 ? 'green' : winRate ? 'red' : 'neutral'}">${winRate != null ? fmt(winRate,1)+'%' : '—'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Realized P&L</span>
|
||||
<span class="stat-value ${pnlClass(realizedPnl)}">${pnlSign(realizedPnl)}$${fmt(realizedPnl)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Fees</span>
|
||||
<span class="stat-value red">-$${fmt(totalFees)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2><span class="icon">🎯</span>Game Info</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Game</span>
|
||||
<span class="stat-value">${game.name || gid.slice(0,8)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Type</span>
|
||||
<span class="stat-value"><span class="badge badge-blue">${game.game_type || 'stock'}</span></span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Player</span>
|
||||
<span class="stat-value">${player}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Open Positions</span>
|
||||
<span class="stat-value">${Object.keys(pf.positions || {}).length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equity Chart -->
|
||||
${snapshots.length > 1 ? `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h2><span class="icon">📊</span>Equity Curve</h2>
|
||||
<div class="chart-wrap"><canvas id="equityChart"></canvas></div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Positions -->
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h2><span class="icon">📋</span>Open Positions</h2>
|
||||
${Object.keys(pf.positions||{}).length ? `
|
||||
<table>
|
||||
<thead><tr><th>Symbol</th><th>Shares/Qty</th><th>Avg Cost</th><th>Current</th><th>Value</th><th>P&L</th></tr></thead>
|
||||
<tbody>
|
||||
${Object.entries(pf.positions||{}).map(([sym, pos]) => {
|
||||
const upnl = pos.unrealized_pnl || ((pos.current_price||pos.avg_cost) - pos.avg_cost) * (pos.shares||pos.quantity||0);
|
||||
const val = pos.market_value || (pos.current_price||pos.avg_cost) * (pos.shares||pos.quantity||0);
|
||||
return `<tr>
|
||||
<td><strong>${sym}</strong></td>
|
||||
<td>${pos.shares||pos.quantity||0}</td>
|
||||
<td>$${fmt(pos.avg_cost)}</td>
|
||||
<td>$${fmt(pos.current_price||pos.live_price||pos.avg_cost)}</td>
|
||||
<td>$${fmtK(val)}</td>
|
||||
<td class="${pnlClass(upnl)}">${pnlSign(upnl)}$${fmt(upnl)}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>` : '<div class="empty">No open positions</div>'}
|
||||
</div>
|
||||
|
||||
<!-- Recent Trades -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">🔄</span>Recent Trades (last 25)</h2>
|
||||
${trades.length ? `
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Action</th><th>Symbol</th><th>Qty</th><th>Price</th><th>P&L</th></tr></thead>
|
||||
<tbody>
|
||||
${trades.slice(0,25).map(t => {
|
||||
const rpnl = t.realized_pnl || 0;
|
||||
const actionClass = t.action === 'BUY' ? 'badge-green' : t.action === 'SELL' ? 'badge-red' : 'badge-blue';
|
||||
return `<tr>
|
||||
<td style="font-size:0.8rem;color:#7a8bb5">${t.timestamp ? new Date(t.timestamp).toLocaleString() : '—'}</td>
|
||||
<td><span class="badge ${actionClass}">${t.action}</span></td>
|
||||
<td><strong>${t.ticker||t.symbol||'—'}</strong></td>
|
||||
<td>${t.shares||t.quantity||'—'}</td>
|
||||
<td>$${fmt(t.price)}</td>
|
||||
<td class="${pnlClass(rpnl)}">${t.action==='SELL' ? pnlSign(rpnl)+'$'+fmt(rpnl) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>` : '<div class="empty">No trades yet</div>'}
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Draw equity chart
|
||||
if (snapshots.length > 1) {
|
||||
const ctx = document.getElementById('equityChart').getContext('2d');
|
||||
const labels = snapshots.map(s => {
|
||||
const d = s.timestamp || s.date;
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt) ? String(d).slice(0,10) : dt.toLocaleDateString();
|
||||
});
|
||||
const values = snapshots.map(s => s.total_value || s.equity || starting);
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Equity',
|
||||
data: values,
|
||||
borderColor: '#4ecdc4',
|
||||
backgroundColor: 'rgba(78,205,196,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
}, {
|
||||
label: 'Starting',
|
||||
data: Array(labels.length).fill(starting),
|
||||
borderColor: '#7a8bb544',
|
||||
borderDash: [5,5],
|
||||
borderWidth: 1,
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: '#1e2a4a' }, ticks: { color: '#7a8bb5', maxTicksLimit: 8 } },
|
||||
y: { grid: { color: '#1e2a4a' }, ticks: { color: '#7a8bb5', callback: v => '$'+fmtK(v) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
games = await fetchJSON('/api/games');
|
||||
if (!games.length) {
|
||||
document.getElementById('gameContent').innerHTML = '<div class="empty">No games found</div>';
|
||||
return;
|
||||
}
|
||||
renderTabs();
|
||||
await renderGame(games[activeGameIdx]);
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
document.getElementById('gameContent').innerHTML = `<div class="empty">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
setInterval(async () => {
|
||||
try {
|
||||
games = await fetchJSON('/api/games');
|
||||
await renderGame(games[activeGameIdx]);
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
} catch(e) {}
|
||||
}, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,424 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Market Watch Web Portal - Multiplayer GARP Paper Trading."""
|
||||
"""Market Watch Web Portal — modern dark-themed dashboard."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
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")
|
||||
PORTAL_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
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 _fetch_live_prices(tickers):
|
||||
"""Fetch live prices via yfinance. Returns {ticker: price}."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
data = yf.download(tickers, period="1d", progress=False)
|
||||
prices = {}
|
||||
if len(tickers) == 1:
|
||||
t = tickers[0]
|
||||
if "Close" in data.columns and len(data) > 0:
|
||||
prices[t] = float(data["Close"].iloc[-1])
|
||||
else:
|
||||
if "Close" in data.columns:
|
||||
for t in tickers:
|
||||
try:
|
||||
val = data["Close"][t].iloc[-1]
|
||||
if val == val: # not NaN
|
||||
prices[t] = float(val)
|
||||
except Exception:
|
||||
pass
|
||||
return prices
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def nav(active=""):
|
||||
return f"""<nav class="navbar">
|
||||
<a href="/" style="text-decoration:none"><div class="nav-brand">📊 Market Watch</div></a>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="{'active' if active=='home' else ''}">Games</a>
|
||||
<a href="/scans" class="{'active' if active=='scans' else ''}">Scans</a>
|
||||
</div></nav>"""
|
||||
|
||||
|
||||
class MarketWatchHandler(BaseHTTPRequestHandler):
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
params = parse_qs(parsed.query)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
|
||||
if path == "" or path == "/":
|
||||
self.serve_home()
|
||||
elif path == "/create-game":
|
||||
self.serve_create_game()
|
||||
elif path.startswith("/game/") and "/player/" in path:
|
||||
parts = path.split("/") # /game/{gid}/player/{user}
|
||||
self.serve_player(parts[2], parts[4])
|
||||
elif path.startswith("/game/"):
|
||||
game_id = path.split("/")[2]
|
||||
self.serve_game(game_id)
|
||||
elif path == "/scans":
|
||||
self.serve_scans()
|
||||
# API
|
||||
elif path.startswith("/api/games") and len(path.split("/")) == 3:
|
||||
self.send_json(game_engine.list_games(active_only=False))
|
||||
elif path.startswith("/api/games/") and path.endswith("/leaderboard"):
|
||||
gid = path.split("/")[3]
|
||||
self.send_json(game_engine.get_leaderboard(gid))
|
||||
elif "/portfolio" in path:
|
||||
parts = path.split("/")
|
||||
self.send_json(game_engine.get_portfolio(parts[3], parts[5]))
|
||||
else:
|
||||
self.send_error(404)
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_len).decode() if content_len else ""
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
if path == "/":
|
||||
return self._serve_file("index.html", "text/html")
|
||||
|
||||
# API endpoints
|
||||
if path == "/api/games":
|
||||
data = parse_form(body)
|
||||
name = data.get("name", "Untitled Game")
|
||||
cash = float(data.get("starting_cash", 100000))
|
||||
end_date = data.get("end_date") or None
|
||||
gid = game_engine.create_game(name, cash, end_date)
|
||||
self.redirect(f"/game/{gid}")
|
||||
|
||||
elif path.endswith("/join"):
|
||||
data = parse_form(body)
|
||||
parts = path.split("/")
|
||||
gid = parts[3]
|
||||
username = data.get("username", "").strip().lower()
|
||||
if username:
|
||||
game_engine.join_game(gid, username)
|
||||
self.redirect(f"/game/{gid}")
|
||||
|
||||
elif path.endswith("/trade"):
|
||||
data = parse_form(body)
|
||||
parts = path.split("/")
|
||||
gid, username = parts[3], parts[5]
|
||||
action = data.get("action", "").upper()
|
||||
ticker = data.get("ticker", "").upper().strip()
|
||||
shares = int(data.get("shares", 0))
|
||||
|
||||
if ticker and shares > 0:
|
||||
import yfinance as yf
|
||||
price = yf.Ticker(ticker).info.get("currentPrice") or yf.Ticker(ticker).info.get("regularMarketPrice", 0)
|
||||
if price and price > 0:
|
||||
if action == "BUY":
|
||||
game_engine.buy(gid, username, ticker, shares, price, reason="Manual trade")
|
||||
elif action == "SELL":
|
||||
game_engine.sell(gid, username, ticker, shares, price, reason="Manual trade")
|
||||
|
||||
self.redirect(f"/game/{gid}/player/{username}")
|
||||
else:
|
||||
self.send_error(404)
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
|
||||
|
||||
def serve_home(self):
|
||||
games = game_engine.list_games(active_only=False)
|
||||
rows = ""
|
||||
# Enrich with summary
|
||||
for g in games:
|
||||
players = len(g.get("players", []))
|
||||
status_badge = '<span class="badge badge-ai">Active</span>' if g["status"] == "active" else '<span class="badge">Ended</span>'
|
||||
rows += f"""<tr>
|
||||
<td><a href="/game/{g['game_id']}" class="player-link">{g['name']}</a></td>
|
||||
<td>{players}</td>
|
||||
<td>${g['starting_cash']:,.0f}</td>
|
||||
<td>{g['start_date']}</td>
|
||||
<td>{g.get('end_date', '—') or '—'}</td>
|
||||
<td>{status_badge}</td>
|
||||
</tr>"""
|
||||
board = game_engine.get_leaderboard(g["game_id"])
|
||||
g["leaderboard"] = board
|
||||
trades_all = []
|
||||
for p in g.get("players", []):
|
||||
trades_all.extend(game_engine.get_trades(g["game_id"], p))
|
||||
g["total_trades"] = len(trades_all)
|
||||
sells = [t for t in trades_all if t.get("action") == "SELL"]
|
||||
wins = [t for t in sells if t.get("realized_pnl", 0) > 0]
|
||||
g["win_rate"] = round(len(wins)/len(sells)*100, 1) if sells else None
|
||||
return self._json(games)
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Market Watch</title><style>{CSS}</style></head><body>
|
||||
{nav('home')}
|
||||
<div class="container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
|
||||
<h2>🎮 Active Games</h2>
|
||||
<a href="/create-game" class="btn">+ New Game</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table><thead><tr><th>Game</th><th>Players</th><th>Starting Cash</th><th>Started</th><th>Ends</th><th>Status</th></tr></thead>
|
||||
<tbody>{rows if rows else '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary)">No games yet — create one!</td></tr>'}</tbody></table>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
self.send_html(html)
|
||||
# /api/game/{id}
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 4 and parts[1] == "api" and parts[2] == "game":
|
||||
gid = parts[3]
|
||||
|
||||
def serve_create_game(self):
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Create Game - Market Watch</title><style>{CSS}</style></head><body>
|
||||
{nav()}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h3>🎮 Create New Game</h3>
|
||||
<form method="POST" action="/api/games">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Game Name</label><input type="text" name="name" placeholder="GARP Challenge" required></div>
|
||||
<div class="form-group"><label>Starting Cash ($)</label><input type="number" name="starting_cash" value="100000" min="1000" step="1000"></div>
|
||||
<div class="form-group"><label>End Date (optional)</label><input type="date" name="end_date"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Game</button>
|
||||
</form>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_game(self, game_id):
|
||||
game = game_engine.get_game(game_id)
|
||||
if len(parts) == 4:
|
||||
game = game_engine.get_game(gid)
|
||||
if not game:
|
||||
return self.send_error(404)
|
||||
return self._json({"error": "not found"}, 404)
|
||||
game["leaderboard"] = game_engine.get_leaderboard(gid)
|
||||
# Add snapshots for each player
|
||||
game["snapshots"] = {}
|
||||
for p in game.get("players", []):
|
||||
game["snapshots"][p] = game_engine.get_snapshots(gid, p)
|
||||
return self._json(game)
|
||||
|
||||
board = game_engine.get_leaderboard(game_id)
|
||||
if len(parts) == 5 and parts[4] == "trades":
|
||||
game = game_engine.get_game(gid)
|
||||
if not game:
|
||||
return self._json({"error": "not found"}, 404)
|
||||
all_trades = []
|
||||
for p in game.get("players", []):
|
||||
for t in game_engine.get_trades(gid, p):
|
||||
t["player"] = p
|
||||
all_trades.append(t)
|
||||
all_trades.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
return self._json(all_trades)
|
||||
|
||||
rank_rows = ""
|
||||
for i, entry in enumerate(board):
|
||||
rank_class = f"rank-{i+1}" if i < 3 else ""
|
||||
medal = ["🥇", "🥈", "🥉"][i] if i < 3 else f"#{i+1}"
|
||||
pnl_class = "positive" if entry["pnl_pct"] >= 0 else "negative"
|
||||
badge = ' <span class="badge badge-ai">AI</span>' if entry["username"] == "case" else ""
|
||||
rank_rows += f"""<tr>
|
||||
<td class="{rank_class}">{medal}</td>
|
||||
<td><a href="/game/{game_id}/player/{entry['username']}" class="player-link">{entry['username']}</a>{badge}</td>
|
||||
<td>${entry['total_value']:,.2f}</td>
|
||||
<td class="{pnl_class}">{entry['pnl_pct']:+.2f}%</td>
|
||||
<td class="{pnl_class}">${entry['total_pnl']:+,.2f}</td>
|
||||
<td>{entry['num_positions']}</td>
|
||||
<td>{entry['num_trades']}</td>
|
||||
</tr>"""
|
||||
if len(parts) == 5 and parts[4] == "portfolio":
|
||||
game = game_engine.get_game(gid)
|
||||
if not game:
|
||||
return self._json({"error": "not found"}, 404)
|
||||
portfolios = {}
|
||||
all_tickers = []
|
||||
for p in game.get("players", []):
|
||||
pf = game_engine.get_portfolio(gid, p)
|
||||
if pf:
|
||||
portfolios[p] = pf
|
||||
all_tickers.extend(pf["positions"].keys())
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{game['name']} - Market Watch</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
|
||||
{nav()}
|
||||
<div class="container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
|
||||
<h2>🏆 {game['name']}</h2>
|
||||
<span class="badge badge-ai">{game['status'].upper()}</span>
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);margin-bottom:1.5rem">Started {game['start_date']} · ${game['starting_cash']:,.0f} starting cash · {len(game['players'])} players</p>
|
||||
|
||||
<div class="card">
|
||||
<h3>Leaderboard</h3>
|
||||
<table><thead><tr><th>Rank</th><th>Player</th><th>Portfolio</th><th>Return</th><th>P&L</th><th>Positions</th><th>Trades</th></tr></thead>
|
||||
<tbody>{rank_rows if rank_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No players yet</td></tr>'}</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Join This Game</h3>
|
||||
<form method="POST" action="/api/games/{game_id}/join">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Username</label><input type="text" name="username" placeholder="your name" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, dashes, underscores only"></div>
|
||||
<button type="submit" class="btn">Join Game</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_player(self, game_id, username):
|
||||
game = game_engine.get_game(game_id)
|
||||
p = game_engine.get_portfolio(game_id, username)
|
||||
if not game or not p:
|
||||
return self.send_error(404)
|
||||
|
||||
trades = game_engine.get_trades(game_id, username)
|
||||
snapshots = game_engine.get_snapshots(game_id, username)
|
||||
|
||||
pnl_class = "positive" if p["total_pnl"] >= 0 else "negative"
|
||||
is_ai = username == "case"
|
||||
badge = '<span class="badge badge-ai">AI Player</span>' if is_ai else '<span class="badge badge-human">Human</span>'
|
||||
|
||||
# Positions table
|
||||
pos_rows = ""
|
||||
for ticker, pos in sorted(p["positions"].items()):
|
||||
pc = "positive" if pos["unrealized_pnl"] >= 0 else "negative"
|
||||
pos_rows += f"""<tr>
|
||||
<td><strong>{ticker}</strong></td><td>{pos['shares']}</td>
|
||||
<td>${pos['avg_cost']:.2f}</td><td>${pos['current_price']:.2f}</td>
|
||||
<td>${pos['market_value']:,.2f}</td><td class="{pc}">${pos['unrealized_pnl']:+,.2f}</td>
|
||||
<td>${pos.get('trailing_stop',0):.2f}</td>
|
||||
</tr>"""
|
||||
if not pos_rows:
|
||||
pos_rows = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No positions</td></tr>'
|
||||
|
||||
# Trade log
|
||||
trade_rows = ""
|
||||
for t in reversed(trades[-30:]):
|
||||
action_class = "positive" if t["action"] == "BUY" else "negative"
|
||||
pnl_cell = ""
|
||||
if t["action"] == "SELL":
|
||||
rpnl = t.get("realized_pnl", 0)
|
||||
rpnl_class = "positive" if rpnl >= 0 else "negative"
|
||||
pnl_cell = f'<span class="{rpnl_class}">${rpnl:+,.2f}</span>'
|
||||
trade_rows += f"""<tr>
|
||||
<td class="{action_class}">{t['action']}</td><td>{t['ticker']}</td><td>{t['shares']}</td>
|
||||
<td>${t['price']:.2f}</td><td>{pnl_cell}</td>
|
||||
<td>{t.get('reason','')[:40]}</td><td>{t['timestamp'][:16]}</td>
|
||||
</tr>"""
|
||||
|
||||
chart_labels = json.dumps([s["date"] for s in snapshots])
|
||||
chart_values = json.dumps([s["total_value"] for s in snapshots])
|
||||
# Fetch live prices
|
||||
all_tickers = list(set(all_tickers))
|
||||
if all_tickers:
|
||||
live = _fetch_live_prices(all_tickers)
|
||||
for p, pf in portfolios.items():
|
||||
total_value = pf["cash"]
|
||||
for ticker, pos in pf["positions"].items():
|
||||
if ticker in live:
|
||||
pos["live_price"] = live[ticker]
|
||||
pos["current_price"] = live[ticker]
|
||||
pos["unrealized_pnl"] = round((live[ticker] - pos["avg_cost"]) * pos["shares"], 2)
|
||||
pos["market_value"] = round(live[ticker] * pos["shares"], 2)
|
||||
total_value += pos["market_value"]
|
||||
pf["total_value"] = round(total_value, 2)
|
||||
starting = game.get("starting_cash", 100000)
|
||||
pf["total_pnl"] = round(total_value - starting, 2)
|
||||
pf["pnl_pct"] = round((total_value - starting) / starting * 100, 2)
|
||||
|
||||
# Trade form (only for humans)
|
||||
trade_form = "" if is_ai else f"""
|
||||
<div class="card">
|
||||
<h3>📝 Place Trade</h3>
|
||||
<form method="POST" action="/api/games/{game_id}/players/{username}/trade">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Action</label>
|
||||
<select name="action"><option value="BUY">BUY</option><option value="SELL">SELL</option></select></div>
|
||||
<div class="form-group"><label>Ticker</label><input type="text" name="ticker" placeholder="AAPL" required style="text-transform:uppercase"></div>
|
||||
<div class="form-group"><label>Shares</label><input type="number" name="shares" min="1" value="10" required></div>
|
||||
<button type="submit" class="btn btn-green">Execute</button>
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);font-size:.8rem;margin-top:.5rem">Trades execute at current market price via Yahoo Finance</p>
|
||||
</form>
|
||||
</div>"""
|
||||
return self._json(portfolios)
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{username} - {game['name']}</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
|
||||
{nav()}
|
||||
<div class="container">
|
||||
<div style="margin-bottom:.5rem"><a href="/game/{game_id}" style="color:var(--text-secondary)">← {game['name']}</a></div>
|
||||
<h2>{username} {badge}</h2>
|
||||
<div class="cards" style="margin-top:1rem">
|
||||
<div class="card"><h3>Portfolio Value</h3><div class="metric-large">${p['total_value']:,.2f}</div><div class="metric-small">Started at ${starting:,.0f}</div></div>
|
||||
<div class="card"><h3>Cash</h3><div class="metric-large">${p['cash']:,.2f}</div><div class="metric-small">{p['cash']/max(p['total_value'],1)*100:.1f}% available</div></div>
|
||||
<div class="card"><h3>Return</h3><div class="metric-large {pnl_class}">{p['pnl_pct']:+.2f}%</div><div class="metric-small {pnl_class}">${p['total_pnl']:+,.2f}</div></div>
|
||||
<div class="card"><h3>Positions</h3><div class="metric-large">{p['num_positions']}</div><div class="metric-small">{len(trades)} total trades</div></div>
|
||||
</div>
|
||||
self._error(404)
|
||||
except Exception as e:
|
||||
self._json({"error": str(e), "trace": traceback.format_exc()}, 500)
|
||||
|
||||
<div class="card"><h3>Performance</h3><canvas id="chart" height="80"></canvas></div>
|
||||
|
||||
{trade_form}
|
||||
|
||||
<div class="card"><h3>Positions</h3>
|
||||
<table><thead><tr><th>Ticker</th><th>Shares</th><th>Avg Cost</th><th>Price</th><th>Value</th><th>P&L</th><th>Stop</th></tr></thead>
|
||||
<tbody>{pos_rows}</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>Trade Log</h3>
|
||||
<table><thead><tr><th>Action</th><th>Ticker</th><th>Shares</th><th>Price</th><th>P&L</th><th>Reason</th><th>Time</th></tr></thead>
|
||||
<tbody>{trade_rows if trade_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No trades yet</td></tr>'}</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
const labels = {chart_labels}; const values = {chart_values};
|
||||
if (labels.length > 0) {{
|
||||
new Chart(ctx, {{type:'line',data:{{labels:labels,datasets:[
|
||||
{{label:'Portfolio',data:values,borderColor:'#58a6ff',backgroundColor:'rgba(88,166,255,0.1)',fill:true,tension:0.3}},
|
||||
{{label:'Starting',data:labels.map(()=>{starting}),borderColor:'#30363d',borderDash:[5,5],pointRadius:0}}
|
||||
]}},options:{{responsive:true,plugins:{{legend:{{labels:{{color:'#f0f6fc'}}}}}},scales:{{x:{{ticks:{{color:'#8b949e'}},grid:{{color:'#21262d'}}}},y:{{ticks:{{color:'#8b949e',callback:v=>'$'+v.toLocaleString()}},grid:{{color:'#21262d'}}}}}}}}
|
||||
}});
|
||||
}} else {{ ctx.canvas.parentElement.innerHTML += '<div style="text-align:center;color:#8b949e;padding:2rem">Chart populates after first trading day</div>'; }}
|
||||
</script></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_scans(self):
|
||||
rows = ""
|
||||
if os.path.exists(SCANS_DIR):
|
||||
for sf in sorted(os.listdir(SCANS_DIR), reverse=True)[:30]:
|
||||
if not sf.endswith(".json"): continue
|
||||
data = {}
|
||||
with open(os.path.join(SCANS_DIR, sf)) as f:
|
||||
data = json.load(f)
|
||||
n = data.get("candidates_found", len(data.get("candidates", [])))
|
||||
top = ", ".join(c.get("ticker","?") for c in data.get("candidates", [])[:8])
|
||||
rows += f'<tr><td>{sf.replace(".json","")}</td><td>{data.get("total_scanned",0)}</td><td>{n}</td><td>{top or "—"}</td></tr>'
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Scans - Market Watch</title><style>{CSS}</style></head><body>
|
||||
{nav('scans')}
|
||||
<div class="container"><div class="card"><h3>📡 GARP Scan History</h3>
|
||||
<table><thead><tr><th>Date</th><th>Scanned</th><th>Candidates</th><th>Top Picks</th></tr></thead>
|
||||
<tbody>{rows if rows else '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary)">No scans yet</td></tr>'}</tbody></table>
|
||||
</div></div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def redirect(self, url):
|
||||
self.send_response(303)
|
||||
self.send_header("Location", url)
|
||||
self.end_headers()
|
||||
|
||||
def send_html(self, content):
|
||||
def _serve_file(self, filename, content_type):
|
||||
filepath = os.path.join(PORTAL_DIR, filename)
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.end_headers()
|
||||
self.wfile.write(content.encode())
|
||||
self.wfile.write(data)
|
||||
|
||||
def send_json(self, data):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "application/json")
|
||||
def _json(self, data, code=200):
|
||||
body = json.dumps(data, default=str).encode()
|
||||
self.send_response(code)
|
||||
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())
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
def _error(self, code):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"{code}".encode())
|
||||
|
||||
def log_message(self, fmt, *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)
|
||||
print(f"📊 Market Watch Portal → http://localhost:{PORT}")
|
||||
server = ThreadedHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nPortal stopped")
|
||||
print("\nStopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
389
tools/analyze_tweet.py
Executable file
389
tools/analyze_tweet.py
Executable file
@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tweet Analysis Tool - Scrapes and analyzes tweets via Chrome CDP."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
print("ERROR: playwright not installed. Run: pip install playwright", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
def extract_tickers(text: str) -> list[str]:
|
||||
"""Extract $TICKER patterns from text."""
|
||||
return list(set(re.findall(r'\$([A-Z]{1,5}(?:\.[A-Z]{1,2})?)', text.upper())))
|
||||
|
||||
|
||||
def lookup_tickers(tickers: list[str]) -> dict:
|
||||
"""Look up ticker data via yfinance."""
|
||||
if not yf or not tickers:
|
||||
return {}
|
||||
results = {}
|
||||
for t in tickers[:5]: # limit to 5
|
||||
try:
|
||||
info = yf.Ticker(t).info
|
||||
results[t] = {
|
||||
"price": info.get("currentPrice") or info.get("regularMarketPrice"),
|
||||
"market_cap": info.get("marketCap"),
|
||||
"name": info.get("shortName"),
|
||||
"volume": info.get("volume"),
|
||||
"day_change_pct": info.get("regularMarketChangePercent"),
|
||||
"52w_high": info.get("fiftyTwoWeekHigh"),
|
||||
"52w_low": info.get("fiftyTwoWeekLow"),
|
||||
}
|
||||
except Exception:
|
||||
results[t] = {"error": "lookup failed"}
|
||||
return results
|
||||
|
||||
|
||||
async def scrape_tweet(url: str) -> dict:
|
||||
"""Connect to Chrome CDP and scrape tweet data."""
|
||||
# Normalize URL
|
||||
url = url.replace("twitter.com", "x.com")
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url
|
||||
|
||||
data = {
|
||||
"url": url,
|
||||
"author": None,
|
||||
"handle": None,
|
||||
"text": None,
|
||||
"timestamp": None,
|
||||
"metrics": {},
|
||||
"images": [],
|
||||
"bio": None,
|
||||
"followers": None,
|
||||
"following": None,
|
||||
"reply_to": None,
|
||||
"replies_sample": [],
|
||||
"scrape_error": None,
|
||||
}
|
||||
|
||||
async with async_playwright() as p:
|
||||
try:
|
||||
browser = await p.chromium.connect_over_cdp("http://localhost:9222")
|
||||
except Exception as e:
|
||||
data["scrape_error"] = f"CDP connection failed: {e}"
|
||||
return data
|
||||
|
||||
try:
|
||||
ctx = browser.contexts[0] if browser.contexts else await browser.new_context()
|
||||
page = await ctx.new_page()
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||
await page.wait_for_timeout(4000)
|
||||
|
||||
# Get the main tweet article
|
||||
# Try to find the focal tweet
|
||||
tweet_sel = 'article[data-testid="tweet"]'
|
||||
articles = await page.query_selector_all(tweet_sel)
|
||||
|
||||
if not articles:
|
||||
data["scrape_error"] = "No tweet articles found on page"
|
||||
await page.close()
|
||||
return data
|
||||
|
||||
# The focal tweet is typically the one with the largest text or specific structure
|
||||
# On a tweet permalink, it's usually the first or second article
|
||||
focal = None
|
||||
for art in articles:
|
||||
# The focal tweet has a different time display (absolute vs relative)
|
||||
time_el = await art.query_selector('time')
|
||||
if time_el:
|
||||
dt = await time_el.get_attribute('datetime')
|
||||
if dt:
|
||||
focal = art
|
||||
data["timestamp"] = dt
|
||||
break
|
||||
if not focal:
|
||||
focal = articles[0]
|
||||
|
||||
# Author info
|
||||
user_links = await focal.query_selector_all('a[role="link"]')
|
||||
for link in user_links:
|
||||
href = await link.get_attribute("href") or ""
|
||||
if href.startswith("/") and href.count("/") == 1 and len(href) > 1:
|
||||
spans = await link.query_selector_all("span")
|
||||
for span in spans:
|
||||
txt = (await span.inner_text()).strip()
|
||||
if txt.startswith("@"):
|
||||
data["handle"] = txt
|
||||
elif txt and not data["author"] and not txt.startswith("@"):
|
||||
data["author"] = txt
|
||||
break
|
||||
|
||||
# Tweet text
|
||||
text_el = await focal.query_selector('div[data-testid="tweetText"]')
|
||||
if text_el:
|
||||
data["text"] = await text_el.inner_text()
|
||||
|
||||
# Metrics (replies, retweets, likes, views)
|
||||
group = await focal.query_selector('div[role="group"]')
|
||||
if group:
|
||||
buttons = await group.query_selector_all('button')
|
||||
metric_names = ["replies", "retweets", "likes", "bookmarks"]
|
||||
for i, btn in enumerate(buttons):
|
||||
aria = await btn.get_attribute("aria-label") or ""
|
||||
# Parse numbers from aria labels like "123 replies"
|
||||
nums = re.findall(r'[\d,]+', aria)
|
||||
if nums and i < len(metric_names):
|
||||
data["metrics"][metric_names[i]] = nums[0].replace(",", "")
|
||||
|
||||
# Views - often in a separate span
|
||||
view_spans = await focal.query_selector_all('a[role="link"] span')
|
||||
for vs in view_spans:
|
||||
txt = (await vs.inner_text()).strip()
|
||||
if "views" in txt.lower() or "Views" in txt:
|
||||
nums = re.findall(r'[\d,.KkMm]+', txt)
|
||||
if nums:
|
||||
data["metrics"]["views"] = nums[0]
|
||||
|
||||
# Images
|
||||
imgs = await focal.query_selector_all('img[alt="Image"]')
|
||||
for img in imgs:
|
||||
src = await img.get_attribute("src")
|
||||
if src:
|
||||
data["images"].append(src)
|
||||
|
||||
# Check if it's a reply
|
||||
reply_indicators = await page.query_selector_all('div[data-testid="tweet"] a[role="link"]')
|
||||
|
||||
# Try to get author profile info by hovering or checking
|
||||
# We'll grab it from the page if visible
|
||||
if data["handle"]:
|
||||
handle_clean = data["handle"].lstrip("@")
|
||||
# Check for bio/follower info in any hover cards or visible elements
|
||||
all_text = await page.inner_text("body")
|
||||
# Look for follower patterns
|
||||
follower_match = re.search(r'([\d,.]+[KkMm]?)\s+Followers', all_text)
|
||||
following_match = re.search(r'([\d,.]+[KkMm]?)\s+Following', all_text)
|
||||
if follower_match:
|
||||
data["followers"] = follower_match.group(1)
|
||||
if following_match:
|
||||
data["following"] = following_match.group(1)
|
||||
|
||||
# Sample some replies (articles after the focal tweet)
|
||||
if len(articles) > 1:
|
||||
for art in articles[1:4]:
|
||||
reply_text_el = await art.query_selector('div[data-testid="tweetText"]')
|
||||
if reply_text_el:
|
||||
rt = await reply_text_el.inner_text()
|
||||
if rt:
|
||||
data["replies_sample"].append(rt[:200])
|
||||
|
||||
await page.close()
|
||||
|
||||
except Exception as e:
|
||||
data["scrape_error"] = str(e)
|
||||
try:
|
||||
await page.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def analyze(data: dict) -> dict:
|
||||
"""Produce structured analysis from scraped data."""
|
||||
text = data.get("text") or ""
|
||||
tickers = extract_tickers(text)
|
||||
ticker_data = lookup_tickers(tickers)
|
||||
|
||||
# Red flags detection
|
||||
red_flags = []
|
||||
text_lower = text.lower()
|
||||
promo_words = ["100x", "1000x", "moon", "gem", "rocket", "guaranteed", "easy money",
|
||||
"don't miss", "last chance", "about to explode", "next big", "sleeping giant",
|
||||
"never stops printing", "true freedom", "beat the institutions", "revolution",
|
||||
"empire", "vault", "get rich", "financial freedom", "life changing",
|
||||
"without a degree", "from a bedroom", "join this"]
|
||||
for w in promo_words:
|
||||
if w in text_lower:
|
||||
red_flags.append(f"Promotional language: '{w}'")
|
||||
|
||||
if len(tickers) > 3:
|
||||
red_flags.append(f"Multiple tickers mentioned ({len(tickers)})")
|
||||
|
||||
if len(text) > 2000:
|
||||
red_flags.append("Extremely long promotional thread")
|
||||
if "github" in text_lower and ("star" in text_lower or "repo" in text_lower):
|
||||
red_flags.append("Pushing GitHub repo (potential funnel to paid product)")
|
||||
if any(w in text_lower for w in ["course", "discord", "premium", "paid group", "subscribe"]):
|
||||
red_flags.append("Funneling to paid product/community")
|
||||
|
||||
# Check replies for coordinated patterns
|
||||
replies = data.get("replies_sample", [])
|
||||
if replies:
|
||||
rocket_replies = sum(1 for r in replies if any(e in r for e in ["🚀", "💎", "🔥", "LFG"]))
|
||||
if rocket_replies >= 2:
|
||||
red_flags.append("Replies show coordinated hype patterns")
|
||||
|
||||
# Check for penny stock characteristics
|
||||
for t, info in ticker_data.items():
|
||||
if isinstance(info, dict) and not info.get("error"):
|
||||
price = info.get("price")
|
||||
mcap = info.get("market_cap")
|
||||
if price and price < 1:
|
||||
red_flags.append(f"${t} is a penny stock (${price})")
|
||||
if mcap and mcap < 50_000_000:
|
||||
red_flags.append(f"${t} micro-cap (<$50M market cap)")
|
||||
|
||||
# Build verdict
|
||||
if len(red_flags) >= 3:
|
||||
verdict = "High risk - multiple red flags detected, exercise extreme caution"
|
||||
elif len(red_flags) >= 1:
|
||||
verdict = "Some concerns - verify claims independently before acting"
|
||||
elif tickers:
|
||||
verdict = "Worth investigating - do your own due diligence"
|
||||
else:
|
||||
verdict = "Informational tweet - no immediate financial claims detected"
|
||||
|
||||
return {
|
||||
"tweet_data": data,
|
||||
"tickers_found": tickers,
|
||||
"ticker_data": ticker_data,
|
||||
"red_flags": red_flags,
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def format_markdown(analysis: dict) -> str:
|
||||
"""Format analysis as markdown."""
|
||||
d = analysis["tweet_data"]
|
||||
lines = [f"# Tweet Analysis", ""]
|
||||
lines.append(f"**URL:** {d['url']}")
|
||||
lines.append(f"**Analyzed:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append("")
|
||||
|
||||
# WHO
|
||||
lines.append("## 👤 WHO")
|
||||
lines.append(f"- **Author:** {d.get('author') or 'Unknown'}")
|
||||
lines.append(f"- **Handle:** {d.get('handle') or 'Unknown'}")
|
||||
if d.get("followers"):
|
||||
lines.append(f"- **Followers:** {d['followers']}")
|
||||
if d.get("following"):
|
||||
lines.append(f"- **Following:** {d['following']}")
|
||||
if d.get("bio"):
|
||||
lines.append(f"- **Bio:** {d['bio']}")
|
||||
lines.append("")
|
||||
|
||||
# WHAT
|
||||
lines.append("## 📝 WHAT")
|
||||
lines.append(f"> {d.get('text') or 'Could not extract tweet text'}")
|
||||
lines.append("")
|
||||
if d.get("timestamp"):
|
||||
lines.append(f"**Posted:** {d['timestamp']}")
|
||||
metrics = d.get("metrics", {})
|
||||
if metrics:
|
||||
m_parts = [f"{v} {k}" for k, v in metrics.items()]
|
||||
lines.append(f"**Metrics:** {' | '.join(m_parts)}")
|
||||
if d.get("images"):
|
||||
lines.append(f"**Images:** {len(d['images'])} attached")
|
||||
lines.append("")
|
||||
|
||||
# VERIFY
|
||||
lines.append("## ✅ VERIFY")
|
||||
tickers = analysis.get("tickers_found", [])
|
||||
td = analysis.get("ticker_data", {})
|
||||
if tickers:
|
||||
lines.append(f"**Tickers mentioned:** {', '.join('$' + t for t in tickers)}")
|
||||
lines.append("")
|
||||
for t, info in td.items():
|
||||
if isinstance(info, dict) and not info.get("error"):
|
||||
lines.append(f"### ${t}" + (f" - {info.get('name', '')}" if info.get('name') else ""))
|
||||
if info.get("price"):
|
||||
lines.append(f"- **Price:** ${info['price']}")
|
||||
if info.get("market_cap"):
|
||||
mc = info["market_cap"]
|
||||
if mc > 1e9:
|
||||
lines.append(f"- **Market Cap:** ${mc/1e9:.2f}B")
|
||||
else:
|
||||
lines.append(f"- **Market Cap:** ${mc/1e6:.1f}M")
|
||||
if info.get("volume"):
|
||||
lines.append(f"- **Volume:** {info['volume']:,}")
|
||||
if info.get("day_change_pct"):
|
||||
lines.append(f"- **Day Change:** {info['day_change_pct']:.2f}%")
|
||||
if info.get("52w_high") and info.get("52w_low"):
|
||||
lines.append(f"- **52W Range:** ${info['52w_low']} - ${info['52w_high']}")
|
||||
lines.append("")
|
||||
elif isinstance(info, dict) and info.get("error"):
|
||||
lines.append(f"- ${t}: lookup failed")
|
||||
else:
|
||||
lines.append("No tickers mentioned in tweet.")
|
||||
lines.append("")
|
||||
|
||||
# RED FLAGS
|
||||
lines.append("## 🚩 RED FLAGS")
|
||||
flags = analysis.get("red_flags", [])
|
||||
if flags:
|
||||
for f in flags:
|
||||
lines.append(f"- ⚠️ {f}")
|
||||
else:
|
||||
lines.append("- None detected")
|
||||
lines.append("")
|
||||
|
||||
# MONEY
|
||||
lines.append("## 💰 MONEY")
|
||||
if tickers and not flags:
|
||||
lines.append("Potential opportunity identified. Research further before any position.")
|
||||
elif tickers and flags:
|
||||
lines.append("Tickers mentioned but red flags present. High risk of promoted/manipulated asset.")
|
||||
else:
|
||||
lines.append("No direct financial opportunity identified in this tweet.")
|
||||
lines.append("")
|
||||
|
||||
# VERDICT
|
||||
lines.append("## 🎯 VERDICT")
|
||||
lines.append(f"**{analysis['verdict']}**")
|
||||
lines.append("")
|
||||
|
||||
# Scrape issues
|
||||
if d.get("scrape_error"):
|
||||
lines.append(f"---\n⚠️ *Scrape warning: {d['scrape_error']}*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Analyze a tweet")
|
||||
parser.add_argument("url", help="Tweet URL (x.com or twitter.com)")
|
||||
parser.add_argument("--json", action="store_true", dest="json_output", help="Output JSON")
|
||||
parser.add_argument("-o", "--output", help="Write output to file")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate URL
|
||||
if not re.search(r'(x\.com|twitter\.com)/.+/status/\d+', args.url):
|
||||
print("ERROR: Invalid tweet URL", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("Scraping tweet...", file=sys.stderr)
|
||||
data = await scrape_tweet(args.url)
|
||||
|
||||
print("Analyzing...", file=sys.stderr)
|
||||
analysis = analyze(data)
|
||||
|
||||
if args.json_output:
|
||||
output = json.dumps(analysis, indent=2, default=str)
|
||||
else:
|
||||
output = format_markdown(analysis)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output)
|
||||
print(f"Written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
51
tools/data_sources/README.md
Normal file
51
tools/data_sources/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Data Source Connectors
|
||||
|
||||
Standalone Python scripts for fetching crypto/market data. Each has CLI with `--pretty` (JSON formatting) and `--summary` (human-readable output).
|
||||
|
||||
## defillama.py ✅ (no auth needed)
|
||||
|
||||
DefiLlama API — DeFi protocol data, token prices, yield farming opportunities.
|
||||
|
||||
```bash
|
||||
./defillama.py protocols --limit 10 --summary # Top protocols by TVL
|
||||
./defillama.py tvl aave --pretty # TVL for specific protocol
|
||||
./defillama.py prices coingecko:bitcoin coingecko:ethereum --summary
|
||||
./defillama.py yields --limit 20 --stablecoins --summary # Top stablecoin yields
|
||||
```
|
||||
|
||||
**Endpoints used:** api.llama.fi/protocols, api.llama.fi/tvl/{name}, coins.llama.fi/prices, yields.llama.fi/pools
|
||||
|
||||
## coinglass.py 🔑 (API key recommended)
|
||||
|
||||
Coinglass — funding rates, open interest, long/short ratios.
|
||||
|
||||
```bash
|
||||
export COINGLASS_API_KEY=your_key # Get at coinglass.com/pricing
|
||||
./coinglass.py funding --summary
|
||||
./coinglass.py oi --summary
|
||||
./coinglass.py long-short --summary
|
||||
```
|
||||
|
||||
**Note:** Free internal API endpoints often return empty data. API key required for reliable access.
|
||||
|
||||
## arkham.py 🔑 (API key required)
|
||||
|
||||
Arkham Intelligence — whale wallet tracking, token transfers, entity search.
|
||||
|
||||
```bash
|
||||
export ARKHAM_API_KEY=your_key # Sign up at platform.arkhamintelligence.com
|
||||
./arkham.py notable --summary # List known whale addresses
|
||||
./arkham.py address vitalik --summary # Address intelligence (supports name shortcuts)
|
||||
./arkham.py transfers 0x1234... --limit 10 --pretty
|
||||
./arkham.py search "binance" --pretty
|
||||
```
|
||||
|
||||
**Built-in shortcuts:** vitalik, justin-sun, binance-hot, coinbase-prime, aave-treasury, uniswap-deployer
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
```python
|
||||
from tools.data_sources.defillama import get_protocols, get_prices, get_yield_pools
|
||||
from tools.data_sources.coinglass import get_funding_rates
|
||||
from tools.data_sources.arkham import get_address_info, NOTABLE_ADDRESSES
|
||||
```
|
||||
4
tools/data_sources/__init__.py
Executable file
4
tools/data_sources/__init__.py
Executable file
@ -0,0 +1,4 @@
|
||||
"""Crypto & market data source connectors."""
|
||||
from pathlib import Path
|
||||
|
||||
DATA_SOURCES_DIR = Path(__file__).parent
|
||||
167
tools/data_sources/arkham.py
Executable file
167
tools/data_sources/arkham.py
Executable file
@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Arkham Intelligence connector — whale tracking, token flows, address intelligence.
|
||||
|
||||
Requires API key for most endpoints. Set ARKHAM_API_KEY env var.
|
||||
Sign up at https://platform.arkhamintelligence.com
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "https://api.arkhamintelligence.com"
|
||||
TIMEOUT = 30
|
||||
|
||||
NOTABLE_ADDRESSES = {
|
||||
"vitalik": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"justin-sun": "0x3DdfA8eC3052539b6C9549F12cEA2C295cfF5296",
|
||||
"binance-hot": "0x28C6c06298d514Db089934071355E5743bf21d60",
|
||||
"coinbase-prime": "0xA9D1e08C7793af67e9d92fe308d5697FB81d3E43",
|
||||
"aave-treasury": "0x464C71f6c2F760DdA6093dCB91C24c39e5d6e18c",
|
||||
"uniswap-deployer": "0x41653c7d61609D856f29355E404F310Ec4142Cfb",
|
||||
}
|
||||
|
||||
|
||||
def _get(path: str, params: dict | None = None) -> Any:
|
||||
key = os.environ.get("ARKHAM_API_KEY")
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
if key:
|
||||
headers["API-Key"] = key
|
||||
r = requests.get(f"{BASE}/{path}", params=params, headers=headers, timeout=TIMEOUT)
|
||||
if r.status_code in (401, 403) or "api key" in r.text.lower():
|
||||
raise EnvironmentError(
|
||||
"Arkham API key required. Set ARKHAM_API_KEY env var.\n"
|
||||
"Sign up at https://platform.arkhamintelligence.com"
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def resolve_address(name_or_addr: str) -> str:
|
||||
return NOTABLE_ADDRESSES.get(name_or_addr.lower(), name_or_addr)
|
||||
|
||||
|
||||
# ── Data fetchers ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_address_info(address: str) -> dict:
|
||||
return _get(f"intelligence/address/{resolve_address(address)}")
|
||||
|
||||
|
||||
def get_transfers(address: str, limit: int = 20) -> dict:
|
||||
return _get("token/transfers", {"address": resolve_address(address), "limit": limit})
|
||||
|
||||
|
||||
def search_entity(query: str) -> dict:
|
||||
return _get("intelligence/search", {"query": query})
|
||||
|
||||
|
||||
# ── Summary helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def summary_address(data: dict) -> str:
|
||||
lines = ["═══ Address Intelligence ═══", ""]
|
||||
if isinstance(data, dict):
|
||||
entity = data.get("entity", {}) or {}
|
||||
if entity:
|
||||
lines.append(f" Entity: {entity.get('name', 'Unknown')}")
|
||||
lines.append(f" Type: {entity.get('type', 'Unknown')}")
|
||||
lines.append(f" Address: {data.get('address', '?')}")
|
||||
labels = data.get("labels", [])
|
||||
if labels:
|
||||
lines.append(f" Labels: {', '.join(str(l) for l in labels)}")
|
||||
else:
|
||||
lines.append(f" {data}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def summary_transfers(data) -> str:
|
||||
lines = ["═══ Recent Transfers ═══", ""]
|
||||
transfers = data if isinstance(data, list) else (data.get("transfers", data.get("data", [])) if isinstance(data, dict) else [])
|
||||
if not transfers:
|
||||
lines.append(" No transfers found.")
|
||||
return "\n".join(lines)
|
||||
for t in transfers[:15]:
|
||||
token = t.get("token", {}).get("symbol", "?") if isinstance(t.get("token"), dict) else "?"
|
||||
amount = t.get("amount", t.get("value", "?"))
|
||||
fr = t.get("from", {})
|
||||
to = t.get("to", {})
|
||||
fl = (fr.get("label") or fr.get("address", "?")[:12]) if isinstance(fr, dict) else str(fr)[:12]
|
||||
tl = (to.get("label") or to.get("address", "?")[:12]) if isinstance(to, dict) else str(to)[:12]
|
||||
lines.append(f" {token:<8} {str(amount):>15} {fl} → {tl}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def summary_notable() -> str:
|
||||
lines = ["═══ Notable/Whale Addresses ═══", ""]
|
||||
for name, addr in NOTABLE_ADDRESSES.items():
|
||||
lines.append(f" {name:<20} {addr}")
|
||||
lines.append("")
|
||||
lines.append(" Use these as shortcuts: arkham.py address vitalik")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
|
||||
common.add_argument("--summary", action="store_true", help="Human-readable summary")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Arkham Intelligence connector", parents=[common])
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_addr = sub.add_parser("address", help="Address intelligence", parents=[common])
|
||||
p_addr.add_argument("address", help="Ethereum address or notable name")
|
||||
|
||||
p_tx = sub.add_parser("transfers", help="Recent token transfers", parents=[common])
|
||||
p_tx.add_argument("address")
|
||||
p_tx.add_argument("--limit", type=int, default=20)
|
||||
|
||||
p_search = sub.add_parser("search", help="Search entities", parents=[common])
|
||||
p_search.add_argument("query")
|
||||
|
||||
sub.add_parser("notable", help="List notable/whale addresses", parents=[common])
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "notable":
|
||||
if args.summary:
|
||||
print(summary_notable())
|
||||
else:
|
||||
json.dump(NOTABLE_ADDRESSES, sys.stdout, indent=2 if args.pretty else None)
|
||||
print()
|
||||
return
|
||||
|
||||
if args.command == "address":
|
||||
data = get_address_info(args.address)
|
||||
if args.summary:
|
||||
print(summary_address(data)); return
|
||||
result = data
|
||||
elif args.command == "transfers":
|
||||
data = get_transfers(args.address, args.limit)
|
||||
if args.summary:
|
||||
print(summary_transfers(data)); return
|
||||
result = data
|
||||
elif args.command == "search":
|
||||
result = search_entity(args.query)
|
||||
else:
|
||||
parser.print_help(); return
|
||||
|
||||
json.dump(result, sys.stdout, indent=2 if args.pretty else None)
|
||||
print()
|
||||
|
||||
except EnvironmentError as e:
|
||||
print(str(e), file=sys.stderr); sys.exit(1)
|
||||
except requests.HTTPError as e:
|
||||
detail = e.response.text[:200] if e.response is not None else ""
|
||||
print(json.dumps({"error": str(e), "detail": detail}), file=sys.stderr); sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}), file=sys.stderr); sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
181
tools/data_sources/coinglass.py
Executable file
181
tools/data_sources/coinglass.py
Executable file
@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Coinglass data connector — funding rates, open interest, long/short ratios.
|
||||
|
||||
Uses the free fapi.coinglass.com internal API where available.
|
||||
Some endpoints may return empty data without authentication.
|
||||
Set COINGLASS_API_KEY env var for authenticated access to open-api.coinglass.com.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
FREE_BASE = "https://fapi.coinglass.com/api"
|
||||
AUTH_BASE = "https://open-api.coinglass.com/public/v2"
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
def _free_get(path: str, params: dict | None = None) -> Any:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
"Referer": "https://www.coinglass.com/",
|
||||
}
|
||||
r = requests.get(f"{FREE_BASE}/{path}", params=params, headers=headers, timeout=TIMEOUT)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("code") == "0" or data.get("success"):
|
||||
return data.get("data", [])
|
||||
raise ValueError(f"API error: {data.get('msg', 'unknown')}")
|
||||
|
||||
|
||||
def _auth_get(path: str, params: dict | None = None) -> Any:
|
||||
key = os.environ.get("COINGLASS_API_KEY")
|
||||
if not key:
|
||||
raise EnvironmentError("COINGLASS_API_KEY not set. Get one at https://www.coinglass.com/pricing")
|
||||
headers = {"coinglassSecret": key}
|
||||
r = requests.get(f"{AUTH_BASE}/{path}", params=params, headers=headers, timeout=TIMEOUT)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("success") or data.get("code") == "0":
|
||||
return data.get("data", [])
|
||||
raise ValueError(f"API error: {data.get('msg', 'unknown')}")
|
||||
|
||||
|
||||
# ── Data fetchers ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_funding_rates() -> list[dict]:
|
||||
"""Funding rates across exchanges."""
|
||||
try:
|
||||
data = _free_get("fundingRate/v2/home")
|
||||
if data:
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return _auth_get("funding")
|
||||
|
||||
|
||||
def get_open_interest() -> list[dict]:
|
||||
"""Aggregated open interest data."""
|
||||
try:
|
||||
data = _free_get("openInterest/v3/home")
|
||||
if data:
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return _auth_get("open_interest")
|
||||
|
||||
|
||||
def get_long_short_ratio() -> list[dict]:
|
||||
"""Global long/short account ratios."""
|
||||
try:
|
||||
data = _free_get("futures/longShort/v2/home")
|
||||
if data:
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return _auth_get("long_short")
|
||||
|
||||
|
||||
# ── Summary helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _no_data_msg(name: str) -> str:
|
||||
return (f"No {name} data available (free API may be restricted).\n"
|
||||
"Set COINGLASS_API_KEY for full access: https://www.coinglass.com/pricing")
|
||||
|
||||
|
||||
def summary_funding(data: list[dict]) -> str:
|
||||
if not data:
|
||||
return _no_data_msg("funding rate")
|
||||
lines = ["═══ Funding Rates ═══", ""]
|
||||
for item in data[:20]:
|
||||
symbol = item.get("symbol", item.get("coin", "?"))
|
||||
rate = None
|
||||
if "uMarginList" in item:
|
||||
for m in item["uMarginList"]:
|
||||
rate = m.get("rate")
|
||||
if rate is not None:
|
||||
break
|
||||
else:
|
||||
rate = item.get("rate")
|
||||
if rate is not None:
|
||||
lines.append(f" {symbol:<10} {float(rate)*100:>8.4f}%")
|
||||
else:
|
||||
lines.append(f" {symbol:<10} (rate unavailable)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def summary_oi(data: list[dict]) -> str:
|
||||
if not data:
|
||||
return _no_data_msg("open interest")
|
||||
lines = ["═══ Open Interest ═══", ""]
|
||||
for item in data[:20]:
|
||||
symbol = item.get("symbol", item.get("coin", "?"))
|
||||
oi = item.get("openInterest", item.get("oi", 0))
|
||||
lines.append(f" {symbol:<10} OI: ${float(oi):>15,.0f}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def summary_ls(data: list[dict]) -> str:
|
||||
if not data:
|
||||
return _no_data_msg("long/short")
|
||||
lines = ["═══ Long/Short Ratios ═══", ""]
|
||||
for item in data[:20]:
|
||||
symbol = item.get("symbol", item.get("coin", "?"))
|
||||
long_rate = item.get("longRate", item.get("longRatio", "?"))
|
||||
short_rate = item.get("shortRate", item.get("shortRatio", "?"))
|
||||
lines.append(f" {symbol:<10} Long: {long_rate} Short: {short_rate}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
|
||||
common.add_argument("--summary", action="store_true", help="Human-readable summary")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Coinglass data connector", parents=[common])
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("funding", help="Funding rates across exchanges", parents=[common])
|
||||
sub.add_parser("oi", help="Open interest overview", parents=[common])
|
||||
sub.add_parser("long-short", help="Long/short ratios", parents=[common])
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "funding":
|
||||
data = get_funding_rates()
|
||||
if args.summary:
|
||||
print(summary_funding(data)); return
|
||||
result = data
|
||||
elif args.command == "oi":
|
||||
data = get_open_interest()
|
||||
if args.summary:
|
||||
print(summary_oi(data)); return
|
||||
result = data
|
||||
elif args.command == "long-short":
|
||||
data = get_long_short_ratio()
|
||||
if args.summary:
|
||||
print(summary_ls(data)); return
|
||||
result = data
|
||||
else:
|
||||
parser.print_help(); return
|
||||
|
||||
json.dump(result, sys.stdout, indent=2 if args.pretty else None)
|
||||
print()
|
||||
|
||||
except EnvironmentError as e:
|
||||
print(str(e), file=sys.stderr); sys.exit(1)
|
||||
except requests.HTTPError as e:
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr); sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}), file=sys.stderr); sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
176
tools/data_sources/defillama.py
Executable file
176
tools/data_sources/defillama.py
Executable file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DefiLlama API connector — TVL, token prices, yield/APY data.
|
||||
|
||||
No authentication required. All endpoints are free.
|
||||
API base: https://api.llama.fi | Prices: https://coins.llama.fi | Yields: https://yields.llama.fi
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "https://api.llama.fi"
|
||||
COINS_BASE = "https://coins.llama.fi"
|
||||
YIELDS_BASE = "https://yields.llama.fi"
|
||||
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
def _get(url: str, params: dict | None = None) -> Any:
|
||||
r = requests.get(url, params=params, timeout=TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── Protocol / TVL ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_protocols(limit: int = 20) -> list[dict]:
|
||||
"""Top protocols by TVL."""
|
||||
data = _get(f"{BASE}/protocols")
|
||||
# Sort by tvl descending, filter out CEXes
|
||||
protos = [p for p in data if p.get("category") != "CEX" and p.get("tvl")]
|
||||
protos.sort(key=lambda p: p.get("tvl", 0), reverse=True)
|
||||
return protos[:limit]
|
||||
|
||||
|
||||
def get_tvl(protocol: str) -> dict:
|
||||
"""Get current TVL for a specific protocol (slug name)."""
|
||||
val = _get(f"{BASE}/tvl/{protocol}")
|
||||
return {"protocol": protocol, "tvl": val}
|
||||
|
||||
|
||||
def get_protocol_detail(protocol: str) -> dict:
|
||||
"""Full protocol details including chain breakdowns."""
|
||||
return _get(f"{BASE}/protocol/{protocol}")
|
||||
|
||||
|
||||
# ── Token Prices ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_prices(coins: list[str]) -> dict:
|
||||
"""Get current prices. Coins format: 'coingecko:ethereum', 'ethereum:0x...', etc."""
|
||||
joined = ",".join(coins)
|
||||
data = _get(f"{COINS_BASE}/prices/current/{joined}")
|
||||
return data.get("coins", {})
|
||||
|
||||
|
||||
# ── Yields / APY ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_yield_pools(limit: int = 30, min_tvl: float = 1_000_000, stablecoin_only: bool = False) -> list[dict]:
|
||||
"""Top yield pools sorted by APY."""
|
||||
data = _get(f"{YIELDS_BASE}/pools")
|
||||
pools = data.get("data", [])
|
||||
# Filter
|
||||
pools = [p for p in pools if (p.get("tvlUsd") or 0) >= min_tvl and (p.get("apy") or 0) > 0]
|
||||
if stablecoin_only:
|
||||
pools = [p for p in pools if p.get("stablecoin")]
|
||||
pools.sort(key=lambda p: p.get("apy", 0), reverse=True)
|
||||
return pools[:limit]
|
||||
|
||||
|
||||
# ── Summary helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _fmt_usd(v: float) -> str:
|
||||
if v >= 1e9:
|
||||
return f"${v/1e9:.2f}B"
|
||||
if v >= 1e6:
|
||||
return f"${v/1e6:.1f}M"
|
||||
return f"${v:,.0f}"
|
||||
|
||||
|
||||
def summary_protocols(protos: list[dict]) -> str:
|
||||
lines = ["═══ Top Protocols by TVL ═══", ""]
|
||||
for i, p in enumerate(protos, 1):
|
||||
lines.append(f" {i:>2}. {p['name']:<25} TVL: {_fmt_usd(p.get('tvl', 0)):>12} chain: {p.get('chain', '?')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def summary_prices(prices: dict) -> str:
|
||||
lines = ["═══ Token Prices ═══", ""]
|
||||
for coin, info in prices.items():
|
||||
lines.append(f" {info.get('symbol', coin):<10} ${info['price']:>12,.2f} (confidence: {info.get('confidence', '?')})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def summary_yields(pools: list[dict]) -> str:
|
||||
lines = ["═══ Top Yield Pools ═══", ""]
|
||||
for i, p in enumerate(pools, 1):
|
||||
lines.append(
|
||||
f" {i:>2}. {p.get('symbol','?'):<25} APY: {p.get('apy',0):>8.2f}% "
|
||||
f"TVL: {_fmt_usd(p.get('tvlUsd',0)):>10} {p.get('chain','?')}/{p.get('project','?')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
|
||||
common.add_argument("--summary", action="store_true", help="Human-readable summary")
|
||||
parser = argparse.ArgumentParser(description="DefiLlama data connector", parents=[common])
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# protocols
|
||||
p_proto = sub.add_parser("protocols", help="Top protocols by TVL", parents=[common])
|
||||
p_proto.add_argument("--limit", type=int, default=20)
|
||||
|
||||
# tvl
|
||||
p_tvl = sub.add_parser("tvl", help="TVL for a specific protocol", parents=[common])
|
||||
p_tvl.add_argument("protocol", help="Protocol slug (e.g. aave, lido)")
|
||||
|
||||
# prices
|
||||
p_price = sub.add_parser("prices", help="Token prices", parents=[common])
|
||||
p_price.add_argument("coins", nargs="+", help="Coin IDs: coingecko:ethereum, ethereum:0x...")
|
||||
|
||||
# yields
|
||||
p_yield = sub.add_parser("yields", help="Top yield pools", parents=[common])
|
||||
p_yield.add_argument("--limit", type=int, default=30)
|
||||
p_yield.add_argument("--min-tvl", type=float, default=1_000_000)
|
||||
p_yield.add_argument("--stablecoins", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "protocols":
|
||||
data = get_protocols(args.limit)
|
||||
if args.summary:
|
||||
print(summary_protocols(data))
|
||||
return
|
||||
result = [{"name": p["name"], "tvl": p.get("tvl"), "chain": p.get("chain"), "category": p.get("category"), "symbol": p.get("symbol")} for p in data]
|
||||
elif args.command == "tvl":
|
||||
result = get_tvl(args.protocol)
|
||||
if args.summary:
|
||||
print(f"{args.protocol}: {_fmt_usd(result['tvl'])}")
|
||||
return
|
||||
elif args.command == "prices":
|
||||
result = get_prices(args.coins)
|
||||
if args.summary:
|
||||
print(summary_prices(result))
|
||||
return
|
||||
elif args.command == "yields":
|
||||
data = get_yield_pools(args.limit, args.min_tvl, args.stablecoins)
|
||||
if args.summary:
|
||||
print(summary_yields(data))
|
||||
return
|
||||
result = [{"symbol": p.get("symbol"), "apy": p.get("apy"), "tvlUsd": p.get("tvlUsd"), "chain": p.get("chain"), "project": p.get("project"), "pool": p.get("pool")} for p in data]
|
||||
else:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
indent = 2 if args.pretty else None
|
||||
json.dump(result, sys.stdout, indent=indent)
|
||||
print()
|
||||
|
||||
except requests.HTTPError as e:
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"Unexpected: {e}"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
15
tools/tweet_analyzer_wrapper.sh
Executable file
15
tools/tweet_analyzer_wrapper.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tweet Analyzer Wrapper - for OpenClaw agent use
|
||||
# Usage: ./tweet_analyzer_wrapper.sh <tweet_url> [output_file]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
URL="${1:?Usage: $0 <tweet_url> [output_file]}"
|
||||
OUTPUT="${2:-}"
|
||||
|
||||
if [ -n "$OUTPUT" ]; then
|
||||
python3 "$SCRIPT_DIR/analyze_tweet.py" "$URL" -o "$OUTPUT"
|
||||
echo "Analysis written to $OUTPUT"
|
||||
else
|
||||
python3 "$SCRIPT_DIR/analyze_tweet.py" "$URL"
|
||||
fi
|
||||
Reference in New Issue
Block a user