Initial commit with translated description
This commit is contained in:
336
scripts/watchlist.py
Normal file
336
scripts/watchlist.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user