#!/usr/bin/env python3 """ Price Target Alerts - Track buy zone alerts for stocks. Features: - Set price target alerts (buy zone triggers) - Check alerts against current prices - Snooze, update, delete alerts - Multi-currency support (USD, EUR, JPY, SGD, MXN) Usage: alerts.py list # Show all alerts alerts.py set CRWD 400 --note 'Kaufzone' # Set alert alerts.py check # Check triggered alerts alerts.py delete CRWD # Delete alert alerts.py snooze CRWD --days 7 # Snooze for 7 days alerts.py update CRWD 380 # Update target price """ import argparse import json import sys from datetime import datetime, timedelta from pathlib import Path from utils import ensure_venv ensure_venv() # Lazy import to avoid numpy issues at module load fetch_market_data = None def get_fetch_market_data(): global fetch_market_data if fetch_market_data is None: from fetch_news import fetch_market_data as fmd fetch_market_data = fmd return fetch_market_data SCRIPT_DIR = Path(__file__).parent CONFIG_DIR = SCRIPT_DIR.parent / "config" ALERTS_FILE = CONFIG_DIR / "alerts.json" SUPPORTED_CURRENCIES = ["USD", "EUR", "JPY", "SGD", "MXN"] def load_alerts() -> dict: """Load alerts from JSON file.""" if not ALERTS_FILE.exists(): return {"_meta": {"version": 1, "supported_currencies": SUPPORTED_CURRENCIES}, "alerts": []} return json.loads(ALERTS_FILE.read_text()) def save_alerts(data: dict) -> None: """Save alerts to JSON file.""" data["_meta"]["updated_at"] = datetime.now().isoformat() ALERTS_FILE.write_text(json.dumps(data, indent=2)) def get_alert_by_ticker(alerts: list, ticker: str) -> dict | None: """Find alert by ticker.""" ticker = ticker.upper() for alert in alerts: if alert["ticker"] == ticker: return alert return None def format_price(price: float, currency: str) -> str: """Format price with currency symbol.""" symbols = {"USD": "$", "EUR": "€", "JPY": "Β₯", "SGD": "S$", "MXN": "MX$"} symbol = symbols.get(currency, currency + " ") if currency == "JPY": return f"{symbol}{price:,.0f}" return f"{symbol}{price:,.2f}" def cmd_list(args) -> None: """List all alerts.""" data = load_alerts() alerts = data.get("alerts", []) if not alerts: print("πŸ“­ No price alerts set") return print(f"πŸ“Š Price Alerts ({len(alerts)} total)\n") now = datetime.now() active = [] snoozed = [] for alert in alerts: snooze_until = alert.get("snooze_until") if snooze_until and datetime.fromisoformat(snooze_until) > now: snoozed.append(alert) else: active.append(alert) if active: print("### Active Alerts") for a in active: target = format_price(a["target_price"], a.get("currency", "USD")) note = f' β€” "{a["note"]}"' if a.get("note") else "" user = f" (by {a['set_by']})" if a.get("set_by") else "" print(f" β€’ {a['ticker']}: {target}{note}{user}") print() if snoozed: print("### Snoozed") for a in snoozed: target = format_price(a["target_price"], a.get("currency", "USD")) until = datetime.fromisoformat(a["snooze_until"]).strftime("%Y-%m-%d") print(f" β€’ {a['ticker']}: {target} (until {until})") print() def cmd_set(args) -> None: """Set a new alert.""" data = load_alerts() alerts = data.get("alerts", []) ticker = args.ticker.upper() # Check if alert exists existing = get_alert_by_ticker(alerts, ticker) if existing: print(f"⚠️ Alert for {ticker} already exists. Use 'update' to change target.") return # Validate target price if args.target <= 0: print(f"❌ Target price must be greater than 0") return currency = args.currency.upper() if args.currency else "USD" if currency not in SUPPORTED_CURRENCIES: print(f"❌ Currency {currency} not supported. Use: {', '.join(SUPPORTED_CURRENCIES)}") return # Warn about currency mismatch based on ticker suffix ticker_currency_map = { ".T": "JPY", # Tokyo ".SI": "SGD", # Singapore ".MX": "MXN", # Mexico ".DE": "EUR", ".F": "EUR", ".PA": "EUR", # Europe } expected_currency = "USD" # Default for US stocks for suffix, curr in ticker_currency_map.items(): if ticker.endswith(suffix): expected_currency = curr break if currency != expected_currency: print(f"⚠️ Warning: {ticker} trades in {expected_currency}, but alert set in {currency}") # Fetch current price (optional - may fail if numpy broken) current_price = None try: quotes = get_fetch_market_data()([ticker], timeout=10) if ticker in quotes and quotes[ticker].get("price"): current_price = quotes[ticker]["price"] except Exception as e: print(f"⚠️ Could not fetch current price: {e}", file=sys.stderr) alert = { "ticker": ticker, "target_price": args.target, "currency": currency, "note": args.note or "", "set_by": args.user or "", "set_date": datetime.now().strftime("%Y-%m-%d"), "status": "active", "snooze_until": None, "triggered_count": 0, "last_triggered": None, } alerts.append(alert) data["alerts"] = alerts save_alerts(data) target_str = format_price(args.target, currency) print(f"βœ… Alert set: {ticker} under {target_str}") if current_price: pct_diff = ((current_price - args.target) / current_price) * 100 current_str = format_price(current_price, currency) print(f" Current: {current_str} ({pct_diff:+.1f}% to target)") def cmd_delete(args) -> None: """Delete an alert.""" data = load_alerts() alerts = data.get("alerts", []) ticker = args.ticker.upper() new_alerts = [a for a in alerts if a["ticker"] != ticker] if len(new_alerts) == len(alerts): print(f"❌ No alert found for {ticker}") return data["alerts"] = new_alerts save_alerts(data) print(f"πŸ—‘οΈ Alert deleted: {ticker}") def cmd_snooze(args) -> None: """Snooze an alert.""" data = load_alerts() alerts = data.get("alerts", []) ticker = args.ticker.upper() alert = get_alert_by_ticker(alerts, ticker) if not alert: print(f"❌ No alert found for {ticker}") return days = args.days or 7 snooze_until = datetime.now() + timedelta(days=days) alert["snooze_until"] = snooze_until.isoformat() save_alerts(data) print(f"😴 Alert snoozed: {ticker} until {snooze_until.strftime('%Y-%m-%d')}") def cmd_update(args) -> None: """Update alert target price.""" data = load_alerts() alerts = data.get("alerts", []) ticker = args.ticker.upper() alert = get_alert_by_ticker(alerts, ticker) if not alert: print(f"❌ No alert found for {ticker}") return # Validate target price if args.target <= 0: print(f"❌ Target price must be greater than 0") return old_target = alert["target_price"] alert["target_price"] = args.target if args.note: alert["note"] = args.note save_alerts(data) currency = alert.get("currency", "USD") old_str = format_price(old_target, currency) new_str = format_price(args.target, currency) print(f"✏️ Alert updated: {ticker} {old_str} β†’ {new_str}") def cmd_check(args) -> None: """Check alerts against current prices.""" data = load_alerts() alerts = data.get("alerts", []) if not alerts: if args.json: print(json.dumps({"triggered": [], "watching": []})) else: print("πŸ“­ No alerts to check") return now = datetime.now() active_alerts = [] for alert in alerts: snooze_until = alert.get("snooze_until") if snooze_until and datetime.fromisoformat(snooze_until) > now: continue active_alerts.append(alert) if not active_alerts: if args.json: print(json.dumps({"triggered": [], "watching": []})) else: print("πŸ“­ All alerts snoozed") return # Fetch prices for all active alerts tickers = [a["ticker"] for a in active_alerts] quotes = get_fetch_market_data()(tickers, timeout=30) triggered = [] watching = [] for alert in active_alerts: ticker = alert["ticker"] target = alert["target_price"] currency = alert.get("currency", "USD") quote = quotes.get(ticker, {}) price = quote.get("price") if price is None: continue # Divide-by-zero protection if target == 0: pct_diff = 0 else: pct_diff = ((price - target) / target) * 100 result = { "ticker": ticker, "target_price": target, "current_price": price, "currency": currency, "pct_from_target": round(pct_diff, 2), "note": alert.get("note", ""), "set_by": alert.get("set_by", ""), } if price <= target: triggered.append(result) # Update triggered count (only once per day to avoid inflation) last_triggered = alert.get("last_triggered") today = now.strftime("%Y-%m-%d") if not last_triggered or not last_triggered.startswith(today): alert["triggered_count"] = alert.get("triggered_count", 0) + 1 alert["last_triggered"] = now.isoformat() else: watching.append(result) save_alerts(data) if args.json: print(json.dumps({"triggered": triggered, "watching": watching}, indent=2)) return # Translations lang = getattr(args, 'lang', 'en') if lang == "de": labels = { "title": "PREISWARNUNGEN", "in_zone": "IN KAUFZONE", "buy": "KAUFEN!", "target": "Ziel", "watching": "BEOBACHTUNG", "to_target": "noch", "no_data": "Keine Preisdaten fΓΌr Alerts verfΓΌgbar", } else: labels = { "title": "PRICE ALERTS", "in_zone": "IN BUY ZONE", "buy": "BUY SIGNAL", "target": "target", "watching": "WATCHING", "to_target": "to target", "no_data": "No price data available for alerts", } # Date header date_str = datetime.now().strftime("%b %d, %Y") if lang == "en" else datetime.now().strftime("%d. %b %Y") print(f"πŸ“Š {labels['title']} β€” {date_str}\n") # Human-readable output if triggered: print(f"🟒 {labels['in_zone']}:\n") for t in triggered: target_str = format_price(t["target_price"], t["currency"]) current_str = format_price(t["current_price"], t["currency"]) note = f'\n "{t["note"]}"' if t.get("note") else "" user = f" β€” {t['set_by']}" if t.get("set_by") else "" print(f"β€’ {t['ticker']}: {current_str} ({labels['target']}: {target_str}) ← {labels['buy']}{note}{user}") print() if watching: print(f"⏳ {labels['watching']}:\n") for w in sorted(watching, key=lambda x: x["pct_from_target"]): target_str = format_price(w["target_price"], w["currency"]) current_str = format_price(w["current_price"], w["currency"]) print(f"β€’ {w['ticker']}: {current_str} ({labels['target']}: {target_str}) β€” {labels['to_target']} {abs(w['pct_from_target']):.1f}%") print() if not triggered and not watching: print(f"πŸ“­ {labels['no_data']}") def check_alerts() -> dict: """ Check alerts and return results for briefing integration. Returns: {"triggered": [...], "watching": [...]} """ data = load_alerts() alerts = data.get("alerts", []) if not alerts: return {"triggered": [], "watching": []} now = datetime.now() active_alerts = [ a for a in alerts if not a.get("snooze_until") or datetime.fromisoformat(a["snooze_until"]) <= now ] if not active_alerts: return {"triggered": [], "watching": []} tickers = [a["ticker"] for a in active_alerts] quotes = get_fetch_market_data()(tickers, timeout=30) triggered = [] watching = [] for alert in active_alerts: ticker = alert["ticker"] target = alert["target_price"] currency = alert.get("currency", "USD") quote = quotes.get(ticker, {}) price = quote.get("price") if price is None: continue # Divide-by-zero protection if target == 0: pct_diff = 0 else: pct_diff = ((price - target) / target) * 100 result = { "ticker": ticker, "target_price": target, "current_price": price, "currency": currency, "pct_from_target": round(pct_diff, 2), "note": alert.get("note", ""), "set_by": alert.get("set_by", ""), } if price <= target: triggered.append(result) # Update triggered count (only once per day to avoid inflation) last_triggered = alert.get("last_triggered") today = now.strftime("%Y-%m-%d") if not last_triggered or not last_triggered.startswith(today): alert["triggered_count"] = alert.get("triggered_count", 0) + 1 alert["last_triggered"] = now.isoformat() else: watching.append(result) save_alerts(data) return {"triggered": triggered, "watching": watching} def main(): parser = argparse.ArgumentParser(description="Price target alerts") subparsers = parser.add_subparsers(dest="command", required=True) # list subparsers.add_parser("list", help="List all alerts") # set set_parser = subparsers.add_parser("set", help="Set new alert") set_parser.add_argument("ticker", help="Stock ticker") set_parser.add_argument("target", type=float, help="Target price") set_parser.add_argument("--note", help="Note/reason") set_parser.add_argument("--user", help="Who set the alert") set_parser.add_argument("--currency", default="USD", help="Currency (USD, EUR, JPY, SGD, MXN)") # delete del_parser = subparsers.add_parser("delete", help="Delete alert") del_parser.add_argument("ticker", help="Stock ticker") # snooze snooze_parser = subparsers.add_parser("snooze", help="Snooze alert") snooze_parser.add_argument("ticker", help="Stock ticker") snooze_parser.add_argument("--days", type=int, default=7, help="Days to snooze") # update update_parser = subparsers.add_parser("update", help="Update alert target") update_parser.add_argument("ticker", help="Stock ticker") update_parser.add_argument("target", type=float, help="New target price") update_parser.add_argument("--note", help="Update note") # check check_parser = subparsers.add_parser("check", help="Check alerts against prices") check_parser.add_argument("--json", action="store_true", help="JSON output") check_parser.add_argument("--lang", default="en", help="Output language (en, de)") args = parser.parse_args() if args.command == "list": cmd_list(args) elif args.command == "set": cmd_set(args) elif args.command == "delete": cmd_delete(args) elif args.command == "snooze": cmd_snooze(args) elif args.command == "update": cmd_update(args) elif args.command == "check": cmd_check(args) if __name__ == "__main__": main()