501 lines
16 KiB
Python
501 lines
16 KiB
Python
#!/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()
|