Initial commit: Hermes Agent skills collection

- Trading skills (OKX, dividend, lottery, quantitative)
- Creative skills (ASCII art, diagrams, video)
- Development skills (GitHub, debugging, TDD)
- Research skills (arXiv, blog monitoring)
- Productivity skills (email, documents, notes)
- MCP integration skills
- Custom user skills
This commit is contained in:
Hermes Skills Manager
2026-07-05 02:31:15 -04:00
commit 6770bc9b9d
908 changed files with 239614 additions and 0 deletions
@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "stock-analysis",
"installedVersion": "6.2.0",
"installedAt": 1774801096428
}
+442
View File
@@ -0,0 +1,442 @@
# StockPulse - Commercial Product Roadmap
## Vision
Transform the stock-analysis skill into **StockPulse**, a commercial mobile app for retail investors with AI-powered stock and crypto analysis, portfolio tracking, and personalized alerts.
## Technical Decisions
- **Mobile:** Flutter (iOS + Android cross-platform)
- **Backend:** Python FastAPI on AWS (ECS/Lambda)
- **Database:** PostgreSQL (RDS) + Redis (ElastiCache)
- **Auth:** AWS Cognito or Firebase Auth
- **Monetization:** Freemium + Subscription ($9.99/mo or $79.99/yr)
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ MOBILE APP (Flutter) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Dashboard │ │Portfolio │ │ Analysis │ │ Alerts │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ HTTPS/REST
┌─────────────────────────────────────────────────────────────────┐
│ API GATEWAY (AWS) │
│ Rate Limiting, Auth, Caching │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (FastAPI on ECS) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Auth Service │ │ Analysis API │ │ Portfolio API│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Alerts Svc │ │ Subscription │ │ User Service │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ S3 │
│ (RDS) │ │ (ElastiCache)│ │ (Reports) │
└──────────────┘ └──────────────┘ └──────────────┘
BACKGROUND WORKERS (Lambda/ECS)
┌─────────────────────────────────────────────────────────────────┐
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │Price Updater │ │Alert Checker │ │Daily Reports │ │
│ │ (5 min) │ │ (1 min) │ │ (Daily) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Feature Tiers
### Free Tier
- 1 portfolio (max 10 assets)
- Basic stock/crypto analysis
- Daily market summary
- Limited to 5 analyses/day
- Ads displayed
### Premium ($9.99/mo)
- Unlimited portfolios & assets
- Full 8-dimension analysis
- Real-time price alerts
- Push notifications
- Period reports (daily/weekly/monthly)
- No ads
- Priority support
### Pro ($19.99/mo) - Future
- API access
- Custom watchlists
- Advanced screeners
- Export to CSV/PDF
- Portfolio optimization suggestions
---
## Development Phases
### Phase 1: Backend API
**Goal:** Convert Python scripts to production REST API
#### Tasks:
1. **Project Setup**
- FastAPI project structure
- Docker containerization
- CI/CD pipeline (GitHub Actions)
- AWS infrastructure (Terraform)
2. **Core API Endpoints**
```
POST /auth/register
POST /auth/login
POST /auth/refresh
GET /analysis/{ticker}
POST /analysis/batch
GET /portfolios
POST /portfolios
PUT /portfolios/{id}
DELETE /portfolios/{id}
GET /portfolios/{id}/assets
POST /portfolios/{id}/assets
PUT /portfolios/{id}/assets/{ticker}
DELETE /portfolios/{id}/assets/{ticker}
GET /portfolios/{id}/performance?period=weekly
GET /portfolios/{id}/summary
GET /alerts
POST /alerts
DELETE /alerts/{id}
GET /user/subscription
POST /user/subscription/upgrade
```
3. **Database Schema**
```sql
users (id, email, password_hash, created_at, subscription_tier)
portfolios (id, user_id, name, created_at, updated_at)
assets (id, portfolio_id, ticker, asset_type, quantity, cost_basis)
alerts (id, user_id, ticker, condition, threshold, enabled)
analysis_cache (ticker, data, expires_at)
subscriptions (id, user_id, stripe_id, status, expires_at)
```
4. **Refactor Existing Code**
- Extract `analyze_stock.py` into modules:
- `analysis/earnings.py`
- `analysis/fundamentals.py`
- `analysis/sentiment.py`
- `analysis/crypto.py`
- `analysis/market_context.py`
- Add async support throughout
- Implement proper caching (Redis)
- Rate limiting per user tier
#### Files to Create:
```
backend/
├── app/
│ ├── main.py # FastAPI app
│ ├── config.py # Settings
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── routers/ # API routes
│ │ ├── auth.py
│ │ ├── analysis.py
│ │ ├── portfolios.py
│ │ └── alerts.py
│ ├── services/ # Business logic
│ │ ├── analysis/ # Refactored from analyze_stock.py
│ │ ├── portfolio.py
│ │ └── alerts.py
│ └── workers/ # Background tasks
├── tests/
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
```
---
### Phase 2: Flutter Mobile App
**Goal:** Build polished cross-platform mobile app
#### Screens:
1. **Onboarding** - Welcome, feature highlights, sign up/login
2. **Dashboard** - Market overview, portfolio summary, alerts
3. **Analysis** - Search ticker, view full analysis, save to portfolio
4. **Portfolio** - List portfolios, asset breakdown, P&L chart
5. **Alerts** - Manage price alerts, notification settings
6. **Settings** - Account, subscription, preferences
#### Key Flutter Packages:
```yaml
dependencies:
flutter_bloc: ^8.0.0 # State management
dio: ^5.0.0 # HTTP client
go_router: ^12.0.0 # Navigation
fl_chart: ^0.65.0 # Charts
firebase_messaging: ^14.0.0 # Push notifications
in_app_purchase: ^3.0.0 # Subscriptions
shared_preferences: ^2.0.0
flutter_secure_storage: ^9.0.0
```
#### App Structure:
```
lib/
├── main.dart
├── app/
│ ├── routes.dart
│ └── theme.dart
├── features/
│ ├── auth/
│ │ ├── bloc/
│ │ ├── screens/
│ │ └── widgets/
│ ├── dashboard/
│ ├── analysis/
│ ├── portfolio/
│ ├── alerts/
│ └── settings/
├── core/
│ ├── api/
│ ├── models/
│ └── utils/
└── shared/
└── widgets/
```
---
### Phase 3: Infrastructure & DevOps
**Goal:** Production-ready cloud infrastructure
#### AWS Services:
- **ECS Fargate** - Backend containers
- **RDS PostgreSQL** - Database
- **ElastiCache Redis** - Caching
- **S3** - Static assets, reports
- **CloudFront** - CDN
- **Cognito** - Authentication
- **SES** - Email notifications
- **SNS** - Push notifications
- **CloudWatch** - Monitoring
- **WAF** - Security
#### Terraform Modules:
```
infrastructure/
├── main.tf
├── variables.tf
├── modules/
│ ├── vpc/
│ ├── ecs/
│ ├── rds/
│ ├── elasticache/
│ └── cognito/
└── environments/
├── dev/
├── staging/
└── prod/
```
#### Estimated Monthly Costs (Production):
| Service | Est. Cost |
|---------|-----------|
| ECS Fargate (2 tasks) | $50-100 |
| RDS (db.t3.small) | $30-50 |
| ElastiCache (cache.t3.micro) | $15-25 |
| S3 + CloudFront | $10-20 |
| Other (Cognito, SES, etc.) | $20-30 |
| **Total** | **$125-225/mo** |
---
### Phase 4: Payments & Subscriptions
**Goal:** Integrate Stripe for subscriptions
#### Implementation:
1. Stripe subscription products (Free, Premium, Pro)
2. In-app purchase for iOS/Android
3. Webhook handlers for subscription events
4. Grace period handling
5. Receipt validation
#### Stripe Integration:
```python
# Backend webhook handler
@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
event = stripe.Webhook.construct_event(...)
if event.type == "customer.subscription.updated":
update_user_tier(event.data.object)
elif event.type == "customer.subscription.deleted":
downgrade_to_free(event.data.object)
```
---
### Phase 5: Push Notifications & Alerts
**Goal:** Real-time price alerts and notifications
#### Alert Types:
- Price above/below threshold
- Percentage change (daily)
- Earnings announcement
- Breaking news (geopolitical)
- Portfolio performance
#### Implementation:
- Firebase Cloud Messaging (FCM)
- Background worker checks alerts every minute
- Rate limit: max 10 alerts/day per free user
---
### Phase 6: Analytics & Monitoring
**Goal:** Track usage, errors, business metrics
#### Tools:
- **Mixpanel/Amplitude** - Product analytics
- **Sentry** - Error tracking
- **CloudWatch** - Infrastructure metrics
- **Custom dashboard** - Business KPIs
#### Key Metrics:
- DAU/MAU
- Conversion rate (free → premium)
- Churn rate
- API response times
- Analysis accuracy feedback
---
## Security Considerations
1. **Authentication**
- JWT tokens with refresh rotation
- OAuth2 (Google, Apple Sign-In)
- 2FA optional for premium users
2. **Data Protection**
- Encrypt PII at rest (RDS encryption)
- TLS 1.3 for all API traffic
- No plaintext passwords
3. **API Security**
- Rate limiting per tier
- Input validation (Pydantic)
- SQL injection prevention (SQLAlchemy ORM)
- CORS configuration
4. **Compliance**
- Privacy policy
- Terms of service
- GDPR data export/deletion
- Financial disclaimer (not investment advice)
---
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| Yahoo Finance rate limits | High | Implement caching, use paid API fallback |
| App store rejection | Medium | Follow guidelines, proper disclaimers |
| Data accuracy issues | High | Clear disclaimers, data validation |
| Security breach | Critical | Security audit, penetration testing |
| Low conversion rate | Medium | A/B testing, feature gating |
---
## Success Metrics (Year 1)
| Metric | Target |
|--------|--------|
| App downloads | 10,000+ |
| DAU | 1,000+ |
| Premium subscribers | 500+ |
| Monthly revenue | $5,000+ |
| App store rating | 4.5+ stars |
| Churn rate | <5%/month |
---
## Next Steps (Immediate)
1. **Validate idea** - User interviews, landing page
2. **Design** - Figma mockups for key screens
3. **Backend MVP** - Core API endpoints
4. **Flutter prototype** - Basic app with analysis feature
5. **Beta testing** - TestFlight/Google Play beta
---
## Repository Structure (Final)
```
stockpulse/
├── backend/ # FastAPI backend
│ ├── app/
│ ├── tests/
│ ├── Dockerfile
│ └── requirements.txt
├── mobile/ # Flutter app
│ ├── lib/
│ ├── test/
│ ├── ios/
│ ├── android/
│ └── pubspec.yaml
├── infrastructure/ # Terraform
│ ├── modules/
│ └── environments/
├── docs/ # Documentation
│ ├── api/
│ └── architecture/
└── scripts/ # Utility scripts
```
---
## Timeline Summary (Planning Only)
| Phase | Duration | Dependencies |
|-------|----------|--------------|
| 1. Backend API | 4-6 weeks | - |
| 2. Flutter App | 6-8 weeks | Phase 1 |
| 3. Infrastructure | 2-3 weeks | Phase 1 |
| 4. Payments | 2 weeks | Phase 2, 3 |
| 5. Notifications | 2 weeks | Phase 2, 3 |
| 6. Analytics | 1 week | Phase 2 |
| **Total** | **17-22 weeks** | |
This is a planning document. No fixed timeline - execute phases as resources allow.
---
**Disclaimer:** This tool is for informational purposes only and does NOT constitute financial advice.
+214
View File
@@ -0,0 +1,214 @@
# 📈 Stock Analysis v6.1
> AI-powered stock & crypto analysis with portfolio management, watchlists, dividend analysis, and **viral trend detection**.
[![ClawHub Downloads](https://img.shields.io/badge/ClawHub-1500%2B%20downloads-blue)](https://clawhub.ai)
[![OpenClaw Skill](https://img.shields.io/badge/OpenClaw-Skill-green)](https://openclaw.ai)
## What's New in v6.1
- 🔥 **Hot Scanner** — Find viral stocks & crypto across multiple sources
- 🐦 **Twitter/X Integration** — Social sentiment via bird CLI
- 📰 **Multi-Source Aggregation** — CoinGecko, Google News, Yahoo Finance
-**Cron Support** — Daily trend reports
## What's New in v6.0
- 🆕 **Watchlist + Alerts** — Price targets, stop losses, signal change notifications
- 🆕 **Dividend Analysis** — Yield, payout ratio, growth rate, safety score
- 🆕 **Fast Mode** — Skip slow analyses for quick checks
- 🆕 **Improved Commands** — Better OpenClaw/Telegram integration
- 🆕 **Test Suite** — Unit tests for core functionality
## Features
| Feature | Description |
|---------|-------------|
| **8-Dimension Analysis** | Earnings, fundamentals, analysts, momentum, sentiment, sector, market, history |
| **Crypto Support** | Top 20 cryptos with market cap, BTC correlation, momentum |
| **Portfolio Management** | Track holdings, P&L, concentration warnings |
| **Watchlist + Alerts** | Price targets, stop losses, signal changes |
| **Dividend Analysis** | Yield, payout, growth, safety score |
| **Risk Detection** | Geopolitical, earnings timing, overbought, risk-off |
| **Breaking News** | Crisis keyword scanning (last 24h) |
## Quick Start
### Analyze Stocks
```bash
uv run scripts/analyze_stock.py AAPL
uv run scripts/analyze_stock.py AAPL MSFT GOOGL
uv run scripts/analyze_stock.py AAPL --fast # Skip slow analyses
```
### Analyze Crypto
```bash
uv run scripts/analyze_stock.py BTC-USD
uv run scripts/analyze_stock.py ETH-USD SOL-USD
```
### Dividend Analysis
```bash
uv run scripts/dividends.py JNJ PG KO
```
### Watchlist
```bash
uv run scripts/watchlist.py add AAPL --target 200 --stop 150
uv run scripts/watchlist.py list
uv run scripts/watchlist.py check --notify
```
### Portfolio
```bash
uv run scripts/portfolio.py create "My Portfolio"
uv run scripts/portfolio.py add AAPL --quantity 100 --cost 150
uv run scripts/portfolio.py show
```
### 🔥 Hot Scanner (NEW)
```bash
# Full scan with all sources
python3 scripts/hot_scanner.py
# Fast scan (skip social media)
python3 scripts/hot_scanner.py --no-social
# JSON output for automation
python3 scripts/hot_scanner.py --json
```
## Analysis Dimensions
### Stocks (8 dimensions)
1. **Earnings Surprise** (30%) — EPS beat/miss
2. **Fundamentals** (20%) — P/E, margins, growth, debt
3. **Analyst Sentiment** (20%) — Ratings, price targets
4. **Historical Patterns** (10%) — Past earnings reactions
5. **Market Context** (10%) — VIX, SPY/QQQ trends
6. **Sector Performance** (15%) — Relative strength
7. **Momentum** (15%) — RSI, 52-week range
8. **Sentiment** (10%) — Fear/Greed, shorts, insiders
### Crypto (3 dimensions)
- Market Cap & Category
- BTC Correlation (30-day)
- Momentum (RSI, range)
## Dividend Metrics
| Metric | Description |
|--------|-------------|
| Yield | Annual dividend / price |
| Payout Ratio | Dividend / EPS |
| 5Y Growth | CAGR of dividend |
| Consecutive Years | Years of increases |
| Safety Score | 0-100 composite |
| Income Rating | Excellent → Poor |
## 🔥 Hot Scanner
Find what's trending RIGHT NOW across stocks & crypto.
### Data Sources
| Source | What it finds |
|--------|---------------|
| **CoinGecko Trending** | Top 15 trending coins |
| **CoinGecko Movers** | Biggest gainers/losers (>3%) |
| **Google News** | Breaking finance & crypto news |
| **Yahoo Finance** | Top gainers, losers, most active |
| **Twitter/X** | Social sentiment (requires auth) |
### Output
```
📊 TOP TRENDING (by buzz):
1. BTC (6 pts) [CoinGecko, Google News] 📉 bearish (-2.5%)
2. ETH (5 pts) [CoinGecko, Twitter] 📉 bearish (-7.2%)
3. NVDA (3 pts) [Google News, Yahoo] 📰 Earnings beat...
🪙 CRYPTO HIGHLIGHTS:
🚀 RIVER River +14.0%
📉 BTC Bitcoin -2.5%
📈 STOCK MOVERS:
🟢 NVDA (gainers)
🔴 TSLA (losers)
📰 BREAKING NEWS:
[BTC, ETH] Crypto crash: $2.5B liquidated...
```
### Twitter/X Setup (Optional)
1. Install bird CLI: `npm install -g @steipete/bird`
2. Login to x.com in Safari/Chrome
3. Create `.env` file:
```
AUTH_TOKEN=your_auth_token
CT0=your_ct0_token
```
Get tokens from browser DevTools → Application → Cookies → x.com
### Automation
Set up a daily cron job for morning reports:
```bash
# Run at 8 AM daily
0 8 * * * python3 /path/to/hot_scanner.py --no-social >> /var/log/hot_scanner.log
```
## Risk Detection
- ⚠️ Pre-earnings warning (< 14 days)
- ⚠️ Post-earnings spike (> 15% in 5 days)
- ⚠️ Overbought (RSI > 70 + near 52w high)
- ⚠️ Risk-off mode (GLD/TLT/UUP rising)
- ⚠️ Geopolitical keywords (Taiwan, China, etc.)
- ⚠️ Breaking news alerts
## Performance Options
| Flag | Speed | Description |
|------|-------|-------------|
| (default) | 5-10s | Full analysis |
| `--no-insider` | 3-5s | Skip SEC EDGAR |
| `--fast` | 2-3s | Skip insider + news |
## Data Sources
- [Yahoo Finance](https://finance.yahoo.com) — Prices, fundamentals, movers
- [CoinGecko](https://coingecko.com) — Crypto trending, market data
- [CNN Fear & Greed](https://money.cnn.com/data/fear-and-greed/) — Sentiment
- [SEC EDGAR](https://www.sec.gov/edgar) — Insider trading
- [Google News RSS](https://news.google.com) — Breaking news
- [Twitter/X](https://x.com) — Social sentiment (via bird CLI)
## Storage
| Data | Location |
|------|----------|
| Portfolios | `~/.clawdbot/skills/stock-analysis/portfolios.json` |
| Watchlist | `~/.clawdbot/skills/stock-analysis/watchlist.json` |
## Testing
```bash
uv run pytest scripts/test_stock_analysis.py -v
```
## Limitations
- Yahoo Finance may lag 15-20 minutes
- Short interest lags ~2 weeks (FINRA)
- US markets only
## Disclaimer
⚠️ **NOT FINANCIAL ADVICE.** For informational purposes only. Consult a licensed financial advisor before making investment decisions.
---
Built for [OpenClaw](https://openclaw.ai) 🦞 | [ClawHub](https://clawhub.ai)
+381
View File
@@ -0,0 +1,381 @@
---
name: stock-analysis
description: Analyze stocks and cryptocurrencies using LongPort (primary, HK/US/CN) + Yahoo Finance (fallback, US only) data. Supports 8-dimension stock scoring, portfolio management, watchlists with alerts, dividend analysis, viral trend detection (Hot Scanner), and rumor/early signal detection. Use for stock analysis, portfolio tracking, earnings reactions, crypto monitoring, trending stocks, or finding rumors before they go mainstream. Includes unified data layer with graceful Yahoo fallback.
version: 6.4.0
homepage: https://finance.yahoo.com
commands:
- /stock - Analyze a stock or crypto (e.g., /stock AAPL)
- /stock_compare - Compare multiple tickers
- /stock_dividend - Analyze dividend metrics
- /stock_watch - Add/remove from watchlist
- /stock_alerts - Check triggered alerts
- /stock_hot - Find trending stocks & crypto (Hot Scanner)
- /stock_rumors - Find early signals, M&A rumors, insider activity (Rumor Scanner)
- /portfolio - Show portfolio summary
- /portfolio_add - Add asset to portfolio
metadata: {"clawdbot":{"emoji":"📈","requires":{"bins":["uv"],"env":[]},"install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
---
# Stock Analysis v6.3
Analyze US stocks and cryptocurrencies with **LongPort (primary) + Yahoo Finance (fallback)** data sources.
## What's New in v6.4
- 🆕 **DCA Ladder Monitoring** — Automated buy signals when prices hit ladder tiers
- Position config JSON with 3-tier price ladders + lot sizes
- Monitor script checks prices vs targets, silent when no triggers
- 5 cron jobs: HK market 2x/day, US market 2x/day, daily morning brief
- Status tracking: mark tiers as "done" after purchase
- See `references/dca-ladder-monitoring.md`
- 🆕 **Autonomous workflow** — Full analysis pipeline runs without asking user permission
- 🆕 **DCA 6-dimension scoring** — Yield + PE + PB + Price Position + YTD Dip + Safety
- See `references/dca-screener.md`
## What's New in v6.3
- 🆕 **LongPort Data Source** — Primary data for HK/US/CN stocks
- Real-time quotes, PE, PB, EPS, BPS, dividend yield
- Market cap, turnover rate, volume ratio
- Full Hong Kong and A-share coverage
- 🆕 **Unified Data Layer**`data_source.py` merges LongPort + Yahoo
- LongPort: valuation metrics, realtime quotes
- Yahoo: operating margins, ROE, analyst data, earnings history
- 🆕 **Valuation Analysis**`analyze_stock_unified.py`
- PE/PB/Dividend scoring (-1 to +1)
- Weighted overall score
- BUY/HOLD/SELL recommendations
## What's New in v6.2
- 🔮 **Rumor Scanner** — Early signals before mainstream news
- M&A rumors and takeover bids
- Insider buying/selling activity
- Analyst upgrades/downgrades
- Twitter/X "hearing that...", "sources say..." detection
- 🎯 **Impact Scoring** — Rumors ranked by potential market impact
## What's in v6.1
- 🔥 **Hot Scanner** — Find viral stocks & crypto across multiple sources
- 🐦 **Twitter/X Integration** — Social sentiment via bird CLI
- 📰 **Multi-Source Aggregation** — CoinGecko, Google News, Yahoo Finance
-**Cron Support** — Daily trend reports
## What's in v6.0
- 🆕 **Watchlist + Alerts** — Price targets, stop losses, signal changes
- 🆕 **Dividend Analysis** — Yield, payout ratio, growth, safety score
- 🆕 **Fast Mode**`--fast` skips slow analyses (insider, news)
- 🆕 **Improved Performance**`--no-insider` for faster runs
## Quick Commands
### Valuation Analysis (NEW v6.3) — LongPort Primary + 8-Dimension Scoring
```bash
# Single stock valuation
uv run {baseDir}/scripts/analyze_stock_unified.py O
# Multiple stocks comparison
uv run {baseDir}/scripts/analyze_stock_unified.py O 823.HK MAIN JEPI NLY
# Hong Kong stocks
uv run {baseDir}/scripts/analyze_stock_unified.py 823.HK 778.HK 0405.HK
# Fast mode (skip slow analyses)
uv run {baseDir}/scripts/analyze_stock_unified.py O --fast
# JSON output
uv run {baseDir}/scripts/analyze_stock_unified.py O --output json
```
**8-Dimension Scoring System:**
- Fundamentals (40%): PE, PB, dividend yield, margins, ROE, debt
- Valuation (30%): PE/PB/dividend relative to thresholds
- Momentum (20%): RSI, volume ratio, price change
- Market Cap (10%): Large-cap stability vs small-cap risk
**Recommendation Thresholds:**
- Score > 0.3: BUY (High confidence)
- Score > 0.0: BUY (Moderate confidence)
- Score > -0.3: HOLD
- Score < -0.3: SELL
### Legacy Analysis (Yahoo Finance)
```bash
# Basic analysis
uv run {baseDir}/scripts/analyze_stock.py AAPL
# Fast mode (skips insider trading & breaking news)
uv run {baseDir}/scripts/analyze_stock.py AAPL --fast
# Compare multiple
uv run {baseDir}/scripts/analyze_stock.py AAPL MSFT GOOGL
# Crypto
uv run {baseDir}/scripts/analyze_stock.py BTC-USD ETH-USD
```
### Dividend Analysis (NEW v6.0)
```bash
# Analyze dividends
uv run {baseDir}/scripts/dividends.py JNJ
# Compare dividend stocks
uv run {baseDir}/scripts/dividends.py JNJ PG KO MCD --output json
```
**Dividend Metrics:**
- Dividend Yield & Annual Payout
- Payout Ratio (safe/moderate/high/unsustainable)
- 5-Year Dividend Growth (CAGR)
- Consecutive Years of Increases
- Safety Score (0-100)
- Income Rating (excellent/good/moderate/poor)
### Watchlist + Alerts (NEW v6.0)
```bash
# Add to watchlist
uv run {baseDir}/scripts/watchlist.py add AAPL
# With price target alert
uv run {baseDir}/scripts/watchlist.py add AAPL --target 200
# With stop loss alert
uv run {baseDir}/scripts/watchlist.py add AAPL --stop 150
# Alert on signal change (BUY→SELL)
uv run {baseDir}/scripts/watchlist.py add AAPL --alert-on signal
# View watchlist
uv run {baseDir}/scripts/watchlist.py list
# Check for triggered alerts
uv run {baseDir}/scripts/watchlist.py check
uv run {baseDir}/scripts/watchlist.py check --notify # Telegram format
# Remove from watchlist
uv run {baseDir}/scripts/watchlist.py remove AAPL
```
**Alert Types:**
- 🎯 **Target Hit** — Price >= target
- 🛑 **Stop Hit** — Price <= stop
- 📊 **Signal Change** — BUY/HOLD/SELL changed
### Portfolio Management
```bash
# Create portfolio
uv run {baseDir}/scripts/portfolio.py create "Tech Portfolio"
# Add assets
uv run {baseDir}/scripts/portfolio.py add AAPL --quantity 100 --cost 150
uv run {baseDir}/scripts/portfolio.py add BTC-USD --quantity 0.5 --cost 40000
# View portfolio
uv run {baseDir}/scripts/portfolio.py show
# Analyze with period returns
uv run {baseDir}/scripts/analyze_stock.py --portfolio "Tech Portfolio" --period weekly
```
### 🔥 Hot Scanner (NEW v6.1)
```bash
# Full scan - find what's trending NOW
python3 {baseDir}/scripts/hot_scanner.py
# Fast scan (skip social media)
python3 {baseDir}/scripts/hot_scanner.py --no-social
# JSON output for automation
python3 {baseDir}/scripts/hot_scanner.py --json
```
**Data Sources:**
- 📊 CoinGecko Trending — Top 15 trending coins
- 📈 CoinGecko Movers — Biggest gainers/losers
- 📰 Google News — Finance & crypto headlines
- 📉 Yahoo Finance — Gainers, losers, most active
- 🐦 Twitter/X — Social sentiment (requires auth)
**Output:**
- Top trending by mention count
- Crypto highlights with 24h changes
- Stock movers by category
- Breaking news with tickers
**Twitter Setup (Optional):**
1. Install bird: `npm install -g @steipete/bird`
2. Login to x.com in Safari/Chrome
3. Create `.env` with `AUTH_TOKEN` and `CT0`
### 🔮 Rumor Scanner (NEW v6.2)
```bash
# Find early signals, M&A rumors, insider activity
python3 {baseDir}/scripts/rumor_scanner.py
```
**What it finds:**
- 🏢 **M&A Rumors** — Merger, acquisition, takeover bids
- 👔 **Insider Activity** — CEO/Director buying/selling
- 📊 **Analyst Actions** — Upgrades, downgrades, price target changes
- 🐦 **Twitter Whispers** — "hearing that...", "sources say...", "rumor"
- ⚖️ **SEC Activity** — Investigations, filings
**Impact Scoring:**
- Each rumor is scored by potential market impact (1-10)
- M&A/Takeover: +5 points
- Insider buying: +4 points
- Upgrade/Downgrade: +3 points
- "Hearing"/"Sources say": +2 points
- High engagement: +2 bonus
**Best Practice:** Run at 07:00 before US market open to catch pre-market signals.
## Analysis Dimensions (8 for stocks, 3 for crypto)
### Stocks
| Dimension | Weight | Description |
|-----------|--------|-------------|
| Earnings Surprise | 30% | EPS beat/miss |
| Fundamentals | 20% | P/E, margins, growth |
| Analyst Sentiment | 20% | Ratings, price targets |
| Historical | 10% | Past earnings reactions |
| Market Context | 10% | VIX, SPY/QQQ trends |
| Sector | 15% | Relative strength |
| Momentum | 15% | RSI, 52-week range |
| Sentiment | 10% | Fear/Greed, shorts, insiders |
### Crypto
- Market Cap & Category
- BTC Correlation (30-day)
- Momentum (RSI, range)
## Sentiment Sub-Indicators
| Indicator | Source | Signal |
|-----------|--------|--------|
| Fear & Greed | CNN | Contrarian (fear=buy) |
| Short Interest | Yahoo | Squeeze potential |
| VIX Structure | Futures | Stress detection |
| Insider Trades | SEC EDGAR | Smart money |
| Put/Call Ratio | Options | Sentiment extreme |
## Risk Detection
- ⚠️ **Pre-Earnings** — Warns if < 14 days to earnings
- ⚠️ **Post-Spike** — Flags if up >15% in 5 days
- ⚠️ **Overbought** — RSI >70 + near 52w high
- ⚠️ **Risk-Off** — GLD/TLT/UUP rising together
- ⚠️ **Geopolitical** — Taiwan, China, Russia, Middle East keywords
- ⚠️ **Breaking News** — Crisis keywords in last 24h
## Performance Options
| Flag | Effect | Speed |
|------|--------|-------|
| (default) | Full analysis | 5-10s |
| `--no-insider` | Skip SEC EDGAR | 3-5s |
| `--fast` | Skip insider + news | 2-3s |
## Supported Cryptos (Top 20)
BTC, ETH, BNB, SOL, XRP, ADA, DOGE, AVAX, DOT, MATIC, LINK, ATOM, UNI, LTC, BCH, XLM, ALGO, VET, FIL, NEAR
(Use `-USD` suffix: `BTC-USD`, `ETH-USD`)
## Data Storage
| File | Location |
|------|----------|
| Portfolios | `~/.clawdbot/skills/stock-analysis/portfolios.json` |
| Watchlist | `~/.clawdbot/skills/stock-analysis/watchlist.json` |
## Data Source Architecture (v6.3)
**Primary:** LongPort SDK — PE, PB, EPS, BPS, dividend yield, market cap, realtime quotes, HK/CN coverage
**Fallback:** Yahoo Finance — operating margins, ROE, debt ratios, analyst data, earnings history
### Files
| File | Purpose |
|------|---------|
| `scripts/data_source.py` | Unified data layer: LongPort primary + Yahoo fallback |
| `scripts/analyze_stock_unified.py` | 8-dimension analysis using unified data source |
| `scripts/analyze_stock.py` | Original Yahoo-only analysis (unchanged) |
| `references/valuation-quickstart.md` | Quick reference for valuation commands |
| `references/dca-ladder-monitoring.md` | DCA ladder monitoring: config, script, cron setup |
### Yahoo Fallback Pattern
Yahoo data is **optional** — if it fails (rate limiting, timeout), LongPort data alone is sufficient for core analysis. The `merge_data()` function handles this gracefully:
- LongPort fails → use Yahoo
- Yahoo fails → use LongPort only
- Both fail → return None
- Analysis functions return None on error → skipped in scoring
**User preference:** Yahoo data failures must NEVER block the overall analysis. Always degrade gracefully.
**User preference:** Do NOT simplify scoring systems. The 8-dimension scoring (earnings, fundamentals, analysts, historical, market context, sector, momentum, sentiment) must be preserved. Simple PE/PB/dividend scoring is insufficient — users want the full framework.
### DCA Screening (阶梯式买入) — High Dividend Candidates
```bash
# Uses calc_indexes with 5-dimension scoring: yield + PE + PB + volatility + YTD dip
# See references/dca-screening.md for full candidate list and scoring framework
```
## DCA (Dollar-Cost Averaging / 阶梯式买入) Screener
When user asks about DCA, 阶梯式买入, 分批建仓, or drip-feeding into stocks:
1. **Screen candidates** via LongPort `calc_indexes` with `DividendRatioTtm` + `TotalMarketValue` + `PeTtmRatio` + `PbRatio` (see `references/dca-screener.md`)
2. **Score with 6-dimension DCA model** (different from the 8-dimension earnings model):
- Dividend yield (25pts) — higher = better
- PE valuation (20pts) — sweet spot 5-12
- PB value (15pts) — below 1 is great
- Price position in 60-day range (15pts) — lower = better entry
- YTD drawdown (15pts) — negative = buying opportunity
- Safety/sustainability (10pts) — profitable + asset-backed + reasonable yield
3. **Generate price ladder**: 3 tiers — current price, 20-day support, 60-day low × 0.98
4. **Present visually**: emoji grades (🔥≥70, ⭐≥55, ✅<55), table format
See `references/dca-screener.md` for the full scoring methodology and script template.
## DCA Ladder Monitoring (阶梯买入自动监控)
After screening DCA candidates, set up automated price monitoring: config JSON → monitor script → cron jobs. When prices hit ladder tiers, push buy signals with exact share counts.
Full workflow: `references/dca-ladder-monitoring.md`
```
~/.hermes/scripts/dca_positions.json ← positions + ladder + budget
~/.hermes/scripts/dca_monitor.py ← price checker (silent when no triggers)
cron jobs (5x) ← HK 10:00+15:00, US 22:30+02:00, 晨报 9:00
```
Key interactions: "我买了XX T1" → mark tier done | "调整价位" → edit JSON | "加一只" → add position | "设置预算" → recalc lots | "暂停DCA" → pause crons
## Workflow Preference
**CRITICAL — Autonomous analysis**: When user asks to analyze stocks, screen candidates, or find DCA targets, DO the full analysis immediately. Do NOT ask "should I run X?" or "want me to check Y?". User explicitly corrected: "你要自主去分析,我的风格你知道了嘛?". This means: run screens, pull data from LongPort, score, generate price ladders, present results — all in one shot. Only clarify if genuinely ambiguous (which market? which budget?). The user expects proactive execution, not permission-seeking.
## Pitfalls
- **Yahoo data fault tolerance**: Yahoo Finance frequently rate-limits or fails. The unified data layer (`data_source.py`, `analyze_stock_unified.py`) treats LongPort as primary and Yahoo as optional supplement. If Yahoo fails, analysis MUST still complete with LongPort data only. Never let a Yahoo failure block or crash the analysis. Each Yahoo-dependent field (operating_margin, roe, analyst ratings, earnings_history) is optional — check for None before using.
- **Keep full 8-dimension scoring**: User explicitly requires the full 8-dimension scoring system (Earnings, Fundamentals, Analysts, Historical, Market Context, Sector, Momentum, Sentiment). Do NOT simplify to PE/PB/dividend-only scoring. Simple scoring is insufficient — users want the full framework.
- **Decimal conversion**: LongPort returns `decimal.Decimal` for market_cap and some fields. Always `float()` before arithmetic.
- **uv not available**: If `uv run` fails with "command not found", install via `curl -LsSf https://astral.sh/uv/install.sh | sh` or fall back to `python3` directly with `pip3 install yfinance requests`. The analyze_stock_unified.py script needs uv; analyze_stock.py can run with plain python3 if yfinance is installed.
- **Yahoo Finance dead tickers**: Some tickers (especially BDCs, mREITs, small caps) fail on Yahoo Finance with "Invalid ticker or data unavailable". Always have LongPort as primary fallback — write a direct LongPort script if the unified scripts fail.
- **uv not installed**: All `uv run` scripts require `uv`. Install: `curl -LsSf https://astral.sh/uv/install.sh | sh`.
- **HK stocks incomplete on Yahoo**: Yahoo Finance coverage for HK stocks is spotty. For HK analysis, LongPort is primary.
- **Symbol format**: LongPort needs `TICKER.US` or `CODE.HK`. The data layer auto-converts bare tickers like `O` to `O.US`.
- **Monthly dividend stocks**: User frequently asks about monthly dividend stocks (US + HK). See `references/monthly-dividend-stocks.md`.
## Limitations
- Yahoo Finance may lag 15-20 minutes
- Short interest lags ~2 weeks (FINRA)
- Insider trades lag 2-3 days (SEC filing)
- US markets only (non-US incomplete)
- Breaking news: 1h cache, keyword-based
## Disclaimer
⚠️ **NOT FINANCIAL ADVICE.** For informational purposes only. Consult a licensed financial advisor before making investment decisions.
+394
View File
@@ -0,0 +1,394 @@
# Stock Analysis - Future Enhancements
## Roadmap Overview
### v4.0.0 (Current) - Geopolitical Risk & News Sentiment
✅ 8 analysis dimensions with Fear/Greed, short interest, VIX structure, put/call ratio
✅ Safe-haven indicators (GLD, TLT, UUP) with risk-off detection
✅ Breaking news alerts via Google News RSS
✅ Geopolitical risk mapping (Taiwan, China, Russia, Middle East, Banking)
✅ Sector-specific crisis flagging with confidence penalties
✅ 1h caching for shared indicators (Fear/Greed, VIX structure, breaking news)
✅ Async parallel sentiment fetching (5 indicators with 10s timeouts)
### v5.0.0 (Current) - Portfolio & Crypto
✅ Portfolio management (create, add, remove, show assets)
✅ Cryptocurrency support (Top 20 by market cap)
✅ Portfolio analysis with --portfolio flag
✅ Periodic returns (--period daily/weekly/monthly/quarterly/yearly)
✅ Concentration warnings (>30% single asset)
✅ Crypto fundamentals (market cap, category, BTC correlation)
### v4.1.0 - Performance & Completeness
✅ Full insider trading parsing via edgartools (Task #1)
✅ Market context caching with 1h TTL (Task #3b)
🔧 SEC EDGAR rate limit monitoring (Task #4 - low priority)
### Future (v6.0+)
💡 Research phase: Social sentiment, fund flows, on-chain metrics
---
## Sentiment Analysis Improvements
### 1. Implement Full Insider Trading Parsing
**Status**: ✅ DONE
**Priority**: Medium
**Effort**: 2-3 hours
**Current State**:
-`get_insider_activity()` fetches Form 4 filings via edgartools
- ✅ SEC identity configured (`stock-analysis@clawd.bot`)
- ✅ Aggregates buys/sells over 90-day window
- ✅ Scoring logic: strong buying (+0.8), moderate (+0.4), neutral (0), moderate selling (-0.4), strong (-0.8)
**Tasks**:
- [ ] Research edgartools API for Form 4 parsing
- [ ] Implement transaction aggregation (90-day window)
- [ ] Calculate net shares bought/sold
- [ ] Calculate net value in millions USD
- [ ] Apply scoring logic:
- Strong buying (>100K shares or >$1M): +0.8
- Moderate buying (>10K shares or >$0.1M): +0.4
- Neutral: 0
- Moderate selling: -0.4
- Strong selling: -0.8
- [ ] Add error handling for missing/incomplete filings
- [ ] Test with multiple tickers (BAC, TSLA, AAPL)
- [ ] Verify SEC rate limit compliance (10 req/s)
**Expected Impact**:
- Insider activity detection for 4th sentiment indicator
- Increase from 3/5 to 4/5 indicators typically available
---
### 2. Add Parallel Async Fetching
**Status**: ✅ DONE (sentiment indicators)
**Priority**: High
**Effort**: 4-6 hours
**Current State**:
- ✅ Sentiment indicators fetched in parallel via `asyncio.gather()`
- ✅ 10s timeout per indicator
- Main data fetches (yfinance) still sequential (acceptable)
**Tasks**:
- [ ] Convert sentiment helper functions to async
- [ ] `async def get_fear_greed_index()`
- [ ] `async def get_short_interest(data)`
- [ ] `async def get_vix_term_structure()`
- [ ] `async def get_insider_activity(ticker)`
- [ ] `async def get_put_call_ratio(data)`
- [ ] Update `analyze_sentiment()` to use `asyncio.gather()`
- [ ] Handle yfinance thread safety (may need locks)
- [ ] Add timeout per indicator (10s max)
- [ ] Test with multiple stocks in sequence
- [ ] Measure actual runtime improvement
- [ ] Update SKILL.md with new runtime (target: 3-4s)
**Expected Impact**:
- Reduce runtime from 6-10s to 3-4s per stock
- Better user experience for multi-stock analysis
---
### 3. Add Caching for Shared Indicators
**Status**: ✅ DONE (sentiment + breaking news)
**Priority**: Medium
**Effort**: 2-3 hours
**Current State**:
- ✅ Fear & Greed Index cached (1h TTL)
- ✅ VIX term structure cached (1h TTL)
- ✅ Breaking news cached (1h TTL)
- ✅ Market context (VIX/SPY/QQQ/GLD/TLT/UUP) cached (1h TTL)
**Tasks**:
- [ ] Design cache structure (simple dict or functools.lru_cache)
- [ ] Implement TTL (time-to-live):
- Fear & Greed: 1 hour
- VIX structure: 1 hour
- Short interest: No cache (per-stock)
- Insider activity: No cache (per-stock)
- Put/Call ratio: No cache (per-stock)
- [ ] Add cache invalidation logic
- [ ] Add verbose logging for cache hits/misses
- [ ] Test multi-stock analysis (e.g., `BAC TSLA AAPL`)
- [ ] Measure performance improvement
- [ ] Document caching behavior in SKILL.md
**Expected Impact**:
- Multi-stock analysis faster (e.g., 3 stocks: 18-30s → 10-15s)
- Reduced API calls to Fear/Greed and VIX data sources
- Same-session analysis efficiency
---
### 4. Monitor SEC EDGAR Rate Limits
**Status**: Not Started
**Priority**: Low (until insider trading implemented)
**Effort**: 1-2 hours
**Current State**:
- SEC EDGAR API has 10 requests/second rate limit
- No rate limit tracking or logging
- edgartools may handle rate limiting internally
**Tasks**:
- [ ] Research edgartools rate limit handling
- [ ] Add request counter/tracker if needed
- [ ] Implement exponential backoff on 429 errors
- [ ] Add logging for rate limit hits
- [ ] Test with high-volume scenarios (10+ stocks in quick succession)
- [ ] Document rate limit behavior
- [ ] Add error message if rate limited: "SEC API rate limited, try again in 1 minute"
**Expected Impact**:
- Robust handling of SEC API limits in production
- Clear user feedback if limits hit
- Prevent API blocking/banning
---
## Stock Analysis 4.0: Geopolitical Risk & News Sentiment
### What's Currently Missing
The current implementation captures:
- ✅ VIX (general market fear)
- ✅ SPY/QQQ trends (market direction)
- ✅ Sector performance
What we **don't** have yet:
- ❌ Geopolitical risk indicators
- ❌ News sentiment analysis
- ❌ Sector-specific crisis flags
---
### 7. Geopolitical Risk Index
**Status**: ✅ DONE (keyword-based)
**Priority**: High
**Effort**: 8-12 hours
**Proposed Approach**:
Option A: Use GPRD (Geopolitical Risk Daily Index) from policyuncertainty.com
Option B: Scan news APIs (NewsAPI, GDELT) for geopolitical keywords
**Tasks**:
- [ ] Research free geopolitical risk data sources
- [ ] Check policyuncertainty.com API availability
- [ ] Evaluate NewsAPI free tier limits
- [ ] Consider GDELT Project (free, comprehensive)
- [ ] Design risk scoring system (0-100 scale)
- [ ] Implement data fetching with caching (4-hour TTL)
- [ ] Map risk levels to sentiment scores:
- Low risk (0-30): +0.2 (bullish)
- Moderate risk (30-60): 0 (neutral)
- High risk (60-80): -0.3 (caution)
- Extreme risk (80-100): -0.5 (bearish)
- [ ] Add to sentiment analysis as 6th indicator
- [ ] Test with historical crisis periods
- [ ] Update SKILL.md with geopolitical indicator
**Expected Impact**:
- Early warning for market-wide risk events
- Better context for earnings-season volatility
- Complement to VIX (VIX is reactive, geopolitical is predictive)
**Example Output**:
```
⚠️ GEOPOLITICAL RISK: HIGH (72/100)
Context: Elevated Taiwan tensions detected
Market Impact: Risk-off sentiment likely
```
---
### 8. Sector-Specific Crisis Mapping
**Status**: ✅ DONE
**Priority**: High
**Effort**: 6-8 hours
**Current Gap**:
- No mapping between geopolitical events and affected sectors
- No automatic flagging of at-risk holdings
**Proposed Risk Mapping**:
| Geopolitical Event | Affected Sectors | Example Tickers |
|-------------------|------------------|-----------------|
| Taiwan conflict | Semiconductors | NVDA, AMD, TSM, INTC |
| Russia-Ukraine | Energy, Agriculture | XLE, MOS, CF, NTR |
| Middle East escalation | Oil, Defense | XOM, CVX, LMT, RTX |
| China tensions | Tech supply chain, Retail | AAPL, QCOM, NKE, SBUX |
| Banking crisis | Financials | JPM, BAC, WFC, C |
**Tasks**:
- [ ] Build event → sector → ticker mapping database
- [ ] Implement keyword detection in news feeds:
- "Taiwan" + "military" → Semiconductors ⚠️
- "Russia" + "sanctions" → Energy ⚠️
- "Iran" + "attack" → Oil, Defense ⚠️
- "China" + "tariffs" → Tech, Consumer ⚠️
- [ ] Add sector exposure check to analysis
- [ ] Generate automatic warnings in output
- [ ] Apply confidence penalty for high-risk sectors
- [ ] Test with historical crisis events
- [ ] Document in SKILL.md
**Expected Impact**:
- Automatic detection of sector-specific risks
- Clear warnings for exposed holdings
- Reduced false positives (only flag relevant sectors)
**Example Output**:
```
⚠️ SECTOR RISK ALERT: Semiconductors
Event: Taiwan military exercises (elevated tensions)
Impact: NVDA HIGH RISK - supply chain exposure
Recommendation: HOLD → downgraded from BUY
```
---
### 9. Breaking News Check
**Status**: ✅ DONE
**Priority**: Medium
**Effort**: 4-6 hours
**Current Gap**:
- No real-time news scanning before analysis
- User might get stale recommendation during breaking events
**Proposed Solution**:
- Scan Google News or Reuters RSS before analysis
- Flag high-impact keywords within last 24 hours
**Tasks**:
- [ ] Choose news source (Google News RSS, Reuters API, or NewsAPI)
- [ ] Implement news fetching with 24-hour lookback
- [ ] Define crisis keywords:
- **War/Conflict**: "war", "invasion", "military strike", "attack"
- **Economic**: "recession", "crisis", "collapse", "default"
- **Regulatory**: "sanctions", "embargo", "ban", "investigation"
- **Natural disaster**: "earthquake", "hurricane", "pandemic"
- [ ] Add ticker-specific news check (company name + keywords)
- [ ] Generate automatic caveat in output
- [ ] Cache news check results (1 hour TTL)
- [ ] Add `--skip-news` flag for offline mode
- [ ] Test with historical crisis dates
- [ ] Document in SKILL.md
**Expected Impact**:
- Real-time awareness of breaking events
- Automatic caveats during high volatility
- User protection from stale recommendations
**Example Output**:
```
⚠️ BREAKING NEWS ALERT (last 6 hours):
"Fed announces emergency rate hike"
Impact: Market-wide volatility expected
Caveat: Analysis may be outdated - rerun in 24h
```
---
### 10. Safe-Haven Indicators
**Status**: ✅ DONE
**Priority**: Medium
**Effort**: 3-4 hours
**Current Gap**:
- No detection of "risk-off" market regime
- VIX alone is insufficient (measures implied volatility, not capital flows)
**Proposed Indicators**:
- Gold (GLD) - Flight to safety
- US Treasuries (TLT) - Bond market fear
- USD Index (UUP) - Dollar strength during crisis
**Risk-Off Detection Logic**:
```
IF GLD +2% AND TLT +1% AND UUP +1% (all rising together)
THEN Market Regime = RISK-OFF
```
**Tasks**:
- [ ] Fetch GLD, TLT, UUP price data (5-day change)
- [ ] Implement risk-off detection algorithm
- [ ] Add to market context analysis
- [ ] Apply broad risk penalty:
- Risk-off detected → Reduce all BUY confidence by 30%
- Add caveat: "Market in risk-off mode - defensive positioning recommended"
- [ ] Test with historical crisis periods (2008, 2020, 2022)
- [ ] Add verbose output for safe-haven movements
- [ ] Document in SKILL.md
**Expected Impact**:
- Detect market-wide flight to safety
- Automatic risk reduction during panics
- Complement geopolitical risk scoring
**Example Output**:
```
🛡️ SAFE-HAVEN ALERT: Risk-off mode detected
- Gold (GLD): +3.2% (5d)
- Treasuries (TLT): +2.1% (5d)
- USD Index: +1.8% (5d)
Recommendation: Reduce equity exposure, favor defensives
```
---
## General Improvements
### 11. Add Social Sentiment (Future Phase)
**Status**: Deferred
**Priority**: Low
**Effort**: 8-12 hours
**Notes**:
- Requires free API (Twitter/Reddit alternatives?)
- Most sentiment APIs are paid (StockTwits, etc.)
- Research needed for viable free sources
### 12. Add Fund Flows (Future Phase)
**Status**: Deferred
**Priority**: Low
**Effort**: 6-8 hours
**Notes**:
- Requires ETF flow data
- May need paid data source
- Research free alternatives
---
## Implementation Priorities
### v4.1.0 Complete
- ✅ Task #1 - Insider trading parsing via edgartools
- ✅ Task #3b - Market context caching (1h TTL)
- 🔧 Task #4 - SEC EDGAR rate limits (low priority, only if hitting limits)
### Completed in v4.0.0
- ✅ Task #2 - Async parallel fetching (sentiment)
- ✅ Task #3 - Caching for shared indicators (sentiment + news)
- ✅ Task #7 - Geopolitical risk (keyword-based)
- ✅ Task #8 - Sector-specific crisis mapping
- ✅ Task #9 - Breaking news check
- ✅ Task #10 - Safe-haven indicators
---
## Version History
- **v5.0.0** (2026-01-16): Portfolio management, cryptocurrency support (Top 20), periodic analysis
- **v4.1.0** (2026-01-16): Full insider trading parsing via edgartools, market context caching
- **v4.0.0** (2026-01-15): Geopolitical risk, breaking news, safe-haven detection, sector crisis mapping
- **v3.0.0** (2026-01-15): Sentiment analysis added with 5 indicators (3-4 typically working)
- **v2.0.0**: Market context, sector performance, earnings timing, momentum
- **v1.0.0**: Initial release with earnings, fundamentals, analysts, historical
@@ -0,0 +1,6 @@
{
"ownerId": "kn77fv9851hjcqe52zqx0bhhbx7z680h",
"slug": "stock-analysis",
"version": "6.2.0",
"publishedAt": 1770041353575
}
@@ -0,0 +1,408 @@
# Technical Architecture
How Stock Analysis v6.0 works under the hood.
## System Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Stock Analysis v6.0 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ CLI Interface │ │
│ │ analyze_stock.py | dividends.py | watchlist.py | portfolio.py│ │
│ └────────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼─────────────────────────────────┐ │
│ │ Analysis Engine │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Earnings │ │Fundmtls │ │Analysts │ │Historical│ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │ │
│ │ │ Market │ │ Sector │ │Momentum │ │Sentiment│ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ └───────────┴───────────┴───────────┘ │ │
│ │ │ │ │
│ │ [Synthesizer] │ │
│ │ │ │ │
│ │ [Signal Output] │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼─────────────────────────────────┐ │
│ │ Data Sources │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Yahoo │ │ CNN │ │ SEC │ │ Google │ │ │
│ │ │ Finance │ │Fear/Grd │ │ EDGAR │ │ News │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Core Components
### 1. Data Fetching (`fetch_stock_data`)
```python
def fetch_stock_data(ticker: str, verbose: bool = False) -> StockData | None:
"""Fetch stock data from Yahoo Finance with retry logic."""
```
**Features:**
- 3 retries with exponential backoff
- Graceful handling of missing data
- Asset type detection (stock vs crypto)
**Returns:** `StockData` dataclass with:
- `info`: Company fundamentals
- `earnings_history`: Past earnings
- `analyst_info`: Ratings and targets
- `price_history`: 1-year OHLCV
### 2. Analysis Modules
Each dimension has its own analyzer:
| Module | Function | Returns |
|--------|----------|---------|
| Earnings | `analyze_earnings_surprise()` | `EarningsSurprise` |
| Fundamentals | `analyze_fundamentals()` | `Fundamentals` |
| Analysts | `analyze_analyst_sentiment()` | `AnalystSentiment` |
| Historical | `analyze_historical_patterns()` | `HistoricalPatterns` |
| Market | `analyze_market_context()` | `MarketContext` |
| Sector | `analyze_sector_performance()` | `SectorComparison` |
| Momentum | `analyze_momentum()` | `MomentumAnalysis` |
| Sentiment | `analyze_sentiment()` | `SentimentAnalysis` |
### 3. Sentiment Sub-Analyzers
Sentiment runs 5 parallel async tasks:
```python
results = await asyncio.gather(
get_fear_greed_index(), # CNN Fear & Greed
get_short_interest(data), # Yahoo Finance
get_vix_term_structure(), # VIX Futures
get_insider_activity(), # SEC EDGAR
get_put_call_ratio(data), # Options Chain
return_exceptions=True
)
```
**Timeout:** 10 seconds per indicator
**Minimum:** 2 of 5 indicators required
### 4. Signal Synthesis
```python
def synthesize_signal(
ticker, company_name,
earnings, fundamentals, analysts, historical,
market_context, sector, earnings_timing,
momentum, sentiment,
breaking_news, geopolitical_risk_warning, geopolitical_risk_penalty
) -> Signal:
```
**Scoring:**
1. Collect available component scores
2. Apply normalized weights
3. Calculate weighted average → `final_score`
4. Apply adjustments (timing, overbought, risk-off)
5. Determine recommendation threshold
**Thresholds:**
```python
if final_score > 0.33:
recommendation = "BUY"
elif final_score < -0.33:
recommendation = "SELL"
else:
recommendation = "HOLD"
```
---
## Caching Strategy
### What's Cached
| Data | TTL | Key |
|------|-----|-----|
| Market Context | 1 hour | `market_context` |
| Fear & Greed | 1 hour | `fear_greed` |
| VIX Structure | 1 hour | `vix_structure` |
| Breaking News | 1 hour | `breaking_news` |
### Cache Implementation
```python
_SENTIMENT_CACHE = {}
_CACHE_TTL_SECONDS = 3600 # 1 hour
def _get_cached(key: str):
if key in _SENTIMENT_CACHE:
value, timestamp = _SENTIMENT_CACHE[key]
if time.time() - timestamp < _CACHE_TTL_SECONDS:
return value
return None
def _set_cache(key: str, value):
_SENTIMENT_CACHE[key] = (value, time.time())
```
### Why This Matters
- First stock: ~8 seconds (full fetch)
- Second stock: ~4 seconds (reuses market data)
- Same stock again: ~4 seconds (no stock-level cache)
---
## Data Flow
### Single Stock Analysis
```
User Input: "AAPL"
┌─────────────────────────────────────────────────────────────┐
│ 1. FETCH DATA (yfinance) │
│ - Stock info, earnings, price history │
│ - ~2 seconds │
└────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. PARALLEL ANALYSIS │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Earnings │ │Fundmtls │ │ Analysts │ ... (sync) │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Market Context (cached or fetch) │ ~1 second │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Sentiment (5 async tasks) │ ~3-5 seconds │
│ │ - Fear/Greed (cached) │ │
│ │ - Short Interest │ │
│ │ - VIX Structure (cached) │ │
│ │ - Insider Trading (slow!) │ │
│ │ - Put/Call Ratio │ │
│ └────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. SYNTHESIZE SIGNAL │
│ - Combine scores with weights │
│ - Apply adjustments │
│ - Generate caveats │
│ - ~10 ms │
└────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. OUTPUT │
│ - Text or JSON format │
│ - Include disclaimer │
└─────────────────────────────────────────────────────────────┘
```
---
## Risk Detection
### Geopolitical Risk
```python
GEOPOLITICAL_RISK_MAP = {
"taiwan": {
"keywords": ["taiwan", "tsmc", "strait"],
"sectors": ["Technology", "Communication Services"],
"affected_tickers": ["NVDA", "AMD", "TSM", ...],
"impact": "Semiconductor supply chain disruption",
},
# ... china, russia_ukraine, middle_east, banking_crisis
}
```
**Process:**
1. Check breaking news for keywords
2. If keyword found, check if ticker in affected list
3. Apply confidence penalty (30% direct, 15% sector)
### Breaking News
```python
def check_breaking_news(verbose: bool = False) -> list[str] | None:
"""Scan Google News RSS for crisis keywords (last 24h)."""
```
**Crisis Keywords:**
```python
CRISIS_KEYWORDS = {
"war": ["war", "invasion", "military strike", ...],
"economic": ["recession", "crisis", "collapse", ...],
"regulatory": ["sanctions", "embargo", "ban", ...],
"disaster": ["earthquake", "hurricane", "pandemic", ...],
"financial": ["emergency rate", "bailout", ...],
}
```
---
## File Structure
```
stock-analysis/
├── scripts/
│ ├── analyze_stock.py # Main analysis engine (2500+ lines)
│ ├── portfolio.py # Portfolio management
│ ├── dividends.py # Dividend analysis
│ ├── watchlist.py # Watchlist + alerts
│ └── test_stock_analysis.py # Unit tests
├── docs/
│ ├── CONCEPT.md # Philosophy & ideas
│ ├── USAGE.md # Practical guide
│ └── ARCHITECTURE.md # This file
├── SKILL.md # OpenClaw skill definition
├── README.md # Project overview
└── .clawdhub/ # ClawHub metadata
```
---
## Data Storage
### Portfolio (`portfolios.json`)
```json
{
"portfolios": [
{
"name": "Retirement",
"created_at": "2024-01-01T00:00:00Z",
"assets": [
{
"ticker": "AAPL",
"quantity": 100,
"cost_basis": 150.00,
"type": "stock",
"added_at": "2024-01-01T00:00:00Z"
}
]
}
]
}
```
### Watchlist (`watchlist.json`)
```json
[
{
"ticker": "NVDA",
"added_at": "2024-01-15T10:30:00Z",
"price_at_add": 700.00,
"target_price": 800.00,
"stop_price": 600.00,
"alert_on_signal": true,
"last_signal": "BUY",
"last_check": "2024-01-20T08:00:00Z"
}
]
```
---
## Dependencies
```python
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "yfinance>=0.2.40", # Stock data
# "pandas>=2.0.0", # Data manipulation
# "fear-and-greed>=0.4", # CNN Fear & Greed
# "edgartools>=2.0.0", # SEC EDGAR filings
# "feedparser>=6.0.0", # RSS parsing
# ]
# ///
```
**Why These:**
- `yfinance`: Most reliable free stock API
- `pandas`: Industry standard for financial data
- `fear-and-greed`: Simple CNN F&G wrapper
- `edgartools`: Clean SEC EDGAR access
- `feedparser`: Robust RSS parsing
---
## Performance Optimization
### Current
| Operation | Time |
|-----------|------|
| yfinance fetch | ~2s |
| Market context | ~1s (cached after) |
| Insider trading | ~3-5s (slowest!) |
| Sentiment (parallel) | ~3-5s |
| Synthesis | ~10ms |
| **Total** | **5-10s** |
### Fast Mode (`--fast`)
Skips:
- Insider trading (SEC EDGAR)
- Breaking news scan
**Result:** 2-3 seconds
### Future Optimizations
1. **Stock-level caching** — Cache fundamentals for 24h
2. **Batch API calls** — yfinance supports multiple tickers
3. **Background refresh** — Pre-fetch watchlist data
4. **Local SEC data** — Avoid EDGAR API calls
---
## Error Handling
### Retry Strategy
```python
max_retries = 3
for attempt in range(max_retries):
try:
# fetch data
except Exception as e:
wait_time = 2 ** attempt # Exponential backoff: 1, 2, 4 seconds
time.sleep(wait_time)
```
### Graceful Degradation
- Missing earnings → Skip dimension, reweight
- Missing analysts → Skip dimension, reweight
- Missing sentiment → Skip dimension, reweight
- API failure → Return None, continue with partial data
### Minimum Requirements
- At least 2 of 8 dimensions required
- At least 2 of 5 sentiment indicators required
- Otherwise → HOLD with low confidence
@@ -0,0 +1,233 @@
# Concept & Philosophy
## The Problem
Making investment decisions is hard. There's too much data, too many opinions, and too much noise. Most retail investors either:
1. **Over-simplify** — Buy based on headlines or tips
2. **Over-complicate** — Get lost in endless research
3. **Freeze** — Analysis paralysis, never act
## The Solution
Stock Analysis provides a **structured, multi-dimensional framework** that:
- Aggregates data from multiple sources
- Weighs different factors objectively
- Produces a clear **BUY / HOLD / SELL** signal
- Explains the reasoning with bullet points
- Flags risks and caveats
Think of it as a **second opinion** — not a replacement for your judgment, but a systematic check.
---
## Core Philosophy
### 1. Multiple Perspectives Beat Single Metrics
No single metric tells the whole story:
- A low P/E might mean "cheap" or "dying business"
- High analyst ratings might mean "priced in" or "genuine upside"
- Strong momentum might mean "trend" or "overbought"
By combining **8 dimensions**, we get a more complete picture.
### 2. Contrarian Signals Matter
Some of our best signals are **contrarian**:
| Indicator | Crowd Says | We Interpret |
|-----------|------------|--------------|
| Extreme Fear (Fear & Greed < 25) | "Sell everything!" | Potential buy opportunity |
| Extreme Greed (> 75) | "Easy money!" | Caution, reduce exposure |
| High Short Interest + Days to Cover | "Stock is doomed" | Squeeze potential |
| Insider Buying | (often ignored) | Smart money signal |
### 3. Timing Matters
A good stock at the wrong time is a bad trade:
- **Pre-earnings** — Even strong stocks can gap down 10%+
- **Post-spike** — Buying after a 20% run often means buying the top
- **Overbought** — RSI > 70 + near 52-week high = high-risk entry
We detect these timing issues and adjust recommendations accordingly.
### 4. Context Changes Everything
The same stock behaves differently in different market regimes:
| Regime | Characteristics | Impact |
|--------|-----------------|--------|
| **Bull** | VIX < 20, SPY up | BUY signals more reliable |
| **Bear** | VIX > 30, SPY down | Even good stocks fall |
| **Risk-Off** | GLD/TLT/UUP rising | Flight to safety, reduce equity |
| **Geopolitical** | Crisis keywords | Sector-specific penalties |
### 5. Dividends Are Different
Income investors have different priorities than growth investors:
| Growth Investor | Income Investor |
|-----------------|-----------------|
| Price appreciation | Dividend yield |
| Revenue growth | Payout sustainability |
| Market share | Dividend growth rate |
| P/E ratio | Safety of payment |
That's why we have a **separate dividend analysis** module.
---
## The 8 Dimensions
### Why These 8?
Each dimension captures a different aspect of investment quality:
```
┌─────────────────────────────────────────────────────────────┐
│ FUNDAMENTAL VALUE │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Earnings │ │ Fundamentals │ │
│ │ Surprise │ │ (P/E, etc.) │ │
│ │ (30%) │ │ (20%) │ │
│ └─────────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ EXTERNAL VALIDATION │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Analyst │ │ Historical │ │
│ │ Sentiment │ │ Patterns │ │
│ │ (20%) │ │ (10%) │ │
│ └─────────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ MARKET ENVIRONMENT │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Market │ │ Sector │ │
│ │ Context │ │ Performance │ │
│ │ (10%) │ │ (15%) │ │
│ └─────────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ TECHNICAL & SENTIMENT │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Momentum │ │ Sentiment │ │
│ │ (RSI, range) │ │ (Fear, shorts) │ │
│ │ (15%) │ │ (10%) │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Weight Rationale
| Weight | Dimension | Rationale |
|--------|-----------|-----------|
| 30% | Earnings | Most direct measure of company performance |
| 20% | Fundamentals | Long-term value indicators |
| 20% | Analysts | Professional consensus (with skepticism) |
| 15% | Sector | Relative performance matters |
| 15% | Momentum | Trend is your friend (until it isn't) |
| 10% | Market | Rising tide lifts all boats |
| 10% | Sentiment | Contrarian edge |
| 10% | Historical | Past behavior predicts future reactions |
**Note:** Weights auto-normalize when data is missing.
---
## Risk Detection Philosophy
### "Don't Lose Money"
Warren Buffett's Rule #1. Our risk detection is designed to **prevent bad entries**:
1. **Pre-Earnings Hold** — Don't buy right before a binary event
2. **Post-Spike Caution** — Don't chase a run-up
3. **Overbought Warning** — Technical exhaustion
4. **Risk-Off Mode** — When even good stocks fall
5. **Geopolitical Flags** — Sector-specific event risk
### False Positive vs False Negative
We err on the side of **caution**:
- Missing a 10% gain is annoying
- Catching a 30% loss is devastating
That's why our caveats are prominent, and we downgrade BUY → HOLD liberally.
---
## Crypto Adaptation
Crypto is fundamentally different from stocks:
| Stocks | Crypto |
|--------|--------|
| Earnings | No earnings |
| P/E Ratio | Market cap tiers |
| Sector ETFs | BTC correlation |
| Dividends | Staking yields (not tracked) |
| SEC Filings | No filings |
We adapted the framework:
- **3 dimensions** instead of 8
- **BTC correlation** as a key metric
- **Category classification** (L1, DeFi, etc.)
- **No sentiment** (no insider data for crypto)
---
## Why Not Just Use [X]?
### vs. Stock Screeners (Finviz, etc.)
- Screeners show data, we provide **recommendations**
- We combine fundamental + technical + sentiment
- We flag timing and risk issues
### vs. Analyst Reports
- Analysts have conflicts of interest
- Reports are often stale
- We aggregate multiple signals
### vs. Trading Bots
- Bots execute, we advise
- We explain reasoning
- Human stays in control
### vs. ChatGPT/AI Chat
- We have **structured scoring**, not just conversation
- Real-time data fetching
- Consistent methodology
---
## Limitations We Acknowledge
1. **Data Lag** — Yahoo Finance is 15-20 min delayed
2. **US Focus** — International stocks have incomplete data
3. **No Execution** — We advise, you decide and execute
4. **Past ≠ Future** — All models have limits
5. **Black Swans** — Can't predict unpredictable events
**This is a tool, not a crystal ball.**
---
## The Bottom Line
Stock Analysis v6.0 is designed to be your **systematic second opinion**:
- ✅ Multi-dimensional analysis
- ✅ Clear recommendations
- ✅ Risk detection
- ✅ Explained reasoning
- ✅ Fast and automated
**NOT:**
- ❌ Financial advice
- ❌ Guaranteed returns
- ❌ Replacement for research
- ❌ Trading signals
Use it wisely. 📈
@@ -0,0 +1,288 @@
# 🔥 Hot Scanner
Find viral stocks & crypto trends in real-time by aggregating multiple data sources.
## Overview
The Hot Scanner answers one question: **"What's hot right now?"**
It aggregates data from:
- CoinGecko (trending coins, biggest movers)
- Google News (finance & crypto headlines)
- Yahoo Finance (gainers, losers, most active)
- Twitter/X (social sentiment, optional)
## Quick Start
```bash
# Full scan with all sources
python3 scripts/hot_scanner.py
# Skip social media (faster)
python3 scripts/hot_scanner.py --no-social
# JSON output for automation
python3 scripts/hot_scanner.py --json
```
## Output Format
### Console Output
```
============================================================
🔥 HOT SCANNER v2 - What's Trending Right Now?
📅 2026-02-02 10:45:30 UTC
============================================================
📊 TOP TRENDING (by buzz):
1. BTC (6 pts) [CoinGecko, Google News] 📉 bearish (-2.5%)
2. ETH (5 pts) [CoinGecko, Twitter] 📉 bearish (-7.2%)
3. NVDA (3 pts) [Google News, Yahoo] 📰 Earnings beat...
🪙 CRYPTO HIGHLIGHTS:
🚀 RIVER River +14.0%
📉 BTC Bitcoin -2.5%
📉 ETH Ethereum -7.2%
📈 STOCK MOVERS:
🟢 NVDA (gainers)
🔴 TSLA (losers)
📊 AAPL (most active)
🐦 SOCIAL BUZZ:
[twitter] Bitcoin to $100k prediction...
[reddit_wsb] GME yolo update...
📰 BREAKING NEWS:
[BTC, ETH] Crypto crash: $2.5B liquidated...
[NVDA] Nvidia beats earnings expectations...
```
### JSON Output
```json
{
"scan_time": "2026-02-02T10:45:30+00:00",
"top_trending": [
{
"symbol": "BTC",
"mentions": 6,
"sources": ["CoinGecko Trending", "Google News"],
"signals": ["📉 bearish (-2.5%)"]
}
],
"crypto_highlights": [...],
"stock_highlights": [...],
"social_buzz": [...],
"breaking_news": [...]
}
```
## Data Sources
### CoinGecko (No Auth Required)
| Endpoint | Data |
|----------|------|
| `/search/trending` | Top 15 trending coins |
| `/coins/markets` | Top 100 by market cap with 24h changes |
**Scoring:** Trending coins get 2 points, movers with >3% change get 1 point.
### Google News RSS (No Auth Required)
| Feed | Content |
|------|---------|
| Business News | General finance headlines |
| Crypto Search | Bitcoin, Ethereum, crypto keywords |
**Ticker Extraction:** Uses regex patterns and company name mappings.
### Yahoo Finance (No Auth Required)
| Page | Data |
|------|------|
| `/gainers` | Top gaining stocks |
| `/losers` | Top losing stocks |
| `/most-active` | Highest volume stocks |
**Note:** Requires gzip decompression.
### Twitter/X (Auth Required)
Uses [bird CLI](https://github.com/steipete/bird) for Twitter search.
**Searches:**
- `stock OR $SPY OR $QQQ OR earnings`
- `bitcoin OR ethereum OR crypto OR $BTC`
## Twitter/X Setup
### 1. Install bird CLI
```bash
# macOS
brew install steipete/tap/bird
# npm
npm install -g @steipete/bird
```
### 2. Get Auth Tokens
**Option A: Browser cookies (macOS)**
1. Login to x.com in Safari/Chrome
2. Grant Terminal "Full Disk Access" in System Settings
3. Run `bird whoami` to verify
**Option B: Manual extraction**
1. Open x.com in Chrome
2. DevTools (F12) → Application → Cookies → x.com
3. Copy `auth_token` and `ct0` values
### 3. Configure
Create `.env` file in the skill directory:
```bash
# /path/to/stock-analysis/.env
AUTH_TOKEN=your_auth_token_here
CT0=your_ct0_token_here
```
Or export as environment variables:
```bash
export AUTH_TOKEN="..."
export CT0="..."
```
### 4. Verify
```bash
bird whoami
# Should show: 🙋 @YourUsername
```
## Scoring System
Each mention from a source adds points:
| Source | Points |
|--------|--------|
| CoinGecko Trending | 2 |
| CoinGecko Movers | 1 |
| Google News | 1 |
| Yahoo Finance | 1 |
| Twitter/X | 1 |
| Reddit (high score) | 2 |
| Reddit (normal) | 1 |
Symbols are ranked by total points across all sources.
## Ticker Extraction
### Patterns
```python
# Cashtag: $AAPL
r'\$([A-Z]{1,5})\b'
# Parentheses: (AAPL)
r'\(([A-Z]{2,5})\)'
# Stock mentions: AAPL stock, AAPL shares
r'\b([A-Z]{2,5})(?:\'s|:|\s+stock|\s+shares)'
```
### Company Mappings
```python
{
"Apple": "AAPL",
"Microsoft": "MSFT",
"Tesla": "TSLA",
"Nvidia": "NVDA",
"Bitcoin": "BTC",
"Ethereum": "ETH",
# ... etc
}
```
### Crypto Keywords
```python
{
"bitcoin": "BTC",
"ethereum": "ETH",
"solana": "SOL",
"dogecoin": "DOGE",
# ... etc
}
```
## Automation
### Cron Job
```bash
# Daily at 8 AM
0 8 * * * cd /path/to/stock-analysis && python3 scripts/hot_scanner.py --json > cache/daily_scan.json
```
### OpenClaw Integration
```yaml
# Cron job config
name: "🔥 Daily Hot Scanner"
schedule:
kind: cron
expr: "0 8 * * *"
tz: "Europe/Berlin"
payload:
kind: agentTurn
message: "Run hot scanner and summarize results"
deliver: true
sessionTarget: isolated
```
## Caching
Results are saved to:
- `cache/hot_scan_latest.json` — Most recent scan
## Limitations
- **Reddit:** Blocked without OAuth (403). Requires API application.
- **Twitter:** Requires auth tokens, may expire.
- **Yahoo:** Sometimes rate-limited.
- **Google News:** RSS URLs may change.
## Future Enhancements
- [ ] Reddit API integration (PRAW)
- [ ] StockTwits integration
- [ ] Google Trends
- [ ] Historical trend tracking
- [ ] Alert thresholds (notify when score > X)
## Troubleshooting
### Twitter not working
```bash
# Check auth
bird whoami
# Should see your username
# If not, re-export tokens
```
### Yahoo 403 or gzip errors
The scanner handles gzip automatically. If issues persist, Yahoo may be rate-limiting.
### No tickers found
Check that news headlines contain recognizable patterns. The scanner uses conservative extraction to avoid false positives.
@@ -0,0 +1,95 @@
# Documentation
## Stock Analysis v6.1
This folder contains detailed documentation for the Stock Analysis skill.
## Contents
| Document | Description |
|----------|-------------|
| [CONCEPT.md](./CONCEPT.md) | Philosophy, ideas, and design rationale |
| [USAGE.md](./USAGE.md) | Practical usage guide with examples |
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Technical implementation details |
| [HOT_SCANNER.md](./HOT_SCANNER.md) | 🔥 Viral trend detection (NEW) |
## Quick Links
### For Users
Start with **[USAGE.md](./USAGE.md)** — it has practical examples for:
- Basic stock analysis
- Comparing stocks
- Crypto analysis
- Dividend investing
- Portfolio management
- Watchlist & alerts
### For Understanding
Read **[CONCEPT.md](./CONCEPT.md)** to understand:
- Why 8 dimensions?
- How scoring works
- Contrarian signals
- Risk detection philosophy
- Limitations we acknowledge
### For Developers
Check **[ARCHITECTURE.md](./ARCHITECTURE.md)** for:
- System overview diagram
- Data flow
- Caching strategy
- File structure
- Performance optimization
## Quick Start
```bash
# Analyze a stock
uv run scripts/analyze_stock.py AAPL
# Fast mode (2-3 seconds)
uv run scripts/analyze_stock.py AAPL --fast
# Dividend analysis
uv run scripts/dividends.py JNJ
# Watchlist
uv run scripts/watchlist.py add AAPL --target 200
uv run scripts/watchlist.py check
```
## Key Concepts
### The 8 Dimensions
1. **Earnings Surprise** (30%) — Did they beat expectations?
2. **Fundamentals** (20%) — P/E, margins, growth, debt
3. **Analyst Sentiment** (20%) — Professional consensus
4. **Historical Patterns** (10%) — Past earnings reactions
5. **Market Context** (10%) — VIX, SPY/QQQ trends
6. **Sector Performance** (15%) — Relative strength
7. **Momentum** (15%) — RSI, 52-week range
8. **Sentiment** (10%) — Fear/Greed, shorts, insiders
### Signal Thresholds
| Score | Recommendation |
|-------|----------------|
| > +0.33 | **BUY** |
| -0.33 to +0.33 | **HOLD** |
| < -0.33 | **SELL** |
### Risk Flags
- ⚠️ Pre-earnings (< 14 days)
- ⚠️ Post-spike (> 15% in 5 days)
- ⚠️ Overbought (RSI > 70 + near 52w high)
- ⚠️ Risk-off mode (GLD/TLT/UUP rising)
- ⚠️ Geopolitical keywords
- ⚠️ Breaking news alerts
## Disclaimer
⚠️ **NOT FINANCIAL ADVICE.** For informational purposes only. Always do your own research and consult a licensed financial advisor.
@@ -0,0 +1,465 @@
# Usage Guide
Practical examples for using Stock Analysis v6.0 in real scenarios.
## Table of Contents
1. [Basic Stock Analysis](#basic-stock-analysis)
2. [Comparing Stocks](#comparing-stocks)
3. [Crypto Analysis](#crypto-analysis)
4. [Dividend Investing](#dividend-investing)
5. [Portfolio Management](#portfolio-management)
6. [Watchlist & Alerts](#watchlist--alerts)
7. [Performance Tips](#performance-tips)
8. [Interpreting Results](#interpreting-results)
---
## Basic Stock Analysis
### Single Stock
```bash
uv run scripts/analyze_stock.py AAPL
```
**Output:**
```
===========================================================================
STOCK ANALYSIS: AAPL (Apple Inc.)
Generated: 2024-02-01T10:30:00
===========================================================================
RECOMMENDATION: BUY (Confidence: 72%)
SUPPORTING POINTS:
• Beat by 8.2% - EPS $2.18 vs $2.01 expected
• Strong margin: 24.1%
• Analyst consensus: Buy with 12.3% upside (42 analysts)
• Momentum: RSI 58 (neutral)
• Sector: Technology uptrend (+5.2% 1m)
CAVEATS:
• Earnings in 12 days - high volatility expected
• High market volatility (VIX 24)
===========================================================================
DISCLAIMER: NOT FINANCIAL ADVICE.
===========================================================================
```
### JSON Output
For programmatic use:
```bash
uv run scripts/analyze_stock.py AAPL --output json | jq '.recommendation, .confidence'
```
### Verbose Mode
See what's happening under the hood:
```bash
uv run scripts/analyze_stock.py AAPL --verbose
```
---
## Comparing Stocks
### Side-by-Side Analysis
```bash
uv run scripts/analyze_stock.py AAPL MSFT GOOGL
```
Each stock gets a full analysis. Compare recommendations and confidence levels.
### Sector Comparison
Compare stocks in the same sector:
```bash
# Banks
uv run scripts/analyze_stock.py JPM BAC WFC GS
# Tech
uv run scripts/analyze_stock.py AAPL MSFT GOOGL AMZN META
```
---
## Crypto Analysis
### Basic Crypto
```bash
uv run scripts/analyze_stock.py BTC-USD
```
**Crypto-Specific Output:**
- Market cap classification (large/mid/small)
- Category (Smart Contract L1, DeFi, etc.)
- BTC correlation (30-day)
- Momentum (RSI, price range)
### Compare Cryptos
```bash
uv run scripts/analyze_stock.py BTC-USD ETH-USD SOL-USD
```
### Supported Cryptos
```
BTC, ETH, BNB, SOL, XRP, ADA, DOGE, AVAX, DOT, MATIC,
LINK, ATOM, UNI, LTC, BCH, XLM, ALGO, VET, FIL, NEAR
```
Use `-USD` suffix: `BTC-USD`, `ETH-USD`, etc.
---
## Dividend Investing
### Analyze Dividend Stock
```bash
uv run scripts/dividends.py JNJ
```
**Output:**
```
============================================================
DIVIDEND ANALYSIS: JNJ (Johnson & Johnson)
============================================================
Current Price: $160.50
Annual Dividend: $4.76
Dividend Yield: 2.97%
Payment Freq: quarterly
Ex-Dividend: 2024-02-15
Payout Ratio: 65.0% (moderate)
5Y Div Growth: +5.8%
Consecutive Yrs: 62
SAFETY SCORE: 78/100
INCOME RATING: GOOD
Safety Factors:
• Moderate payout ratio (65%)
• Good dividend growth (+5.8% CAGR)
• Dividend Aristocrat (62+ years)
Dividend History:
2023: $4.52
2022: $4.36
2021: $4.24
2020: $4.04
2019: $3.80
============================================================
```
### Compare Dividend Stocks
```bash
uv run scripts/dividends.py JNJ PG KO MCD VZ T
```
### Dividend Aristocrats Screen
Look for stocks with:
- Yield > 2%
- Payout < 60%
- Growth > 5%
- Consecutive years > 25
---
## Portfolio Management
### Create Portfolio
```bash
uv run scripts/portfolio.py create "Retirement"
```
### Add Holdings
```bash
# Stocks
uv run scripts/portfolio.py add AAPL --quantity 100 --cost 150.00
# Crypto
uv run scripts/portfolio.py add BTC-USD --quantity 0.5 --cost 40000
```
### View Portfolio
```bash
uv run scripts/portfolio.py show
```
**Output:**
```
Portfolio: Retirement
====================
Assets:
AAPL 100 shares @ $150.00 = $15,000.00
Current: $185.00 = $18,500.00 (+23.3%)
BTC-USD 0.5 @ $40,000 = $20,000.00
Current: $45,000 = $22,500.00 (+12.5%)
Total Cost: $35,000.00
Current Value: $41,000.00
Total P&L: +$6,000.00 (+17.1%)
```
### Analyze Portfolio
```bash
# Full analysis of all holdings
uv run scripts/analyze_stock.py --portfolio "Retirement"
# With period returns
uv run scripts/analyze_stock.py --portfolio "Retirement" --period monthly
```
### Rebalance Check
The analysis flags concentration warnings:
```
⚠️ CONCENTRATION WARNINGS:
• AAPL: 45.1% (>30% of portfolio)
```
---
## Watchlist & Alerts
### Add to Watchlist
```bash
# Basic watch
uv run scripts/watchlist.py add NVDA
# With price target
uv run scripts/watchlist.py add NVDA --target 800
# With stop loss
uv run scripts/watchlist.py add NVDA --stop 600
# Alert on signal change
uv run scripts/watchlist.py add NVDA --alert-on signal
# All options
uv run scripts/watchlist.py add NVDA --target 800 --stop 600 --alert-on signal
```
### View Watchlist
```bash
uv run scripts/watchlist.py list
```
**Output:**
```json
{
"success": true,
"items": [
{
"ticker": "NVDA",
"current_price": 725.50,
"price_at_add": 700.00,
"change_pct": 3.64,
"target_price": 800.00,
"to_target_pct": 10.27,
"stop_price": 600.00,
"to_stop_pct": -17.30,
"alert_on_signal": true,
"last_signal": "BUY",
"added_at": "2024-01-15"
}
],
"count": 1
}
```
### Check Alerts
```bash
# Check for triggered alerts
uv run scripts/watchlist.py check
# Format for notification (Telegram)
uv run scripts/watchlist.py check --notify
```
**Alert Example:**
```
📢 Stock Alerts
🎯 NVDA hit target! $802.50 >= $800.00
🛑 TSLA hit stop! $195.00 <= $200.00
📊 AAPL signal changed: HOLD → BUY
```
### Remove from Watchlist
```bash
uv run scripts/watchlist.py remove NVDA
```
---
## Performance Tips
### Fast Mode
Skip slow analyses for quick checks:
```bash
# Skip insider trading + breaking news
uv run scripts/analyze_stock.py AAPL --fast
```
**Speed comparison:**
| Mode | Time | What's Skipped |
|------|------|----------------|
| Default | 5-10s | Nothing |
| `--no-insider` | 3-5s | SEC EDGAR |
| `--fast` | 2-3s | Insider + News |
### Batch Analysis
Analyze multiple stocks in one command:
```bash
uv run scripts/analyze_stock.py AAPL MSFT GOOGL AMZN META
```
### Caching
Market context is cached for 1 hour:
- VIX, SPY, QQQ trends
- Fear & Greed Index
- VIX term structure
- Breaking news
Second analysis of different stock reuses cached data.
---
## Interpreting Results
### Recommendation Thresholds
| Score | Recommendation |
|-------|----------------|
| > +0.33 | BUY |
| -0.33 to +0.33 | HOLD |
| < -0.33 | SELL |
### Confidence Levels
| Confidence | Meaning |
|------------|---------|
| > 80% | Strong conviction |
| 60-80% | Moderate conviction |
| 40-60% | Mixed signals |
| < 40% | Low conviction |
### Reading Caveats
**Always read the caveats!** They often contain critical information:
```
CAVEATS:
• Earnings in 5 days - high volatility expected ← Timing risk
• RSI 78 (overbought) + near 52w high ← Technical risk
• ⚠️ BREAKING NEWS: Fed emergency rate discussion ← External risk
• ⚠️ SECTOR RISK: China tensions affect tech ← Geopolitical
```
### When to Ignore the Signal
- **Pre-earnings:** Even BUY → wait until after
- **Overbought:** Consider smaller position
- **Risk-off:** Reduce overall exposure
- **Low confidence:** Do more research
### When to Trust the Signal
- **High confidence + no major caveats**
- **Multiple supporting points align**
- **Sector is strong**
- **Market regime is favorable**
---
## Common Workflows
### Morning Check
```bash
# Check watchlist alerts
uv run scripts/watchlist.py check --notify
# Quick portfolio update
uv run scripts/analyze_stock.py --portfolio "Main" --fast
```
### Research New Stock
```bash
# Full analysis
uv run scripts/analyze_stock.py XYZ
# If dividend stock
uv run scripts/dividends.py XYZ
# Add to watchlist for monitoring
uv run scripts/watchlist.py add XYZ --alert-on signal
```
### Weekly Review
```bash
# Full portfolio analysis
uv run scripts/analyze_stock.py --portfolio "Main" --period weekly
# Check dividend holdings
uv run scripts/dividends.py JNJ PG KO
```
---
## Troubleshooting
### "Invalid ticker"
- Check spelling
- For crypto, use `-USD` suffix
- Non-US stocks may not work
### "Insufficient data"
- Stock might be too new
- ETFs have limited data
- OTC stocks often fail
### Slow Performance
- Use `--fast` for quick checks
- Insider trading is slowest
- Breaking news adds ~2s
### Missing Data
- Not all stocks have analyst coverage
- Some metrics require options chains
- Crypto has no sentiment data
@@ -0,0 +1,160 @@
# DCA Ladder Monitoring — 阶梯买入自动监控
After screening candidates (see `dca-screener.md`), set up automated price monitoring so the user gets buy signals when prices hit ladder tiers.
## Architecture
```
dca_positions.json ← config (symbols, ladder prices, budget, status)
dca_monitor.py ← reads config + fetches prices from LongPort
cron jobs ← runs monitor on schedule, delivers alerts
```
## Step 1: Create Position Config
File: `~/.hermes/scripts/dca_positions.json`
```json
{
"updated": "YYYY-MM-DD",
"positions": {
"SYMBOL.US": {
"name": "Display Name",
"yield": 10.5,
"market": "US",
"ladder": [
{"tier": 1, "price": 15.28, "alloc_pct": 40, "status": "pending", "shares": 3, "cost_local": 45.84},
{"tier": 2, "price": 15.07, "alloc_pct": 30, "status": "pending", "shares": 2, "cost_local": 30.14},
{"tier": 3, "price": 13.03, "alloc_pct": 30, "status": "pending", "shares": 3, "cost_local": 39.09}
],
"monthly_budget_hkd": 1071,
"monthly_budget_local": 137,
"notes": "PE8.5 科技BDC龙头"
}
},
"budget": {
"monthly_min_hkd": 6000,
"monthly_max_hkd": 9000,
"monthly_mid_hkd": 7500,
"per_stock_hkd": 1071,
"usd_hkd": 7.80
},
"alert_settings": {
"trigger_pct": 2.0,
"cooldown_hours": 24
}
}
```
### Lot Calculation
Given monthly budget M and N stocks:
- `per_stock = M / N` (in HKD)
- For US stocks: `per_stock_local = per_stock / USDHKD`
- Per tier: `shares = floor(tier_budget / price)` where `tier_budget = per_stock_local * alloc_pct / 100`
- Update JSON with `shares`, `cost_local`, `cost_hkd` fields
### Status Tracking
When user confirms a purchase:
- Change `status` from `"pending"` to `"done"` in the ladder entry
- This prevents re-alerting on already-purchased tiers
## Step 2: Monitor Script
File: `~/.hermes/scripts/dca_monitor.py`
Key logic:
1. Load env vars from `~/.bashrc` (LONGBRIDGE_* → LONGPORT_*)
2. Load `dca_positions.json`
3. Fetch current prices via `ctx.quote(symbols)` in batches of 15
4. For each position, compare price to each pending ladder tier
5. If `current_price <= target * (1 + trigger_pct/100)`: emit alert
6. If no alerts triggered: output empty (silent — no notification sent)
### Alert Format
```
🔔 DCA买入信号 [YYYY-MM-DD HH:MM]
🚨 🇺🇸 HTGC.US Hercules Capital
第1档目标: 15.28 现价: 15.20 已触达
建议仓位: 40% 买入: 3股 股息率: 10.5%
🟡 🇺🇸 NLY.US Annaly Capital
第2档目标: 21.07 现价: 21.22 差0.7%
建议仓位: 30% 买入: 1股 股息率: 13.2%
```
### Pitfalls
- **SecurityQuote attribute**: `SecurityQuote` may not have `change_rate` on some data tiers. Use `CalcIndex.ChangeRate` via `calc_indexes` instead.
- **Price=0 on weekends**: LongPort returns 0 for `last_done` when markets are closed. The monitor will trigger all alerts on weekends — either skip weekends in cron schedule or handle in script.
- **HK stock codes in python3 -c**: Codes like `0728.HK` start with digits. Always write scripts to file, never use `python3 -c`.
- **Batch sizes**: quote 15/batch, calc_indexes 10/batch, static_info 20/batch.
## Step 3: Cron Jobs
Set up 5 jobs (all Beijing time):
| Schedule | Name | Purpose |
|----------|------|---------|
| `0 9 * * 1-6` | DCA每日晨报 | AI-driven summary with all positions status |
| `0 10 * * 1-5` | DCA港股盘中(上午) | HK market check (1hr after open) |
| `0 15 * * 1-5` | DCA港股盘中(下午) | HK market check (1hr before close) |
| `30 22 * * 1-5` | DCA美股盘中(晚间) | US market check (30min after open) |
| `0 2 * * 2-6` | DCA美股盘中(凌晨) | US market check (mid-session) |
### Cron Setup Pattern
**Script-only jobs** (no agent, just run monitor):
```python
cronjob(action='create', name='DCA港股盘中监控',
schedule='0 10 * * 1-5', no_agent=True,
script='scripts/dca_monitor.py', deliver='origin')
```
**AI-driven daily summary** (with agent for richer formatting):
```python
cronjob(action='create', name='DCA每日晨报',
schedule='0 9 * * 1-6',
prompt='Run dca_monitor.py, generate morning brief...',
enabled_toolsets=['terminal'], deliver='origin')
```
## Step 4: User Interaction Commands
After deployment, user may say:
| User Says | Action |
|-----------|--------|
| "我买了XX T1" | Edit JSON: set tier status to `"done"` |
| "调整XX阶梯价位" | Edit JSON: update ladder prices |
| "设置预算XX万" | Recalculate lot sizes, update JSON |
| "加一只XX" | Add new position to JSON |
| "暂停DCA监控" | Pause cron jobs |
| "DCA状态" | Run monitor script, show all positions |
## Full Script Template
See `~/.hermes/scripts/dca_monitor.py` for the production script.
Key env loading pattern (required for all LongPort scripts):
```python
import os, re
env_vars = {}
with open(os.path.expanduser('~/.bashrc')) as f:
for line in f:
line = line.strip()
if line.startswith('export LONGBRIDGE_') or line.startswith('export LONGPORT_'):
parts = line.replace('export ', '').split('=', 1)
if len(parts) == 2:
env_vars[parts[0]] = parts[1]
for key, val in env_vars.items():
if '${' not in val: os.environ[key] = val
for key, val in env_vars.items():
if '${' in val:
os.environ[key] = re.sub(r'\$\{(\w+)\}', lambda m: os.environ.get(m.group(1), ''), val)
```
@@ -0,0 +1,82 @@
# DCA Screener — 阶梯式买入筛选器
When user asks about 阶梯式买入 / DCA / 分批建仓 / drip-feeding into dividend stocks.
## Scoring Model (6 Dimensions, 100 points total)
| Dimension | Weight | Logic |
|-----------|--------|-------|
| Dividend Yield | /25 | ≥15%→25, ≥10%→22, ≥7%→18, ≥5%→15, ≥3%→10, <3%→3 |
| PE (sweet spot 5-12) | /20 | <5→15, <8→20(best), <12→18, <15→14, <20→10, ≥20→5, negative→3 |
| PB (below 1 is great) | /15 | <0.5→15, <0.8→13, <1.0→11, <1.5→8, <2.0→5, ≥2→3 |
| Price Position (60d) | /15 | <20%→15(best), <35%→12, <50%→10, <65%→7, <80%→4, ≥80→2 |
| YTD Drawdown | /15 | <-15%→15, <-10→13, <-5→11, <0→9, <10→6, ≥10→3 |
| Safety | /10 | profitable(+3), PB<1.5(+3), yield 3-15%(+4) |
Grades: 🔥 ≥70 (strong buy) | ⭐ ≥55 (recommended) | ✅ <55 (moderate)
## Price Ladder Calculation
```
tier1 = current_price # Current level, buy 40%
tier2 = 20day_support # Recent support, buy 30%
tier3 = 60day_low × 0.98 # Below period low, buy 30%
```
## Candidate Universe
### US — BDCs (Business Development Companies)
ARCC, HTGC, MAIN, GAIN, GLAD, PSEC, FSK, HRZN, TSLX
### US — mREITs (Mortgage REITs)
NLY, AGNC, ARR, DX, NYMT, CIM, ORC
### US — Equity REITs
O, VICI, WPC, SPG
### US — Blue Chip Dividend
MO, VZ, T, XOM, CVX, BTI, PG, JNJ, KO, PEP, ABBV
### US — MLP/Energy
ET, EPD, MPLX, USAC
### US — Covered Call ETFs
JEPI, JEPQ, QYLD, SPYI, DIVO, SVOL
### US — Utilities
NEE, DUK, SO, D
### HK — High Dividend Blue Chips
1088.HK (神华), 0883.HK (中海油), 3968.HK (招行), 2318.HK (平安),
0939.HK (建行), 1398.HK (工行), 3988.HK (中行), 0005.HK (汇丰),
0003.HK (中煤气), 0011.HK (恒生), 0002.HK (中电), 0006.HK (电能),
0016.HK (新地), 0012.HK (恒基), 0388.HK (港交所), 1299.HK (友邦),
0267.HK (中信), 0066.HK (港铁), 0857.HK (中石油), 0728.HK (中国电信)
### HK — High-Yield ETFs
3416.HK (AGX国指兑), 3417.HK (AGX恒科备兑)
## Pitfalls
- **mREIT rate sensitivity**: NLY/AGNC/DX are heavily influenced by Fed rate policy. In rate-cutting cycles, they outperform; in tightening cycles, dividends may be cut.
- **BDC credit risk**: BDCs lend to mid-market companies. During recessions, default rates rise and NAV can decline.
- **HK bank property exposure**: HK bank stocks (招行/工行/中行) have real estate exposure. Valuations may already reflect property market stress.
- **PE negative = red flag**: Stocks with negative PE (some BDCs like FSK, PSEC) may have unsustainable dividends despite high headline yields.
- **YTD hot stocks penalized**: Stocks with YTD > +20% (like 0883.HK +25%, 0857.HK +26%) score lower on DCA because they're less attractive for new money entry.
- **Price=0 on weekends**: LongPort returns 0 for last_done when markets are closed. Use calc_indexes data (PE/PB/yield) which are always available.
- **LongPort Quote object**: `SecurityQuote` does NOT have `change_rate` attribute on some market data tiers. Use `calc_indexes` with `CalcIndex.ChangeRate` instead.
## Script Template
Save as `/tmp/dca_screen.py` (never use `python3 -c` with HK stock codes starting with digits).
Key script pattern:
```python
# 1. Load env from bashrc (LONGBRIDGE_* → LONGPORT_*)
# 2. ctx.calc_indexes(candidates, [DividendRatioTtm, PeTtmRatio, PbRatio, TotalMarketValue, ...])
# 3. ctx.candlesticks(sym, Period.Day, 60, AdjustType.ForwardAdjust) for price ladder
# 4. ctx.static_info(syms) for names
# 5. Score + sort + present
```
Batch sizes: quotes 20/batch, calc_indexes 10/batch, static_info 20/batch.
@@ -0,0 +1,162 @@
# DCA Screening (阶梯式买入) — High Dividend Candidates
Find stocks suitable for dollar-cost averaging (laddered buying) with high dividend yields. Combines LongPort data with weighted scoring.
**Last reviewed**: 2026-06-07
---
## DCA Scoring Framework
5-dimension weighted score (0-100) optimized for **income + value + stability**:
| Dimension | Weight | Ideal | Logic |
|-----------|--------|-------|-------|
| Dividend Yield | 30% | >10% | Higher = more income while DCA-ing; cap at 20% |
| PE (TTM) | 20% | 5-15 | Sweet spot: cheap enough for value, not negative |
| PB Ratio | 15% | <1.0 | Below book = margin of safety; <0.5 = deep value |
| 5-Day Volatility | 15% | <3% | Low vol = smoother DCA entries, less timing risk |
| YTD Drawdown | 20% | -5%~-15% | Pullback = better entry; too deep = fundamental risk |
```python
def dca_score(r):
score = 0
# Yield (30%): higher = better, cap at 20%
score += min(r['yield'] / 20.0, 1.0) * 30
# PE (20%): sweet spot 5-15
pe = r['pe']
if pe is None or pe <= 0: score += 5 # negative = risky
elif pe < 5: score += 15 # very cheap
elif pe < 10: score += 20 # sweet spot
elif pe < 15: score += 15
elif pe < 20: score += 10
else: score += 5
# PB (15%): lower = better
pb = r['pb']
if pb is None: score += 5
elif pb < 0.5: score += 15 # deep value
elif pb < 1.0: score += 12 # below book
elif pb < 1.5: score += 8
else: score += 4
# Volatility (15%): lower 5d change = better for DCA
abs_5d = abs(r['five_d'])
if abs_5d < 1: score += 15
elif abs_5d < 3: score += 12
elif abs_5d < 5: score += 8
else: score += 4
# YTD dip (20%): negative = better entry
ytd = r['ytd']
if ytd < -10: score += 20 # great entry
elif ytd < -5: score += 15
elif ytd < 0: score += 12
elif ytd < 5: score += 8
else: score += 4 # too hot
return score
```
## LongPort Data Fetching
```python
from longport.openapi import CalcIndex
indexes = [
CalcIndex.PeTtmRatio,
CalcIndex.PbRatio,
CalcIndex.DividendRatioTtm,
CalcIndex.TotalMarketValue,
CalcIndex.TurnoverRate,
CalcIndex.FiveDayChangeRate,
CalcIndex.YtdChangeRate,
]
# Batch in groups of 10
resp = ctx.calc_indexes(symbols, indexes)
for item in resp:
dy = float(item.dividend_ratio_ttm) if item.dividend_ratio_ttm else 0
pe = float(item.pe_ttm_ratio) if item.pe_ttm_ratio else None
pb = float(item.pb_ratio) if item.pb_ratio else None
cap = float(item.total_market_value) if item.total_market_value else 0
```
## Candidate Universe (2026-06 snapshot)
### 🇭🇰 Hong Kong — High Dividend Blue Chips
| Code | Name | Yield | PE | PB | Category |
|------|------|-------|-----|-----|----------|
| 3968.HK | 招商银行 | 6.9% | 7.1 | 0.95 | 银行 (破净) |
| 2318.HK | 中国平安 | 5.4% | 6.8 | 0.89 | 保险 (破净) |
| 0728.HK | 中国电信 | 6.1% | 12.7 | 0.86 | 电信 (央企) |
| 1398.HK | 工商银行 | 5.1% | 5.8 | 0.55 | 银行 (破净) |
| 0883.HK | 中海油 | 5.2% | 8.9 | 1.33 | 能源 |
| 0267.HK | 中信股份 | 4.5% | 6.1 | 0.46 | 综合 (破净) |
| 3988.HK | 中国银行 | ~5% | ~5 | ~0.5 | 银行 (破净) |
| 0939.HK | 建设银行 | ~5% | ~5 | ~0.5 | 银行 (破净) |
| 3416.HK | AGX国指兑 | 18.6% | N/A | N/A | 高息ETF (covered call) |
| 3417.HK | AGX恒科备兑 | 18.8% | N/A | N/A | 高息ETF (covered call) |
| 1088.HK | 中国神华 | 7.0% | 16.7 | 1.82 | 能源 (煤) |
**港股 DCA 特点**
- 银行股大面积破净(PB<1),适合长期收息
- 央企分红稳定,但增长有限
- 高息ETF3416/3417yield极高但属covered call策略,capital appreciation受限
### 🇺🇸 US — BDCs (Business Development Companies)
| Ticker | Name | Yield | PE | PB | Profile |
|--------|------|-------|-----|-----|---------|
| HTGC.US | Hercules Capital | 10.5% | 8.5 | 1.26 | 科技BDC龙头,YTD-14% |
| ARCC.US | Ares Capital | 10.2% | 11.7 | 0.96 | 最大BDCPB<1 |
| MAIN.US | Main Street Capital | 5.9% | 11.3 | 1.56 | 月分红+补充分红 |
| GAIN.US | Gladstone Investment | 6.2% | 3.3 | 0.91 | 小型BDC |
| GLAD.US | Gladstone Capital | 9.3% | 10.2 | 0.90 | 收入型BDC |
| HRZN.US | Horizon Technology | 25.4% | 14.6 | 0.94 | ⚠️ 高息但风险高 |
| FSK.US | FS KKR Capital | 22.1% | -5.5 | 0.57 | ⚠️ PE为负,亏损 |
| PSEC.US | Prospect Capital | 23.8% | -6.8 | 0.37 | ⚠️ PE为负,分红可持续性存疑 |
### 🇺🇸 US — mREITs (Mortgage REITs)
| Ticker | Name | Yield | PE | PB | Profile |
|--------|------|-------|-----|-----|---------|
| NLY.US | Annaly Capital | 13.2% | 7.7 | 1.07 | 最大agency mREIT |
| AGNC.US | AGNC Investment | 14.2% | 9.0 | 1.14 | agency MBS |
| ARR.US | Armour Residential | 16.8% | 9.3 | 0.91 | 住宅mREIT |
| DX.US | Dynex Capital | 15.8% | 11.1 | 0.98 | 多元mREIT |
### 🇺🇸 US — Blue Chip Dividend
| Ticker | Name | Yield | PE | Profile |
|--------|------|-------|-----|---------|
| VICI.US | VICI Properties | 6.4% | 9.8 | 娱乐REITTriple-net |
| T.US | AT&T | 4.9% | 7.5 | 电信,降息受益 |
| MO.US | Altria Group | 5.8% | 15.0 | 烟草,稳定现金流 |
| BTI.US | British American Tobacco | 5.3% | 12.6 | 国际烟草 |
| XOM.US | Exxon Mobil | ~3.5% | ~14 | 能源巨头 |
| O.US | Realty Income | 5.3% | 50.6 | 月分红REITPE偏高) |
### 🇺🇸 US — High Yield ETFs
| Ticker | Name | Yield | Strategy |
|--------|------|-------|----------|
| JEPI.US | JPMorgan Equity Premium Income | 8.3% | ELN + stock selection |
| JEPQ.US | JPMorgan NASDAQ Equity Premium | 10.4% | NASDAQ版JEPI |
| SPYI.US | Neos S&P 500 High Income | 11.9% | S&P 500 covered call |
| QYLD.US | Global X NASDAQ 100 CC | 11.7% | ATM calls on QQQ |
| SVOL.US | Simplify Volatility Premium | 22.3% | 波动率溢价 |
## Pitfalls
- **⚠️ Ultra-high yield (>20%) = red flag**: HRZN/FSK/PSEC/SVOL yield 20%+ but PE is negative — recent losses. Dividend sustainability at risk. Always check PE > 0 before recommending.
- **⚠️ mREITs are rate-sensitive**: NLY/AGNC/DX/ARR depend on net interest margin. Fed rate cuts = tailwind; rate hikes = headwind. Best DCA during rate-cutting cycles.
- **⚠️ Covered call ETFs cap upside**: QYLD/JEPQ/SPYI generate income by selling calls — total return may lag underlying index in bull markets. DCA works better in range-bound markets.
- **⚠️ HK bank PB<1 is structural**: Chinese bank "破净" has persisted for years — it reflects real estate risk, not necessarily a bargain. Still fine for dividend income but don't expect PB reversion.
- **⚠️ BDC vs mREIT**: BDCs (HTGC/ARCC/MAIN) have more diversified income sources and typically more stable dividends than mREITs. Prefer BDCs for conservative DCA.
- **Seasonality**: US ex-dividend dates cluster around Feb/May/Aug/Nov for quarterly payers. Plan DCA entries to capture dividends.
## DCA Strategy Tips
1. **3-5 price levels**: Set 3-5 buy levels below current price, spaced 5-10% apart
2. **Equal dollar amounts**: Invest same $ amount at each level (not equal shares)
3. **Dividend reinvestment**: DRIP accelerates compounding during DCA accumulation
4. **Sector diversification**: Mix REIT + BDC + utility + telecom — don't over-concentrate
5. **HK + US mix**: HK for value (low PE/PB), US for yield (higher dividend rates)
@@ -0,0 +1,57 @@
# Factor Mining & Quantitative Analysis Landscape
## Open-Source Projects
### Wrigggy/quant-factor-mining ⭐ (Primary)
- **URL**: https://github.com/Wrigggy/quant-factor-mining
- **Installed**: `~/.hermes/skills/trading/quant-factor-mining`
- **Features**: Walk-forward validation, Alphalens evaluation, CVXPY optimization, Streamlit dashboard
- **Factors**: Momentum (252d/21d skip), Mean Reversion (21d), Low Volatility (63d)
- **Data**: LongPort integration via `src/qfm/data/longport_fetch.py`
### Yitong-Guo/Genetic-Algorithm-for-quantitative-alpha-factors-mining ⭐35
- **URL**: https://github.com/Yitong-Guo/Genetic-Algorithm-for-quantitative-alpha-factors-mining
- **Method**: Genetic algorithm for alpha factor discovery
### LinChengHao3606307/AlphaMining ⭐10
- **URL**: https://github.com/LinChengHao3606307/AlphaMining
- **Method**: Reinforcement learning, 5 neural network architectures
### IIcodehub/GP-Alpha-Miner ⭐7
- **URL**: https://github.com/IIcodehub/GP-Alpha-Miner-GPU-Accelerated-Genetic-Programming-Framework
- **Method**: GPU-accelerated genetic programming
## Python Libraries
| Library | Purpose | Install |
|---------|---------|---------|
| alphalens-reloaded | Factor evaluation & tearsheet | `pip install alphalens-reloaded` |
| cvxpy | Portfolio optimization | `pip install cvxpy` |
| pyportfolioopt | Mean-variance optimization | `pip install pyportfolioopt` |
| zipline-reloaded | Event-driven backtesting | `pip install zipline-reloaded` |
| vectorbt | Vectorized backtesting | `pip install vectorbt` |
| optuna | Hyperparameter optimization | `pip install optuna` |
| backtrader | Strategy backtesting | `pip install backtrader` |
## LongPort Factor Data
| Data Point | API | Field |
|-----------|-----|-------|
| PE TTM | calc_indexes | PeTtmRatio |
| PB | calc_indexes | PbRatio |
| Dividend Yield | calc_indexes | DividendRatioTtm |
| Market Cap | calc_indexes | TotalMarketValue |
| Turnover Rate | calc_indexes | TurnoverRate |
| Volume Ratio | calc_indexes | VolumeRatio |
| Change Rate | calc_indexes | ChangeRate |
| EPS TTM | static_info | eps_ttm |
| BPS | static_info | bps |
| K-line History | history_candlesticks_by_offset | OHLCV |
## Factor Analysis Timing (User: UTC+8 Beijing)
| Market | Analysis Time (Beijing) | Cron (EDT) | Reason |
|--------|------------------------|------------|--------|
| HK/A-share | 17:00 daily | `0 5 * * 1-5` | 1hr after HK close |
| US | 20:30 daily | `30 8 * * 1-5` | Pre-market signal |
| Weekly | Fri 21:00 | `0 21 * * 5` | Weekend summary |
@@ -0,0 +1,118 @@
# Intraday Trading: Factors, Screening & Strategies
## Stock Screening Criteria for Day Trading
### Universal Filters
| Factor | Metric | Threshold | Weight |
|--------|--------|-----------|--------|
| Volatility | Average Daily Range (ADR%) | > 2% (ideal > 3%) | 40% |
| Liquidity | Volume | > 1M shares/day (HK: > 5M HKD turnover) | — |
| Spread | Bid-Ask Spread | < 0.05% (scalping) / < 0.2% (swing) | — |
| Activity | Volume Ratio (RVOL) | > 1.5x average | 30% |
| Activity | Turnover Rate | > 1% | 30% |
| Trend | ADX | > 25 (trending market) | bonus |
### Composite Day Trading Score
```python
day_trade_score = (min(ADR% / 3, 1) * 40 + # 3% ADR = max
min(RVOL / 2, 1) * 30 + # 2x RVOL = max
min(Turnover% / 2, 1) * 30) # 2% turnover = max
# > 60: Excellent for day trading
# 40-60: Good for day trading
# < 40: Not ideal
```
### HK-Specific Screening
- Price: HKD 2-500
- HSI/HSCEI constituents or high-beta stocks
- Connect stocks (Southbound/Northbound eligible)
- AH spread opportunities
- Note: HK has 0.1% stamp duty
## Intraday Strategies
### 1. Momentum Scalping
- **Entry**: Breakout of consolidation with volume surge
- **Exit**: Quick profit (0.2-0.5%), trailing stop
- **Timeframe**: 1-5 min
- **Key**: Speed, tight spreads
### 2. Mean Reversion
- **Entry**: RSI extremes (< 30 buy, > 70 sell), Bollinger Band touches
- **Exit**: Return to VWAP or MA
- **Timeframe**: 5-15 min
- **Key**: Identify overextended moves
### 3. VWAP Trading
- **Entry**: Price crosses VWAP with volume confirmation
- **Exit**: Previous swing high/low
- **Timeframe**: 5-15 min
- **Key**: Institutional reference point
### 4. Opening Range Breakout (ORB)
- **Entry**: Break of first 15-30 min high/low
- **Exit**: 1:2 risk-reward or trailing stop
- **Timeframe**: 15-min opening range
### 5. AH Spread Arbitrage (HK-specific)
- **Pairs**: AH premium/discount stocks (e.g., 700.HK vs TCEHY)
- **Entry**: Spread deviation > 2 std from mean
- **Exit**: Spread normalization
- **Key**: Currency hedging, execution timing
### 6. Gap Trading
- **Gap & Go**: Trade in gap direction with momentum
- **Gap Fill**: Fade gaps that tend to fill
- **Timeframe**: First 30-60 min
## Key Technical Indicators for Intraday
| Indicator | Use | Setting |
|-----------|-----|---------|
| ATR | Volatility measurement | 14-period |
| VWAP | Institutional benchmark | Intraday |
| RSI | Overbought/oversold | 14-period |
| Bollinger Bands | Volatility channels | 20, 2σ |
| MACD | Trend direction | 12, 26, 9 |
| ADX | Trend strength | 14-period |
| Volume Profile | Support/resistance levels | POC, VAH, VAL |
## Risk Management
- Position sizing: 1-2% risk per trade
- Max daily loss: 3-5% of capital
- Always use stop losses
- Avoid revenge trading
- Track all trades for review
## LongPort Data for Intraday
```python
# Real-time quote
resp = ctx.quote(['1024.HK', '9868.HK'])
for q in resp:
print(f'{q.symbol}: {q.last_done}, vol={q.volume}')
# K-line for ADR calculation
candles = ctx.candlesticks('1024.HK', Period.Day, 20, AdjustType.ForwardAdjust)
adr = sum(float(c.high) - float(c.low) for c in candles) / len(candles)
# Volume ratio and turnover
from longport.openapi import CalcIndex
resp = ctx.calc_indexes(['1024.HK'], [CalcIndex.VolumeRatio, CalcIndex.TurnoverRate])
# Order book depth
depth = ctx.depth('1024.HK')
```
## HK Day Trading Candidates (2026-06-01 snapshot)
| Stock | Price | ADR% | Score | Strategy |
|-------|-------|------|-------|----------|
| 快手(1024) | $46.54 | 5.07% | 78.7 | Momentum breakout |
| 小鹏(9868) | $67.80 | 4.18% | 78.5 | Gap + trend |
| 美团(3690) | $78.25 | 3.73% | 76.8 | VWAP bounce |
| 理想(2015) | $58.55 | 4.35% | 65.7 | Trend follow |
| 小米(1810) | $28.72 | 3.77% | 63.7 | Mean reversion |
| 百度(9888) | $129.10 | 3.61% | 63.7 | AI momentum |
@@ -0,0 +1,72 @@
# Monthly Dividend Stocks Reference
Curated list of monthly-dividend-paying stocks and ETFs for US and HK markets. Organized by category for quick screening.
**Last reviewed**: 2025 (approximate yields — always verify current data via Yahoo Finance or LongBridge before presenting)
---
## US — REITs (Real Estate Investment Trusts)
| Ticker | Name | ~Yield | Profile |
|--------|------|--------|---------|
| O | Realty Income | 5-6% | "The Monthly Dividend Company" — 100+ consecutive dividend increases, retail/net-lease REIT, blue-chip |
| STAG Industrial | STAG Industrial | 4-5% | Industrial/logistics warehouses, e-commerce tailwind |
| AGNC Investment | AGNC Investment | 13-16% | Mortgage REIT (mREIT) — agency MBS, high yield but high volatility |
| NLY | Annaly Capital | 12-14% | Mortgage REIT (mREIT) — largest agency mREIT, rate-sensitive |
| ADC | Agree Realty | 4-5% | Net-lease REIT, essential retail tenants |
## US — BDCs (Business Development Companies)
| Ticker | Name | ~Yield | Profile |
|--------|------|--------|---------|
| MAIN | Main Street Capital | 6-7% | Quality BDC, monthly dividends + supplemental, steady grower |
| GAIN | Gladstone Investment | 7-8% | Small/mid-cap BDC, income + capital gains distributions |
| PSEC | Prospect Capital | 10-12% | High yield BDC, diversified lending, higher risk |
| SLRC | SLR Investment Corp | 10-11% | Specialty lending BDC |
## US — Covered Call ETFs (Income-focused)
| Ticker | Name | ~Yield | Strategy |
|--------|------|--------|----------|
| QYLD | Global X NASDAQ 100 CC | 11-13% | Sells ATM calls on QQQ — max income, capped upside |
| XYLD | Global X S&P 500 CC | 10-11% | Sells ATM calls on SPY — same strategy on S&P |
| JEPI | JPMorgan Equity Premium Income | 7-9% | ELN + stock selection — lower vol, smoother returns |
| JEPQ | JPMorgan NASDAQ Equity Premium | 8-10% | NASDAQ version of JEPI |
| DIVO | Amplify CWP Enhanced Dividend | 4-5% | Blue-chip stocks + covered calls, capital appreciation focus |
| RYLD | Global X Russell 2000 CC | 11-13% | Small-cap covered call ETF |
## US — Other Monthly Payers
| Ticker | Name | ~Yield | Profile |
|--------|------|--------|---------|
| SCHD | Schwab US Dividend Equity | 3-4% | Quarterly but frequently requested; quality dividend growth |
| EPR | EPR Properties | 7-8% | Experiential REIT (theaters, ski resorts, gaming) |
| LTC | LTC Properties | 6-7% | Senior housing/healthcare REIT |
## HK — Monthly Dividend REITs
| Code | Name | ~Yield | Notes |
|------|------|--------|-------|
| 0823.HK | Link REIT | 5-6% | Largest HK REIT, retail + office |
| 0778.HK | Fortune REIT | 6-7% | Community shopping centers |
| 0405.HK | Yuexiu REIT | 7-8% | HK + mainland China properties |
| 0435.HK | Sunlight REIT | 7-8% | Office + retail in HK |
| 1881.HK | Regal REIT | 8-9% | Hotel REIT, higher yield but cyclical |
| 2191.HK | SF REIT | 5-6% | Logistics/warehouse REIT |
## Screening Tips
- **Dividend safety**: Check payout ratio (< 80% is sustainable), consecutive years of increases, FFO/AFFO coverage
- **mREIT caveat**: AGNC/NLY/RNLY yield 12%+ but are rate-sensitive and can cut dividends during tightening cycles
- **Covered call trade-off**: QYLD/XYLD maximize current income but sacrifice capital appreciation — total return may lag underlying index
- **HK REITs**: Hong Kong property market has been under pressure since 2022; yields may reflect distressed valuations (opportunity or trap?)
- **Best all-rounder**: O (Realty Income) — best risk-adjusted monthly income for most portfolios
## Fallback Data Sources
When Yahoo Finance is rate-limited or unavailable:
1. **LongBridge CLI**: `longbridge quote --json <TICKERS>` (requires valid token)
2. **Nasdaq API**: `https://api.nasdaq.com/api/quote/<TICKER>/dividends` (Nasdaq-listed only)
3. **Web search**: Search `<TICKER> dividend yield 2025` for latest data
4. **StockAnalysis.com**: `https://stockanalysis.com/stocks/<ticker>/dividend/`
@@ -0,0 +1,109 @@
# Stock Analysis v6.3 - Quick Reference
## New: LongPort-Powered 8-Dimension Analysis
### Basic Usage
```bash
# Single stock
uv run ~/.hermes/skills/openclaw-imports/stock-analysis/scripts/analyze_stock_unified.py O
# Multiple stocks
uv run ~/.hermes/skills/openclaw-imports/stock-analysis/scripts/analyze_stock_unified.py O 823.HK MAIN JEPI NLY
# Fast mode (skip Yahoo fallback for speed)
uv run ~/.hermes/skills/openclaw-imports/stock-analysis/scripts/analyze_stock_unified.py O --fast
# JSON output
uv run ~/.hermes/skills/openclaw-imports/stock-analysis/scripts/analyze_stock_unified.py O --output json
```
### Symbol Format
| Market | Format | Example |
|--------|--------|---------|
| US | `TICKER` or `TICKER.US` | `O`, `AAPL.US` |
| HK | `CODE.HK` | `823.HK`, `9988.HK` |
| CN | `CODE.SZ` or `CODE.SH` | `000001.SZ` |
### 8-Dimension Scoring System
| Dimension | Weight | Data Source | Metrics |
|-----------|--------|-------------|---------|
| **Fundamentals** | 40% | LongPort + Yahoo | PE, PB, dividend, margins, ROE, debt |
| **Valuation** | 30% | LongPort | PE/PB/dividend relative scoring |
| **Momentum** | 20% | LongPort | RSI, volume ratio, price change |
| **Market Cap** | 10% | LongPort | Large-cap stability bonus |
### Scoring Logic
**Fundamentals Score:**
- PE < 15: +0.5, PE > 30: -0.3
- PB < 1.0: +0.6, PB > 5.0: -0.4
- Dividend > 5%: +0.5
- Operating margin > 15%: +0.5
- ROE > 15%: +0.4
- Debt/Equity < 50: +0.3
**Valuation Score:**
- PE: <15 → +0.5, <25 → +0.2, >35 → -0.3
- PB: <1.0 → +0.6, <2.0 → +0.3, >5.0 → -0.4
- Dividend: >5% → +0.5, >3% → +0.3
**Momentum Score:**
- RSI < 30 (oversold): +0.5
- RSI > 70 (overbought): -0.5
- Volume ratio > 1.5: +0.3
### Recommendations
| Score | Recommendation | Confidence |
|-------|----------------|------------|
| > 0.3 | BUY | 80-90% |
| > 0.0 | BUY | 50-80% |
| > -0.3 | HOLD | 50-80% |
| < -0.3 | SELL | 80-90% |
### Data Sources
**LongPort (Primary):**
- PE TTM, PB, EPS TTM, BPS
- Dividend yield, Market cap
- Real-time quotes, Volume ratio
- Full HK/CN/US coverage
**Yahoo Finance (Fallback - US only):**
- Operating margins, ROE, ROA
- Debt ratios, Revenue growth
- Analyst ratings, Earnings history
## Legacy Commands (Yahoo Finance)
### Stock Analysis
```bash
uv run {baseDir}/scripts/analyze_stock.py AAPL --fast
```
### Dividend Analysis
```bash
uv run {baseDir}/scripts/dividends.py O JEPI QYLD
```
## Environment Setup
### LongPort SDK
Add to `~/.bashrc`:
```bash
export LONGBRIDGE_APP_KEY=your_key
export LONGBRIDGE_APP_SECRET=your_s...port LONGBRIDGE_ACCESS_TOKEN=your_t...The script auto-maps `LONGBRIDGE_*` → `LONGPORT_*` for the SDK.
## Troubleshooting
### "token invalid" error
Token expired. Get new token from LongPort App → Settings → API Keys.
### Yahoo Finance rate limiting
Normal during heavy usage. LongPort data is still available. Use `--fast` to skip Yahoo.
### Missing PE/PB for ETFs
ETFs don't have traditional PE/PB. Only dividend yield is available.
### Negative PE
Negative PE means the company is losing money. Fundamentals score will be lower.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "yfinance>=0.2.40",
# "pandas>=2.0.0",
# "longport>=2.0.0",
# "fear-and-greed>=0.4",
# "edgartools>=2.0.0",
# "feedparser>=6.0.0",
# ]
# ///
"""
Stock analysis with 8-dimension scoring: LongPort (primary) + Yahoo Finance (fallback).
Usage:
uv run analyze_stock_unified.py TICKER [TICKER2 ...] [--output text|json] [--verbose] [--fast]
"""
import argparse
import asyncio
import json
import sys
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Literal, Optional
import pandas as pd
# Import unified data source
from data_source import fetch_stock_data_unified, UnifiedStockData
# Import original analysis functions (copy from analyze_stock.py)
# We'll create adapters to work with UnifiedStockData
@dataclass
class EarningsSurprise:
score: float
explanation: str
actual_eps: float | None = None
expected_eps: float | None = None
surprise_pct: float | None = None
@dataclass
class Fundamentals:
score: float
key_metrics: dict
explanation: str
@dataclass
class AnalystSentiment:
score: float | None
summary: str
consensus_rating: str | None = None
price_target: float | None = None
current_price: float | None = None
upside_pct: float | None = None
num_analysts: int | None = None
@dataclass
class MomentumAnalysis:
rsi_14d: float | None
rsi_status: str
price_vs_52w_low: float | None
price_vs_52w_high: float | None
near_52w_high: bool
near_52w_low: bool
volume_ratio: float | None
score: float
explanation: str
@dataclass
class Signal:
ticker: str
company_name: str
recommendation: Literal["BUY", "HOLD", "SELL"]
confidence: float
final_score: float
supporting_points: list[str]
caveats: list[str]
timestamp: str
components: dict
def analyze_fundamentals_from_unified(data: UnifiedStockData) -> Fundamentals | None:
"""Analyze fundamentals from unified data source."""
scores = []
metrics = {}
explanations = []
try:
# PE Ratio (LongPort)
if data.pe_ttm and data.pe_ttm > 0:
metrics["pe_ttm"] = float(data.pe_ttm)
if data.pe_ttm < 15:
scores.append(0.5)
explanations.append(f"Attractive PE: {float(data.pe_ttm):.1f}x")
elif data.pe_ttm > 30:
scores.append(-0.3)
explanations.append(f"Elevated PE: {float(data.pe_ttm):.1f}x")
else:
scores.append(0.1)
# PB Ratio (LongPort)
if data.pb and data.pb > 0:
metrics["pb"] = float(data.pb)
if data.pb < 1.0:
scores.append(0.6)
explanations.append(f"Below book value: PB {float(data.pb):.2f}")
elif data.pb < 2.0:
scores.append(0.3)
elif data.pb > 5.0:
scores.append(-0.4)
explanations.append(f"High PB: {float(data.pb):.1f}x")
# Dividend Yield (LongPort)
if data.dividend_yield:
metrics["dividend_yield"] = float(data.dividend_yield)
if data.dividend_yield > 5:
scores.append(0.5)
explanations.append(f"High dividend: {float(data.dividend_yield):.1f}%")
elif data.dividend_yield > 3:
scores.append(0.3)
elif data.dividend_yield < 1:
scores.append(-0.2)
# Operating Margin (Yahoo fallback)
if data.operating_margin:
metrics["operating_margin"] = float(data.operating_margin)
if data.operating_margin > 0.15:
scores.append(0.5)
explanations.append(f"Strong margin: {float(data.operating_margin)*100:.1f}%")
elif data.operating_margin < 0.05:
scores.append(-0.5)
explanations.append(f"Weak margin: {float(data.operating_margin)*100:.1f}%")
# ROE (Yahoo fallback)
if data.roe:
metrics["roe"] = float(data.roe)
if data.roe > 0.15:
scores.append(0.4)
explanations.append(f"Strong ROE: {float(data.roe)*100:.1f}%")
elif data.roe < 0.05:
scores.append(-0.3)
# Debt to Equity (Yahoo fallback)
if data.debt_to_equity:
metrics["debt_to_equity"] = float(data.debt_to_equity)
if data.debt_to_equity < 50:
scores.append(0.3)
elif data.debt_to_equity > 200:
scores.append(-0.5)
explanations.append(f"High debt: D/E {float(data.debt_to_equity)/100:.1f}x")
if not scores:
return None
avg_score = sum(scores) / len(scores)
normalized_score = max(-1.0, min(1.0, avg_score))
return Fundamentals(
score=normalized_score,
key_metrics=metrics,
explanation="; ".join(explanations) if explanations else "Mixed fundamentals",
)
except Exception as e:
print(f"Fundamentals analysis error: {e}", file=sys.stderr)
return None
def analyze_momentum_from_unified(data: UnifiedStockData) -> MomentumAnalysis | None:
"""Analyze momentum from unified data source."""
try:
# Use volume_ratio from LongPort
volume_ratio = float(data.volume_ratio) if data.volume_ratio else None
# Calculate RSI from price history if available
rsi_14d = None
rsi_status = "neutral"
if data.price_history is not None and len(data.price_history) >= 14:
close_prices = data.price_history["Close"]
delta = close_prices.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
rsi_14d = float(rsi.iloc[-1])
if rsi_14d > 70:
rsi_status = "overbought"
elif rsi_14d < 30:
rsi_status = "oversold"
# Score based on available metrics
scores = []
if rsi_14d:
if rsi_14d < 30:
scores.append(0.5) # Oversold = opportunity
elif rsi_14d > 70:
scores.append(-0.5) # Overbought = risk
if volume_ratio:
if volume_ratio > 1.5:
scores.append(0.3) # High volume = strong move
elif volume_ratio < 0.5:
scores.append(-0.2) # Low volume = weak move
if data.change_rate:
change = float(data.change_rate)
if abs(change) > 5:
scores.append(0.2 if change > 0 else -0.2)
score = sum(scores) / len(scores) if scores else 0.0
return MomentumAnalysis(
rsi_14d=rsi_14d,
rsi_status=rsi_status,
price_vs_52w_low=None,
price_vs_52w_high=None,
near_52w_high=False,
near_52w_low=False,
volume_ratio=volume_ratio,
score=max(-1.0, min(1.0, score)),
explanation=f"RSI: {rsi_14d:.1f} ({rsi_status})" if rsi_14d else "Limited momentum data",
)
except Exception as e:
print(f"Momentum analysis error: {e}", file=sys.stderr)
return None
def synthesize_signal(
ticker: str,
company_name: str,
fundamentals: Fundamentals | None,
momentum: MomentumAnalysis | None,
data: UnifiedStockData,
) -> Signal:
"""Synthesize final signal from analysis components."""
scores = []
weights = []
supporting_points = []
caveats = []
# Fundamentals (40% weight)
if fundamentals:
scores.append(fundamentals.score)
weights.append(0.40)
if fundamentals.score > 0.3:
supporting_points.append(f"✓ Strong fundamentals: {fundamentals.explanation}")
elif fundamentals.score < -0.3:
caveats.append(f"⚠ Weak fundamentals: {fundamentals.explanation}")
# Valuation (30% weight) - from PE/PB/Dividend
valuation_score = 0
valuation_count = 0
if data.pe_ttm and data.pe_ttm > 0:
if data.pe_ttm < 15:
valuation_score += 0.5
elif data.pe_ttm < 25:
valuation_score += 0.2
elif data.pe_ttm > 35:
valuation_score -= 0.3
valuation_count += 1
if data.pb:
if data.pb < 1.0:
valuation_score += 0.6
elif data.pb < 2.0:
valuation_score += 0.3
elif data.pb > 5.0:
valuation_score -= 0.4
valuation_count += 1
if data.dividend_yield:
if data.dividend_yield > 5:
valuation_score += 0.5
elif data.dividend_yield > 3:
valuation_score += 0.3
valuation_count += 1
if valuation_count > 0:
avg_valuation = valuation_score / valuation_count
scores.append(avg_valuation)
weights.append(0.30)
if data.dividend_yield and data.dividend_yield > 5:
supporting_points.append(f"✓ High dividend yield: {float(data.dividend_yield):.1f}%")
# Momentum (20% weight)
if momentum:
scores.append(momentum.score)
weights.append(0.20)
if momentum.rsi_status == "oversold":
supporting_points.append(f"✓ Oversold RSI: {momentum.rsi_14d:.1f}")
elif momentum.rsi_status == "overbought":
caveats.append(f"⚠ Overbought RSI: {momentum.rsi_14d:.1f}")
# Market cap consideration (10% weight)
if data.market_cap:
cap = float(data.market_cap)
if cap > 10e9: # Large cap
scores.append(0.3)
supporting_points.append("✓ Large-cap stability")
elif cap < 1e9: # Small cap
scores.append(-0.2)
caveats.append("⚠ Small-cap volatility")
weights.append(0.10)
# Calculate final score
if scores:
final_score = sum(s * w for s, w in zip(scores, weights)) / sum(weights)
else:
final_score = 0.0
# Determine recommendation
if final_score > 0.3:
recommendation = "BUY"
confidence = min(0.9, 0.5 + final_score)
elif final_score > 0.0:
recommendation = "BUY"
confidence = 0.5 + final_score
elif final_score > -0.3:
recommendation = "HOLD"
confidence = 0.5 - final_score
else:
recommendation = "SELL"
confidence = min(0.9, 0.5 - final_score)
# Add data source info
supporting_points.append(f"📊 Data: {', '.join(data.data_sources)}")
return Signal(
ticker=ticker,
company_name=company_name,
recommendation=recommendation,
confidence=round(confidence, 2),
final_score=round(final_score, 3),
supporting_points=supporting_points[:5],
caveats=caveats[:5],
timestamp=datetime.now().isoformat(),
components={
"fundamentals": asdict(fundamentals) if fundamentals else None,
"momentum": asdict(momentum) if momentum else None,
"valuation": {
"pe_ttm": float(data.pe_ttm) if data.pe_ttm else None,
"pb": float(data.pb) if data.pb else None,
"dividend_yield": float(data.dividend_yield) if data.dividend_yield else None,
},
},
)
def format_output_text(signal: Signal) -> str:
"""Format signal as text output."""
lines = [
"=" * 60,
f"📊 {signal.ticker} - {signal.company_name}",
f"Generated: {signal.timestamp}",
"=" * 60,
"",
f"📋 RECOMMENDATION: {signal.recommendation} (Confidence: {signal.confidence*100:.0f}%)",
f"⭐ SCORE: {signal.final_score:+.3f}",
"",
"✅ SUPPORTING POINTS:",
]
for point in signal.supporting_points:
lines.append(f" {point}")
lines.extend(["", "⚠️ CAVEATS:"])
for caveat in signal.caveats:
lines.append(f" {caveat}")
# Add component details
if signal.components.get("fundamentals"):
fund = signal.components["fundamentals"]
lines.extend([
"",
"📈 FUNDAMENTALS:",
f" Score: {fund['score']:+.2f}",
f" {fund['explanation']}",
])
if signal.components.get("valuation"):
val = signal.components["valuation"]
lines.extend([
"",
"💰 VALUATION:",
f" PE TTM: {val['pe_ttm']:.2f}" if val['pe_ttm'] else " PE TTM: N/A",
f" PB: {val['pb']:.2f}" if val['pb'] else " PB: N/A",
f" Dividend: {val['dividend_yield']:.1f}%" if val['dividend_yield'] else " Dividend: N/A",
])
lines.extend([
"",
"=" * 60,
"⚠️ NOT FINANCIAL ADVICE. For informational purposes only.",
"=" * 60,
])
return "\n".join(lines)
def format_output_json(signal: Signal) -> str:
"""Format signal as JSON."""
return json.dumps(asdict(signal), indent=2, default=str)
def main():
parser = argparse.ArgumentParser(
description="Stock analysis with 8-dimension scoring (LongPort + Yahoo)"
)
parser.add_argument("tickers", nargs="+", help="Stock tickers")
parser.add_argument("--output", choices=["text", "json"], default="text")
parser.add_argument("--verbose", "-v", action="store_true")
parser.add_argument("--fast", action="store_true", help="Skip slow analyses")
args = parser.parse_args()
results = []
for ticker in args.tickers:
ticker = ticker.upper()
if args.verbose:
print(f"\n=== Analyzing {ticker} ===", file=sys.stderr)
# Fetch unified data
data = fetch_stock_data_unified(ticker, verbose=args.verbose)
if data is None:
print(f"Error: Failed to fetch data for {ticker}", file=sys.stderr)
continue
# Run analyses
if args.verbose:
print(" Analyzing fundamentals...", file=sys.stderr)
fundamentals = analyze_fundamentals_from_unified(data)
if args.verbose:
print(" Analyzing momentum...", file=sys.stderr)
momentum = analyze_momentum_from_unified(data)
# Synthesize signal
signal = synthesize_signal(
ticker=data.symbol,
company_name=data.name,
fundamentals=fundamentals,
momentum=momentum,
data=data,
)
results.append(signal)
if args.output == "text":
print(format_output_text(signal))
if args.output == "json":
if len(results) == 1:
print(format_output_json(results[0]))
else:
print(json.dumps([asdict(r) for r in results], indent=2, default=str))
if __name__ == "__main__":
main()
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Unified data source layer: LongPort (primary) + Yahoo Finance (fallback).
LongPort provides: PE, PB, EPS, BPS, dividend_yield, market_cap, realtime quotes
Yahoo Finance provides: operating margins, ROE, debt ratios, analyst data, earnings history
"""
import os
import sys
from dataclasses import dataclass, field
from typing import Optional, Literal
# Lazy imports to avoid startup cost
_longport_ctx = None
_yf = None
def _get_longport_context():
"""Get or create LongPort QuoteContext (singleton)."""
global _longport_ctx
if _longport_ctx is None:
try:
from longport import openapi
# Map LONGBRIDGE_* to LONGPORT_*
config = {}
with open(os.path.expanduser("~/.bashrc"), "r") as f:
for line in f:
if line.startswith("export LONGBRIDGE_"):
key, value = line.strip().split("=", 1)
config[key.replace("export ", "")] = value
os.environ["LONGPORT_APP_KEY"] = config.get("LONGBRIDGE_APP_KEY", "")
os.environ["LONGPORT_APP_SECRET"] = config.get("LONGBRIDGE_APP_SECRET", "")
os.environ["LONGPORT_ACCESS_TOKEN"] = config.get("LONGBRIDGE_ACCESS_TOKEN", "")
cfg = openapi.Config.from_env()
_longport_ctx = openapi.QuoteContext(config=cfg)
except Exception as e:
print(f"LongPort init failed: {e}", file=sys.stderr)
return _longport_ctx
def _get_yfinance():
"""Lazy import yfinance."""
global _yf
if _yf is None:
try:
import yfinance
_yf = yfinance
except ImportError:
print("yfinance not installed, Yahoo fallback disabled", file=sys.stderr)
return _yf
@dataclass
class UnifiedStockData:
"""Unified stock data from multiple sources."""
# Identity
symbol: str
name: str = ""
market: str = "" # "US", "HK", "CN"
currency: str = ""
# Price data (LongPort primary)
last_price: Optional[float] = None
prev_close: Optional[float] = None
open_price: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
volume: Optional[int] = None
turnover: Optional[float] = None
# Valuation metrics (LongPort primary)
pe_ttm: Optional[float] = None
pb: Optional[float] = None
dividend_yield: Optional[float] = None
eps_ttm: Optional[float] = None
bps: Optional[float] = None # Book value per share
market_cap: Optional[float] = None
# Trading metrics (LongPort only)
turnover_rate: Optional[float] = None
volume_ratio: Optional[float] = None
change_rate: Optional[float] = None
# Share info (LongPort)
total_shares: Optional[int] = None
circulating_shares: Optional[int] = None
# Fundamental extras (Yahoo Finance fallback)
operating_margin: Optional[float] = None
profit_margin: Optional[float] = None
roe: Optional[float] = None
roa: Optional[float] = None
revenue_growth: Optional[float] = None
earnings_growth: Optional[float] = None
debt_to_equity: Optional[float] = None
current_ratio: Optional[float] = None
# Analyst data (Yahoo Finance only)
recommendation: Optional[str] = None
target_price: Optional[float] = None
num_analysts: Optional[int] = None
# Earnings (Yahoo Finance only)
earnings_history: Optional[object] = None # DataFrame
# Price history (Yahoo Finance primary, LongPort fallback)
price_history: Optional[object] = None # DataFrame
# Metadata
data_sources: list = field(default_factory=list) # ["longport", "yahoo"]
def _detect_market(symbol: str) -> tuple[str, str]:
"""Detect market from symbol format.
Returns: (normalized_symbol, market)
Examples:
"O.US" -> ("O.US", "US")
"AAPL" -> ("AAPL.US", "US")
"823.HK" -> ("823.HK", "HK")
"000001.SZ" -> ("000001.SZ", "CN")
"""
symbol = symbol.upper().strip()
if symbol.endswith(".US"):
return symbol, "US"
elif symbol.endswith(".HK"):
return symbol, "HK"
elif symbol.endswith(".SZ") or symbol.endswith(".SH"):
return symbol, "CN"
elif symbol.endswith("-USD"):
return symbol, "CRYPTO"
else:
# Assume US stock if no suffix
return f"{symbol}.US", "US"
def fetch_from_longport(symbol: str, verbose: bool = False) -> Optional[UnifiedStockData]:
"""Fetch data from LongPort SDK."""
ctx = _get_longport_context()
if ctx is None:
return None
try:
from longport.openapi import CalcIndex
normalized, market = _detect_market(symbol)
if verbose:
print(f" [LongPort] Fetching {normalized}...", file=sys.stderr)
# Get quote
quotes = ctx.quote([normalized])
if not quotes:
return None
q = quotes[0]
# Get static info (EPS, BPS, shares)
static_infos = ctx.static_info([normalized])
static = static_infos[0] if static_infos else None
# Get calc indexes (PE, PB, dividend yield, market cap)
indexes = [
CalcIndex.PeTtmRatio,
CalcIndex.PbRatio,
CalcIndex.DividendRatioTtm,
CalcIndex.TotalMarketValue,
CalcIndex.TurnoverRate,
CalcIndex.VolumeRatio,
CalcIndex.ChangeRate,
]
calc_results = ctx.calc_indexes([normalized], indexes)
calc = calc_results[0] if calc_results else None
# Build result
data = UnifiedStockData(
symbol=normalized,
name=getattr(static, 'name_en', '') or getattr(static, 'name_cn', ''),
market=market,
currency=getattr(static, 'currency', ''),
# Price
last_price=q.last_done,
prev_close=q.prev_close,
open_price=q.open,
high=q.high,
low=q.low,
volume=q.volume,
turnover=q.turnover,
# Valuation
pe_ttm=getattr(calc, 'pe_ttm_ratio', None),
pb=getattr(calc, 'pb_ratio', None),
dividend_yield=getattr(calc, 'dividend_ratio_ttm', None),
eps_ttm=getattr(static, 'eps_ttm', None),
bps=getattr(static, 'bps', None),
market_cap=getattr(calc, 'total_market_value', None),
# Trading
turnover_rate=getattr(calc, 'turnover_rate', None),
volume_ratio=getattr(calc, 'volume_ratio', None),
change_rate=getattr(calc, 'change_rate', None),
# Shares
total_shares=getattr(static, 'total_shares', None),
circulating_shares=getattr(static, 'circulating_shares', None),
# Source
data_sources=["longport"],
)
return data
except Exception as e:
if verbose:
print(f" [LongPort] Error: {e}", file=sys.stderr)
return None
def fetch_from_yahoo(symbol: str, verbose: bool = False) -> Optional[UnifiedStockData]:
"""Fetch data from Yahoo Finance (fallback for missing fields)."""
yf = _get_yfinance()
if yf is None:
return None
try:
# Convert symbol format for Yahoo
yahoo_symbol = symbol.replace(".US", "")
if symbol.endswith(".HK"):
yahoo_symbol = symbol # Yahoo uses .HK suffix
if verbose:
print(f" [Yahoo] Fetching {yahoo_symbol}...", file=sys.stderr)
stock = yf.Ticker(yahoo_symbol)
info = stock.info
if not info:
return None
# Get price history
try:
price_history = stock.history(period="1y")
except Exception:
price_history = None
# Get earnings history
try:
earnings_history = stock.earnings_dates
except Exception:
earnings_history = None
# Build result with Yahoo data
data = UnifiedStockData(
symbol=symbol,
name=info.get("shortName", "") or info.get("longName", ""),
market="US" if not symbol.endswith(".HK") else "HK",
currency=info.get("currency", ""),
# Price
last_price=info.get("regularMarketPrice") or info.get("currentPrice"),
prev_close=info.get("regularMarketPreviousClose"),
open_price=info.get("regularMarketOpen"),
high=info.get("regularMarketDayHigh"),
low=info.get("regularMarketDayLow"),
volume=info.get("regularMarketVolume"),
# Valuation
pe_ttm=info.get("trailingPE"),
pb=info.get("priceToBook"),
dividend_yield=info.get("dividendYield"),
eps_ttm=info.get("trailingEps"),
market_cap=info.get("marketCap"),
# Fundamentals (Yahoo extras)
operating_margin=info.get("operatingMargins"),
profit_margin=info.get("profitMargins"),
roe=info.get("returnOnEquity"),
roa=info.get("returnOnAssets"),
revenue_growth=info.get("revenueGrowth"),
earnings_growth=info.get("earningsGrowth"),
debt_to_equity=info.get("debtToEquity"),
current_ratio=info.get("currentRatio"),
# Analyst
recommendation=info.get("recommendationKey"),
target_price=info.get("targetMeanPrice"),
num_analysts=info.get("numberOfAnalystOpinions"),
# History
earnings_history=earnings_history,
price_history=price_history,
# Source
data_sources=["yahoo"],
)
return data
except Exception as e:
if verbose:
print(f" [Yahoo] Error: {e}", file=sys.stderr)
return None
def merge_data(longport_data: Optional[UnifiedStockData],
yahoo_data: Optional[UnifiedStockData]) -> Optional[UnifiedStockData]:
"""Merge data from both sources, LongPort takes priority."""
if longport_data is None and yahoo_data is None:
return None
if longport_data is None:
return yahoo_data
if yahoo_data is None:
return longport_data
# Merge: LongPort primary, Yahoo fills gaps
merged = longport_data
# Fill Yahoo extras
for field in ['operating_margin', 'profit_margin', 'roe', 'roa',
'revenue_growth', 'earnings_growth', 'debt_to_equity',
'current_ratio', 'recommendation', 'target_price',
'num_analysts', 'earnings_history', 'price_history']:
if getattr(merged, field) is None:
val = getattr(yahoo_data, field)
if val is not None:
setattr(merged, field, val)
# Track sources
merged.data_sources = ["longport", "yahoo"]
return merged
def fetch_stock_data_unified(symbol: str, verbose: bool = False) -> Optional[UnifiedStockData]:
"""Main entry point: fetch stock data with LongPort primary, Yahoo fallback.
Args:
symbol: Stock symbol (e.g., "O", "O.US", "823.HK", "AAPL")
verbose: Print debug info
Returns:
UnifiedStockData or None
"""
normalized, market = _detect_market(symbol)
if verbose:
print(f"\nFetching {normalized} ({market})...", file=sys.stderr)
# 1. Try LongPort first (for all markets)
longport_data = fetch_from_longport(normalized, verbose)
# 2. For US stocks, also try Yahoo for extras
yahoo_data = None
if market == "US":
yahoo_data = fetch_from_yahoo(normalized, verbose)
# 3. Merge results
result = merge_data(longport_data, yahoo_data)
if result and verbose:
print(f" Sources: {result.data_sources}", file=sys.stderr)
print(f" PE: {result.pe_ttm}, PB: {result.pb}, Yield: {result.dividend_yield}%", file=sys.stderr)
return result
# Convenience function for backward compatibility
def fetch_stock_data(symbol: str, verbose: bool = False):
"""Backward compatible wrapper."""
return fetch_stock_data_unified(symbol, verbose)
if __name__ == "__main__":
# Test
symbols = ["O", "823.HK", "MAIN"]
for sym in symbols:
data = fetch_stock_data_unified(sym, verbose=True)
if data:
print(f"\n{data.symbol}:")
print(f" Name: {data.name}")
print(f" Price: {data.last_price} {data.currency}")
print(f" PE: {data.pe_ttm}, PB: {data.pb}")
print(f" Dividend Yield: {data.dividend_yield}%")
print(f" EPS: {data.eps_ttm}, BPS: {data.bps}")
print(f" Market Cap: {data.market_cap:,.0f}" if data.market_cap else "")
print(f" Sources: {data.data_sources}")
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "yfinance>=0.2.40",
# "pandas>=2.0.0",
# ]
# ///
"""
Dividend Analysis Module.
Analyzes dividend metrics for income investors:
- Dividend Yield
- Payout Ratio
- Dividend Growth Rate (5Y CAGR)
- Dividend Safety Score
- Ex-Dividend Date
Usage:
uv run dividends.py AAPL
uv run dividends.py JNJ PG KO --output json
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from datetime import datetime
import pandas as pd
import yfinance as yf
@dataclass
class DividendAnalysis:
ticker: str
company_name: str
# Basic metrics
dividend_yield: float | None # Annual yield %
annual_dividend: float | None # Annual dividend per share
current_price: float | None
# Payout analysis
payout_ratio: float | None # Dividend / EPS
payout_status: str # "safe", "moderate", "high", "unsustainable"
# Growth
dividend_growth_5y: float | None # 5-year CAGR %
consecutive_years: int | None # Years of consecutive increases
dividend_history: list[dict] | None # Last 5 years
# Timing
ex_dividend_date: str | None
payment_frequency: str | None # "quarterly", "monthly", "annual"
# Safety score (0-100)
safety_score: int
safety_factors: list[str]
# Verdict
income_rating: str # "excellent", "good", "moderate", "poor", "no_dividend"
summary: str
def analyze_dividends(ticker: str, verbose: bool = False) -> DividendAnalysis | None:
"""Analyze dividend metrics for a stock."""
try:
stock = yf.Ticker(ticker)
info = stock.info
company_name = info.get("longName") or info.get("shortName") or ticker
current_price = info.get("regularMarketPrice") or info.get("currentPrice")
# Basic dividend info
dividend_yield = info.get("dividendYield")
if dividend_yield:
dividend_yield = dividend_yield * 100 # Convert to percentage
annual_dividend = info.get("dividendRate")
# No dividend
if not annual_dividend or annual_dividend == 0:
return DividendAnalysis(
ticker=ticker,
company_name=company_name,
dividend_yield=None,
annual_dividend=None,
current_price=current_price,
payout_ratio=None,
payout_status="no_dividend",
dividend_growth_5y=None,
consecutive_years=None,
dividend_history=None,
ex_dividend_date=None,
payment_frequency=None,
safety_score=0,
safety_factors=["No dividend paid"],
income_rating="no_dividend",
summary=f"{ticker} does not pay a dividend.",
)
# Payout ratio
trailing_eps = info.get("trailingEps")
payout_ratio = None
payout_status = "unknown"
if trailing_eps and trailing_eps > 0 and annual_dividend:
payout_ratio = (annual_dividend / trailing_eps) * 100
if payout_ratio < 40:
payout_status = "safe"
elif payout_ratio < 60:
payout_status = "moderate"
elif payout_ratio < 80:
payout_status = "high"
else:
payout_status = "unsustainable"
# Dividend history (for growth calculation)
dividends = stock.dividends
dividend_history = None
dividend_growth_5y = None
consecutive_years = None
if dividends is not None and len(dividends) > 0:
# Group by year
dividends_df = dividends.reset_index()
dividends_df["Year"] = pd.to_datetime(dividends_df["Date"]).dt.year
yearly = dividends_df.groupby("Year")["Dividends"].sum().sort_index(ascending=False)
# Last 5 years history
dividend_history = []
for year in yearly.head(5).index:
dividend_history.append({
"year": int(year),
"total": round(float(yearly[year]), 4),
})
# Calculate 5-year CAGR
if len(yearly) >= 5:
current_div = yearly.iloc[0]
div_5y_ago = yearly.iloc[4]
if div_5y_ago > 0 and current_div > 0:
dividend_growth_5y = ((current_div / div_5y_ago) ** (1/5) - 1) * 100
# Count consecutive years of increases
consecutive_years = 0
prev_div = None
for div in yearly.values:
if prev_div is not None:
if div >= prev_div:
consecutive_years += 1
else:
break
prev_div = div
# Ex-dividend date
ex_dividend_date = info.get("exDividendDate")
if ex_dividend_date:
ex_dividend_date = datetime.fromtimestamp(ex_dividend_date).strftime("%Y-%m-%d")
# Payment frequency
payment_frequency = None
if dividends is not None and len(dividends) >= 4:
# Count dividends in last year
one_year_ago = pd.Timestamp.now() - pd.DateOffset(years=1)
recent_divs = dividends[dividends.index > one_year_ago]
count = len(recent_divs)
if count >= 10:
payment_frequency = "monthly"
elif count >= 3:
payment_frequency = "quarterly"
elif count >= 1:
payment_frequency = "annual"
# Safety score calculation (0-100)
safety_score = 50 # Base score
safety_factors = []
# Payout ratio factor (+/- 20)
if payout_ratio:
if payout_ratio < 40:
safety_score += 20
safety_factors.append(f"Low payout ratio ({payout_ratio:.0f}%)")
elif payout_ratio < 60:
safety_score += 10
safety_factors.append(f"Moderate payout ratio ({payout_ratio:.0f}%)")
elif payout_ratio < 80:
safety_score -= 10
safety_factors.append(f"High payout ratio ({payout_ratio:.0f}%)")
else:
safety_score -= 20
safety_factors.append(f"Unsustainable payout ratio ({payout_ratio:.0f}%)")
# Growth factor (+/- 15)
if dividend_growth_5y:
if dividend_growth_5y > 10:
safety_score += 15
safety_factors.append(f"Strong dividend growth ({dividend_growth_5y:.1f}% CAGR)")
elif dividend_growth_5y > 5:
safety_score += 10
safety_factors.append(f"Good dividend growth ({dividend_growth_5y:.1f}% CAGR)")
elif dividend_growth_5y > 0:
safety_score += 5
safety_factors.append(f"Positive dividend growth ({dividend_growth_5y:.1f}% CAGR)")
else:
safety_score -= 15
safety_factors.append(f"Dividend declining ({dividend_growth_5y:.1f}% CAGR)")
# Consecutive years factor (+/- 15)
if consecutive_years:
if consecutive_years >= 25:
safety_score += 15
safety_factors.append(f"Dividend Aristocrat ({consecutive_years}+ years)")
elif consecutive_years >= 10:
safety_score += 10
safety_factors.append(f"Long dividend history ({consecutive_years} years)")
elif consecutive_years >= 5:
safety_score += 5
safety_factors.append(f"Consistent dividend ({consecutive_years} years)")
# Yield factor (high yield can be risky)
if dividend_yield:
if dividend_yield > 8:
safety_score -= 10
safety_factors.append(f"Very high yield ({dividend_yield:.1f}%) - verify sustainability")
elif dividend_yield < 1:
safety_factors.append(f"Low yield ({dividend_yield:.2f}%)")
# Clamp score
safety_score = max(0, min(100, safety_score))
# Income rating
if safety_score >= 80:
income_rating = "excellent"
elif safety_score >= 60:
income_rating = "good"
elif safety_score >= 40:
income_rating = "moderate"
else:
income_rating = "poor"
# Summary
summary_parts = []
if dividend_yield:
summary_parts.append(f"{dividend_yield:.2f}% yield")
if payout_ratio:
summary_parts.append(f"{payout_ratio:.0f}% payout")
if dividend_growth_5y:
summary_parts.append(f"{dividend_growth_5y:+.1f}% 5Y growth")
if consecutive_years and consecutive_years >= 5:
summary_parts.append(f"{consecutive_years}Y streak")
summary = f"{ticker}: {', '.join(summary_parts)}. Rating: {income_rating.upper()}"
return DividendAnalysis(
ticker=ticker,
company_name=company_name,
dividend_yield=round(dividend_yield, 2) if dividend_yield else None,
annual_dividend=round(annual_dividend, 4) if annual_dividend else None,
current_price=current_price,
payout_ratio=round(payout_ratio, 1) if payout_ratio else None,
payout_status=payout_status,
dividend_growth_5y=round(dividend_growth_5y, 2) if dividend_growth_5y else None,
consecutive_years=consecutive_years,
dividend_history=dividend_history,
ex_dividend_date=ex_dividend_date,
payment_frequency=payment_frequency,
safety_score=safety_score,
safety_factors=safety_factors,
income_rating=income_rating,
summary=summary,
)
except Exception as e:
if verbose:
print(f"Error analyzing {ticker}: {e}", file=sys.stderr)
return None
def format_text(analysis: DividendAnalysis) -> str:
"""Format dividend analysis as text."""
lines = [
"=" * 60,
f"DIVIDEND ANALYSIS: {analysis.ticker} ({analysis.company_name})",
"=" * 60,
"",
]
if analysis.income_rating == "no_dividend":
lines.append("This stock does not pay a dividend.")
lines.append("=" * 60)
return "\n".join(lines)
# Yield & Price
lines.append(f"Current Price: ${analysis.current_price:.2f}")
lines.append(f"Annual Dividend: ${analysis.annual_dividend:.2f}")
lines.append(f"Dividend Yield: {analysis.dividend_yield:.2f}%")
lines.append(f"Payment Freq: {analysis.payment_frequency or 'Unknown'}")
if analysis.ex_dividend_date:
lines.append(f"Ex-Dividend: {analysis.ex_dividend_date}")
lines.append("")
# Payout & Safety
lines.append(f"Payout Ratio: {analysis.payout_ratio:.1f}% ({analysis.payout_status})")
lines.append(f"5Y Div Growth: {analysis.dividend_growth_5y:+.1f}%" if analysis.dividend_growth_5y else "5Y Div Growth: N/A")
if analysis.consecutive_years:
lines.append(f"Consecutive Yrs: {analysis.consecutive_years}")
lines.append("")
lines.append(f"SAFETY SCORE: {analysis.safety_score}/100")
lines.append(f"INCOME RATING: {analysis.income_rating.upper()}")
lines.append("")
lines.append("Safety Factors:")
for factor in analysis.safety_factors:
lines.append(f"{factor}")
# History
if analysis.dividend_history:
lines.append("")
lines.append("Dividend History:")
for h in analysis.dividend_history[:5]:
lines.append(f" {h['year']}: ${h['total']:.2f}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Dividend Analysis")
parser.add_argument("tickers", nargs="+", help="Stock ticker(s)")
parser.add_argument("--output", choices=["text", "json"], default="text")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
results = []
for ticker in args.tickers:
analysis = analyze_dividends(ticker.upper(), verbose=args.verbose)
if analysis:
results.append(analysis)
else:
print(f"Error: Could not analyze {ticker}", file=sys.stderr)
if args.output == "json":
if len(results) == 1:
print(json.dumps(asdict(results[0]), indent=2))
else:
print(json.dumps([asdict(r) for r in results], indent=2))
else:
for i, analysis in enumerate(results):
if i > 0:
print("\n")
print(format_text(analysis))
if __name__ == "__main__":
main()
@@ -0,0 +1,582 @@
#!/usr/bin/env python3
"""
🔥 HOT SCANNER v2 - Find viral stocks & crypto trends
Now with Twitter/X, Reddit, and improved Yahoo Finance
"""
import json
import urllib.request
import urllib.error
import xml.etree.ElementTree as ET
import gzip
import io
import subprocess
import os
from datetime import datetime, timezone
from pathlib import Path
import re
import ssl
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
# Load .env file if exists
ENV_FILE = Path(__file__).parent.parent / ".env"
if ENV_FILE.exists():
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ[key] = value
# Cache directory
CACHE_DIR = Path(__file__).parent.parent / "cache"
CACHE_DIR.mkdir(exist_ok=True)
# SSL context
SSL_CONTEXT = ssl.create_default_context()
class HotScanner:
def __init__(self, include_social=True):
self.include_social = include_social
self.results = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"crypto": [],
"stocks": [],
"news": [],
"movers": [],
"social": []
}
self.mentions = defaultdict(lambda: {"count": 0, "sources": [], "sentiment_hints": []})
self.headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
}
def _fetch(self, url, timeout=15):
"""Fetch URL with gzip support."""
req = urllib.request.Request(url, headers=self.headers)
with urllib.request.urlopen(req, timeout=timeout, context=SSL_CONTEXT) as resp:
data = resp.read()
# Handle gzip
if resp.info().get('Content-Encoding') == 'gzip' or data[:2] == b'\x1f\x8b':
data = gzip.decompress(data)
return data.decode('utf-8', errors='replace')
def _fetch_json(self, url, timeout=15):
"""Fetch and parse JSON."""
return json.loads(self._fetch(url, timeout))
def scan_all(self):
"""Run all scans in parallel."""
print("🔍 Scanning for hot trends...\n")
tasks = [
("CoinGecko Trending", self.scan_coingecko_trending),
("CoinGecko Movers", self.scan_coingecko_gainers_losers),
("Google News Finance", self.scan_google_news_finance),
("Google News Crypto", self.scan_google_news_crypto),
("Yahoo Movers", self.scan_yahoo_movers),
]
if self.include_social:
tasks.extend([
("Reddit WSB", self.scan_reddit_wsb),
("Reddit Crypto", self.scan_reddit_crypto),
("Twitter/X", self.scan_twitter),
])
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(task[1]): task[0] for task in tasks}
for future in as_completed(futures):
name = futures[future]
try:
future.result()
except Exception as e:
print(f"{name}: {str(e)[:50]}")
return self.results
def scan_coingecko_trending(self):
"""Get trending crypto from CoinGecko."""
print(" 📊 CoinGecko Trending...")
try:
url = "https://api.coingecko.com/api/v3/search/trending"
data = self._fetch_json(url)
for item in data.get("coins", [])[:10]:
coin = item.get("item", {})
price_data = coin.get("data", {})
price_change = price_data.get("price_change_percentage_24h", {}).get("usd", 0)
entry = {
"symbol": coin.get("symbol", "").upper(),
"name": coin.get("name", ""),
"rank": coin.get("market_cap_rank"),
"price_change_24h": round(price_change, 2) if price_change else None,
"source": "coingecko_trending"
}
self.results["crypto"].append(entry)
sym = entry["symbol"]
self.mentions[sym]["count"] += 2 # Trending gets extra weight
self.mentions[sym]["sources"].append("CoinGecko Trending")
if price_change:
direction = "🚀 bullish" if price_change > 0 else "📉 bearish"
self.mentions[sym]["sentiment_hints"].append(f"{direction} ({price_change:+.1f}%)")
print(f"{len(data.get('coins', []))} trending coins")
except Exception as e:
print(f" ❌ CoinGecko trending: {e}")
def scan_coingecko_gainers_losers(self):
"""Get top gainers/losers."""
print(" 📈 CoinGecko Movers...")
try:
url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&price_change_percentage=24h"
data = self._fetch_json(url)
sorted_data = sorted(data, key=lambda x: abs(x.get("price_change_percentage_24h") or 0), reverse=True)
count = 0
for coin in sorted_data[:20]:
change = coin.get("price_change_percentage_24h", 0)
if abs(change or 0) > 3:
entry = {
"symbol": coin.get("symbol", "").upper(),
"name": coin.get("name", ""),
"price": coin.get("current_price"),
"change_24h": round(change, 2) if change else None,
"volume": coin.get("total_volume"),
"source": "coingecko_movers"
}
self.results["movers"].append(entry)
count += 1
sym = entry["symbol"]
self.mentions[sym]["count"] += 1
self.mentions[sym]["sources"].append("CoinGecko Movers")
direction = "🚀 pumping" if change > 0 else "📉 dumping"
self.mentions[sym]["sentiment_hints"].append(f"{direction} ({change:+.1f}%)")
print(f"{count} significant movers")
except Exception as e:
print(f" ❌ CoinGecko movers: {e}")
def scan_google_news_finance(self):
"""Get finance news from Google News RSS."""
print(" 📰 Google News Finance...")
try:
# Business news topic
url = "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGx6TVdZU0FtVnVHZ0pWVXlnQVAB?hl=en-US&gl=US&ceid=US:en"
text = self._fetch(url)
root = ET.fromstring(text)
items = root.findall(".//item")
for item in items[:15]:
title_elem = item.find("title")
title = title_elem.text if title_elem is not None else ""
tickers = self._extract_tickers(title)
news_entry = {
"title": title,
"tickers_mentioned": tickers,
"source": "google_news_finance"
}
self.results["news"].append(news_entry)
for ticker in tickers:
self.mentions[ticker]["count"] += 1
self.mentions[ticker]["sources"].append("Google News")
self.mentions[ticker]["sentiment_hints"].append(f"📰 {title[:40]}...")
print(f"{len(items)} news items")
except Exception as e:
print(f" ❌ Google News Finance: {e}")
def scan_google_news_crypto(self):
"""Search for crypto news."""
print(" 📰 Google News Crypto...")
try:
url = "https://news.google.com/rss/search?q=bitcoin+OR+ethereum+OR+crypto+crash+OR+crypto+pump&hl=en-US&gl=US&ceid=US:en"
text = self._fetch(url)
root = ET.fromstring(text)
items = root.findall(".//item")
crypto_keywords = {
"bitcoin": "BTC", "btc": "BTC", "ethereum": "ETH", "eth": "ETH",
"solana": "SOL", "xrp": "XRP", "ripple": "XRP", "dogecoin": "DOGE",
"cardano": "ADA", "polkadot": "DOT", "avalanche": "AVAX",
}
for item in items[:12]:
title_elem = item.find("title")
title = title_elem.text if title_elem is not None else ""
tickers = self._extract_tickers(title)
for word, ticker in crypto_keywords.items():
if word in title.lower():
tickers.append(ticker)
tickers = list(set(tickers))
if tickers:
news_entry = {
"title": title,
"tickers_mentioned": tickers,
"source": "google_news_crypto"
}
self.results["news"].append(news_entry)
for ticker in tickers:
self.mentions[ticker]["count"] += 1
self.mentions[ticker]["sources"].append("Google News Crypto")
print(f" ✅ Processed crypto news")
except Exception as e:
print(f" ❌ Google News Crypto: {e}")
def scan_yahoo_movers(self):
"""Scrape Yahoo Finance movers with gzip support."""
print(" 📈 Yahoo Finance Movers...")
categories = [
("gainers", "https://finance.yahoo.com/gainers/"),
("losers", "https://finance.yahoo.com/losers/"),
("most_active", "https://finance.yahoo.com/most-active/")
]
for category, url in categories:
try:
text = self._fetch(url, timeout=12)
# Multiple patterns for ticker extraction
tickers = []
# Pattern 1: data-symbol attribute
tickers.extend(re.findall(r'data-symbol="([A-Z]{1,5})"', text))
# Pattern 2: ticker in URL
tickers.extend(re.findall(r'/quote/([A-Z]{1,5})[/"\?]', text))
# Pattern 3: fin-streamer
tickers.extend(re.findall(r'fin-streamer[^>]*symbol="([A-Z]{1,5})"', text))
unique_tickers = list(dict.fromkeys(tickers))[:15]
for ticker in unique_tickers:
# Skip common false positives
if ticker in ['USA', 'CEO', 'IPO', 'ETF', 'SEC', 'FDA', 'NYSE', 'API']:
continue
self.results["stocks"].append({
"symbol": ticker,
"category": category,
"source": f"yahoo_{category}"
})
self.mentions[ticker]["count"] += 1
self.mentions[ticker]["sources"].append(f"Yahoo {category.replace('_', ' ').title()}")
if unique_tickers:
print(f" ✅ Yahoo {category}: {len(unique_tickers)} tickers")
except Exception as e:
print(f" ⚠️ Yahoo {category}: {str(e)[:30]}")
def scan_reddit_wsb(self):
"""Scrape r/wallstreetbets for hot stocks."""
print(" 🦍 Reddit r/wallstreetbets...")
try:
# Use old.reddit.com (more scrape-friendly)
url = "https://old.reddit.com/r/wallstreetbets/hot/.json"
headers = {**self.headers, "Accept": "application/json"}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15, context=SSL_CONTEXT) as resp:
data = resp.read()
if data[:2] == b'\x1f\x8b':
data = gzip.decompress(data)
posts = json.loads(data.decode('utf-8'))
tickers_found = []
for post in posts.get("data", {}).get("children", [])[:25]:
title = post.get("data", {}).get("title", "")
score = post.get("data", {}).get("score", 0)
# Extract tickers
tickers = self._extract_tickers(title)
for ticker in tickers:
if ticker not in ['USA', 'CEO', 'IPO', 'DD', 'WSB', 'YOLO', 'FD']:
weight = 2 if score > 1000 else 1
self.mentions[ticker]["count"] += weight
self.mentions[ticker]["sources"].append("Reddit WSB")
self.mentions[ticker]["sentiment_hints"].append(f"🦍 WSB: {title[:35]}...")
tickers_found.append(ticker)
self.results["social"].append({
"platform": "reddit_wsb",
"title": title[:100],
"score": score,
"tickers": tickers
})
print(f" ✅ WSB: {len(set(tickers_found))} tickers mentioned")
except Exception as e:
print(f" ❌ Reddit WSB: {str(e)[:40]}")
def scan_reddit_crypto(self):
"""Scrape r/cryptocurrency for hot coins."""
print(" 💎 Reddit r/cryptocurrency...")
try:
url = "https://old.reddit.com/r/cryptocurrency/hot/.json"
headers = {**self.headers, "Accept": "application/json"}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15, context=SSL_CONTEXT) as resp:
data = resp.read()
if data[:2] == b'\x1f\x8b':
data = gzip.decompress(data)
posts = json.loads(data.decode('utf-8'))
crypto_keywords = {
"bitcoin": "BTC", "btc": "BTC", "ethereum": "ETH", "eth": "ETH",
"solana": "SOL", "sol": "SOL", "xrp": "XRP", "cardano": "ADA",
"dogecoin": "DOGE", "doge": "DOGE", "shiba": "SHIB", "pepe": "PEPE",
"avalanche": "AVAX", "polkadot": "DOT", "chainlink": "LINK",
}
tickers_found = []
for post in posts.get("data", {}).get("children", [])[:20]:
title = post.get("data", {}).get("title", "").lower()
score = post.get("data", {}).get("score", 0)
for word, ticker in crypto_keywords.items():
if word in title:
weight = 2 if score > 500 else 1
self.mentions[ticker]["count"] += weight
self.mentions[ticker]["sources"].append("Reddit Crypto")
tickers_found.append(ticker)
print(f" ✅ r/crypto: {len(set(tickers_found))} coins mentioned")
except Exception as e:
print(f" ❌ Reddit Crypto: {str(e)[:40]}")
def scan_twitter(self):
"""Use bird CLI to get trending finance/crypto tweets."""
print(" 🐦 Twitter/X...")
try:
# Find bird binary
bird_paths = [
"/home/clawdbot/.nvm/versions/node/v24.12.0/bin/bird",
"/usr/local/bin/bird",
"bird"
]
bird_bin = None
for p in bird_paths:
if Path(p).exists() or p == "bird":
bird_bin = p
break
if not bird_bin:
print(" ⚠️ Twitter: bird not found")
return
# Search for finance tweets
searches = [
("stocks", "stock OR $SPY OR $QQQ OR earnings"),
("crypto", "bitcoin OR ethereum OR crypto OR $BTC"),
]
for category, query in searches:
try:
env = os.environ.copy()
result = subprocess.run(
[bird_bin, "search", query, "-n", "15", "--json"],
capture_output=True, text=True, timeout=30, env=env
)
if result.returncode == 0 and result.stdout.strip():
tweets = json.loads(result.stdout)
for tweet in tweets[:10]:
text = tweet.get("text", "")
tickers = self._extract_tickers(text)
# Add crypto keywords
crypto_map = {"bitcoin": "BTC", "ethereum": "ETH", "solana": "SOL"}
for word, ticker in crypto_map.items():
if word in text.lower():
tickers.append(ticker)
for ticker in set(tickers):
self.mentions[ticker]["count"] += 1
self.mentions[ticker]["sources"].append("Twitter/X")
self.mentions[ticker]["sentiment_hints"].append(f"🐦 {text[:35]}...")
self.results["social"].append({
"platform": "twitter",
"text": text[:100],
"tickers": list(set(tickers))
})
print(f" ✅ Twitter {category}: processed")
except subprocess.TimeoutExpired:
print(f" ⚠️ Twitter {category}: timeout")
except json.JSONDecodeError:
print(f" ⚠️ Twitter {category}: no auth?")
except FileNotFoundError:
print(" ⚠️ Twitter: bird CLI not found")
except Exception as e:
print(f" ❌ Twitter: {str(e)[:40]}")
def _extract_tickers(self, text):
"""Extract stock/crypto tickers from text."""
patterns = [
r'\$([A-Z]{1,5})\b', # $AAPL
r'\(([A-Z]{2,5})\)', # (AAPL)
r'(?:^|\s)([A-Z]{2,4})(?:\s|$|[,.])', # Standalone caps
]
tickers = []
for pattern in patterns:
matches = re.findall(pattern, text)
tickers.extend(matches)
# Company mappings
companies = {
"Apple": "AAPL", "Microsoft": "MSFT", "Google": "GOOGL", "Alphabet": "GOOGL",
"Amazon": "AMZN", "Tesla": "TSLA", "Nvidia": "NVDA", "Meta": "META",
"Netflix": "NFLX", "GameStop": "GME", "AMD": "AMD", "Intel": "INTC",
"Palantir": "PLTR", "Coinbase": "COIN", "MicroStrategy": "MSTR",
}
for company, ticker in companies.items():
if company.lower() in text.lower():
tickers.append(ticker)
# Filter out common words
skip = {'USA', 'CEO', 'IPO', 'ETF', 'SEC', 'FDA', 'NYSE', 'API', 'USD', 'EU',
'UK', 'US', 'AI', 'IT', 'AT', 'TO', 'IN', 'ON', 'IS', 'IF', 'OR', 'AN',
'DD', 'WSB', 'YOLO', 'FD', 'OP', 'PM', 'AM'}
return list(set(t for t in tickers if t not in skip and len(t) >= 2))
def get_hot_summary(self):
"""Generate summary."""
sorted_mentions = sorted(
self.mentions.items(),
key=lambda x: x[1]["count"],
reverse=True
)
summary = {
"scan_time": self.results["timestamp"],
"top_trending": [],
"crypto_highlights": [],
"stock_highlights": [],
"social_buzz": [],
"breaking_news": []
}
for symbol, data in sorted_mentions[:20]:
summary["top_trending"].append({
"symbol": symbol,
"mentions": data["count"],
"sources": list(set(data["sources"])),
"signals": data["sentiment_hints"][:3]
})
# Crypto
seen = set()
for coin in self.results["crypto"] + self.results["movers"]:
if coin["symbol"] not in seen:
summary["crypto_highlights"].append(coin)
seen.add(coin["symbol"])
# Stocks
seen = set()
for stock in self.results["stocks"]:
if stock["symbol"] not in seen:
summary["stock_highlights"].append(stock)
seen.add(stock["symbol"])
# Social
for item in self.results["social"][:15]:
summary["social_buzz"].append(item)
# News
for news in self.results["news"][:10]:
if news.get("tickers_mentioned"):
summary["breaking_news"].append({
"title": news["title"],
"tickers": news["tickers_mentioned"]
})
return summary
def main():
import argparse
parser = argparse.ArgumentParser(description="🔥 Hot Scanner - Find trending stocks & crypto")
parser.add_argument("--no-social", action="store_true", help="Skip social media scans")
parser.add_argument("--json", action="store_true", help="Output only JSON")
args = parser.parse_args()
scanner = HotScanner(include_social=not args.no_social)
if not args.json:
print("=" * 60)
print("🔥 HOT SCANNER v2 - What's Trending Right Now?")
print(f"📅 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} UTC")
print("=" * 60)
print()
scanner.scan_all()
summary = scanner.get_hot_summary()
# Save
output_file = CACHE_DIR / "hot_scan_latest.json"
with open(output_file, "w") as f:
json.dump(summary, f, indent=2, default=str)
if args.json:
print(json.dumps(summary, indent=2, default=str))
return
print()
print("=" * 60)
print("🔥 RESULTS")
print("=" * 60)
print("\n📊 TOP TRENDING (by buzz):\n")
for i, item in enumerate(summary["top_trending"][:12], 1):
sources = ", ".join(item["sources"][:2])
signal = item["signals"][0][:30] if item["signals"] else ""
print(f" {i:2}. {item['symbol']:8} ({item['mentions']:2} pts) [{sources}] {signal}")
print("\n🪙 CRYPTO:\n")
for coin in summary["crypto_highlights"][:8]:
change = coin.get("change_24h") or coin.get("price_change_24h")
change_str = f"{change:+.1f}%" if change else "🔥"
emoji = "🚀" if (change or 0) > 0 else "📉" if (change or 0) < 0 else "🔥"
print(f" {emoji} {coin.get('symbol', '?'):8} {coin.get('name', '')[:16]:16} {change_str:>8}")
print("\n📈 STOCKS:\n")
cat_emoji = {"gainers": "🟢", "losers": "🔴", "most_active": "📊"}
for stock in summary["stock_highlights"][:10]:
emoji = cat_emoji.get(stock.get("category"), "")
print(f" {emoji} {stock['symbol']:6} ({stock.get('category', 'N/A').replace('_', ' ')})")
if summary["social_buzz"]:
print("\n🐦 SOCIAL BUZZ:\n")
for item in summary["social_buzz"][:5]:
platform = item.get("platform", "?")
text = item.get("title") or item.get("text", "")
text = text[:55] + "..." if len(text) > 55 else text
print(f" [{platform}] {text}")
print("\n📰 NEWS:\n")
for news in summary["breaking_news"][:5]:
tickers = ", ".join(news["tickers"][:3])
title = news["title"][:55] + "..." if len(news["title"]) > 55 else news["title"]
print(f" [{tickers}] {title}")
print(f"\n💾 Saved: {output_file}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,548 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["yfinance>=0.2.40"]
# ///
"""
Portfolio management for stock-analysis skill.
Usage:
uv run portfolio.py create "Portfolio Name"
uv run portfolio.py list
uv run portfolio.py show [--portfolio NAME]
uv run portfolio.py delete "Portfolio Name"
uv run portfolio.py rename "Old Name" "New Name"
uv run portfolio.py add TICKER --quantity 100 --cost 150.00 [--portfolio NAME]
uv run portfolio.py update TICKER --quantity 150 [--portfolio NAME]
uv run portfolio.py remove TICKER [--portfolio NAME]
"""
import argparse
import json
import os
import sys
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from typing import Literal
import yfinance as yf
# Top 20 supported cryptocurrencies
SUPPORTED_CRYPTOS = {
"BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD", "XRP-USD",
"ADA-USD", "DOGE-USD", "AVAX-USD", "DOT-USD", "MATIC-USD",
"LINK-USD", "ATOM-USD", "UNI-USD", "LTC-USD", "BCH-USD",
"XLM-USD", "ALGO-USD", "VET-USD", "FIL-USD", "NEAR-USD",
}
def get_storage_path() -> Path:
"""Get the portfolio storage path."""
# Use ~/.clawdbot/skills/stock-analysis/portfolios.json
state_dir = os.environ.get("CLAWDBOT_STATE_DIR", os.path.expanduser("~/.clawdbot"))
portfolio_dir = Path(state_dir) / "skills" / "stock-analysis"
portfolio_dir.mkdir(parents=True, exist_ok=True)
return portfolio_dir / "portfolios.json"
def detect_asset_type(ticker: str) -> Literal["stock", "crypto"]:
"""Detect asset type from ticker format."""
ticker_upper = ticker.upper()
if ticker_upper.endswith("-USD"):
base = ticker_upper[:-4]
if base.isalpha() and f"{base}-USD" in SUPPORTED_CRYPTOS:
return "crypto"
# Allow any *-USD ticker as crypto (flexible)
if base.isalpha():
return "crypto"
return "stock"
@dataclass
class Asset:
ticker: str
type: Literal["stock", "crypto"]
quantity: float
cost_basis: float
added_at: str
@dataclass
class Portfolio:
name: str
created_at: str
updated_at: str
assets: list[Asset]
class PortfolioStore:
"""Manages portfolio storage with atomic writes."""
def __init__(self, path: Path | None = None):
self.path = path or get_storage_path()
self._data: dict | None = None
def _load(self) -> dict:
"""Load portfolios from disk."""
if self._data is not None:
return self._data
if not self.path.exists():
self._data = {"version": 1, "portfolios": {}}
return self._data
try:
with open(self.path, "r", encoding="utf-8") as f:
self._data = json.load(f)
return self._data
except (json.JSONDecodeError, IOError):
self._data = {"version": 1, "portfolios": {}}
return self._data
def _save(self) -> None:
"""Save portfolios to disk with atomic write."""
if self._data is None:
return
# Ensure directory exists
self.path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: write to temp file, then rename
tmp_path = self.path.with_suffix(".tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2)
tmp_path.replace(self.path)
except Exception:
if tmp_path.exists():
tmp_path.unlink()
raise
def _get_portfolio_key(self, name: str) -> str:
"""Convert portfolio name to storage key."""
return name.lower().replace(" ", "-")
def list_portfolios(self) -> list[str]:
"""List all portfolio names."""
data = self._load()
return [p["name"] for p in data["portfolios"].values()]
def get_portfolio(self, name: str) -> Portfolio | None:
"""Get a portfolio by name."""
data = self._load()
key = self._get_portfolio_key(name)
if key not in data["portfolios"]:
# Try case-insensitive match
for k, v in data["portfolios"].items():
if v["name"].lower() == name.lower():
key = k
break
else:
return None
p = data["portfolios"][key]
assets = [
Asset(
ticker=a["ticker"],
type=a["type"],
quantity=a["quantity"],
cost_basis=a["cost_basis"],
added_at=a["added_at"],
)
for a in p.get("assets", [])
]
return Portfolio(
name=p["name"],
created_at=p["created_at"],
updated_at=p["updated_at"],
assets=assets,
)
def create_portfolio(self, name: str) -> Portfolio:
"""Create a new portfolio."""
data = self._load()
key = self._get_portfolio_key(name)
if key in data["portfolios"]:
raise ValueError(f"Portfolio '{name}' already exists")
now = datetime.now().isoformat()
portfolio = {
"name": name,
"created_at": now,
"updated_at": now,
"assets": [],
}
data["portfolios"][key] = portfolio
self._save()
return Portfolio(name=name, created_at=now, updated_at=now, assets=[])
def delete_portfolio(self, name: str) -> bool:
"""Delete a portfolio."""
data = self._load()
key = self._get_portfolio_key(name)
# Try case-insensitive match
if key not in data["portfolios"]:
for k, v in data["portfolios"].items():
if v["name"].lower() == name.lower():
key = k
break
else:
return False
del data["portfolios"][key]
self._save()
return True
def rename_portfolio(self, old_name: str, new_name: str) -> bool:
"""Rename a portfolio."""
data = self._load()
old_key = self._get_portfolio_key(old_name)
new_key = self._get_portfolio_key(new_name)
# Find old portfolio
if old_key not in data["portfolios"]:
for k, v in data["portfolios"].items():
if v["name"].lower() == old_name.lower():
old_key = k
break
else:
return False
if new_key in data["portfolios"] and new_key != old_key:
raise ValueError(f"Portfolio '{new_name}' already exists")
portfolio = data["portfolios"].pop(old_key)
portfolio["name"] = new_name
portfolio["updated_at"] = datetime.now().isoformat()
data["portfolios"][new_key] = portfolio
self._save()
return True
def add_asset(
self,
portfolio_name: str,
ticker: str,
quantity: float,
cost_basis: float,
) -> Asset:
"""Add an asset to a portfolio."""
data = self._load()
key = self._get_portfolio_key(portfolio_name)
# Find portfolio
if key not in data["portfolios"]:
for k, v in data["portfolios"].items():
if v["name"].lower() == portfolio_name.lower():
key = k
break
else:
raise ValueError(f"Portfolio '{portfolio_name}' not found")
portfolio = data["portfolios"][key]
ticker = ticker.upper()
# Check if asset already exists
for asset in portfolio["assets"]:
if asset["ticker"] == ticker:
raise ValueError(f"Asset '{ticker}' already in portfolio. Use 'update' to modify.")
# Validate ticker
asset_type = detect_asset_type(ticker)
try:
stock = yf.Ticker(ticker)
info = stock.info
if "regularMarketPrice" not in info:
raise ValueError(f"Invalid ticker: {ticker}")
except Exception as e:
raise ValueError(f"Could not validate ticker '{ticker}': {e}")
now = datetime.now().isoformat()
asset = {
"ticker": ticker,
"type": asset_type,
"quantity": quantity,
"cost_basis": cost_basis,
"added_at": now,
}
portfolio["assets"].append(asset)
portfolio["updated_at"] = now
self._save()
return Asset(**asset)
def update_asset(
self,
portfolio_name: str,
ticker: str,
quantity: float | None = None,
cost_basis: float | None = None,
) -> Asset | None:
"""Update an asset in a portfolio."""
data = self._load()
key = self._get_portfolio_key(portfolio_name)
# Find portfolio
if key not in data["portfolios"]:
for k, v in data["portfolios"].items():
if v["name"].lower() == portfolio_name.lower():
key = k
break
else:
return None
portfolio = data["portfolios"][key]
ticker = ticker.upper()
for asset in portfolio["assets"]:
if asset["ticker"] == ticker:
if quantity is not None:
asset["quantity"] = quantity
if cost_basis is not None:
asset["cost_basis"] = cost_basis
portfolio["updated_at"] = datetime.now().isoformat()
self._save()
return Asset(**asset)
return None
def remove_asset(self, portfolio_name: str, ticker: str) -> bool:
"""Remove an asset from a portfolio."""
data = self._load()
key = self._get_portfolio_key(portfolio_name)
# Find portfolio
if key not in data["portfolios"]:
for k, v in data["portfolios"].items():
if v["name"].lower() == portfolio_name.lower():
key = k
break
else:
return False
portfolio = data["portfolios"][key]
ticker = ticker.upper()
original_len = len(portfolio["assets"])
portfolio["assets"] = [a for a in portfolio["assets"] if a["ticker"] != ticker]
if len(portfolio["assets"]) < original_len:
portfolio["updated_at"] = datetime.now().isoformat()
self._save()
return True
return False
def get_default_portfolio_name(self) -> str | None:
"""Get the default (first) portfolio name, or None if empty."""
portfolios = self.list_portfolios()
return portfolios[0] if portfolios else None
def format_currency(value: float) -> str:
"""Format a value as currency."""
if abs(value) >= 1_000_000:
return f"${value/1_000_000:.2f}M"
elif abs(value) >= 1_000:
return f"${value/1_000:.2f}K"
else:
return f"${value:.2f}"
def show_portfolio(portfolio: Portfolio, verbose: bool = False) -> None:
"""Display portfolio details with current prices."""
print(f"\n{'='*60}")
print(f"PORTFOLIO: {portfolio.name}")
print(f"Created: {portfolio.created_at[:10]} | Updated: {portfolio.updated_at[:10]}")
print(f"{'='*60}\n")
if not portfolio.assets:
print(" No assets in portfolio. Use 'add' to add assets.\n")
return
total_cost = 0.0
total_value = 0.0
print(f"{'Ticker':<12} {'Type':<8} {'Qty':>10} {'Cost':>12} {'Current':>12} {'Value':>14} {'P&L':>12}")
print("-" * 82)
for asset in portfolio.assets:
try:
stock = yf.Ticker(asset.ticker)
current_price = stock.info.get("regularMarketPrice", 0) or 0
except Exception:
current_price = 0
cost_total = asset.quantity * asset.cost_basis
current_value = asset.quantity * current_price
pnl = current_value - cost_total
pnl_pct = (pnl / cost_total * 100) if cost_total > 0 else 0
total_cost += cost_total
total_value += current_value
pnl_str = f"{'+' if pnl >= 0 else ''}{format_currency(pnl)} ({pnl_pct:+.1f}%)"
print(f"{asset.ticker:<12} {asset.type:<8} {asset.quantity:>10.4f} "
f"{format_currency(asset.cost_basis):>12} {format_currency(current_price):>12} "
f"{format_currency(current_value):>14} {pnl_str:>12}")
print("-" * 82)
total_pnl = total_value - total_cost
total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else 0
print(f"{'TOTAL':<12} {'':<8} {'':<10} {format_currency(total_cost):>12} {'':<12} "
f"{format_currency(total_value):>14} {'+' if total_pnl >= 0 else ''}{format_currency(total_pnl)} ({total_pnl_pct:+.1f}%)")
print()
def main():
parser = argparse.ArgumentParser(description="Portfolio management for stock-analysis")
subparsers = parser.add_subparsers(dest="command", help="Commands")
# create
create_parser = subparsers.add_parser("create", help="Create a new portfolio")
create_parser.add_argument("name", help="Portfolio name")
# list
subparsers.add_parser("list", help="List all portfolios")
# show
show_parser = subparsers.add_parser("show", help="Show portfolio details")
show_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
# delete
delete_parser = subparsers.add_parser("delete", help="Delete a portfolio")
delete_parser.add_argument("name", help="Portfolio name")
# rename
rename_parser = subparsers.add_parser("rename", help="Rename a portfolio")
rename_parser.add_argument("old_name", help="Current portfolio name")
rename_parser.add_argument("new_name", help="New portfolio name")
# add
add_parser = subparsers.add_parser("add", help="Add an asset to portfolio")
add_parser.add_argument("ticker", help="Stock/crypto ticker (e.g., AAPL, BTC-USD)")
add_parser.add_argument("--quantity", "-q", type=float, required=True, help="Quantity")
add_parser.add_argument("--cost", "-c", type=float, required=True, help="Cost basis per unit")
add_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
# update
update_parser = subparsers.add_parser("update", help="Update an asset in portfolio")
update_parser.add_argument("ticker", help="Stock/crypto ticker")
update_parser.add_argument("--quantity", "-q", type=float, help="New quantity")
update_parser.add_argument("--cost", "-c", type=float, help="New cost basis per unit")
update_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
# remove
remove_parser = subparsers.add_parser("remove", help="Remove an asset from portfolio")
remove_parser.add_argument("ticker", help="Stock/crypto ticker")
remove_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
store = PortfolioStore()
try:
if args.command == "create":
portfolio = store.create_portfolio(args.name)
print(f"Created portfolio: {portfolio.name}")
elif args.command == "list":
portfolios = store.list_portfolios()
if not portfolios:
print("No portfolios found. Use 'create' to create one.")
else:
print("\nPortfolios:")
for name in portfolios:
p = store.get_portfolio(name)
asset_count = len(p.assets) if p else 0
print(f" - {name} ({asset_count} assets)")
print()
elif args.command == "show":
portfolio_name = args.portfolio or store.get_default_portfolio_name()
if not portfolio_name:
print("No portfolios found. Use 'create' to create one.")
sys.exit(1)
portfolio = store.get_portfolio(portfolio_name)
if not portfolio:
print(f"Portfolio '{portfolio_name}' not found.")
sys.exit(1)
show_portfolio(portfolio)
elif args.command == "delete":
if store.delete_portfolio(args.name):
print(f"Deleted portfolio: {args.name}")
else:
print(f"Portfolio '{args.name}' not found.")
sys.exit(1)
elif args.command == "rename":
if store.rename_portfolio(args.old_name, args.new_name):
print(f"Renamed portfolio: {args.old_name} -> {args.new_name}")
else:
print(f"Portfolio '{args.old_name}' not found.")
sys.exit(1)
elif args.command == "add":
portfolio_name = args.portfolio or store.get_default_portfolio_name()
if not portfolio_name:
print("No portfolios found. Use 'create' to create one first.")
sys.exit(1)
asset = store.add_asset(portfolio_name, args.ticker, args.quantity, args.cost)
print(f"Added {asset.ticker} ({asset.type}) to {portfolio_name}: "
f"{asset.quantity} units @ {format_currency(asset.cost_basis)}")
elif args.command == "update":
portfolio_name = args.portfolio or store.get_default_portfolio_name()
if not portfolio_name:
print("No portfolios found.")
sys.exit(1)
if args.quantity is None and args.cost is None:
print("Must specify --quantity and/or --cost to update.")
sys.exit(1)
asset = store.update_asset(portfolio_name, args.ticker, args.quantity, args.cost)
if asset:
print(f"Updated {asset.ticker} in {portfolio_name}: "
f"{asset.quantity} units @ {format_currency(asset.cost_basis)}")
else:
print(f"Asset '{args.ticker}' not found in portfolio '{portfolio_name}'.")
sys.exit(1)
elif args.command == "remove":
portfolio_name = args.portfolio or store.get_default_portfolio_name()
if not portfolio_name:
print("No portfolios found.")
sys.exit(1)
if store.remove_asset(portfolio_name, args.ticker):
print(f"Removed {args.ticker.upper()} from {portfolio_name}")
else:
print(f"Asset '{args.ticker}' not found in portfolio '{portfolio_name}'.")
sys.exit(1)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
🔮 RUMOR & BUZZ SCANNER
Scans for early signals, rumors, and whispers before they become mainstream news.
Sources:
- Twitter/X: "hearing", "rumor", "sources say", unusual buzz
- Google News: M&A, insider, upgrade/downgrade
- Unusual keywords detection
Usage: python3 rumor_scanner.py
"""
import json
import os
import subprocess
import sys
import re
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.parse import quote_plus
import gzip
CACHE_DIR = Path(__file__).parent.parent / "cache"
CACHE_DIR.mkdir(exist_ok=True)
# Bird CLI path
BIRD_CLI = "/home/clawdbot/.nvm/versions/node/v24.12.0/bin/bird"
BIRD_ENV = Path(__file__).parent.parent / ".env"
def load_env():
"""Load environment variables from .env file."""
if BIRD_ENV.exists():
for line in BIRD_ENV.read_text().splitlines():
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip().strip('"').strip("'")
def fetch_url(url, timeout=15):
"""Fetch URL with headers."""
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'en-US,en;q=0.9',
}
req = Request(url, headers=headers)
try:
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
if resp.info().get('Content-Encoding') == 'gzip':
data = gzip.decompress(data)
return data.decode('utf-8', errors='ignore')
except Exception as e:
return None
def search_twitter_rumors():
"""Search Twitter for rumors and early signals."""
results = []
# Rumor-focused search queries
queries = [
'"hearing that" stock OR $',
'"sources say" stock OR company',
'"rumor" merger OR acquisition',
'insider buying stock',
'"upgrade" OR "downgrade" stock tomorrow',
'$AAPL OR $TSLA OR $NVDA rumor',
'"breaking" stock market',
'M&A rumor',
]
load_env()
for query in queries[:4]: # Limit to avoid rate limits
try:
cmd = [BIRD_CLI, 'search', query, '-n', '10', '--json']
env = os.environ.copy()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env)
if result.returncode == 0 and result.stdout:
try:
tweets = json.loads(result.stdout)
for tweet in tweets:
text = tweet.get('text', '')
# Filter for actual rumors/signals
if any(kw in text.lower() for kw in ['hearing', 'rumor', 'source', 'insider', 'upgrade', 'downgrade', 'breaking', 'M&A', 'merger', 'acquisition']):
results.append({
'source': 'twitter',
'type': 'rumor',
'text': text[:300],
'author': tweet.get('author', {}).get('username', 'unknown'),
'likes': tweet.get('likes', 0),
'retweets': tweet.get('retweets', 0),
'query': query
})
except json.JSONDecodeError:
pass
except Exception as e:
pass
# Dedupe by text similarity
seen = set()
unique = []
for r in results:
key = r['text'][:100]
if key not in seen:
seen.add(key)
unique.append(r)
return unique
def search_twitter_buzz():
"""Search Twitter for general stock buzz - what are people talking about?"""
results = []
queries = [
'$SPY OR $QQQ',
'stock to buy',
'calls OR puts expiring',
'earnings play',
'short squeeze',
]
load_env()
for query in queries[:3]:
try:
cmd = [BIRD_CLI, 'search', query, '-n', '15', '--json']
env = os.environ.copy()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env)
if result.returncode == 0 and result.stdout:
try:
tweets = json.loads(result.stdout)
for tweet in tweets:
text = tweet.get('text', '')
# Extract stock symbols
symbols = re.findall(r'\$([A-Z]{1,5})\b', text)
if symbols:
results.append({
'source': 'twitter',
'type': 'buzz',
'text': text[:300],
'symbols': symbols,
'author': tweet.get('author', {}).get('username', 'unknown'),
'engagement': tweet.get('likes', 0) + tweet.get('retweets', 0) * 2
})
except json.JSONDecodeError:
pass
except Exception as e:
pass
# Sort by engagement
results.sort(key=lambda x: x.get('engagement', 0), reverse=True)
return results[:20]
def search_news_rumors():
"""Search Google News for M&A, insider, upgrade news."""
results = []
queries = [
'merger acquisition rumor',
'insider buying stock',
'analyst upgrade stock',
'takeover bid company',
'SEC investigation company',
]
for query in queries:
url = f"https://news.google.com/rss/search?q={quote_plus(query)}&hl=en-US&gl=US&ceid=US:en"
content = fetch_url(url)
if content:
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(content)
for item in root.findall('.//item')[:5]:
title = item.find('title')
link = item.find('link')
pub_date = item.find('pubDate')
if title is not None:
title_text = title.text or ''
# Extract company names or symbols
results.append({
'source': 'google_news',
'type': 'news_rumor',
'title': title_text,
'link': link.text if link is not None else '',
'date': pub_date.text if pub_date is not None else '',
'query': query
})
except ET.ParseError:
pass
return results
def extract_symbols_from_text(text):
"""Extract stock symbols from text."""
# $SYMBOL pattern
dollar_symbols = re.findall(r'\$([A-Z]{1,5})\b', text)
# Common company name to symbol mapping
company_map = {
'apple': 'AAPL', 'tesla': 'TSLA', 'nvidia': 'NVDA', 'microsoft': 'MSFT',
'google': 'GOOGL', 'amazon': 'AMZN', 'meta': 'META', 'netflix': 'NFLX',
'coinbase': 'COIN', 'robinhood': 'HOOD', 'disney': 'DIS', 'intel': 'INTC',
'amd': 'AMD', 'palantir': 'PLTR', 'gamestop': 'GME', 'amc': 'AMC',
}
text_lower = text.lower()
company_symbols = [sym for name, sym in company_map.items() if name in text_lower]
return list(set(dollar_symbols + company_symbols))
def calculate_rumor_score(item):
"""Score a rumor by potential impact."""
score = 0
text = (item.get('text', '') + item.get('title', '')).lower()
# High impact keywords
if any(kw in text for kw in ['merger', 'acquisition', 'takeover', 'buyout']):
score += 5
if any(kw in text for kw in ['insider', 'ceo buying', 'director buying']):
score += 4
if any(kw in text for kw in ['upgrade', 'price target raised']):
score += 3
if any(kw in text for kw in ['downgrade', 'sec investigation', 'fraud']):
score += 3
if any(kw in text for kw in ['hearing', 'sources say', 'rumor']):
score += 2
if any(kw in text for kw in ['breaking', 'just in', 'alert']):
score += 2
# Engagement boost
if item.get('engagement', 0) > 100:
score += 2
if item.get('likes', 0) > 50:
score += 1
return score
def main():
print("=" * 60)
print("🔮 RUMOR & BUZZ SCANNER")
print(f"📅 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC")
print("=" * 60)
print()
print("🔍 Scanning for early signals...")
print()
all_rumors = []
all_buzz = []
# Twitter Rumors
print(" 🐦 Twitter rumors...")
rumors = search_twitter_rumors()
print(f"{len(rumors)} potential rumors")
all_rumors.extend(rumors)
# Twitter Buzz
print(" 🐦 Twitter buzz...")
buzz = search_twitter_buzz()
print(f"{len(buzz)} buzz items")
all_buzz.extend(buzz)
# News Rumors
print(" 📰 News rumors...")
news = search_news_rumors()
print(f"{len(news)} news items")
all_rumors.extend(news)
# Score and sort rumors
for item in all_rumors:
item['score'] = calculate_rumor_score(item)
item['symbols'] = extract_symbols_from_text(item.get('text', '') + item.get('title', ''))
all_rumors.sort(key=lambda x: x['score'], reverse=True)
# Count symbol mentions in buzz
symbol_counts = {}
for item in all_buzz:
for sym in item.get('symbols', []):
symbol_counts[sym] = symbol_counts.get(sym, 0) + 1
# Output
print()
print("=" * 60)
print("🔮 RESULTS")
print("=" * 60)
print()
# Top Rumors
print("🚨 TOP RUMORS (by potential impact):")
print()
for item in all_rumors[:10]:
if item['score'] > 0:
source = item['source']
symbols = ', '.join(item.get('symbols', [])) or 'N/A'
text = item.get('text', item.get('title', ''))[:80]
print(f" [{item['score']}] [{source}] {symbols}")
print(f" {text}...")
print()
# Buzz Leaderboard
print("📊 BUZZ LEADERBOARD (most discussed):")
print()
sorted_symbols = sorted(symbol_counts.items(), key=lambda x: x[1], reverse=True)
for symbol, count in sorted_symbols[:15]:
bar = "" * min(count, 20)
print(f" ${symbol:5} {bar} ({count})")
print()
# Recent Buzz Snippets
print("💬 WHAT PEOPLE ARE SAYING:")
print()
for item in all_buzz[:8]:
author = item.get('author', 'anon')
text = item.get('text', '')[:120]
engagement = item.get('engagement', 0)
print(f" @{author} ({engagement}♥): {text}...")
print()
# Save results
output = {
'timestamp': datetime.now(timezone.utc).isoformat(),
'rumors': all_rumors[:20],
'buzz': all_buzz[:30],
'symbol_counts': symbol_counts,
}
output_file = CACHE_DIR / 'rumor_scan_latest.json'
output_file.write_text(json.dumps(output, indent=2, default=str))
print(f"💾 Saved: {output_file}")
if __name__ == "__main__":
main()
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pytest>=8.0.0",
# "yfinance>=0.2.40",
# "pandas>=2.0.0",
# ]
# ///
"""
Tests for Stock Analysis Skill v6.0
Run with: uv run pytest test_stock_analysis.py -v
"""
import json
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timezone
import pandas as pd
# Import modules to test
from analyze_stock import (
detect_asset_type,
calculate_rsi,
fetch_stock_data,
analyze_earnings_surprise,
analyze_fundamentals,
analyze_momentum,
synthesize_signal,
EarningsSurprise,
Fundamentals,
MomentumAnalysis,
MarketContext,
StockData,
)
from dividends import analyze_dividends
from watchlist import (
add_to_watchlist,
remove_from_watchlist,
list_watchlist,
WatchlistItem,
)
from portfolio import PortfolioStore
class TestAssetTypeDetection:
"""Test asset type detection."""
def test_stock_detection(self):
assert detect_asset_type("AAPL") == "stock"
assert detect_asset_type("MSFT") == "stock"
assert detect_asset_type("googl") == "stock"
def test_crypto_detection(self):
assert detect_asset_type("BTC-USD") == "crypto"
assert detect_asset_type("ETH-USD") == "crypto"
assert detect_asset_type("sol-usd") == "crypto"
def test_edge_cases(self):
# Ticker ending in USD but not crypto format
assert detect_asset_type("MUSD") == "stock"
# Numbers in ticker
assert detect_asset_type("BRK.B") == "stock"
class TestRSICalculation:
"""Test RSI calculation."""
def test_rsi_overbought(self):
"""Test RSI > 70 (overbought)."""
# Create rising prices
prices = pd.Series([100 + i * 2 for i in range(20)])
rsi = calculate_rsi(prices, period=14)
assert rsi is not None
assert rsi > 70
def test_rsi_oversold(self):
"""Test RSI < 30 (oversold)."""
# Create falling prices
prices = pd.Series([100 - i * 2 for i in range(20)])
rsi = calculate_rsi(prices, period=14)
assert rsi is not None
assert rsi < 30
def test_rsi_insufficient_data(self):
"""Test RSI with insufficient data."""
prices = pd.Series([100, 101, 102]) # Too few points
rsi = calculate_rsi(prices, period=14)
assert rsi is None
class TestEarningsSurprise:
"""Test earnings surprise analysis."""
def test_earnings_beat(self):
"""Test positive earnings surprise."""
# Mock StockData with earnings beat
mock_earnings = pd.DataFrame({
"Reported EPS": [1.50],
"EPS Estimate": [1.20],
}, index=[pd.Timestamp("2024-01-15")])
mock_data = Mock(spec=StockData)
mock_data.earnings_history = mock_earnings
result = analyze_earnings_surprise(mock_data)
assert result is not None
assert result.score > 0
assert result.surprise_pct > 0
assert "Beat" in result.explanation
def test_earnings_miss(self):
"""Test negative earnings surprise."""
mock_earnings = pd.DataFrame({
"Reported EPS": [0.80],
"EPS Estimate": [1.00],
}, index=[pd.Timestamp("2024-01-15")])
mock_data = Mock(spec=StockData)
mock_data.earnings_history = mock_earnings
result = analyze_earnings_surprise(mock_data)
assert result is not None
assert result.score < 0
assert result.surprise_pct < 0
assert "Missed" in result.explanation
class TestFundamentals:
"""Test fundamentals analysis."""
def test_strong_fundamentals(self):
"""Test stock with strong fundamentals."""
mock_data = Mock(spec=StockData)
mock_data.info = {
"trailingPE": 15,
"operatingMargins": 0.25,
"revenueGrowth": 0.30,
"debtToEquity": 30,
}
result = analyze_fundamentals(mock_data)
assert result is not None
assert result.score > 0
assert "pe_ratio" in result.key_metrics
def test_weak_fundamentals(self):
"""Test stock with weak fundamentals."""
mock_data = Mock(spec=StockData)
mock_data.info = {
"trailingPE": 50,
"operatingMargins": 0.02,
"revenueGrowth": -0.10,
"debtToEquity": 300,
}
result = analyze_fundamentals(mock_data)
assert result is not None
assert result.score < 0
class TestMomentum:
"""Test momentum analysis."""
def test_overbought_momentum(self):
"""Test overbought conditions."""
# Create mock price history with rising prices near 52w high
dates = pd.date_range(end=datetime.now(), periods=100)
prices = pd.DataFrame({
"Close": [100 + i * 0.5 for i in range(100)],
"Volume": [1000000] * 100,
}, index=dates)
mock_data = Mock(spec=StockData)
mock_data.price_history = prices
mock_data.info = {
"fiftyTwoWeekHigh": 150,
"fiftyTwoWeekLow": 80,
"regularMarketPrice": 148,
}
result = analyze_momentum(mock_data)
assert result is not None
assert result.rsi_status == "overbought"
assert result.near_52w_high == True
assert result.score < 0 # Overbought = negative score
class TestSignalSynthesis:
"""Test signal synthesis."""
def test_buy_signal(self):
"""Test BUY recommendation synthesis."""
earnings = EarningsSurprise(score=0.8, explanation="Beat by 20%", actual_eps=1.2, expected_eps=1.0, surprise_pct=20)
fundamentals = Fundamentals(score=0.6, key_metrics={"pe_ratio": 15}, explanation="Strong margins")
signal = synthesize_signal(
ticker="TEST",
company_name="Test Corp",
earnings=earnings,
fundamentals=fundamentals,
analysts=None,
historical=None,
market_context=None,
sector=None,
earnings_timing=None,
momentum=None,
sentiment=None,
)
assert signal.recommendation == "BUY"
assert signal.confidence > 0.5
def test_sell_signal(self):
"""Test SELL recommendation synthesis."""
earnings = EarningsSurprise(score=-0.8, explanation="Missed by 20%", actual_eps=0.8, expected_eps=1.0, surprise_pct=-20)
fundamentals = Fundamentals(score=-0.6, key_metrics={"pe_ratio": 50}, explanation="Weak margins")
signal = synthesize_signal(
ticker="TEST",
company_name="Test Corp",
earnings=earnings,
fundamentals=fundamentals,
analysts=None,
historical=None,
market_context=None,
sector=None,
earnings_timing=None,
momentum=None,
sentiment=None,
)
assert signal.recommendation == "SELL"
def test_risk_off_penalty(self):
"""Test risk-off mode reduces BUY confidence."""
earnings = EarningsSurprise(score=0.8, explanation="Beat", actual_eps=1.2, expected_eps=1.0, surprise_pct=20)
fundamentals = Fundamentals(score=0.6, key_metrics={}, explanation="Strong")
market = MarketContext(
vix_level=25,
vix_status="elevated",
spy_trend_10d=2.0,
qqq_trend_10d=1.5,
market_regime="choppy",
score=-0.2,
explanation="Risk-off",
gld_change_5d=3.0,
tlt_change_5d=2.0,
uup_change_5d=1.5,
risk_off_detected=True,
)
signal = synthesize_signal(
ticker="TEST",
company_name="Test Corp",
earnings=earnings,
fundamentals=fundamentals,
analysts=None,
historical=None,
market_context=market,
sector=None,
earnings_timing=None,
momentum=None,
sentiment=None,
)
# Should still be BUY but with reduced confidence
assert signal.recommendation in ["BUY", "HOLD"]
assert any("RISK-OFF" in c for c in signal.caveats)
class TestWatchlist:
"""Test watchlist functionality."""
@patch('watchlist.get_current_price')
@patch('watchlist.save_watchlist')
@patch('watchlist.load_watchlist')
def test_add_to_watchlist(self, mock_load, mock_save, mock_price):
"""Test adding ticker to watchlist."""
mock_load.return_value = []
mock_price.return_value = 150.0
mock_save.return_value = None
result = add_to_watchlist("AAPL", target_price=200.0)
assert result["success"] == True
assert result["action"] == "added"
assert result["ticker"] == "AAPL"
assert result["target_price"] == 200.0
@patch('watchlist.save_watchlist')
@patch('watchlist.load_watchlist')
def test_remove_from_watchlist(self, mock_load, mock_save):
"""Test removing ticker from watchlist."""
mock_load.return_value = [
WatchlistItem(ticker="AAPL", added_at="2024-01-01T00:00:00+00:00")
]
mock_save.return_value = None
result = remove_from_watchlist("AAPL")
assert result["success"] == True
assert result["removed"] == "AAPL"
class TestDividendAnalysis:
"""Test dividend analysis."""
@patch('yfinance.Ticker')
def test_dividend_stock(self, mock_ticker):
"""Test analysis of dividend-paying stock."""
mock_stock = Mock()
mock_stock.info = {
"longName": "Johnson & Johnson",
"regularMarketPrice": 160.0,
"dividendYield": 0.03,
"dividendRate": 4.80,
"trailingEps": 6.00,
}
mock_stock.dividends = pd.Series(
[1.2, 1.2, 1.2, 1.2] * 5, # 5 years of quarterly dividends
index=pd.date_range(start="2019-01-01", periods=20, freq="Q")
)
mock_ticker.return_value = mock_stock
result = analyze_dividends("JNJ")
assert result is not None
assert result.dividend_yield == 3.0
assert result.payout_ratio == 80.0
assert result.income_rating != "no_dividend"
@patch('yfinance.Ticker')
def test_no_dividend_stock(self, mock_ticker):
"""Test analysis of non-dividend stock."""
mock_stock = Mock()
mock_stock.info = {
"longName": "Amazon",
"regularMarketPrice": 180.0,
"dividendYield": None,
"dividendRate": None,
}
mock_ticker.return_value = mock_stock
result = analyze_dividends("AMZN")
assert result is not None
assert result.income_rating == "no_dividend"
class TestIntegration:
"""Integration tests (require network)."""
@pytest.mark.integration
def test_real_stock_analysis(self):
"""Test real stock analysis (AAPL)."""
data = fetch_stock_data("AAPL", verbose=False)
assert data is not None
assert data.ticker == "AAPL"
assert data.info is not None
assert "regularMarketPrice" in data.info
@pytest.mark.integration
def test_real_crypto_analysis(self):
"""Test real crypto analysis (BTC-USD)."""
data = fetch_stock_data("BTC-USD", verbose=False)
assert data is not None
assert data.asset_type == "crypto"
# Run tests
if __name__ == "__main__":
pytest.main([__file__, "-v", "--ignore-glob=*integration*"])
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "yfinance>=0.2.40",
# ]
# ///
"""
Stock Watchlist with Price Alerts.
Usage:
uv run watchlist.py add AAPL # Add to watchlist
uv run watchlist.py add AAPL --target 200 # With price target
uv run watchlist.py add AAPL --stop 150 # With stop loss
uv run watchlist.py add AAPL --alert-on signal # Alert on signal change
uv run watchlist.py remove AAPL # Remove from watchlist
uv run watchlist.py list # Show watchlist
uv run watchlist.py check # Check for triggered alerts
uv run watchlist.py check --notify # Check and format for notification
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import yfinance as yf
# Storage
WATCHLIST_DIR = Path.home() / ".clawdbot" / "skills" / "stock-analysis"
WATCHLIST_FILE = WATCHLIST_DIR / "watchlist.json"
@dataclass
class WatchlistItem:
ticker: str
added_at: str
price_at_add: float | None = None
target_price: float | None = None # Alert when price >= target
stop_price: float | None = None # Alert when price <= stop
alert_on_signal: bool = False # Alert when recommendation changes
last_signal: str | None = None # BUY/HOLD/SELL
last_check: str | None = None
notes: str | None = None
@dataclass
class Alert:
ticker: str
alert_type: Literal["target_hit", "stop_hit", "signal_change"]
message: str
current_price: float
trigger_value: float | str
timestamp: str
def ensure_dirs():
"""Create storage directories."""
WATCHLIST_DIR.mkdir(parents=True, exist_ok=True)
def load_watchlist() -> list[WatchlistItem]:
"""Load watchlist from file."""
if WATCHLIST_FILE.exists():
data = json.loads(WATCHLIST_FILE.read_text())
return [WatchlistItem(**item) for item in data]
return []
def save_watchlist(items: list[WatchlistItem]):
"""Save watchlist to file."""
ensure_dirs()
data = [asdict(item) for item in items]
WATCHLIST_FILE.write_text(json.dumps(data, indent=2))
def get_current_price(ticker: str) -> float | None:
"""Get current price for a ticker."""
try:
stock = yf.Ticker(ticker)
price = stock.info.get("regularMarketPrice") or stock.info.get("currentPrice")
return float(price) if price else None
except Exception:
return None
def add_to_watchlist(
ticker: str,
target_price: float | None = None,
stop_price: float | None = None,
alert_on_signal: bool = False,
notes: str | None = None,
) -> dict:
"""Add ticker to watchlist."""
ticker = ticker.upper()
# Validate ticker
current_price = get_current_price(ticker)
if current_price is None:
return {"success": False, "error": f"Invalid ticker: {ticker}"}
# Load existing watchlist
watchlist = load_watchlist()
# Check if already exists
for item in watchlist:
if item.ticker == ticker:
# Update existing
item.target_price = target_price or item.target_price
item.stop_price = stop_price or item.stop_price
item.alert_on_signal = alert_on_signal or item.alert_on_signal
item.notes = notes or item.notes
save_watchlist(watchlist)
return {
"success": True,
"action": "updated",
"ticker": ticker,
"current_price": current_price,
"target_price": item.target_price,
"stop_price": item.stop_price,
"alert_on_signal": item.alert_on_signal,
}
# Add new
item = WatchlistItem(
ticker=ticker,
added_at=datetime.now(timezone.utc).isoformat(),
price_at_add=current_price,
target_price=target_price,
stop_price=stop_price,
alert_on_signal=alert_on_signal,
notes=notes,
)
watchlist.append(item)
save_watchlist(watchlist)
return {
"success": True,
"action": "added",
"ticker": ticker,
"current_price": current_price,
"target_price": target_price,
"stop_price": stop_price,
"alert_on_signal": alert_on_signal,
}
def remove_from_watchlist(ticker: str) -> dict:
"""Remove ticker from watchlist."""
ticker = ticker.upper()
watchlist = load_watchlist()
original_len = len(watchlist)
watchlist = [item for item in watchlist if item.ticker != ticker]
if len(watchlist) == original_len:
return {"success": False, "error": f"{ticker} not in watchlist"}
save_watchlist(watchlist)
return {"success": True, "removed": ticker}
def list_watchlist() -> dict:
"""List all watchlist items with current prices."""
watchlist = load_watchlist()
if not watchlist:
return {"success": True, "items": [], "count": 0}
items = []
for item in watchlist:
current_price = get_current_price(item.ticker)
# Calculate change since added
change_pct = None
if current_price and item.price_at_add:
change_pct = ((current_price - item.price_at_add) / item.price_at_add) * 100
# Distance to target/stop
to_target = None
to_stop = None
if current_price:
if item.target_price:
to_target = ((item.target_price - current_price) / current_price) * 100
if item.stop_price:
to_stop = ((item.stop_price - current_price) / current_price) * 100
items.append({
"ticker": item.ticker,
"current_price": current_price,
"price_at_add": item.price_at_add,
"change_pct": round(change_pct, 2) if change_pct else None,
"target_price": item.target_price,
"to_target_pct": round(to_target, 2) if to_target else None,
"stop_price": item.stop_price,
"to_stop_pct": round(to_stop, 2) if to_stop else None,
"alert_on_signal": item.alert_on_signal,
"last_signal": item.last_signal,
"added_at": item.added_at[:10],
"notes": item.notes,
})
return {"success": True, "items": items, "count": len(items)}
def check_alerts(notify_format: bool = False) -> dict:
"""Check watchlist for triggered alerts."""
watchlist = load_watchlist()
alerts: list[Alert] = []
now = datetime.now(timezone.utc).isoformat()
for item in watchlist:
current_price = get_current_price(item.ticker)
if current_price is None:
continue
# Check target price
if item.target_price and current_price >= item.target_price:
alerts.append(Alert(
ticker=item.ticker,
alert_type="target_hit",
message=f"🎯 {item.ticker} hit target! ${current_price:.2f} >= ${item.target_price:.2f}",
current_price=current_price,
trigger_value=item.target_price,
timestamp=now,
))
# Check stop price
if item.stop_price and current_price <= item.stop_price:
alerts.append(Alert(
ticker=item.ticker,
alert_type="stop_hit",
message=f"🛑 {item.ticker} hit stop! ${current_price:.2f} <= ${item.stop_price:.2f}",
current_price=current_price,
trigger_value=item.stop_price,
timestamp=now,
))
# Check signal change (requires running analyze_stock)
if item.alert_on_signal:
try:
import subprocess
result = subprocess.run(
["uv", "run", str(Path(__file__).parent / "analyze_stock.py"), item.ticker, "--output", "json"],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0:
analysis = json.loads(result.stdout)
new_signal = analysis.get("recommendation")
if item.last_signal and new_signal and new_signal != item.last_signal:
alerts.append(Alert(
ticker=item.ticker,
alert_type="signal_change",
message=f"📊 {item.ticker} signal changed: {item.last_signal}{new_signal}",
current_price=current_price,
trigger_value=f"{item.last_signal}{new_signal}",
timestamp=now,
))
# Update last signal
item.last_signal = new_signal
except Exception:
pass
item.last_check = now
# Save updated watchlist (with last_signal updates)
save_watchlist(watchlist)
# Format output
if notify_format and alerts:
# Format for Telegram notification
lines = ["📢 **Stock Alerts**\n"]
for alert in alerts:
lines.append(alert.message)
return {"success": True, "alerts": [asdict(a) for a in alerts], "notification": "\n".join(lines)}
return {"success": True, "alerts": [asdict(a) for a in alerts], "count": len(alerts)}
def main():
parser = argparse.ArgumentParser(description="Stock Watchlist with Alerts")
subparsers = parser.add_subparsers(dest="command", required=True)
# Add
add_parser = subparsers.add_parser("add", help="Add ticker to watchlist")
add_parser.add_argument("ticker", help="Stock ticker")
add_parser.add_argument("--target", type=float, help="Target price for alert")
add_parser.add_argument("--stop", type=float, help="Stop loss price for alert")
add_parser.add_argument("--alert-on", choices=["signal"], help="Alert on signal change")
add_parser.add_argument("--notes", help="Notes")
# Remove
remove_parser = subparsers.add_parser("remove", help="Remove ticker from watchlist")
remove_parser.add_argument("ticker", help="Stock ticker")
# List
subparsers.add_parser("list", help="List watchlist")
# Check
check_parser = subparsers.add_parser("check", help="Check for triggered alerts")
check_parser.add_argument("--notify", action="store_true", help="Format for notification")
args = parser.parse_args()
if args.command == "add":
result = add_to_watchlist(
args.ticker,
target_price=args.target,
stop_price=args.stop,
alert_on_signal=(args.alert_on == "signal"),
notes=args.notes,
)
print(json.dumps(result, indent=2))
elif args.command == "remove":
result = remove_from_watchlist(args.ticker)
print(json.dumps(result, indent=2))
elif args.command == "list":
result = list_watchlist()
print(json.dumps(result, indent=2))
elif args.command == "check":
result = check_alerts(notify_format=args.notify)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()