Files

337 lines
11 KiB
Python
Raw Permalink Normal View History

#!/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()