From df0cee1b8a895a106edffb04b30a2643d2ccc322 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:10:01 +0000 Subject: [PATCH 01/18] docs: add @hassan1731996 to contributors --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7867026a..3069cb2a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,7 +15,7 @@ Format: - **@username** — ability-name ([ability-name](community/ability-name/ - **[@Rizwan-algoryc](https://github.com/Rizwan-algoryc)** — slow-music ([slow-music](community/slow-music/)) - **[@engrumair842-arch](https://github.com/engrumair842-arch)** — reddit-daily-digest ([reddit-daily-digest](community/reddit-daily-digest/)), smart-sous-chef ([smart-sous-chef](community/smart-sous-chef/)) - **[@samsonadmasu](https://github.com/samsonadmasu)** — voice-unit-converter ([voice-unit-converter](community/voice-unit-converter/)), food-water-log ([food-water-log](community/food-water-log/)), gmail-connector ([gmail-connector](community/gmail-connector/)), google-tasks ([google-tasks](community/google-tasks/)), traffic-travel-time ([traffic-travel-time](community/traffic-travel-time/)) -- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)) +- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)) - **[@BhargavTelu](https://github.com/BhargavTelu)** — grocery-list-manager ([grocery-list-manager](community/grocery-list-manager/)), package-tracker ([package-tracker](community/package-tracker/)) - **[@ArturKozhushnyi](https://github.com/ArturKozhushnyi)** — coin-flipper ([coin-flipper](community/coin-flipper/)), Bedtime-Wind-Down ([Bedtime-Wind-Down](community/Bedtime-Wind-Down/)), Twilio-SMS ([Twilio-SMS](community/Twilio-SMS/)) - **[@ammyyou112](https://github.com/ammyyou112)** — dad-joke-teller ([dad-joke-teller](community/dad-joke-teller/)), youtube-search-play ([youtube-search-play](community/youtube-search-play/)), google-daily-brief ([google-daily-brief](community/google-daily-brief/)) From 474078464e9b841ab21d52b7c1bf66d7cd4c1fc7 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 15:12:24 +0500 Subject: [PATCH 02/18] Add Portfolio Monitor ability Tracks a stock portfolio with real-time price monitoring via Finnhub (Alpha Vantage fallback). Fires proactive voice alerts on price thresholds, morning open summaries, and end-of-day wrap-ups. Supports add/remove/check/movers/alerts by voice with company name resolution. --- community/portfolio-monitor/README.md | 57 ++ community/portfolio-monitor/__init__.py | 0 community/portfolio-monitor/background.py | 425 ++++++++++++ community/portfolio-monitor/main.py | 785 ++++++++++++++++++++++ 4 files changed, 1267 insertions(+) create mode 100644 community/portfolio-monitor/README.md create mode 100644 community/portfolio-monitor/__init__.py create mode 100644 community/portfolio-monitor/background.py create mode 100644 community/portfolio-monitor/main.py diff --git a/community/portfolio-monitor/README.md b/community/portfolio-monitor/README.md new file mode 100644 index 00000000..8b9bc96a --- /dev/null +++ b/community/portfolio-monitor/README.md @@ -0,0 +1,57 @@ +# Portfolio Monitor + +A passive background ability that tracks your stock portfolio in real time, fires proactive alerts when positions move beyond your thresholds, and delivers a full P&L breakdown on demand — all by voice. + +Just add your stocks once and it handles the rest: morning open summary, live price monitoring every 5 minutes during market hours, and an end-of-day wrap-up when the bell rings. + +## Setup + +1. Get a free API key at [finnhub.io](https://finnhub.io) (60 calls/minute, no daily cap) +2. Replace `your_finnhub_api_key` in both `main.py` and `background.py` +3. Optionally add an [Alpha Vantage](https://www.alphavantage.co) key as fallback (25 calls/day free) + +## Trigger Phrases + +- `portfolio monitor` / `my portfolio` / `portfolio update` +- `check my stocks` / `stock update` / `stock check` +- `check Apple` / `how's Tesla` / `how's NVDA doing` +- `add a stock` / `add to portfolio` / `log a stock` +- `remove from portfolio` / `remove a stock` +- `set a stock alert` / `price alert` +- `biggest movers` / `what's moving` / `gainers today` / `losers today` +- `clear my portfolio` / `wipe my portfolio` + +## Features + +**Passive Monitoring** +- Polls every 5 minutes during market hours (9:30am–4:00pm ET, Mon–Fri) +- Sleeps 30 minutes outside market hours — no wasted API calls +- Price cache with 3-minute TTL prevents redundant fetches + +**Proactive Alerts** +- Morning open: brief summary of what you're tracking when market opens +- Price alerts: fires immediately when a stock drops or rises beyond your threshold (day change %) +- End-of-day wrap-up: portfolio value, day P&L, top gainer and loser +- Each alert fires at most once per day per direction per stock + +**Interactive Queries** +- PORTFOLIO: full breakdown — current value, day P&L, overall P&L, per-stock detail +- CHECK: single stock — price, day change, position value, P&L vs your avg cost +- MOVERS: biggest gainer and loser in your portfolio today +- ADD: add a stock by name or ticker, specify shares and avg cost +- SET_ALERT: set drop/rise percentage thresholds per stock +- REMOVE: remove a stock from your portfolio (with confirmation) +- CLEAR: wipe the entire portfolio (with confirmation) + +**Smart Details** +- Resolves company names to tickers (say "Apple", not "AAPL") +- LLM fallback for companies not in the built-in map +- Finnhub primary API with Alpha Vantage fallback +- On-demand price fetch if cache is empty when you ask for your portfolio +- Market-hours-aware ET timezone detection (DST handled, no external library) + +## Notes + +- Price alerts are based on the day's change percentage (vs previous close), not vs your avg cost +- The background daemon only runs while OpenHome is active — not a 24/7 service +- Supports 50+ major US companies by name out of the box; any ticker symbol works directly diff --git a/community/portfolio-monitor/__init__.py b/community/portfolio-monitor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py new file mode 100644 index 00000000..6ca10f02 --- /dev/null +++ b/community/portfolio-monitor/background.py @@ -0,0 +1,425 @@ +import json +import re +import requests +from datetime import datetime, timedelta + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +DATA_FILE = "portfolio_monitor.json" +POLL_MARKET_OPEN = 300.0 +POLL_MARKET_CLOSED = 1800.0 +CACHE_TTL_SECONDS = 180 +MAX_API_CALLS_PER_POLL = 10 + +# Get a free key at finnhub.io — 60 calls/minute, no daily cap +FINNHUB_KEY = "your_finnhub_api_key" +# Optional fallback — alphavantage.co (25 calls/day free) +AV_KEY = "your_alphavantage_key" + +FINNHUB_BASE = "https://finnhub.io/api/v1" +AV_BASE = "https://www.alphavantage.co/query" + + +def _empty_data() -> dict: + return { + "holdings": [], + "alert_thresholds": {}, + "price_cache": {}, + "alerted_today": [], + "meta": { + "api_calls_today": 0, + "api_calls_date": "", + "last_eod_summary": "", + }, + } + + +def _new_state() -> dict: + return { + "current_day": "", + "open_notified": False, + "eod_spoken": False, + } + + +class PortfolioMonitorBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + # {{register capability}} + + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + + async def _load_data(self) -> dict: + try: + exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) + if not exists: + return _empty_data() + raw = await self.capability_worker.read_file(DATA_FILE, False) + if not raw or not raw.strip(): + return _empty_data() + return json.loads(raw) + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Load error: {e}") + return _empty_data() + + async def _save_data(self, data: dict): + try: + exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) + if exists: + await self.capability_worker.delete_file(DATA_FILE, False) + await self.capability_worker.write_file( + DATA_FILE, json.dumps(data, indent=2), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") + + # ------------------------------------------------------------------ + # Market hours (ET, no pytz) + # ------------------------------------------------------------------ + + def _et_now(self) -> datetime: + utc = datetime.utcnow() + m, y = utc.month, utc.year + if 4 <= m <= 10: + offset = -4 + elif m == 3: + first_day = datetime(y, 3, 1) + first_sun = first_day + timedelta(days=(6 - first_day.weekday()) % 7) + second_sun = first_sun + timedelta(days=7) + # 2am EST = 7am UTC + offset = -4 if utc >= second_sun.replace(hour=7) else -5 + elif m == 11: + first_day = datetime(y, 11, 1) + first_sun = first_day + timedelta(days=(6 - first_day.weekday()) % 7) + # 2am EDT = 6am UTC + offset = -5 if utc >= first_sun.replace(hour=6) else -4 + else: + offset = -5 + return utc + timedelta(hours=offset) + + def _is_market_open_et(self, et: datetime) -> bool: + if et.weekday() >= 5: + return False + mins = et.hour * 60 + et.minute + return 9 * 60 + 30 <= mins < 16 * 60 + + def _is_eod_window_et(self, et: datetime) -> bool: + if et.weekday() >= 5: + return False + mins = et.hour * 60 + et.minute + return 16 * 60 <= mins <= 16 * 60 + 10 + + # ------------------------------------------------------------------ + # API + # ------------------------------------------------------------------ + + def _fetch_quote_finnhub(self, ticker: str) -> dict | None: + try: + resp = requests.get( + f"{FINNHUB_BASE}/quote", + params={"symbol": ticker, "token": FINNHUB_KEY}, + timeout=10, + ) + if resp.status_code == 200: + d = resp.json() + price = d.get("c", 0) + if price: + return { + "price": float(price), + "change_pct": float(d.get("dp", 0)), + "prev_close": float(d.get("pc", 0)), + "high": float(d.get("h", 0)), + "low": float(d.get("l", 0)), + } + return None + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] Finnhub error for {ticker}: {e}" + ) + return None + + def _fetch_quote_av(self, ticker: str) -> dict | None: + try: + resp = requests.get( + AV_BASE, + params={"function": "GLOBAL_QUOTE", "symbol": ticker, "apikey": AV_KEY}, + timeout=10, + ) + if resp.status_code == 200: + gq = resp.json().get("Global Quote", {}) + price = float(gq.get("05. price", 0)) + if price: + raw_pct = gq.get("10. change percent", "0%").replace("%", "") + return { + "price": price, + "change_pct": float(raw_pct), + "prev_close": float(gq.get("08. previous close", 0)), + "high": float(gq.get("03. high", 0)), + "low": float(gq.get("04. low", 0)), + } + return None + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] AV error for {ticker}: {e}" + ) + return None + + def _fetch_quote(self, ticker: str) -> dict | None: + quote = self._fetch_quote_finnhub(ticker) + if quote: + return quote + return self._fetch_quote_av(ticker) + + def _is_cache_fresh(self, ticker: str, data: dict) -> bool: + entry = data.get("price_cache", {}).get(ticker) + if not entry: + return False + try: + cached_at = datetime.strptime(entry["cached_at"], "%Y-%m-%dT%H:%M:%S") + return (datetime.utcnow() - cached_at).total_seconds() < CACHE_TTL_SECONDS + except Exception: + return False + + # ------------------------------------------------------------------ + # Alert logic + # ------------------------------------------------------------------ + + def _check_alerts(self, ticker: str, holding: dict, quote: dict, data: dict) -> list: + thresholds = data.get("alert_thresholds", {}).get(ticker, {}) + drop_pct = thresholds.get("drop_pct") + rise_pct = thresholds.get("rise_pct") + if not drop_pct and not rise_pct: + return [] + + change_pct = quote.get("change_pct", 0) + price = quote.get("price", 0) + shares = holding.get("shares", 0) + avg_cost = holding.get("avg_cost", 0) + name = holding.get("name", ticker) + + alerts = [] + + if drop_pct and change_pct <= -drop_pct: + pnl = (price - avg_cost) * shares + pnl_str = f"down ${abs(pnl):,.0f}" if pnl < 0 else f"up ${pnl:,.0f}" + msg = ( + f"Heads up — {name} is down {abs(change_pct):.1f} percent today. " + f"Your position is {pnl_str}. Say 'portfolio monitor' to review." + ) + alerts.append(("drop", msg)) + + if rise_pct and change_pct >= rise_pct: + pnl = (price - avg_cost) * shares + pnl_str = f"up ${pnl:,.0f}" if pnl >= 0 else f"down ${abs(pnl):,.0f}" + msg = ( + f"Nice — {name} is up {change_pct:.1f} percent today. " + f"Your position is {pnl_str}. Say 'portfolio monitor' to review." + ) + alerts.append(("rise", msg)) + + return alerts + + # ------------------------------------------------------------------ + # Proactive voice + # ------------------------------------------------------------------ + + async def _speak_morning_open(self, data: dict): + holdings = data.get("holdings", []) + count = len(holdings) + names = ", ".join(h.get("name", h["ticker"]) for h in holdings[:3]) + suffix = f" and {count - 3} more" if count > 3 else "" + try: + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak( + f"Market just opened. You're tracking {count} " + f"{'stock' if count == 1 else 'stocks'}: {names}{suffix}. " + "Say 'portfolio monitor' for an update." + ) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] Morning open error: {e}" + ) + + async def _speak_eod_summary(self, data: dict): + holdings = data.get("holdings", []) + cache = data.get("price_cache", {}) + if not holdings or not cache: + return + + total_value = 0.0 + total_cost = 0.0 + day_pnl = 0.0 + best = None + best_pct = -float("inf") + worst = None + worst_pct = float("inf") + + for h in holdings: + ticker = h["ticker"] + q = cache.get(ticker) + if not q: + continue + price = q["price"] + prev_close = q.get("prev_close", price) + shares = h.get("shares", 0) + avg_cost = h.get("avg_cost", 0) + change_pct = q.get("change_pct", 0) + + total_value += price * shares + total_cost += avg_cost * shares + day_pnl += (price - prev_close) * shares + + if change_pct > best_pct: + best_pct = change_pct + best = h.get("name", ticker) + if change_pct < worst_pct: + worst_pct = change_pct + worst = h.get("name", ticker) + + if not total_value: + return + + day_dir = "up" if day_pnl >= 0 else "down" + overall_pnl = total_value - total_cost + overall_dir = "up" if overall_pnl >= 0 else "down" + + parts = [ + f"Market's closed. Your portfolio ended at ${total_value:,.0f}, " + f"{day_dir} ${abs(day_pnl):,.0f} today, " + f"{overall_dir} ${abs(overall_pnl):,.0f} overall." + ] + if best and best_pct > 0: + parts.append(f"{best} led, up {best_pct:.1f} percent.") + if worst and worst_pct < 0: + parts.append(f"{worst} lagged, down {abs(worst_pct):.1f} percent.") + + try: + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(" ".join(parts)) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] EOD summary error: {e}" + ) + + # ------------------------------------------------------------------ + # Main daemon loop + # ------------------------------------------------------------------ + + async def watch_loop(self): + s = _new_state() + self.worker.editor_logging_handler.info("[PortfolioMonitor] daemon started") + self.capability_worker.resume_normal_flow() + + while True: + sleep_time = POLL_MARKET_CLOSED + try: + data = await self._load_data() + holdings = data.get("holdings", []) + + if not holdings: + await self.worker.session_tasks.sleep(POLL_MARKET_CLOSED) + continue + + et = self._et_now() + today_str = et.strftime("%Y-%m-%d") + + # Reset daily state + if today_str != s["current_day"]: + s["current_day"] = today_str + s["open_notified"] = False + s["eod_spoken"] = False + data["alerted_today"] = [] + meta = data.setdefault("meta", {}) + meta["api_calls_today"] = 0 + meta["api_calls_date"] = today_str + await self._save_data(data) + + market_open = self._is_market_open_et(et) + + # Morning open notification + if market_open and not s["open_notified"]: + s["open_notified"] = True + await self._speak_morning_open(data) + + # EOD summary + if self._is_eod_window_et(et) and not s["eod_spoken"]: + await self._speak_eod_summary(data) + s["eod_spoken"] = True + + # Price polling during market hours + if market_open: + sleep_time = POLL_MARKET_OPEN + calls_this_poll = 0 + changed = False + pending_alerts = [] + + for holding in holdings: + if calls_this_poll >= MAX_API_CALLS_PER_POLL: + break + ticker = holding["ticker"] + if self._is_cache_fresh(ticker, data): + continue + + quote = self._fetch_quote(ticker) + calls_this_poll += 1 + if not quote: + continue + + data.setdefault("price_cache", {})[ticker] = { + **quote, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + data.setdefault("meta", {}) + data["meta"]["api_calls_today"] = ( + data["meta"].get("api_calls_today", 0) + 1 + ) + changed = True + + self.worker.editor_logging_handler.info( + f"[PortfolioMonitor] {ticker}: ${quote['price']:.2f} " + f"({quote['change_pct']:+.1f}%)" + ) + + alerts = self._check_alerts(ticker, holding, quote, data) + for direction, msg in alerts: + alert_key = f"{ticker}_{direction}" + if alert_key not in data.get("alerted_today", []): + data.setdefault("alerted_today", []).append(alert_key) + pending_alerts.append(msg) + changed = True + + if changed: + await self._save_data(data) + + for msg in pending_alerts: + try: + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(msg) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] Alert error: {e}" + ) + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] Loop error: {e}" + ) + + await self.worker.session_tasks.sleep(sleep_time) + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.background_daemon_mode = background_daemon_mode + self.worker.session_tasks.create(self.watch_loop()) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py new file mode 100644 index 00000000..39bea909 --- /dev/null +++ b/community/portfolio-monitor/main.py @@ -0,0 +1,785 @@ +import json +import re +import requests +from datetime import datetime, timedelta + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +DATA_FILE = "portfolio_monitor.json" + +# Get a free key at finnhub.io — 60 calls/minute, no daily cap +FINNHUB_KEY = "your_finnhub_api_key" +# Optional fallback — alphavantage.co (25 calls/day free) +AV_KEY = "your_alphavantage_key" + +FINNHUB_BASE = "https://finnhub.io/api/v1" +AV_BASE = "https://www.alphavantage.co/query" + +HOTWORDS = { + "portfolio monitor", "my portfolio", "check my stocks", "stock update", + "portfolio update", "my stocks", "stock alert", "stock check", + "add to portfolio", "add a stock", "log a stock", "track a stock", + "remove from portfolio", "remove a stock", + "set stock alert", "set a stock alert", "stock price alert", + "what's moving", "biggest movers", "gainers today", "losers today", + "clear my portfolio", "wipe my portfolio", + "check apple", "check tesla", "check nvidia", "check amazon", + "check microsoft", "check google", "check meta", "check netflix", + "how's apple", "how's tesla", "how's nvidia", "how's amazon", + "how's microsoft", "how's google", "how's meta", "how's netflix", +} + +TICKER_MAP = { + "apple": "AAPL", + "tesla": "TSLA", + "microsoft": "MSFT", + "amazon": "AMZN", + "google": "GOOGL", + "alphabet": "GOOGL", + "nvidia": "NVDA", + "meta": "META", + "facebook": "META", + "netflix": "NFLX", + "disney": "DIS", + "salesforce": "CRM", + "visa": "V", + "mastercard": "MA", + "jpmorgan": "JPM", + "jp morgan": "JPM", + "johnson": "JNJ", + "walmart": "WMT", + "exxon": "XOM", + "chevron": "CVX", + "unitedhealth": "UNH", + "home depot": "HD", + "adobe": "ADBE", + "paypal": "PYPL", + "intel": "INTC", + "amd": "AMD", + "qualcomm": "QCOM", + "broadcom": "AVGO", + "uber": "UBER", + "lyft": "LYFT", + "airbnb": "ABNB", + "spotify": "SPOT", + "shopify": "SHOP", + "palantir": "PLTR", + "coinbase": "COIN", + "robinhood": "HOOD", + "snapchat": "SNAP", + "snap": "SNAP", + "twitter": "X", + "berkshire": "BRK.B", + "boeing": "BA", + "ford": "F", + "general motors": "GM", + "gm": "GM", + "bank of america": "BAC", + "wells fargo": "WFC", + "citigroup": "C", + "goldman sachs": "GS", + "morgan stanley": "MS", +} + +COMMON_WORDS = { + "A", "I", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "IF", "IN", + "IS", "IT", "ME", "MY", "NO", "OF", "ON", "OR", "SO", "TO", "UP", "US", + "WE", "ADD", "AND", "ARE", "BUT", "CAN", "FOR", "GET", "GOT", "HAD", + "HAS", "HIM", "HIS", "HOW", "LET", "NOT", "NOW", "OFF", "OUT", "OWN", + "PUT", "SAY", "SEE", "SET", "THE", "TOO", "TWO", "USE", "WAS", "WHO", + "WHY", "YES", "YET", "YOU", "ALL", "NEW", "OLD", "OUR", "TOP", +} + +_EXIT_PATTERN = re.compile( + r'\b(stop|exit|quit|done|cancel|bye|goodbye|never\s*mind|no\s*thanks|' + r"that'?s\s*all|nothing|nah|skip)\b", + re.IGNORECASE, +) + + +def _empty_data() -> dict: + return { + "holdings": [], + "alert_thresholds": {}, + "price_cache": {}, + "alerted_today": [], + "meta": { + "api_calls_today": 0, + "api_calls_date": "", + "last_eod_summary": "", + }, + } + + +class PortfolioMonitorCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + # {{register capability}} + + # ------------------------------------------------------------------ + # Hotword matching + # ------------------------------------------------------------------ + + def does_match(self, text: str) -> bool: + t = text.lower().strip() + if any(hw in t for hw in HOTWORDS): + return True + # "check [company/ticker]" or "how's [company/ticker] doing" + if re.search(r'\b(check|how.?s|how is)\b', t) and "portfolio" not in t: + return bool(self._resolve_ticker(text)) + return False + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _is_exit(self, text: str) -> bool: + if not text or not text.strip(): + return True + stripped = text.strip().rstrip(".,!?").strip().lower() + if stripped in ("no", "skip"): + return True + return bool(_EXIT_PATTERN.search(text)) + + def _classify_intent(self, text: str) -> str: + t = text.lower() + + if any(kw in t for kw in ("clear", "wipe", "reset")) and any( + kw in t for kw in ("portfolio", "stocks", "holdings", "all") + ): + return "CLEAR" + + if any(kw in t for kw in ("remove", "delete", "drop")): + return "REMOVE" + + if ( + any(kw in t for kw in ("set alert", "price alert", "alert me", "notify me")) + or ("alert" in t and any(kw in t for kw in ("if", "when", "drops", "rises", "falls"))) + ): + return "SET_ALERT" + + if any(kw in t for kw in ("add to portfolio", "add a stock", "log a stock", "track a stock")): + return "ADD" + if any(kw in t for kw in ("add", "bought", "purchased")) and any( + kw in t for kw in ("share", "stock", "position") + ): + return "ADD" + + if any(kw in t for kw in ("mover", "moving today", "gainer", "loser", "biggest")): + return "MOVERS" + + if "portfolio" not in t and "my stocks" not in t and re.search( + r'\b(check|how.?s|how is|price of|what.?s)\b', t + ): + return "CHECK" + + return "PORTFOLIO" + + def _resolve_ticker(self, text: str) -> str | None: + if not text: + return None + lower = text.lower() + # Static map first — longest match wins + for company, ticker in sorted(TICKER_MAP.items(), key=lambda x: -len(x[0])): + if company in lower: + return ticker + # Direct ticker pattern in uppercase + for match in re.finditer(r'\b([A-Z]{2,5})\b', text.upper()): + candidate = match.group(1) + if candidate not in COMMON_WORDS: + return candidate + return self._resolve_ticker_llm(text) + + def _resolve_ticker_llm(self, text: str) -> str | None: + try: + raw = self.capability_worker.text_to_text_response( + "Extract the US stock ticker symbol from this text. " + "Return ONLY the ticker (e.g. AAPL, TSLA) or 'NONE' if not found.\n" + f"Text: {text}" + ) + result = raw.strip().upper().split()[0].strip(".,") if raw.strip() else "NONE" + return None if result == "NONE" else result + except Exception: + return None + + def _resolve_company_name(self, ticker: str) -> str: + for company, t in TICKER_MAP.items(): + if t == ticker: + return company.title() + try: + raw = self.capability_worker.text_to_text_response( + f"What company does the US stock ticker {ticker} represent? " + "Reply with ONLY the company name, nothing else." + ) + return raw.strip() or ticker + except Exception: + return ticker + + # ------------------------------------------------------------------ + # API + # ------------------------------------------------------------------ + + def _fetch_quote_finnhub(self, ticker: str) -> dict | None: + try: + resp = requests.get( + f"{FINNHUB_BASE}/quote", + params={"symbol": ticker, "token": FINNHUB_KEY}, + timeout=10, + ) + if resp.status_code == 200: + d = resp.json() + price = d.get("c", 0) + if price: + return { + "price": float(price), + "change_pct": float(d.get("dp", 0)), + "prev_close": float(d.get("pc", 0)), + "high": float(d.get("h", 0)), + "low": float(d.get("l", 0)), + } + return None + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] Finnhub error for {ticker}: {e}" + ) + return None + + def _fetch_quote_av(self, ticker: str) -> dict | None: + try: + resp = requests.get( + AV_BASE, + params={"function": "GLOBAL_QUOTE", "symbol": ticker, "apikey": AV_KEY}, + timeout=10, + ) + if resp.status_code == 200: + gq = resp.json().get("Global Quote", {}) + price = float(gq.get("05. price", 0)) + if price: + raw_pct = gq.get("10. change percent", "0%").replace("%", "") + return { + "price": price, + "change_pct": float(raw_pct), + "prev_close": float(gq.get("08. previous close", 0)), + "high": float(gq.get("03. high", 0)), + "low": float(gq.get("04. low", 0)), + } + return None + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PortfolioMonitor] AV error for {ticker}: {e}" + ) + return None + + def _fetch_quote(self, ticker: str) -> dict | None: + quote = self._fetch_quote_finnhub(ticker) + if quote: + return quote + return self._fetch_quote_av(ticker) + + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + + async def _load_data(self) -> dict: + try: + exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) + if not exists: + return _empty_data() + raw = await self.capability_worker.read_file(DATA_FILE, False) + if not raw or not raw.strip(): + return _empty_data() + return json.loads(raw) + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Load error: {e}") + return _empty_data() + + async def _save_data(self, data: dict): + try: + exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) + if exists: + await self.capability_worker.delete_file(DATA_FILE, False) + await self.capability_worker.write_file( + DATA_FILE, json.dumps(data, indent=2), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") + + # ------------------------------------------------------------------ + # Intent handlers + # ------------------------------------------------------------------ + + async def _handle_portfolio(self): + data = await self._load_data() + holdings = data.get("holdings", []) + if not holdings: + await self.capability_worker.speak( + "No stocks in your portfolio yet. Say 'add a stock' to get started." + ) + return + + cache = data.get("price_cache", {}) + changed = False + + # Fetch any missing quotes on-demand + for h in holdings: + ticker = h["ticker"] + if ticker not in cache: + await self.capability_worker.speak(f"Fetching {h.get('name', ticker)}...") + quote = self._fetch_quote(ticker) + if quote: + cache[ticker] = { + **quote, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + changed = True + + if changed: + data["price_cache"] = cache + await self._save_data(data) + + total_value = 0.0 + total_cost = 0.0 + day_pnl = 0.0 + stock_lines = [] + + for h in holdings: + ticker = h["ticker"] + name = h.get("name", ticker) + shares = h.get("shares", 0) + avg_cost = h.get("avg_cost", 0) + q = cache.get(ticker) + + if not q: + stock_lines.append(f"{name}: no data available") + continue + + price = q["price"] + change_pct = q.get("change_pct", 0) + prev_close = q.get("prev_close", price) + + position_value = price * shares + position_cost = avg_cost * shares + position_pnl = position_value - position_cost + position_pnl_pct = (position_pnl / position_cost * 100) if position_cost else 0 + day_change = (price - prev_close) * shares + + total_value += position_value + total_cost += position_cost + day_pnl += day_change + + day_dir = "up" if change_pct >= 0 else "down" + pos_dir = "up" if position_pnl >= 0 else "down" + stock_lines.append( + f"{name}: ${price:.2f}, {day_dir} {abs(change_pct):.1f}% today, " + f"{pos_dir} ${abs(position_pnl):,.0f} overall" + ) + + total_pnl = total_value - total_cost + total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else 0 + day_dir = "up" if day_pnl >= 0 else "down" + overall_dir = "up" if total_pnl >= 0 else "down" + + await self.capability_worker.speak( + f"Your portfolio is worth ${total_value:,.0f}. " + f"Today you're {day_dir} ${abs(day_pnl):,.0f}. " + f"Overall {overall_dir} ${abs(total_pnl):,.0f} — {abs(total_pnl_pct):.1f} percent." + ) + if stock_lines: + await self.capability_worker.speak(". ".join(stock_lines) + ".") + + await self.capability_worker.speak( + "Say a stock name to check it, say 'movers' to see what's moving, or stop." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + r = reply.lower() + if any(kw in r for kw in ("mover", "moving", "gainer", "loser")): + await self._handle_movers(data) + else: + ticker = self._resolve_ticker(reply) + if ticker: + await self._speak_single_stock(ticker, data) + + async def _speak_single_stock(self, ticker: str, data: dict): + cache = data.get("price_cache", {}) + q = cache.get(ticker) + if not q: + await self.capability_worker.speak(f"Fetching {ticker}...") + q = self._fetch_quote(ticker) + if q: + data.setdefault("price_cache", {})[ticker] = { + **q, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + + if not q: + await self.capability_worker.speak( + f"Couldn't get data for {ticker} right now. Try again in a moment." + ) + return + + price = q["price"] + change_pct = q.get("change_pct", 0) + day_dir = "up" if change_pct >= 0 else "down" + msg = f"{ticker} is at ${price:.2f}, {day_dir} {abs(change_pct):.1f} percent today." + + holding = next( + (h for h in data.get("holdings", []) if h["ticker"] == ticker), None + ) + if holding: + shares = holding["shares"] + avg_cost = holding["avg_cost"] + pos_value = price * shares + pos_pnl = (price - avg_cost) * shares + pos_pnl_pct = (pos_pnl / pos_value * 100) if pos_value else 0 + pos_dir = "up" if pos_pnl >= 0 else "down" + msg += ( + f" Your {shares} shares are worth ${pos_value:,.0f} — " + f"{pos_dir} ${abs(pos_pnl):,.0f} ({abs(pos_pnl_pct):.1f}%) on your position." + ) + + await self.capability_worker.speak(msg) + + async def _handle_check(self, trigger_text: str): + ticker = self._resolve_ticker(trigger_text) + + if not ticker: + await self.capability_worker.speak( + "Which stock should I check? Say the company name or ticker symbol." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + ticker = self._resolve_ticker(reply) + + if not ticker: + await self.capability_worker.speak( + "I couldn't identify that stock. Try using the ticker symbol like AAPL or TSLA." + ) + return + + data = await self._load_data() + + cached = data.get("price_cache", {}).get(ticker) + if cached: + try: + cached_at = datetime.strptime(cached["cached_at"], "%Y-%m-%dT%H:%M:%S") + if (datetime.utcnow() - cached_at).total_seconds() < 180: + await self._speak_single_stock(ticker, data) + return + except Exception: + pass + + await self.capability_worker.speak(f"Checking {ticker}...") + quote = self._fetch_quote(ticker) + + if not quote: + await self.capability_worker.speak( + f"Couldn't get data for {ticker} right now. Try again in a moment." + ) + return + + data.setdefault("price_cache", {})[ticker] = { + **quote, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + holding = next((h for h in data.get("holdings", []) if h["ticker"] == ticker), None) + if holding: + await self._save_data(data) + await self._speak_single_stock(ticker, data) + + async def _handle_movers(self, data: dict | None = None): + if data is None: + data = await self._load_data() + holdings = data.get("holdings", []) + if not holdings: + await self.capability_worker.speak("No stocks in your portfolio yet.") + return + + cache = data.get("price_cache", {}) + movers = [] + for h in holdings: + ticker = h["ticker"] + q = cache.get(ticker) + if q: + movers.append((h.get("name", ticker), q.get("change_pct", 0))) + + if not movers: + await self.capability_worker.speak( + "No price data cached yet — say 'my portfolio' to fetch current prices." + ) + return + + movers.sort(key=lambda x: x[1]) + worst = movers[0] + best = movers[-1] + + parts = [] + if best[1] > 0: + parts.append(f"Biggest gainer: {best[0]}, up {best[1]:.1f} percent.") + if worst[1] < 0: + parts.append(f"Biggest loser: {worst[0]}, down {abs(worst[1]):.1f} percent.") + + if not parts: + await self.capability_worker.speak( + "Everything's flat today — no significant movers in your portfolio." + ) + return + + await self.capability_worker.speak(" ".join(parts)) + + async def _handle_add(self, trigger_text: str): + ticker = self._resolve_ticker(trigger_text) + + if not ticker: + await self.capability_worker.speak( + "Which stock do you want to add? Say the company name or ticker symbol." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + ticker = self._resolve_ticker(reply) + + if not ticker: + await self.capability_worker.speak( + "I couldn't identify that stock. Try using the ticker symbol like AAPL." + ) + return + + data = await self._load_data() + existing = next( + (h for h in data.get("holdings", []) if h["ticker"] == ticker), None + ) + if existing: + await self.capability_worker.speak( + f"{ticker} is already in your portfolio — " + f"{existing['shares']} shares at ${existing['avg_cost']:.2f} average." + ) + return + + await self.capability_worker.speak(f"How many shares of {ticker}?") + shares_reply = await self.capability_worker.user_response() + if self._is_exit(shares_reply): + return + shares_match = re.search(r'[\d,]+\.?\d*', shares_reply) + if not shares_match: + await self.capability_worker.speak( + "I didn't catch the number of shares. Try again." + ) + return + shares = float(shares_match.group().replace(",", "")) + + await self.capability_worker.speak("What's your average cost per share?") + cost_reply = await self.capability_worker.user_response() + if self._is_exit(cost_reply): + return + cost_match = re.search(r'[\d,]+\.?\d*', cost_reply.replace(",", "")) + if not cost_match: + await self.capability_worker.speak( + "I didn't catch the cost. Try again." + ) + return + avg_cost = float(cost_match.group()) + + name = self._resolve_company_name(ticker) + holding = { + "id": str(int(datetime.now().timestamp() * 1000)), + "ticker": ticker, + "name": name, + "shares": shares, + "avg_cost": avg_cost, + "added_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + } + data["holdings"].append(holding) + await self._save_data(data) + await self.capability_worker.speak( + f"Added {shares} shares of {name} at ${avg_cost:.2f} average. " + f"Total position: ${shares * avg_cost:,.0f}. " + "Say 'set a stock alert' to get notified on big moves." + ) + + async def _handle_set_alert(self, trigger_text: str): + ticker = self._resolve_ticker(trigger_text) + + if not ticker: + await self.capability_worker.speak( + "Which stock do you want to set an alert for?" + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + ticker = self._resolve_ticker(reply) + + if not ticker: + await self.capability_worker.speak("I couldn't identify that stock.") + return + + data = await self._load_data() + holding = next( + (h for h in data.get("holdings", []) if h["ticker"] == ticker), None + ) + if not holding: + await self.capability_worker.speak( + f"{ticker} isn't in your portfolio — add it first." + ) + return + + name = holding.get("name", ticker) + + await self.capability_worker.speak( + f"Alert me if {name} drops how many percent in a day? Say a number or skip." + ) + drop_reply = await self.capability_worker.user_response() + drop_pct = None + if not self._is_exit(drop_reply) and "skip" not in drop_reply.lower(): + m = re.search(r'[\d.]+', drop_reply) + if m: + drop_pct = float(m.group()) + + await self.capability_worker.speak( + f"Alert me if {name} rises how many percent in a day? Say a number or skip." + ) + rise_reply = await self.capability_worker.user_response() + rise_pct = None + if not self._is_exit(rise_reply) and "skip" not in rise_reply.lower(): + m = re.search(r'[\d.]+', rise_reply) + if m: + rise_pct = float(m.group()) + + if not drop_pct and not rise_pct: + await self.capability_worker.speak("No thresholds set.") + return + + data.setdefault("alert_thresholds", {})[ticker] = {} + if drop_pct: + data["alert_thresholds"][ticker]["drop_pct"] = drop_pct + if rise_pct: + data["alert_thresholds"][ticker]["rise_pct"] = rise_pct + await self._save_data(data) + + parts = [] + if drop_pct: + parts.append(f"drops {drop_pct}%") + if rise_pct: + parts.append(f"rises {rise_pct}%") + await self.capability_worker.speak( + f"Done — I'll alert you if {name} {' or '.join(parts)} in a day." + ) + + async def _handle_remove(self, trigger_text: str): + data = await self._load_data() + holdings = data.get("holdings", []) + if not holdings: + await self.capability_worker.speak("Your portfolio is empty.") + return + + ticker = self._resolve_ticker(trigger_text) + + if not ticker: + await self.capability_worker.speak("Which stock do you want to remove?") + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + ticker = self._resolve_ticker(reply) + + if not ticker: + await self.capability_worker.speak("I couldn't identify that stock.") + return + + holding = next((h for h in holdings if h["ticker"] == ticker), None) + if not holding: + await self.capability_worker.speak(f"{ticker} isn't in your portfolio.") + return + + name = holding.get("name", ticker) + confirmed = await self.capability_worker.run_confirmation_loop( + f"Remove {name} from your portfolio?" + ) + if confirmed: + data["holdings"] = [h for h in holdings if h["ticker"] != ticker] + data.get("alert_thresholds", {}).pop(ticker, None) + data.get("price_cache", {}).pop(ticker, None) + await self._save_data(data) + await self.capability_worker.speak(f"Removed {name}.") + else: + await self.capability_worker.speak("Keeping it.") + + async def _handle_clear(self): + data = await self._load_data() + count = len(data.get("holdings", [])) + if count == 0: + await self.capability_worker.speak("Portfolio is already empty.") + return + + confirmed = await self.capability_worker.run_confirmation_loop( + f"Clear all {count} {'stock' if count == 1 else 'stocks'} from your portfolio?" + ) + if confirmed: + data["holdings"] = [] + data["alert_thresholds"] = {} + data["price_cache"] = {} + data["alerted_today"] = [] + await self._save_data(data) + await self.capability_worker.speak("Portfolio cleared.") + else: + await self.capability_worker.speak("Keeping everything.") + + # ------------------------------------------------------------------ + # Main run loop + # ------------------------------------------------------------------ + + async def _run(self): + try: + trigger_text = await self.capability_worker.wait_for_complete_transcription() + if not trigger_text or not isinstance(trigger_text, str): + trigger_text = "" + + intent = self._classify_intent(trigger_text) + self.worker.editor_logging_handler.info( + f"[PortfolioMonitor] Intent: {intent} | Trigger: {trigger_text[:80]}" + ) + + if intent == "PORTFOLIO": + await self._handle_portfolio() + elif intent == "CHECK": + await self._handle_check(trigger_text) + elif intent == "MOVERS": + await self._handle_movers() + elif intent == "ADD": + await self._handle_add(trigger_text) + elif intent == "SET_ALERT": + await self._handle_set_alert(trigger_text) + elif intent == "REMOVE": + await self._handle_remove(trigger_text) + elif intent == "CLEAR": + await self._handle_clear() + else: + await self.capability_worker.speak( + "I can show your portfolio, check a stock, track movers, " + "or add a stock. What would you like?" + ) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Skill error: {e}") + try: + await self.capability_worker.speak( + "Something went wrong. Try asking again in a moment." + ) + except Exception: + pass + finally: + self.capability_worker.resume_normal_flow() + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self._run()) From 19803d879e5849ebba02fa45fcc188c965c9c03c Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 15:56:56 +0500 Subject: [PATCH 03/18] Fix empty portfolio flow and cost regex bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep skill active when portfolio is empty — prompts to add inline instead of exiting and handing off to TTT. Also fix cost_match replace(',','') applied to match group, not raw input string. --- community/portfolio-monitor/main.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 39bea909..d9c0e8c6 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -10,9 +10,9 @@ DATA_FILE = "portfolio_monitor.json" # Get a free key at finnhub.io — 60 calls/minute, no daily cap -FINNHUB_KEY = "your_finnhub_api_key" +FINNHUB_KEY = "d844aahr01qkm5ca643gd844aahr01qkm5ca6440" # Optional fallback — alphavantage.co (25 calls/day free) -AV_KEY = "your_alphavantage_key" +AV_KEY = "Q4C71IM9EYRTRSBD" FINNHUB_BASE = "https://finnhub.io/api/v1" AV_BASE = "https://www.alphavantage.co/query" @@ -317,8 +317,12 @@ async def _handle_portfolio(self): holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak( - "No stocks in your portfolio yet. Say 'add a stock' to get started." + "No stocks in your portfolio yet. " + "What's the first one you'd like to track? Say a company name or ticker, or stop." ) + reply = await self.capability_worker.user_response() + if not self._is_exit(reply): + await self._handle_add(reply) return cache = data.get("price_cache", {}) @@ -578,13 +582,13 @@ async def _handle_add(self, trigger_text: str): cost_reply = await self.capability_worker.user_response() if self._is_exit(cost_reply): return - cost_match = re.search(r'[\d,]+\.?\d*', cost_reply.replace(",", "")) + cost_match = re.search(r'[\d,]+\.?\d*', cost_reply) if not cost_match: await self.capability_worker.speak( "I didn't catch the cost. Try again." ) return - avg_cost = float(cost_match.group()) + avg_cost = float(cost_match.group().replace(",", "")) name = self._resolve_company_name(ticker) holding = { @@ -600,7 +604,7 @@ async def _handle_add(self, trigger_text: str): await self.capability_worker.speak( f"Added {shares} shares of {name} at ${avg_cost:.2f} average. " f"Total position: ${shares * avg_cost:,.0f}. " - "Say 'set a stock alert' to get notified on big moves." + "Say 'portfolio monitor' to view your portfolio or set alerts." ) async def _handle_set_alert(self, trigger_text: str): From d31dc152871135ebcb4ed3fec2dcf38b992d0a72 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 18:01:47 +0500 Subject: [PATCH 04/18] Fix user_response() consuming trigger transcript in empty portfolio path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the user_response() call before _handle_add() — the final transcription of the trigger phrase arrives at the exact moment user_response() is called, so it gets consumed immediately, causing _handle_add() to try resolving a non-ticker phrase via LLM. The LLM call blocks long enough that the user's actual reply (Apple, NVDA, etc.) falls outside the user_response() window and is intercepted by TTT. Fix: call _handle_add('') directly — _resolve_ticker('') returns None immediately (no LLM), so the skill asks 'Which stock?' right away with the full response window available. Also add _ADD_COMMAND_PHRASES to skip _resolve_ticker() for generic add commands that never contain a ticker symbol. --- community/portfolio-monitor/main.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index d9c0e8c6..11d5fd67 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -98,6 +98,11 @@ re.IGNORECASE, ) +_ADD_COMMAND_PHRASES = { + "add a stock", "add to portfolio", "log a stock", "track a stock", + "add stock", "new stock", "add another", "add another stock", +} + def _empty_data() -> dict: return { @@ -317,12 +322,9 @@ async def _handle_portfolio(self): holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak( - "No stocks in your portfolio yet. " - "What's the first one you'd like to track? Say a company name or ticker, or stop." + "No stocks in your portfolio yet. Let's add one." ) - reply = await self.capability_worker.user_response() - if not self._is_exit(reply): - await self._handle_add(reply) + await self._handle_add("") return cache = data.get("price_cache", {}) @@ -538,7 +540,12 @@ async def _handle_movers(self, data: dict | None = None): await self.capability_worker.speak(" ".join(parts)) async def _handle_add(self, trigger_text: str): - ticker = self._resolve_ticker(trigger_text) + trigger_clean = trigger_text.lower().strip() + ticker = ( + None + if not trigger_clean or trigger_clean in _ADD_COMMAND_PHRASES + else self._resolve_ticker(trigger_text) + ) if not ticker: await self.capability_worker.speak( From 9b6f0c182acfd9bd833b1d3b6ffe626128b3fc8d Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 18:24:55 +0500 Subject: [PATCH 05/18] Restructure add flow to survive background-daemon user_response() disruption - does_match() now recognises "add Apple" / "add NVDA" via static TICKER_MAP and ticker-pattern scan (no LLM) so the platform routes those utterances to the skill after daemon startup - _classify_intent() mirrors the same static-lookup rule so "add Apple" is routed to ADD instead of falling through to PORTFOLIO - Empty-portfolio branch in _handle_portfolio() now exits with instructions ("Say 'add Apple'...") instead of calling _handle_add(""), eliminating a cold-start user_response() that never fired - _handle_add() no-ticker branch exits with instructions instead of waiting on a user_response() the daemon would swallow - _handle_add() with-ticker path consolidated from two user_response() calls (shares, then cost) to one combined prompt ("10 shares at 180"), halving the number of calls that need to survive post-daemon routing --- community/portfolio-monitor/main.py | 75 ++++++++++++++++------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 11d5fd67..6aedad42 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -136,6 +136,14 @@ def does_match(self, text: str) -> bool: # "check [company/ticker]" or "how's [company/ticker] doing" if re.search(r'\b(check|how.?s|how is)\b', t) and "portfolio" not in t: return bool(self._resolve_ticker(text)) + # "add Apple" / "add NVDA" — static map + ticker pattern only (no LLM) + if re.search(r'\badd\b', t) and "stock" not in t and "portfolio" not in t: + for company in TICKER_MAP: + if company in t: + return True + for match in re.finditer(r'\b([A-Z]{2,5})\b', text.upper()): + if match.group(1) not in COMMON_WORDS: + return True return False # ------------------------------------------------------------------ @@ -173,6 +181,13 @@ def _classify_intent(self, text: str) -> str: kw in t for kw in ("share", "stock", "position") ): return "ADD" + if re.search(r'\badd\b', t): + for company in TICKER_MAP: + if company in t: + return "ADD" + for match in re.finditer(r'\b([A-Z]{2,5})\b', text.upper()): + if match.group(1) not in COMMON_WORDS: + return "ADD" if any(kw in t for kw in ("mover", "moving today", "gainer", "loser", "biggest")): return "MOVERS" @@ -322,9 +337,9 @@ async def _handle_portfolio(self): holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak( - "No stocks in your portfolio yet. Let's add one." + "No stocks in your portfolio yet. " + "Say 'add Apple' or 'add NVDA' to start tracking." ) - await self._handle_add("") return cache = data.get("price_cache", {}) @@ -549,16 +564,7 @@ async def _handle_add(self, trigger_text: str): if not ticker: await self.capability_worker.speak( - "Which stock do you want to add? Say the company name or ticker symbol." - ) - reply = await self.capability_worker.user_response() - if self._is_exit(reply): - return - ticker = self._resolve_ticker(reply) - - if not ticker: - await self.capability_worker.speak( - "I couldn't identify that stock. Try using the ticker symbol like AAPL." + "Say 'add' followed by the company — like 'add Apple' or 'add NVDA'." ) return @@ -569,35 +575,36 @@ async def _handle_add(self, trigger_text: str): if existing: await self.capability_worker.speak( f"{ticker} is already in your portfolio — " - f"{existing['shares']} shares at ${existing['avg_cost']:.2f} average." + f"{existing['shares']:g} shares at ${existing['avg_cost']:.2f} average." ) return - await self.capability_worker.speak(f"How many shares of {ticker}?") - shares_reply = await self.capability_worker.user_response() - if self._is_exit(shares_reply): - return - shares_match = re.search(r'[\d,]+\.?\d*', shares_reply) - if not shares_match: - await self.capability_worker.speak( - "I didn't catch the number of shares. Try again." - ) + name = self._resolve_company_name(ticker) + await self.capability_worker.speak( + f"Adding {name}. How many shares and at what price? " + f"Say both — for example: 10 shares at 180." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): return - shares = float(shares_match.group().replace(",", "")) - await self.capability_worker.speak("What's your average cost per share?") - cost_reply = await self.capability_worker.user_response() - if self._is_exit(cost_reply): - return - cost_match = re.search(r'[\d,]+\.?\d*', cost_reply) - if not cost_match: + nums = re.findall(r'[\d,]+\.?\d*', reply) + nums_clean = [] + for n in nums: + try: + nums_clean.append(float(n.replace(",", ""))) + except ValueError: + pass + + if len(nums_clean) < 2: await self.capability_worker.speak( - "I didn't catch the cost. Try again." + "Say both the number of shares and the price — like '10 shares at 180'." ) return - avg_cost = float(cost_match.group().replace(",", "")) - name = self._resolve_company_name(ticker) + shares = nums_clean[0] + avg_cost = nums_clean[1] + holding = { "id": str(int(datetime.now().timestamp() * 1000)), "ticker": ticker, @@ -609,9 +616,9 @@ async def _handle_add(self, trigger_text: str): data["holdings"].append(holding) await self._save_data(data) await self.capability_worker.speak( - f"Added {shares} shares of {name} at ${avg_cost:.2f} average. " + f"Added {shares:g} shares of {name} at ${avg_cost:.2f} average. " f"Total position: ${shares * avg_cost:,.0f}. " - "Say 'portfolio monitor' to view your portfolio or set alerts." + "Say 'portfolio monitor' to view your portfolio." ) async def _handle_set_alert(self, trigger_text: str): From aff87fa08056951b3c3a0e02e42a8cdf02129846 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 18:37:51 +0500 Subject: [PATCH 06/18] Fix add flow: extend HOTWORDS and eliminate user_response() entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform routes utterances to skills via static HOTWORDS substring matching at the initial invocation level — does_match() is only consulted for within-skill user_response() routing, not for fresh invocations. So 'add Apple' was silently going to TTT because it was never in HOTWORDS. - Extend HOTWORDS with {f"add {company}" for company in TICKER_MAP} so 'add Apple', 'add Tesla', etc. all trigger the skill correctly - Rewrite _handle_add() as a zero-user_response() one-shot parser: extract ticker, shares, and cost all from the trigger phrase; if incomplete, give a concrete example and return - Update empty-portfolio message to show the full one-shot syntax --- community/portfolio-monitor/main.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 6aedad42..5f69a933 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -83,6 +83,9 @@ "morgan stanley": "MS", } +# Extend with "add [company]" for every company so the platform routes those phrases to the skill +HOTWORDS |= {f"add {company}" for company in TICKER_MAP} + COMMON_WORDS = { "A", "I", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "IF", "IN", "IS", "IT", "ME", "MY", "NO", "OF", "ON", "OR", "SO", "TO", "UP", "US", @@ -338,7 +341,7 @@ async def _handle_portfolio(self): if not holdings: await self.capability_worker.speak( "No stocks in your portfolio yet. " - "Say 'add Apple' or 'add NVDA' to start tracking." + "Say 'add Apple 10 shares at 180' to start tracking." ) return @@ -564,7 +567,8 @@ async def _handle_add(self, trigger_text: str): if not ticker: await self.capability_worker.speak( - "Say 'add' followed by the company — like 'add Apple' or 'add NVDA'." + "Say 'add' followed by the company, shares, and price — " + "like 'add Apple 10 shares at 180'." ) return @@ -579,16 +583,8 @@ async def _handle_add(self, trigger_text: str): ) return - name = self._resolve_company_name(ticker) - await self.capability_worker.speak( - f"Adding {name}. How many shares and at what price? " - f"Say both — for example: 10 shares at 180." - ) - reply = await self.capability_worker.user_response() - if self._is_exit(reply): - return - - nums = re.findall(r'[\d,]+\.?\d*', reply) + # Parse shares and cost from the trigger phrase — complete one-shot command + nums = re.findall(r'[\d,]+\.?\d*', trigger_text) nums_clean = [] for n in nums: try: @@ -597,13 +593,16 @@ async def _handle_add(self, trigger_text: str): pass if len(nums_clean) < 2: + name = self._resolve_company_name(ticker) await self.capability_worker.speak( - "Say both the number of shares and the price — like '10 shares at 180'." + f"Say 'add {name} 10 shares at 180' — " + "include the number of shares and your average cost." ) return shares = nums_clean[0] avg_cost = nums_clean[1] + name = self._resolve_company_name(ticker) holding = { "id": str(int(datetime.now().timestamp() * 1000)), From edb7543c030879ff97fc7f8922e87eaba633829c Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 18:41:04 +0500 Subject: [PATCH 07/18] Add daemon tick log to distinguish sleeping from blocked Logs holding count on every wake cycle so we can tell whether the daemon reached its polling loop or hung silently inside _load_data(). --- community/portfolio-monitor/background.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index 6ca10f02..f945aca5 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -14,9 +14,9 @@ MAX_API_CALLS_PER_POLL = 10 # Get a free key at finnhub.io — 60 calls/minute, no daily cap -FINNHUB_KEY = "your_finnhub_api_key" +FINNHUB_KEY = "d844aahr01qkm5ca643gd844aahr01qkm5ca6440" # Optional fallback — alphavantage.co (25 calls/day free) -AV_KEY = "your_alphavantage_key" +AV_KEY = "Q4C71IM9EYRTRSBD" FINNHUB_BASE = "https://finnhub.io/api/v1" AV_BASE = "https://www.alphavantage.co/query" @@ -323,6 +323,9 @@ async def watch_loop(self): data = await self._load_data() holdings = data.get("holdings", []) + self.worker.editor_logging_handler.info( + f"[PortfolioMonitor] daemon tick — {len(holdings)} holding(s)" + ) if not holdings: await self.worker.session_tasks.sleep(POLL_MARKET_CLOSED) continue From f22a91d535b645ab9fbfd1f2a02329e32ee4a9c9 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 18:56:21 +0500 Subject: [PATCH 08/18] Fix routing: inline add-company hotwords; fix daemon empty-portfolio sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform extracts the HOTWORDS set literal at registration time, so the runtime HOTWORDS |= {...} extension was invisible to its routing table — 'add Apple 10 shares at 180' never reached does_match() and went straight to TTT. Fix: list all 'add [company]' phrases directly in the static literal so the platform's pre-compiled routing picks them up. Daemon was not blocked — it ran one tick correctly, found 0 holdings, then slept POLL_MARKET_CLOSED (30 min). That's too long when the portfolio is empty: the daemon should notice new stocks quickly. Add POLL_NO_HOLDINGS=30s so the daemon re-checks every 30 seconds until the user adds their first stock. --- community/portfolio-monitor/background.py | 3 ++- community/portfolio-monitor/main.py | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index f945aca5..f69ae02a 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -10,6 +10,7 @@ DATA_FILE = "portfolio_monitor.json" POLL_MARKET_OPEN = 300.0 POLL_MARKET_CLOSED = 1800.0 +POLL_NO_HOLDINGS = 30.0 CACHE_TTL_SECONDS = 180 MAX_API_CALLS_PER_POLL = 10 @@ -327,7 +328,7 @@ async def watch_loop(self): f"[PortfolioMonitor] daemon tick — {len(holdings)} holding(s)" ) if not holdings: - await self.worker.session_tasks.sleep(POLL_MARKET_CLOSED) + await self.worker.session_tasks.sleep(POLL_NO_HOLDINGS) continue et = self._et_now() diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 5f69a933..8fe489c9 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -29,6 +29,18 @@ "check microsoft", "check google", "check meta", "check netflix", "how's apple", "how's tesla", "how's nvidia", "how's amazon", "how's microsoft", "how's google", "how's meta", "how's netflix", + # "add [company]" triggers — listed statically so the platform picks them up at load time + "add apple", "add tesla", "add microsoft", "add amazon", "add google", + "add alphabet", "add nvidia", "add meta", "add facebook", "add netflix", + "add disney", "add salesforce", "add visa", "add mastercard", + "add jpmorgan", "add jp morgan", "add johnson", "add walmart", + "add exxon", "add chevron", "add unitedhealth", "add home depot", + "add adobe", "add paypal", "add intel", "add amd", "add qualcomm", + "add broadcom", "add uber", "add lyft", "add airbnb", "add spotify", + "add shopify", "add palantir", "add coinbase", "add robinhood", + "add snapchat", "add snap", "add twitter", "add berkshire", "add boeing", + "add ford", "add general motors", "add gm", "add bank of america", + "add wells fargo", "add citigroup", "add goldman sachs", "add morgan stanley", } TICKER_MAP = { @@ -83,9 +95,6 @@ "morgan stanley": "MS", } -# Extend with "add [company]" for every company so the platform routes those phrases to the skill -HOTWORDS |= {f"add {company}" for company in TICKER_MAP} - COMMON_WORDS = { "A", "I", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "IF", "IN", "IS", "IT", "ME", "MY", "NO", "OF", "ON", "OR", "SO", "TO", "UP", "US", From d04eb799365bbb802e665746e8ef7a0d1c38387d Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 16 May 2026 19:06:21 +0500 Subject: [PATCH 09/18] Restore user_response() for add flow; fix messages for exact-match routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform uses exact phrase matching against HOTWORDS — 'add apple 10 shares at 180' never triggers because it's not in HOTWORDS as an exact entry. The exit-with-instructions approach was a dead end: telling the user to say a phrase that the platform can't route is worse than asking for it interactively. New flow: - 'add apple' (exact HOTWORD) triggers the skill - If trigger has both numbers (shares+cost) already: save immediately - Otherwise: ask ONE combined user_response() — daemon is sleeping by this point so resume_normal_flow() is not being called mid-wait, which should allow the response to be captured normally Update empty-portfolio and no-ticker messages to say 'add Apple' (the phrase that actually triggers) rather than the longer one-shot form. --- community/portfolio-monitor/main.py | 43 +++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 8fe489c9..2a1af567 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -350,7 +350,7 @@ async def _handle_portfolio(self): if not holdings: await self.capability_worker.speak( "No stocks in your portfolio yet. " - "Say 'add Apple 10 shares at 180' to start tracking." + "Say 'add Apple' or 'add Tesla' to start — I'll ask for the details." ) return @@ -576,8 +576,7 @@ async def _handle_add(self, trigger_text: str): if not ticker: await self.capability_worker.speak( - "Say 'add' followed by the company, shares, and price — " - "like 'add Apple 10 shares at 180'." + "Say 'add Apple' or 'add Tesla' to add a stock — I'll ask for the details." ) return @@ -592,7 +591,9 @@ async def _handle_add(self, trigger_text: str): ) return - # Parse shares and cost from the trigger phrase — complete one-shot command + name = self._resolve_company_name(ticker) + + # Try parsing shares+cost from trigger text first (e.g. "add apple 10 at 180") nums = re.findall(r'[\d,]+\.?\d*', trigger_text) nums_clean = [] for n in nums: @@ -601,17 +602,35 @@ async def _handle_add(self, trigger_text: str): except ValueError: pass - if len(nums_clean) < 2: - name = self._resolve_company_name(ticker) + if len(nums_clean) >= 2: + shares = nums_clean[0] + avg_cost = nums_clean[1] + else: + # Ask ONE combined question — daemon is sleeping so user_response() should work await self.capability_worker.speak( - f"Say 'add {name} 10 shares at 180' — " - "include the number of shares and your average cost." + f"Adding {name}. How many shares and at what price? " + f"Say both — like '10 shares at 180'." ) - return + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return - shares = nums_clean[0] - avg_cost = nums_clean[1] - name = self._resolve_company_name(ticker) + more_nums = re.findall(r'[\d,]+\.?\d*', reply) + more_clean = [] + for n in more_nums: + try: + more_clean.append(float(n.replace(",", ""))) + except ValueError: + pass + + if len(more_clean) < 2: + await self.capability_worker.speak( + "Say both the number of shares and the price — like '10 at 180'." + ) + return + + shares = more_clean[0] + avg_cost = more_clean[1] holding = { "id": str(int(datetime.now().timestamp() * 1000)), From b0dfb37c36424ce133a49d4adfd96e7dae707c56 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 17 May 2026 00:47:11 +0500 Subject: [PATCH 10/18] Fix add flow: use working trigger, remove dead hotwords, clean two-path design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause established: the platform has a pre-compiled ASR-level hotword detector built at ability registration — Python HOTWORDS changes are only used inside does_match() for within-skill routing, not for initial invocation. 'add apple' (and all 'add [company]' variants we added) never fire the detector. Only the original registered phrases work. Changes: - Remove all 'add [company]' entries from HOTWORDS — they were dead weight - Rewrite _handle_add() with two clean paths: Generic trigger ('add a stock'): ask WHO+HOW_MANY+AT_WHAT in one question via user_response(), parse ticker+numbers from single reply Specific trigger (company in phrase, e.g. future platform update): try to parse numbers from trigger; if incomplete, ask shares+cost via one user_response() - Both paths share a single data load + duplicate check + save block - Update empty portfolio message to reference 'add a stock' (the phrase that actually triggers the skill) Test: 'add a stock' -> 'Apple 10 shares at 180' -> watch for 'daemon tick — 1 holding(s)' to confirm user_response() works in 2nd invocation when daemon is sleeping (not initializing). --- community/portfolio-monitor/main.py | 135 ++++++++++++++++------------ 1 file changed, 77 insertions(+), 58 deletions(-) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 2a1af567..22b5f324 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -29,18 +29,6 @@ "check microsoft", "check google", "check meta", "check netflix", "how's apple", "how's tesla", "how's nvidia", "how's amazon", "how's microsoft", "how's google", "how's meta", "how's netflix", - # "add [company]" triggers — listed statically so the platform picks them up at load time - "add apple", "add tesla", "add microsoft", "add amazon", "add google", - "add alphabet", "add nvidia", "add meta", "add facebook", "add netflix", - "add disney", "add salesforce", "add visa", "add mastercard", - "add jpmorgan", "add jp morgan", "add johnson", "add walmart", - "add exxon", "add chevron", "add unitedhealth", "add home depot", - "add adobe", "add paypal", "add intel", "add amd", "add qualcomm", - "add broadcom", "add uber", "add lyft", "add airbnb", "add spotify", - "add shopify", "add palantir", "add coinbase", "add robinhood", - "add snapchat", "add snap", "add twitter", "add berkshire", "add boeing", - "add ford", "add general motors", "add gm", "add bank of america", - "add wells fargo", "add citigroup", "add goldman sachs", "add morgan stanley", } TICKER_MAP = { @@ -349,8 +337,7 @@ async def _handle_portfolio(self): holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak( - "No stocks in your portfolio yet. " - "Say 'add Apple' or 'add Tesla' to start — I'll ask for the details." + "No stocks in your portfolio yet. Say 'add a stock' to start tracking." ) return @@ -568,70 +555,102 @@ async def _handle_movers(self, data: dict | None = None): async def _handle_add(self, trigger_text: str): trigger_clean = trigger_text.lower().strip() - ticker = ( - None - if not trigger_clean or trigger_clean in _ADD_COMMAND_PHRASES - else self._resolve_ticker(trigger_text) - ) + generic_trigger = not trigger_clean or trigger_clean in _ADD_COMMAND_PHRASES - if not ticker: + if generic_trigger: + # "add a stock" / "add to portfolio" — ask for everything in one shot await self.capability_worker.speak( - "Say 'add Apple' or 'add Tesla' to add a stock — I'll ask for the details." + "Which stock, how many shares, and at what price? " + "Say it all — like 'Apple 10 shares at 180'." ) - return + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return - data = await self._load_data() - existing = next( - (h for h in data.get("holdings", []) if h["ticker"] == ticker), None - ) - if existing: - await self.capability_worker.speak( - f"{ticker} is already in your portfolio — " - f"{existing['shares']:g} shares at ${existing['avg_cost']:.2f} average." - ) - return + ticker = self._resolve_ticker(reply) + if not ticker: + await self.capability_worker.speak( + "I didn't catch the stock. Say 'add a stock' to try again." + ) + return - name = self._resolve_company_name(ticker) + nums = re.findall(r'[\d,]+\.?\d*', reply) + nums_clean = [] + for n in nums: + try: + nums_clean.append(float(n.replace(",", ""))) + except ValueError: + pass - # Try parsing shares+cost from trigger text first (e.g. "add apple 10 at 180") - nums = re.findall(r'[\d,]+\.?\d*', trigger_text) - nums_clean = [] - for n in nums: - try: - nums_clean.append(float(n.replace(",", ""))) - except ValueError: - pass + if len(nums_clean) < 2: + await self.capability_worker.speak( + "I need both the number of shares and the price. " + "Say 'add a stock' and include all three — like 'Apple 10 shares at 180'." + ) + return - if len(nums_clean) >= 2: shares = nums_clean[0] avg_cost = nums_clean[1] + else: - # Ask ONE combined question — daemon is sleeping so user_response() should work - await self.capability_worker.speak( - f"Adding {name}. How many shares and at what price? " - f"Say both — like '10 shares at 180'." - ) - reply = await self.capability_worker.user_response() - if self._is_exit(reply): + # Trigger contains a company name — try to parse numbers from trigger too + ticker = self._resolve_ticker(trigger_text) + if not ticker: + await self.capability_worker.speak( + "Say 'add a stock' to add to your portfolio." + ) return - more_nums = re.findall(r'[\d,]+\.?\d*', reply) - more_clean = [] - for n in more_nums: + nums = re.findall(r'[\d,]+\.?\d*', trigger_text) + nums_clean = [] + for n in nums: try: - more_clean.append(float(n.replace(",", ""))) + nums_clean.append(float(n.replace(",", ""))) except ValueError: pass - if len(more_clean) < 2: + if len(nums_clean) >= 2: + shares = nums_clean[0] + avg_cost = nums_clean[1] + else: + name = self._resolve_company_name(ticker) await self.capability_worker.speak( - "Say both the number of shares and the price — like '10 at 180'." + f"Adding {name}. How many shares and at what price? " + f"Say both — like '10 shares at 180'." ) - return + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + + more_nums = re.findall(r'[\d,]+\.?\d*', reply) + more_clean = [] + for n in more_nums: + try: + more_clean.append(float(n.replace(",", ""))) + except ValueError: + pass + + if len(more_clean) < 2: + await self.capability_worker.speak( + "Say both the number of shares and the price — like '10 at 180'." + ) + return - shares = more_clean[0] - avg_cost = more_clean[1] + shares = more_clean[0] + avg_cost = more_clean[1] + data = await self._load_data() + existing = next( + (h for h in data.get("holdings", []) if h["ticker"] == ticker), None + ) + if existing: + await self.capability_worker.speak( + f"{ticker} is already in your portfolio — " + f"{existing['shares']:g} shares at ${existing['avg_cost']:.2f} average." + ) + return + + name = self._resolve_company_name(ticker) holding = { "id": str(int(datetime.now().timestamp() * 1000)), "ticker": ticker, From c85e7fb1bd326754753ef1c11861495287ce052b Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 17 May 2026 01:04:28 +0500 Subject: [PATCH 11/18] Fix TTS decimals, add portfolio loop, improve single-stock UX - Replace all :.2f and :.1f price/pct formatting with integer-safe equivalents so TTS reads cleanly (no space-inserted decimals) - Smart pct: show "less than 1" when change_pct < 1% - Portfolio view: while-loop follow-up so users can check multiple stocks without re-invoking; unknown input gets a reprompt - _speak_single_stock: use company name instead of ticker; add CTA ("say 'add a stock' to track it") for non-portfolio stocks - Fix avg_cost display in add confirmation and duplicate warning --- community/portfolio-monitor/background.py | 8 ++-- community/portfolio-monitor/main.py | 57 ++++++++++++++--------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index f69ae02a..db74804a 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -211,7 +211,7 @@ def _check_alerts(self, ticker: str, holding: dict, quote: dict, data: dict) -> pnl = (price - avg_cost) * shares pnl_str = f"down ${abs(pnl):,.0f}" if pnl < 0 else f"up ${pnl:,.0f}" msg = ( - f"Heads up — {name} is down {abs(change_pct):.1f} percent today. " + f"Heads up — {name} is down {abs(change_pct):.0f} percent today. " f"Your position is {pnl_str}. Say 'portfolio monitor' to review." ) alerts.append(("drop", msg)) @@ -220,7 +220,7 @@ def _check_alerts(self, ticker: str, holding: dict, quote: dict, data: dict) -> pnl = (price - avg_cost) * shares pnl_str = f"up ${pnl:,.0f}" if pnl >= 0 else f"down ${abs(pnl):,.0f}" msg = ( - f"Nice — {name} is up {change_pct:.1f} percent today. " + f"Nice — {name} is up {change_pct:.0f} percent today. " f"Your position is {pnl_str}. Say 'portfolio monitor' to review." ) alerts.append(("rise", msg)) @@ -297,9 +297,9 @@ async def _speak_eod_summary(self, data: dict): f"{overall_dir} ${abs(overall_pnl):,.0f} overall." ] if best and best_pct > 0: - parts.append(f"{best} led, up {best_pct:.1f} percent.") + parts.append(f"{best} led, up {best_pct:.0f} percent.") if worst and worst_pct < 0: - parts.append(f"{worst} lagged, down {abs(worst_pct):.1f} percent.") + parts.append(f"{worst} lagged, down {abs(worst_pct):.0f} percent.") try: await self.capability_worker.send_interrupt_signal() diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 22b5f324..cc298ce4 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -394,7 +394,7 @@ async def _handle_portfolio(self): day_dir = "up" if change_pct >= 0 else "down" pos_dir = "up" if position_pnl >= 0 else "down" stock_lines.append( - f"{name}: ${price:.2f}, {day_dir} {abs(change_pct):.1f}% today, " + f"{name}: ${price:,.0f}, {day_dir} {abs(change_pct):.0f}% today, " f"{pos_dir} ${abs(position_pnl):,.0f} overall" ) @@ -406,24 +406,29 @@ async def _handle_portfolio(self): await self.capability_worker.speak( f"Your portfolio is worth ${total_value:,.0f}. " f"Today you're {day_dir} ${abs(day_pnl):,.0f}. " - f"Overall {overall_dir} ${abs(total_pnl):,.0f} — {abs(total_pnl_pct):.1f} percent." + f"Overall {overall_dir} ${abs(total_pnl):,.0f} — {abs(total_pnl_pct):.0f} percent." ) if stock_lines: await self.capability_worker.speak(". ".join(stock_lines) + ".") await self.capability_worker.speak( - "Say a stock name to check it, say 'movers' to see what's moving, or stop." + "Say a stock name to check it, 'movers' for biggest movers, or stop." ) - reply = await self.capability_worker.user_response() - if self._is_exit(reply): - return - r = reply.lower() - if any(kw in r for kw in ("mover", "moving", "gainer", "loser")): - await self._handle_movers(data) - else: - ticker = self._resolve_ticker(reply) - if ticker: - await self._speak_single_stock(ticker, data) + while True: + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + break + r = reply.lower() + if any(kw in r for kw in ("mover", "moving", "gainer", "loser")): + await self._handle_movers(data) + else: + ticker = self._resolve_ticker(reply) + if ticker: + await self._speak_single_stock(ticker, data) + else: + await self.capability_worker.speak( + "Say a stock name, 'movers', or 'stop'." + ) async def _speak_single_stock(self, ticker: str, data: dict): cache = data.get("price_cache", {}) @@ -446,11 +451,17 @@ async def _speak_single_stock(self, ticker: str, data: dict): price = q["price"] change_pct = q.get("change_pct", 0) day_dir = "up" if change_pct >= 0 else "down" - msg = f"{ticker} is at ${price:.2f}, {day_dir} {abs(change_pct):.1f} percent today." + pct_str = f"{abs(change_pct):.0f}" if abs(change_pct) >= 1 else "less than 1" holding = next( (h for h in data.get("holdings", []) if h["ticker"] == ticker), None ) + name = holding.get("name", ticker) if holding else ( + next((c.title() for c, t in TICKER_MAP.items() if t == ticker), ticker) + ) + + msg = f"{name} is at ${price:,.0f}, {day_dir} {pct_str} percent today." + if holding: shares = holding["shares"] avg_cost = holding["avg_cost"] @@ -459,9 +470,11 @@ async def _speak_single_stock(self, ticker: str, data: dict): pos_pnl_pct = (pos_pnl / pos_value * 100) if pos_value else 0 pos_dir = "up" if pos_pnl >= 0 else "down" msg += ( - f" Your {shares} shares are worth ${pos_value:,.0f} — " - f"{pos_dir} ${abs(pos_pnl):,.0f} ({abs(pos_pnl_pct):.1f}%) on your position." + f" Your {shares:g} shares are worth ${pos_value:,.0f} — " + f"{pos_dir} ${abs(pos_pnl):,.0f} ({abs(pos_pnl_pct):.0f}%) on your position." ) + else: + msg += " Not in your portfolio — say 'add a stock' to track it." await self.capability_worker.speak(msg) @@ -541,9 +554,9 @@ async def _handle_movers(self, data: dict | None = None): parts = [] if best[1] > 0: - parts.append(f"Biggest gainer: {best[0]}, up {best[1]:.1f} percent.") + parts.append(f"Biggest gainer: {best[0]}, up {best[1]:.0f} percent.") if worst[1] < 0: - parts.append(f"Biggest loser: {worst[0]}, down {abs(worst[1]):.1f} percent.") + parts.append(f"Biggest loser: {worst[0]}, down {abs(worst[1]):.0f} percent.") if not parts: await self.capability_worker.speak( @@ -646,7 +659,7 @@ async def _handle_add(self, trigger_text: str): if existing: await self.capability_worker.speak( f"{ticker} is already in your portfolio — " - f"{existing['shares']:g} shares at ${existing['avg_cost']:.2f} average." + f"{existing['shares']:g} shares at ${existing['avg_cost']:,.0f} average." ) return @@ -662,7 +675,7 @@ async def _handle_add(self, trigger_text: str): data["holdings"].append(holding) await self._save_data(data) await self.capability_worker.speak( - f"Added {shares:g} shares of {name} at ${avg_cost:.2f} average. " + f"Added {shares:g} shares of {name} at ${avg_cost:,.0f} average. " f"Total position: ${shares * avg_cost:,.0f}. " "Say 'portfolio monitor' to view your portfolio." ) @@ -728,9 +741,9 @@ async def _handle_set_alert(self, trigger_text: str): parts = [] if drop_pct: - parts.append(f"drops {drop_pct}%") + parts.append(f"drops {drop_pct:.0f}%") if rise_pct: - parts.append(f"rises {rise_pct}%") + parts.append(f"rises {rise_pct:.0f}%") await self.capability_worker.speak( f"Done — I'll alert you if {name} {' or '.join(parts)} in a day." ) From 26c48420d43ec1e74094f6fecb92fcc4ca691b9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 20:09:00 +0000 Subject: [PATCH 12/18] style: auto-format Python files with autoflake + autopep8 --- community/portfolio-monitor/background.py | 1 - community/portfolio-monitor/main.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index db74804a..089c295c 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -1,5 +1,4 @@ import json -import re import requests from datetime import datetime, timedelta diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index cc298ce4..877bb3cb 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -1,7 +1,7 @@ import json import re import requests -from datetime import datetime, timedelta +from datetime import datetime from src.agent.capability import MatchingCapability from src.agent.capability_worker import CapabilityWorker @@ -384,7 +384,7 @@ async def _handle_portfolio(self): position_value = price * shares position_cost = avg_cost * shares position_pnl = position_value - position_cost - position_pnl_pct = (position_pnl / position_cost * 100) if position_cost else 0 + (position_pnl / position_cost * 100) if position_cost else 0 day_change = (price - prev_close) * shares total_value += position_value From 3e1ecc322359c4a1612e0949d10f84e5f353cb79 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 17 May 2026 01:16:26 +0500 Subject: [PATCH 13/18] Restore CONTRIBUTORS.md to upstream dev state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the file in sync with upstream/dev so this PR has no diff outside the community/ folder — required by the path-check CI gate. --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3069cb2a..726ff9e0 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,7 +15,7 @@ Format: - **@username** — ability-name ([ability-name](community/ability-name/ - **[@Rizwan-algoryc](https://github.com/Rizwan-algoryc)** — slow-music ([slow-music](community/slow-music/)) - **[@engrumair842-arch](https://github.com/engrumair842-arch)** — reddit-daily-digest ([reddit-daily-digest](community/reddit-daily-digest/)), smart-sous-chef ([smart-sous-chef](community/smart-sous-chef/)) - **[@samsonadmasu](https://github.com/samsonadmasu)** — voice-unit-converter ([voice-unit-converter](community/voice-unit-converter/)), food-water-log ([food-water-log](community/food-water-log/)), gmail-connector ([gmail-connector](community/gmail-connector/)), google-tasks ([google-tasks](community/google-tasks/)), traffic-travel-time ([traffic-travel-time](community/traffic-travel-time/)) -- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)) +- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)), decision-journal ([decision-journal](community/decision-journal/)), social-memory ([social-memory](community/social-memory/)) - **[@BhargavTelu](https://github.com/BhargavTelu)** — grocery-list-manager ([grocery-list-manager](community/grocery-list-manager/)), package-tracker ([package-tracker](community/package-tracker/)) - **[@ArturKozhushnyi](https://github.com/ArturKozhushnyi)** — coin-flipper ([coin-flipper](community/coin-flipper/)), Bedtime-Wind-Down ([Bedtime-Wind-Down](community/Bedtime-Wind-Down/)), Twilio-SMS ([Twilio-SMS](community/Twilio-SMS/)) - **[@ammyyou112](https://github.com/ammyyou112)** — dad-joke-teller ([dad-joke-teller](community/dad-joke-teller/)), youtube-search-play ([youtube-search-play](community/youtube-search-play/)), google-daily-brief ([google-daily-brief](community/google-daily-brief/)) From d8e727b2b08ff71d8c2311062f07da103c19a033 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 17 May 2026 01:24:14 +0500 Subject: [PATCH 14/18] Replace hardcoded API keys with placeholder strings --- community/portfolio-monitor/background.py | 4 ++-- community/portfolio-monitor/main.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index 089c295c..873b9845 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -14,9 +14,9 @@ MAX_API_CALLS_PER_POLL = 10 # Get a free key at finnhub.io — 60 calls/minute, no daily cap -FINNHUB_KEY = "d844aahr01qkm5ca643gd844aahr01qkm5ca6440" +FINNHUB_KEY = "your_finnhub_api_key" # Optional fallback — alphavantage.co (25 calls/day free) -AV_KEY = "Q4C71IM9EYRTRSBD" +AV_KEY = "your_alphavantage_api_key" FINNHUB_BASE = "https://finnhub.io/api/v1" AV_BASE = "https://www.alphavantage.co/query" diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 877bb3cb..27c893ee 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -10,9 +10,9 @@ DATA_FILE = "portfolio_monitor.json" # Get a free key at finnhub.io — 60 calls/minute, no daily cap -FINNHUB_KEY = "d844aahr01qkm5ca643gd844aahr01qkm5ca6440" +FINNHUB_KEY = "your_finnhub_api_key" # Optional fallback — alphavantage.co (25 calls/day free) -AV_KEY = "Q4C71IM9EYRTRSBD" +AV_KEY = "your_alphavantage_api_key" FINNHUB_BASE = "https://finnhub.io/api/v1" AV_BASE = "https://www.alphavantage.co/query" From eb751a8ad52553681628a78a9920618bb93ded3a Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Tue, 19 May 2026 14:10:24 +0500 Subject: [PATCH 15/18] Address PR review: storage, API keys, does_match, correctness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch file I/O to SDK Context Storage (get_single_key/create_key/ update_key) — eliminates silent data loss on delete→write failures - API keys via get_api_keys() — no hardcoded secrets; upfront check in _run() and watch_loop() speaks a setup message if key is missing - Add _resolve_ticker_cheap() for does_match — static map + pattern only, no LLM fired on every utterance; drop "stock not in t" exclusion - _handle_add: conversational loop after each add ("want to add another?") so follow-up stocks don't fall through to the agent LLM - _handle_set_alert: use setdefault(ticker, {}) + is not None guards so drop and rise thresholds set in separate sessions don't clobber each other - _handle_portfolio: TTL-aware cache refresh (stale entries re-fetched, not just missing ones); include position_pnl_pct in spoken stock lines - _speak_single_stock: fix pos_pnl_pct denominator (pos_cost not pos_value) - _handle_check: always save cache after fresh fetch, not only for portfolio stocks - _speak_eod_summary: force fresh quote fetch before computing EOD totals - MAX_API_CALLS_PER_POLL: raised 10 → 50 (Finnhub allows 60/min free) --- community/portfolio-monitor/background.py | 83 +++--- community/portfolio-monitor/main.py | 323 ++++++++++++---------- 2 files changed, 224 insertions(+), 182 deletions(-) diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index 873b9845..5f33c4cd 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -1,4 +1,3 @@ -import json import requests from datetime import datetime, timedelta @@ -6,17 +5,12 @@ from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker -DATA_FILE = "portfolio_monitor.json" +STORAGE_KEY = "portfolio_data" POLL_MARKET_OPEN = 300.0 POLL_MARKET_CLOSED = 1800.0 POLL_NO_HOLDINGS = 30.0 CACHE_TTL_SECONDS = 180 -MAX_API_CALLS_PER_POLL = 10 - -# Get a free key at finnhub.io — 60 calls/minute, no daily cap -FINNHUB_KEY = "your_finnhub_api_key" -# Optional fallback — alphavantage.co (25 calls/day free) -AV_KEY = "your_alphavantage_api_key" +MAX_API_CALLS_PER_POLL = 50 FINNHUB_BASE = "https://finnhub.io/api/v1" AV_BASE = "https://www.alphavantage.co/query" @@ -48,35 +42,33 @@ class PortfolioMonitorBackground(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None background_daemon_mode: bool = False + finnhub_key: str = "" + av_key: str = "" # Do not change following tag of register capability # {{register capability}} # ------------------------------------------------------------------ - # File I/O + # Context Storage # ------------------------------------------------------------------ - async def _load_data(self) -> dict: + def _load_data(self) -> dict: try: - exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) - if not exists: - return _empty_data() - raw = await self.capability_worker.read_file(DATA_FILE, False) - if not raw or not raw.strip(): - return _empty_data() - return json.loads(raw) + result = self.capability_worker.get_single_key(STORAGE_KEY) + if result and result.get("value"): + return result["value"] + return _empty_data() except Exception as e: self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Load error: {e}") return _empty_data() - async def _save_data(self, data: dict): + def _save_data(self, data: dict): try: - exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) - if exists: - await self.capability_worker.delete_file(DATA_FILE, False) - await self.capability_worker.write_file( - DATA_FILE, json.dumps(data, indent=2), False - ) + existing = self.capability_worker.get_single_key(STORAGE_KEY) + if existing: + self.capability_worker.update_key(STORAGE_KEY, data) + else: + self.capability_worker.create_key(STORAGE_KEY, data) except Exception as e: self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") @@ -124,7 +116,7 @@ def _fetch_quote_finnhub(self, ticker: str) -> dict | None: try: resp = requests.get( f"{FINNHUB_BASE}/quote", - params={"symbol": ticker, "token": FINNHUB_KEY}, + params={"symbol": ticker, "token": self.finnhub_key}, timeout=10, ) if resp.status_code == 200: @@ -146,10 +138,12 @@ def _fetch_quote_finnhub(self, ticker: str) -> dict | None: return None def _fetch_quote_av(self, ticker: str) -> dict | None: + if not self.av_key: + return None try: resp = requests.get( AV_BASE, - params={"function": "GLOBAL_QUOTE", "symbol": ticker, "apikey": AV_KEY}, + params={"function": "GLOBAL_QUOTE", "symbol": ticker, "apikey": self.av_key}, timeout=10, ) if resp.status_code == 200: @@ -249,10 +243,25 @@ async def _speak_morning_open(self, data: dict): async def _speak_eod_summary(self, data: dict): holdings = data.get("holdings", []) - cache = data.get("price_cache", {}) - if not holdings or not cache: + if not holdings: return + # Force fresh quotes at close so EOD summary reflects final prices + cache = data.get("price_cache", {}) + changed = False + for h in holdings: + ticker = h["ticker"] + quote = self._fetch_quote(ticker) + if quote: + cache[ticker] = { + **quote, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + changed = True + if changed: + data["price_cache"] = cache + self._save_data(data) + total_value = 0.0 total_cost = 0.0 day_pnl = 0.0 @@ -313,6 +322,14 @@ async def _speak_eod_summary(self, data: dict): # ------------------------------------------------------------------ async def watch_loop(self): + self.finnhub_key = self.capability_worker.get_api_keys("finnhub_api_key") or "" + self.av_key = self.capability_worker.get_api_keys("alphavantage_api_key") or "" + + if not self.finnhub_key: + self.worker.editor_logging_handler.warning( + "[PortfolioMonitor] No Finnhub API key — daemon idle until key is configured." + ) + s = _new_state() self.worker.editor_logging_handler.info("[PortfolioMonitor] daemon started") self.capability_worker.resume_normal_flow() @@ -320,7 +337,11 @@ async def watch_loop(self): while True: sleep_time = POLL_MARKET_CLOSED try: - data = await self._load_data() + if not self.finnhub_key: + await self.worker.session_tasks.sleep(POLL_NO_HOLDINGS) + continue + + data = self._load_data() holdings = data.get("holdings", []) self.worker.editor_logging_handler.info( @@ -342,7 +363,7 @@ async def watch_loop(self): meta = data.setdefault("meta", {}) meta["api_calls_today"] = 0 meta["api_calls_date"] = today_str - await self._save_data(data) + self._save_data(data) market_open = self._is_market_open_et(et) @@ -399,7 +420,7 @@ async def watch_loop(self): changed = True if changed: - await self._save_data(data) + self._save_data(data) for msg in pending_alerts: try: diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 27c893ee..cdfe6ab9 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -1,4 +1,3 @@ -import json import re import requests from datetime import datetime @@ -7,15 +6,10 @@ from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker -DATA_FILE = "portfolio_monitor.json" - -# Get a free key at finnhub.io — 60 calls/minute, no daily cap -FINNHUB_KEY = "your_finnhub_api_key" -# Optional fallback — alphavantage.co (25 calls/day free) -AV_KEY = "your_alphavantage_api_key" - FINNHUB_BASE = "https://finnhub.io/api/v1" AV_BASE = "https://www.alphavantage.co/query" +STORAGE_KEY = "portfolio_data" +CACHE_TTL_SECONDS = 180 HOTWORDS = { "portfolio monitor", "my portfolio", "check my stocks", "stock update", @@ -121,6 +115,8 @@ def _empty_data() -> dict: class PortfolioMonitorCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None + finnhub_key: str = "" + av_key: str = "" # Do not change following tag of register capability # {{register capability}} @@ -135,9 +131,9 @@ def does_match(self, text: str) -> bool: return True # "check [company/ticker]" or "how's [company/ticker] doing" if re.search(r'\b(check|how.?s|how is)\b', t) and "portfolio" not in t: - return bool(self._resolve_ticker(text)) - # "add Apple" / "add NVDA" — static map + ticker pattern only (no LLM) - if re.search(r'\badd\b', t) and "stock" not in t and "portfolio" not in t: + return bool(self._resolve_ticker_cheap(text)) + # "add Apple" / "add NVDA" — static map + ticker pattern, no LLM + if re.search(r'\badd\b', t) and "portfolio" not in t: for company in TICKER_MAP: if company in t: return True @@ -199,19 +195,24 @@ def _classify_intent(self, text: str) -> str: return "PORTFOLIO" - def _resolve_ticker(self, text: str) -> str | None: + def _resolve_ticker_cheap(self, text: str) -> str | None: + """Static-only ticker resolution — safe to call from does_match (no LLM).""" if not text: return None lower = text.lower() - # Static map first — longest match wins for company, ticker in sorted(TICKER_MAP.items(), key=lambda x: -len(x[0])): if company in lower: return ticker - # Direct ticker pattern in uppercase for match in re.finditer(r'\b([A-Z]{2,5})\b', text.upper()): candidate = match.group(1) if candidate not in COMMON_WORDS: return candidate + return None + + def _resolve_ticker(self, text: str) -> str | None: + result = self._resolve_ticker_cheap(text) + if result: + return result return self._resolve_ticker_llm(text) def _resolve_ticker_llm(self, text: str) -> str | None: @@ -247,7 +248,7 @@ def _fetch_quote_finnhub(self, ticker: str) -> dict | None: try: resp = requests.get( f"{FINNHUB_BASE}/quote", - params={"symbol": ticker, "token": FINNHUB_KEY}, + params={"symbol": ticker, "token": self.finnhub_key}, timeout=10, ) if resp.status_code == 200: @@ -269,10 +270,12 @@ def _fetch_quote_finnhub(self, ticker: str) -> dict | None: return None def _fetch_quote_av(self, ticker: str) -> dict | None: + if not self.av_key: + return None try: resp = requests.get( AV_BASE, - params={"function": "GLOBAL_QUOTE", "symbol": ticker, "apikey": AV_KEY}, + params={"function": "GLOBAL_QUOTE", "symbol": ticker, "apikey": self.av_key}, timeout=10, ) if resp.status_code == 200: @@ -301,30 +304,26 @@ def _fetch_quote(self, ticker: str) -> dict | None: return self._fetch_quote_av(ticker) # ------------------------------------------------------------------ - # File I/O + # Context Storage # ------------------------------------------------------------------ - async def _load_data(self) -> dict: + def _load_data(self) -> dict: try: - exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) - if not exists: - return _empty_data() - raw = await self.capability_worker.read_file(DATA_FILE, False) - if not raw or not raw.strip(): - return _empty_data() - return json.loads(raw) + result = self.capability_worker.get_single_key(STORAGE_KEY) + if result and result.get("value"): + return result["value"] + return _empty_data() except Exception as e: self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Load error: {e}") return _empty_data() - async def _save_data(self, data: dict): + def _save_data(self, data: dict): try: - exists = await self.capability_worker.check_if_file_exists(DATA_FILE, False) - if exists: - await self.capability_worker.delete_file(DATA_FILE, False) - await self.capability_worker.write_file( - DATA_FILE, json.dumps(data, indent=2), False - ) + existing = self.capability_worker.get_single_key(STORAGE_KEY) + if existing: + self.capability_worker.update_key(STORAGE_KEY, data) + else: + self.capability_worker.create_key(STORAGE_KEY, data) except Exception as e: self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") @@ -333,7 +332,7 @@ async def _save_data(self, data: dict): # ------------------------------------------------------------------ async def _handle_portfolio(self): - data = await self._load_data() + data = self._load_data() holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak( @@ -344,10 +343,18 @@ async def _handle_portfolio(self): cache = data.get("price_cache", {}) changed = False - # Fetch any missing quotes on-demand + # Refresh any missing or stale quotes for h in holdings: ticker = h["ticker"] - if ticker not in cache: + q = cache.get(ticker) + stale = True + if q: + try: + cached_at = datetime.strptime(q["cached_at"], "%Y-%m-%dT%H:%M:%S") + stale = (datetime.utcnow() - cached_at).total_seconds() > CACHE_TTL_SECONDS + except Exception: + pass + if stale: await self.capability_worker.speak(f"Fetching {h.get('name', ticker)}...") quote = self._fetch_quote(ticker) if quote: @@ -359,7 +366,7 @@ async def _handle_portfolio(self): if changed: data["price_cache"] = cache - await self._save_data(data) + self._save_data(data) total_value = 0.0 total_cost = 0.0 @@ -384,7 +391,7 @@ async def _handle_portfolio(self): position_value = price * shares position_cost = avg_cost * shares position_pnl = position_value - position_cost - (position_pnl / position_cost * 100) if position_cost else 0 + position_pnl_pct = (position_pnl / position_cost * 100) if position_cost else 0 day_change = (price - prev_close) * shares total_value += position_value @@ -395,7 +402,7 @@ async def _handle_portfolio(self): pos_dir = "up" if position_pnl >= 0 else "down" stock_lines.append( f"{name}: ${price:,.0f}, {day_dir} {abs(change_pct):.0f}% today, " - f"{pos_dir} ${abs(position_pnl):,.0f} overall" + f"{pos_dir} ${abs(position_pnl):,.0f} ({abs(position_pnl_pct):.0f}%) overall" ) total_pnl = total_value - total_cost @@ -466,8 +473,9 @@ async def _speak_single_stock(self, ticker: str, data: dict): shares = holding["shares"] avg_cost = holding["avg_cost"] pos_value = price * shares - pos_pnl = (price - avg_cost) * shares - pos_pnl_pct = (pos_pnl / pos_value * 100) if pos_value else 0 + pos_cost = avg_cost * shares + pos_pnl = pos_value - pos_cost + pos_pnl_pct = (pos_pnl / pos_cost * 100) if pos_cost else 0 pos_dir = "up" if pos_pnl >= 0 else "down" msg += ( f" Your {shares:g} shares are worth ${pos_value:,.0f} — " @@ -496,13 +504,13 @@ async def _handle_check(self, trigger_text: str): ) return - data = await self._load_data() + data = self._load_data() cached = data.get("price_cache", {}).get(ticker) if cached: try: cached_at = datetime.strptime(cached["cached_at"], "%Y-%m-%dT%H:%M:%S") - if (datetime.utcnow() - cached_at).total_seconds() < 180: + if (datetime.utcnow() - cached_at).total_seconds() < CACHE_TTL_SECONDS: await self._speak_single_stock(ticker, data) return except Exception: @@ -521,14 +529,12 @@ async def _handle_check(self, trigger_text: str): **quote, "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), } - holding = next((h for h in data.get("holdings", []) if h["ticker"] == ticker), None) - if holding: - await self._save_data(data) + self._save_data(data) await self._speak_single_stock(ticker, data) async def _handle_movers(self, data: dict | None = None): if data is None: - data = await self._load_data() + data = self._load_data() holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak("No stocks in your portfolio yet.") @@ -567,118 +573,123 @@ async def _handle_movers(self, data: dict | None = None): await self.capability_worker.speak(" ".join(parts)) async def _handle_add(self, trigger_text: str): - trigger_clean = trigger_text.lower().strip() - generic_trigger = not trigger_clean or trigger_clean in _ADD_COMMAND_PHRASES - - if generic_trigger: - # "add a stock" / "add to portfolio" — ask for everything in one shot - await self.capability_worker.speak( - "Which stock, how many shares, and at what price? " - "Say it all — like 'Apple 10 shares at 180'." - ) - reply = await self.capability_worker.user_response() - if self._is_exit(reply): - return - - ticker = self._resolve_ticker(reply) - if not ticker: - await self.capability_worker.speak( - "I didn't catch the stock. Say 'add a stock' to try again." - ) - return - - nums = re.findall(r'[\d,]+\.?\d*', reply) - nums_clean = [] - for n in nums: - try: - nums_clean.append(float(n.replace(",", ""))) - except ValueError: - pass + while True: + trigger_clean = trigger_text.lower().strip() + generic_trigger = not trigger_clean or trigger_clean in _ADD_COMMAND_PHRASES - if len(nums_clean) < 2: + if generic_trigger: await self.capability_worker.speak( - "I need both the number of shares and the price. " - "Say 'add a stock' and include all three — like 'Apple 10 shares at 180'." + "Which stock, how many shares, and at what price? " + "Say it all — like 'Apple 10 shares at 180'." ) - return + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return - shares = nums_clean[0] - avg_cost = nums_clean[1] + ticker = self._resolve_ticker(reply) + if not ticker: + await self.capability_worker.speak( + "I didn't catch the stock. Say 'add a stock' to try again." + ) + return - else: - # Trigger contains a company name — try to parse numbers from trigger too - ticker = self._resolve_ticker(trigger_text) - if not ticker: - await self.capability_worker.speak( - "Say 'add a stock' to add to your portfolio." - ) - return + nums = re.findall(r'[\d,]+\.?\d*', reply) + nums_clean = [] + for n in nums: + try: + nums_clean.append(float(n.replace(",", ""))) + except ValueError: + pass - nums = re.findall(r'[\d,]+\.?\d*', trigger_text) - nums_clean = [] - for n in nums: - try: - nums_clean.append(float(n.replace(",", ""))) - except ValueError: - pass + if len(nums_clean) < 2: + await self.capability_worker.speak( + "I need both the number of shares and the price. " + "Say 'add a stock' and include all three — like 'Apple 10 shares at 180'." + ) + return - if len(nums_clean) >= 2: shares = nums_clean[0] avg_cost = nums_clean[1] + else: - name = self._resolve_company_name(ticker) - await self.capability_worker.speak( - f"Adding {name}. How many shares and at what price? " - f"Say both — like '10 shares at 180'." - ) - reply = await self.capability_worker.user_response() - if self._is_exit(reply): + ticker = self._resolve_ticker(trigger_text) + if not ticker: + await self.capability_worker.speak( + "Say 'add a stock' to add to your portfolio." + ) return - more_nums = re.findall(r'[\d,]+\.?\d*', reply) - more_clean = [] - for n in more_nums: + nums = re.findall(r'[\d,]+\.?\d*', trigger_text) + nums_clean = [] + for n in nums: try: - more_clean.append(float(n.replace(",", ""))) + nums_clean.append(float(n.replace(",", ""))) except ValueError: pass - if len(more_clean) < 2: + if len(nums_clean) >= 2: + shares = nums_clean[0] + avg_cost = nums_clean[1] + else: + name = self._resolve_company_name(ticker) await self.capability_worker.speak( - "Say both the number of shares and the price — like '10 at 180'." + f"Adding {name}. How many shares and at what price? " + "Say both — like '10 shares at 180'." ) - return - - shares = more_clean[0] - avg_cost = more_clean[1] + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + + more_nums = re.findall(r'[\d,]+\.?\d*', reply) + more_clean = [] + for n in more_nums: + try: + more_clean.append(float(n.replace(",", ""))) + except ValueError: + pass + + if len(more_clean) < 2: + await self.capability_worker.speak( + "Say both the number of shares and the price — like '10 at 180'." + ) + return + + shares = more_clean[0] + avg_cost = more_clean[1] + + data = self._load_data() + existing = next( + (h for h in data.get("holdings", []) if h["ticker"] == ticker), None + ) + if existing: + await self.capability_worker.speak( + f"{existing.get('name', ticker)} is already in your portfolio — " + f"{existing['shares']:g} shares at ${existing['avg_cost']:,.0f} average." + ) + else: + name = self._resolve_company_name(ticker) + holding = { + "id": str(int(datetime.now().timestamp() * 1000)), + "ticker": ticker, + "name": name, + "shares": shares, + "avg_cost": avg_cost, + "added_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + } + data["holdings"].append(holding) + self._save_data(data) + await self.capability_worker.speak( + f"Added {shares:g} shares of {name} at ${avg_cost:,.0f} average. " + f"Total position: ${shares * avg_cost:,.0f}." + ) - data = await self._load_data() - existing = next( - (h for h in data.get("holdings", []) if h["ticker"] == ticker), None - ) - if existing: await self.capability_worker.speak( - f"{ticker} is already in your portfolio — " - f"{existing['shares']:g} shares at ${existing['avg_cost']:,.0f} average." + "Want to add another stock? Say a stock name or stop." ) - return - - name = self._resolve_company_name(ticker) - holding = { - "id": str(int(datetime.now().timestamp() * 1000)), - "ticker": ticker, - "name": name, - "shares": shares, - "avg_cost": avg_cost, - "added_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), - } - data["holdings"].append(holding) - await self._save_data(data) - await self.capability_worker.speak( - f"Added {shares:g} shares of {name} at ${avg_cost:,.0f} average. " - f"Total position: ${shares * avg_cost:,.0f}. " - "Say 'portfolio monitor' to view your portfolio." - ) + next_reply = await self.capability_worker.user_response() + if self._is_exit(next_reply): + return + trigger_text = next_reply async def _handle_set_alert(self, trigger_text: str): ticker = self._resolve_ticker(trigger_text) @@ -696,7 +707,7 @@ async def _handle_set_alert(self, trigger_text: str): await self.capability_worker.speak("I couldn't identify that stock.") return - data = await self._load_data() + data = self._load_data() holding = next( (h for h in data.get("holdings", []) if h["ticker"] == ticker), None ) @@ -728,28 +739,28 @@ async def _handle_set_alert(self, trigger_text: str): if m: rise_pct = float(m.group()) - if not drop_pct and not rise_pct: + if drop_pct is None and rise_pct is None: await self.capability_worker.speak("No thresholds set.") return - data.setdefault("alert_thresholds", {})[ticker] = {} - if drop_pct: - data["alert_thresholds"][ticker]["drop_pct"] = drop_pct - if rise_pct: - data["alert_thresholds"][ticker]["rise_pct"] = rise_pct - await self._save_data(data) + ticker_thresholds = data.setdefault("alert_thresholds", {}).setdefault(ticker, {}) + if drop_pct is not None: + ticker_thresholds["drop_pct"] = drop_pct + if rise_pct is not None: + ticker_thresholds["rise_pct"] = rise_pct + self._save_data(data) parts = [] - if drop_pct: + if drop_pct is not None: parts.append(f"drops {drop_pct:.0f}%") - if rise_pct: + if rise_pct is not None: parts.append(f"rises {rise_pct:.0f}%") await self.capability_worker.speak( f"Done — I'll alert you if {name} {' or '.join(parts)} in a day." ) async def _handle_remove(self, trigger_text: str): - data = await self._load_data() + data = self._load_data() holdings = data.get("holdings", []) if not holdings: await self.capability_worker.speak("Your portfolio is empty.") @@ -781,13 +792,13 @@ async def _handle_remove(self, trigger_text: str): data["holdings"] = [h for h in holdings if h["ticker"] != ticker] data.get("alert_thresholds", {}).pop(ticker, None) data.get("price_cache", {}).pop(ticker, None) - await self._save_data(data) + self._save_data(data) await self.capability_worker.speak(f"Removed {name}.") else: await self.capability_worker.speak("Keeping it.") async def _handle_clear(self): - data = await self._load_data() + data = self._load_data() count = len(data.get("holdings", [])) if count == 0: await self.capability_worker.speak("Portfolio is already empty.") @@ -801,7 +812,7 @@ async def _handle_clear(self): data["alert_thresholds"] = {} data["price_cache"] = {} data["alerted_today"] = [] - await self._save_data(data) + self._save_data(data) await self.capability_worker.speak("Portfolio cleared.") else: await self.capability_worker.speak("Keeping everything.") @@ -812,6 +823,16 @@ async def _handle_clear(self): async def _run(self): try: + self.finnhub_key = self.capability_worker.get_api_keys("finnhub_api_key") or "" + self.av_key = self.capability_worker.get_api_keys("alphavantage_api_key") or "" + + if not self.finnhub_key: + await self.capability_worker.speak( + "Portfolio Monitor needs a Finnhub API key to work. " + "Add it in Settings under API Keys — get a free one at finnhub dot io." + ) + return + trigger_text = await self.capability_worker.wait_for_complete_transcription() if not trigger_text or not isinstance(trigger_text, str): trigger_text = "" From 13731a63558598c4b7d4d364ad13aa8e076bb9b2 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Tue, 19 May 2026 14:32:53 +0500 Subject: [PATCH 16/18] Proactive edge-case hardening: UX, correctness, and race-condition fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix REMOVE intent false-positive: 'drop' now only routes to REMOVE with explicit portfolio context; bare 'drop' phrases go to PORTFOLIO intent - Fix TOCTOU race in _save_data (both files): try update_key first, fall back to create_key on exception instead of check-then-act - Fix chatty fetching in _handle_portfolio: pre-scan stale tickers and announce "Fetching latest prices..." once instead of per-stock - Fix _speak_single_stock: persist fresh quote to storage after fetch - Fix _handle_add: validate shares/avg_cost > 0; cache resolved company name to avoid double LLM call; map affirmative replies ("yes", "sure") to generic trigger so the loop prompts for a new stock correctly - Add follow-up loop to _handle_check: after each stock, ask if user wants to check another rather than dropping back to idle - Add follow-up loop to _handle_set_alert: after setting thresholds, offer to set alerts for additional portfolio stocks - Update README setup section: replace hardcoded-key instructions with Settings → API Keys workflow --- community/portfolio-monitor/README.md | 4 +- community/portfolio-monitor/background.py | 11 +- community/portfolio-monitor/main.py | 225 +++++++++++++--------- 3 files changed, 145 insertions(+), 95 deletions(-) diff --git a/community/portfolio-monitor/README.md b/community/portfolio-monitor/README.md index 8b9bc96a..a9e1d1a2 100644 --- a/community/portfolio-monitor/README.md +++ b/community/portfolio-monitor/README.md @@ -7,8 +7,8 @@ Just add your stocks once and it handles the rest: morning open summary, live pr ## Setup 1. Get a free API key at [finnhub.io](https://finnhub.io) (60 calls/minute, no daily cap) -2. Replace `your_finnhub_api_key` in both `main.py` and `background.py` -3. Optionally add an [Alpha Vantage](https://www.alphavantage.co) key as fallback (25 calls/day free) +2. In OpenHome, go to **Settings → API Keys** and add your key as `finnhub_api_key` +3. Optionally get an [Alpha Vantage](https://www.alphavantage.co) key (25 calls/day free) and add it as `alphavantage_api_key` ## Trigger Phrases diff --git a/community/portfolio-monitor/background.py b/community/portfolio-monitor/background.py index 5f33c4cd..4ed1d69c 100644 --- a/community/portfolio-monitor/background.py +++ b/community/portfolio-monitor/background.py @@ -64,13 +64,12 @@ def _load_data(self) -> dict: def _save_data(self, data: dict): try: - existing = self.capability_worker.get_single_key(STORAGE_KEY) - if existing: - self.capability_worker.update_key(STORAGE_KEY, data) - else: + self.capability_worker.update_key(STORAGE_KEY, data) + except Exception: + try: self.capability_worker.create_key(STORAGE_KEY, data) - except Exception as e: - self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") # ------------------------------------------------------------------ # Market hours (ET, no pytz) diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index cdfe6ab9..78c1e8f8 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -97,6 +97,11 @@ "add stock", "new stock", "add another", "add another stock", } +_AFFIRMATIVE_PATTERN = re.compile( + r'\b(yes|yeah|sure|yep|absolutely|ok|okay|go ahead)\b', + re.IGNORECASE, +) + def _empty_data() -> dict: return { @@ -162,7 +167,11 @@ def _classify_intent(self, text: str) -> str: ): return "CLEAR" - if any(kw in t for kw in ("remove", "delete", "drop")): + if re.search(r'\b(remove|delete)\b', t) or ( + re.search(r'\bdrop\b', t) and any( + kw in t for kw in ("portfolio", "holding", "position", "from my", "from the") + ) + ): return "REMOVE" if ( @@ -319,13 +328,12 @@ def _load_data(self) -> dict: def _save_data(self, data: dict): try: - existing = self.capability_worker.get_single_key(STORAGE_KEY) - if existing: - self.capability_worker.update_key(STORAGE_KEY, data) - else: + self.capability_worker.update_key(STORAGE_KEY, data) + except Exception: + try: self.capability_worker.create_key(STORAGE_KEY, data) - except Exception as e: - self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") + except Exception as e: + self.worker.editor_logging_handler.error(f"[PortfolioMonitor] Save error: {e}") # ------------------------------------------------------------------ # Intent handlers @@ -343,26 +351,35 @@ async def _handle_portfolio(self): cache = data.get("price_cache", {}) changed = False - # Refresh any missing or stale quotes + # Pre-scan for stale tickers; announce once if multiple need fetching + stale_tickers = [] for h in holdings: ticker = h["ticker"] q = cache.get(ticker) - stale = True + is_stale = True if q: try: cached_at = datetime.strptime(q["cached_at"], "%Y-%m-%dT%H:%M:%S") - stale = (datetime.utcnow() - cached_at).total_seconds() > CACHE_TTL_SECONDS + is_stale = (datetime.utcnow() - cached_at).total_seconds() > CACHE_TTL_SECONDS except Exception: pass - if stale: - await self.capability_worker.speak(f"Fetching {h.get('name', ticker)}...") - quote = self._fetch_quote(ticker) - if quote: - cache[ticker] = { - **quote, - "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), - } - changed = True + if is_stale: + stale_tickers.append(h["ticker"]) + + if len(stale_tickers) > 1: + await self.capability_worker.speak("Fetching latest prices...") + elif len(stale_tickers) == 1: + solo = next(h for h in holdings if h["ticker"] == stale_tickers[0]) + await self.capability_worker.speak(f"Fetching {solo.get('name', stale_tickers[0])}...") + + for ticker in stale_tickers: + quote = self._fetch_quote(ticker) + if quote: + cache[ticker] = { + **quote, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + changed = True if changed: data["price_cache"] = cache @@ -448,6 +465,7 @@ async def _speak_single_stock(self, ticker: str, data: dict): **q, "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), } + self._save_data(data) if not q: await self.capability_worker.speak( @@ -504,33 +522,46 @@ async def _handle_check(self, trigger_text: str): ) return - data = self._load_data() + while ticker: + data = self._load_data() - cached = data.get("price_cache", {}).get(ticker) - if cached: - try: - cached_at = datetime.strptime(cached["cached_at"], "%Y-%m-%dT%H:%M:%S") - if (datetime.utcnow() - cached_at).total_seconds() < CACHE_TTL_SECONDS: - await self._speak_single_stock(ticker, data) + cached = data.get("price_cache", {}).get(ticker) + is_fresh = False + if cached: + try: + cached_at = datetime.strptime(cached["cached_at"], "%Y-%m-%dT%H:%M:%S") + is_fresh = (datetime.utcnow() - cached_at).total_seconds() < CACHE_TTL_SECONDS + except Exception: + pass + + if not is_fresh: + await self.capability_worker.speak(f"Checking {ticker}...") + quote = self._fetch_quote(ticker) + if not quote: + await self.capability_worker.speak( + f"Couldn't get data for {ticker} right now. Try again in a moment." + ) return - except Exception: - pass + data.setdefault("price_cache", {})[ticker] = { + **quote, + "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), + } + self._save_data(data) - await self.capability_worker.speak(f"Checking {ticker}...") - quote = self._fetch_quote(ticker) + await self._speak_single_stock(ticker, data) - if not quote: await self.capability_worker.speak( - f"Couldn't get data for {ticker} right now. Try again in a moment." + "Want to check another stock? Say a name or stop." ) - return - - data.setdefault("price_cache", {})[ticker] = { - **quote, - "cached_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"), - } - self._save_data(data) - await self._speak_single_stock(ticker, data) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + break + ticker = self._resolve_ticker(reply) + if not ticker: + await self.capability_worker.speak( + "I couldn't identify that stock. Try using the ticker symbol like AAPL or TSLA." + ) + break async def _handle_movers(self, data: dict | None = None): if data is None: @@ -577,6 +608,8 @@ async def _handle_add(self, trigger_text: str): trigger_clean = trigger_text.lower().strip() generic_trigger = not trigger_clean or trigger_clean in _ADD_COMMAND_PHRASES + name = None # resolved early in non-generic branch; reused at save time + if generic_trigger: await self.capability_worker.speak( "Which stock, how many shares, and at what price? " @@ -657,6 +690,12 @@ async def _handle_add(self, trigger_text: str): shares = more_clean[0] avg_cost = more_clean[1] + if shares <= 0 or avg_cost <= 0: + await self.capability_worker.speak( + "Shares and price must be greater than zero. Say 'add a stock' to try again." + ) + return + data = self._load_data() existing = next( (h for h in data.get("holdings", []) if h["ticker"] == ticker), None @@ -667,7 +706,8 @@ async def _handle_add(self, trigger_text: str): f"{existing['shares']:g} shares at ${existing['avg_cost']:,.0f} average." ) else: - name = self._resolve_company_name(ticker) + if name is None: + name = self._resolve_company_name(ticker) holding = { "id": str(int(datetime.now().timestamp() * 1000)), "ticker": ticker, @@ -689,7 +729,7 @@ async def _handle_add(self, trigger_text: str): next_reply = await self.capability_worker.user_response() if self._is_exit(next_reply): return - trigger_text = next_reply + trigger_text = "" if _AFFIRMATIVE_PATTERN.search(next_reply) else next_reply async def _handle_set_alert(self, trigger_text: str): ticker = self._resolve_ticker(trigger_text) @@ -707,57 +747,68 @@ async def _handle_set_alert(self, trigger_text: str): await self.capability_worker.speak("I couldn't identify that stock.") return - data = self._load_data() - holding = next( - (h for h in data.get("holdings", []) if h["ticker"] == ticker), None - ) - if not holding: - await self.capability_worker.speak( - f"{ticker} isn't in your portfolio — add it first." + while ticker: + data = self._load_data() + holding = next( + (h for h in data.get("holdings", []) if h["ticker"] == ticker), None ) - return + if not holding: + await self.capability_worker.speak( + f"{ticker} isn't in your portfolio — add it first." + ) + return - name = holding.get("name", ticker) + name = holding.get("name", ticker) - await self.capability_worker.speak( - f"Alert me if {name} drops how many percent in a day? Say a number or skip." - ) - drop_reply = await self.capability_worker.user_response() - drop_pct = None - if not self._is_exit(drop_reply) and "skip" not in drop_reply.lower(): - m = re.search(r'[\d.]+', drop_reply) - if m: - drop_pct = float(m.group()) + await self.capability_worker.speak( + f"Alert me if {name} drops how many percent in a day? Say a number or skip." + ) + drop_reply = await self.capability_worker.user_response() + drop_pct = None + if not self._is_exit(drop_reply) and "skip" not in drop_reply.lower(): + m = re.search(r'[\d.]+', drop_reply) + if m: + drop_pct = float(m.group()) - await self.capability_worker.speak( - f"Alert me if {name} rises how many percent in a day? Say a number or skip." - ) - rise_reply = await self.capability_worker.user_response() - rise_pct = None - if not self._is_exit(rise_reply) and "skip" not in rise_reply.lower(): - m = re.search(r'[\d.]+', rise_reply) - if m: - rise_pct = float(m.group()) - - if drop_pct is None and rise_pct is None: - await self.capability_worker.speak("No thresholds set.") - return + await self.capability_worker.speak( + f"Alert me if {name} rises how many percent in a day? Say a number or skip." + ) + rise_reply = await self.capability_worker.user_response() + rise_pct = None + if not self._is_exit(rise_reply) and "skip" not in rise_reply.lower(): + m = re.search(r'[\d.]+', rise_reply) + if m: + rise_pct = float(m.group()) + + if drop_pct is None and rise_pct is None: + await self.capability_worker.speak("No thresholds set.") + else: + ticker_thresholds = data.setdefault("alert_thresholds", {}).setdefault(ticker, {}) + if drop_pct is not None: + ticker_thresholds["drop_pct"] = drop_pct + if rise_pct is not None: + ticker_thresholds["rise_pct"] = rise_pct + self._save_data(data) - ticker_thresholds = data.setdefault("alert_thresholds", {}).setdefault(ticker, {}) - if drop_pct is not None: - ticker_thresholds["drop_pct"] = drop_pct - if rise_pct is not None: - ticker_thresholds["rise_pct"] = rise_pct - self._save_data(data) + parts = [] + if drop_pct is not None: + parts.append(f"drops {drop_pct:.0f}%") + if rise_pct is not None: + parts.append(f"rises {rise_pct:.0f}%") + await self.capability_worker.speak( + f"Done — I'll alert you if {name} {' or '.join(parts)} in a day." + ) - parts = [] - if drop_pct is not None: - parts.append(f"drops {drop_pct:.0f}%") - if rise_pct is not None: - parts.append(f"rises {rise_pct:.0f}%") - await self.capability_worker.speak( - f"Done — I'll alert you if {name} {' or '.join(parts)} in a day." - ) + await self.capability_worker.speak( + "Want to set an alert for another stock? Say a stock name or stop." + ) + next_reply = await self.capability_worker.user_response() + if self._is_exit(next_reply): + return + ticker = self._resolve_ticker(next_reply) + if not ticker: + await self.capability_worker.speak("I couldn't identify that stock.") + return async def _handle_remove(self, trigger_text: str): data = self._load_data() From 96b31d58bec2c891e60689e04648cd3754a22480 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Tue, 19 May 2026 16:46:21 +0500 Subject: [PATCH 17/18] LLM intent router, day-over-day compare, feature-centric portfolio hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace regex _classify_intent with LLM router: covers all intents including ambiguous natural-language requests reliably; cheap pre-filter retained only for CLEAR and REMOVE (unambiguous destructive actions) - Add COMPARE intent and _handle_compare: speaks each holding's move vs yesterday's close — direction, percent, and dollar change per stock - Restructure _handle_portfolio into a feature hub: opens with a tight two-line snapshot (value, day P&L, overall P&L), then prompts user to navigate — 'breakdown' for full detail, 'compare' for day-over-day, 'movers', or a stock name; full per-stock detail moved to _speak_portfolio_breakdown called on demand - Add compare phrases to HOTWORDS and README trigger phrases - Update README features section to reflect new intent router and COMPARE --- community/portfolio-monitor/README.md | 17 ++- community/portfolio-monitor/main.py | 181 +++++++++++++++++--------- 2 files changed, 126 insertions(+), 72 deletions(-) diff --git a/community/portfolio-monitor/README.md b/community/portfolio-monitor/README.md index a9e1d1a2..14b4e3ff 100644 --- a/community/portfolio-monitor/README.md +++ b/community/portfolio-monitor/README.md @@ -15,6 +15,7 @@ Just add your stocks once and it handles the rest: morning open summary, live pr - `portfolio monitor` / `my portfolio` / `portfolio update` - `check my stocks` / `stock update` / `stock check` - `check Apple` / `how's Tesla` / `how's NVDA doing` +- `compare my stocks` / `day over day` / `versus yesterday` / `how did my stocks do` - `add a stock` / `add to portfolio` / `log a stock` - `remove from portfolio` / `remove a stock` - `set a stock alert` / `price alert` @@ -35,19 +36,21 @@ Just add your stocks once and it handles the rest: morning open summary, live pr - Each alert fires at most once per day per direction per stock **Interactive Queries** -- PORTFOLIO: full breakdown — current value, day P&L, overall P&L, per-stock detail -- CHECK: single stock — price, day change, position value, P&L vs your avg cost +- PORTFOLIO: opens with a quick snapshot (total value, today's P&L, overall P&L), then offers navigation — say 'breakdown', 'compare', 'movers', or a stock name +- BREAKDOWN: full per-stock detail — price, day change %, position value, and overall P&L +- COMPARE: day-over-day view — each stock's price move and dollar change vs yesterday's close +- CHECK: current price, day change, and position P&L for a specific stock; follow-up loop to check multiple stocks back-to-back - MOVERS: biggest gainer and loser in your portfolio today -- ADD: add a stock by name or ticker, specify shares and avg cost -- SET_ALERT: set drop/rise percentage thresholds per stock +- ADD: add a stock by name or ticker — specify shares and avg cost in one shot or via follow-up prompts; loop to add multiple stocks +- SET_ALERT: set drop/rise percentage thresholds per stock; loop to set alerts for multiple stocks - REMOVE: remove a stock from your portfolio (with confirmation) - CLEAR: wipe the entire portfolio (with confirmation) **Smart Details** -- Resolves company names to tickers (say "Apple", not "AAPL") -- LLM fallback for companies not in the built-in map +- LLM intent router handles natural, complex requests reliably ("what happened to my Google position", "how's the portfolio looking") +- Resolves company names to tickers (say "Apple", not "AAPL"); LLM fallback for any company not in the built-in map - Finnhub primary API with Alpha Vantage fallback -- On-demand price fetch if cache is empty when you ask for your portfolio +- On-demand price fetch if cache is empty; TTL-aware refresh so data is always current - Market-hours-aware ET timezone detection (DST handled, no external library) ## Notes diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 78c1e8f8..2d47bcd2 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -19,6 +19,8 @@ "set stock alert", "set a stock alert", "stock price alert", "what's moving", "biggest movers", "gainers today", "losers today", "clear my portfolio", "wipe my portfolio", + "compare my stocks", "compare my portfolio", "day over day", + "how did my stocks do", "stock comparison", "versus yesterday", "check apple", "check tesla", "check nvidia", "check amazon", "check microsoft", "check google", "check meta", "check netflix", "how's apple", "how's tesla", "how's nvidia", "how's amazon", @@ -102,6 +104,10 @@ re.IGNORECASE, ) +_VALID_INTENTS = frozenset({ + "PORTFOLIO", "CHECK", "COMPARE", "MOVERS", "ADD", "SET_ALERT", "REMOVE", "CLEAR" +}) + def _empty_data() -> dict: return { @@ -162,11 +168,11 @@ def _is_exit(self, text: str) -> bool: def _classify_intent(self, text: str) -> str: t = text.lower() + # Cheap pre-filter for unambiguous destructive actions only if any(kw in t for kw in ("clear", "wipe", "reset")) and any( kw in t for kw in ("portfolio", "stocks", "holdings", "all") ): return "CLEAR" - if re.search(r'\b(remove|delete)\b', t) or ( re.search(r'\bdrop\b', t) and any( kw in t for kw in ("portfolio", "holding", "position", "from my", "from the") @@ -174,35 +180,25 @@ def _classify_intent(self, text: str) -> str: ): return "REMOVE" - if ( - any(kw in t for kw in ("set alert", "price alert", "alert me", "notify me")) - or ("alert" in t and any(kw in t for kw in ("if", "when", "drops", "rises", "falls"))) - ): - return "SET_ALERT" - - if any(kw in t for kw in ("add to portfolio", "add a stock", "log a stock", "track a stock")): - return "ADD" - if any(kw in t for kw in ("add", "bought", "purchased")) and any( - kw in t for kw in ("share", "stock", "position") - ): - return "ADD" - if re.search(r'\badd\b', t): - for company in TICKER_MAP: - if company in t: - return "ADD" - for match in re.finditer(r'\b([A-Z]{2,5})\b', text.upper()): - if match.group(1) not in COMMON_WORDS: - return "ADD" - - if any(kw in t for kw in ("mover", "moving today", "gainer", "loser", "biggest")): - return "MOVERS" - - if "portfolio" not in t and "my stocks" not in t and re.search( - r'\b(check|how.?s|how is|price of|what.?s)\b', t - ): - return "CHECK" - - return "PORTFOLIO" + try: + raw = self.capability_worker.text_to_text_response( + "Route this request for a stock portfolio voice assistant.\n" + "Pick exactly one intent:\n" + "PORTFOLIO — view overall portfolio value and P&L\n" + "CHECK — price or status of a specific stock\n" + "COMPARE — day-over-day: how each stock moved vs yesterday's close\n" + "MOVERS — biggest gainer and loser in the portfolio today\n" + "ADD — add a stock to the portfolio\n" + "SET_ALERT — set a price drop or rise alert for a stock\n" + "REMOVE — remove a stock from the portfolio\n" + "CLEAR — wipe the entire portfolio\n\n" + "Reply with ONLY the intent label.\n" + f"User input: {text.strip() or '(portfolio update)'}" + ) + intent = raw.strip().upper().split()[0].strip(".,") + return intent if intent in _VALID_INTENTS else "PORTFOLIO" + except Exception: + return "PORTFOLIO" def _resolve_ticker_cheap(self, text: str) -> str | None: """Static-only ticker resolution — safe to call from does_match (no LLM).""" @@ -385,9 +381,60 @@ async def _handle_portfolio(self): data["price_cache"] = cache self._save_data(data) + # Compute totals for snapshot total_value = 0.0 total_cost = 0.0 day_pnl = 0.0 + for h in holdings: + q = cache.get(h["ticker"]) + if not q: + continue + price = q["price"] + prev_close = q.get("prev_close", price) + shares = h.get("shares", 0) + avg_cost = h.get("avg_cost", 0) + total_value += price * shares + total_cost += avg_cost * shares + day_pnl += (price - prev_close) * shares + + total_pnl = total_value - total_cost + total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else 0 + day_dir = "up" if day_pnl >= 0 else "down" + overall_dir = "up" if total_pnl >= 0 else "down" + + # Snapshot — tight two lines, then navigation prompt + await self.capability_worker.speak( + f"Portfolio at ${total_value:,.0f} — {day_dir} ${abs(day_pnl):,.0f} today, " + f"{overall_dir} ${abs(total_pnl):,.0f} ({abs(total_pnl_pct):.0f}%) overall." + ) + await self.capability_worker.speak( + "Say 'breakdown' for full detail, 'compare' for day-over-day, " + "'movers' for biggest movers, or a stock name." + ) + + while True: + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + break + r = reply.lower() + if any(kw in r for kw in ("breakdown", "full", "detail", "all stocks", "list")): + await self._speak_portfolio_breakdown(data) + elif any(kw in r for kw in ("compare", "yesterday", "day over", "vs yesterday", "comparison")): + await self._handle_compare(data) + elif any(kw in r for kw in ("mover", "moving", "gainer", "loser", "biggest")): + await self._handle_movers(data) + else: + ticker = self._resolve_ticker(reply) + if ticker: + await self._speak_single_stock(ticker, data) + else: + await self.capability_worker.speak( + "Say 'breakdown', 'compare', 'movers', a stock name, or stop." + ) + + async def _speak_portfolio_breakdown(self, data: dict): + holdings = data.get("holdings", []) + cache = data.get("price_cache", {}) stock_lines = [] for h in holdings: @@ -403,17 +450,10 @@ async def _handle_portfolio(self): price = q["price"] change_pct = q.get("change_pct", 0) - prev_close = q.get("prev_close", price) - position_value = price * shares position_cost = avg_cost * shares position_pnl = position_value - position_cost position_pnl_pct = (position_pnl / position_cost * 100) if position_cost else 0 - day_change = (price - prev_close) * shares - - total_value += position_value - total_cost += position_cost - day_pnl += day_change day_dir = "up" if change_pct >= 0 else "down" pos_dir = "up" if position_pnl >= 0 else "down" @@ -422,37 +462,46 @@ async def _handle_portfolio(self): f"{pos_dir} ${abs(position_pnl):,.0f} ({abs(position_pnl_pct):.0f}%) overall" ) - total_pnl = total_value - total_cost - total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else 0 - day_dir = "up" if day_pnl >= 0 else "down" - overall_dir = "up" if total_pnl >= 0 else "down" - - await self.capability_worker.speak( - f"Your portfolio is worth ${total_value:,.0f}. " - f"Today you're {day_dir} ${abs(day_pnl):,.0f}. " - f"Overall {overall_dir} ${abs(total_pnl):,.0f} — {abs(total_pnl_pct):.0f} percent." - ) if stock_lines: await self.capability_worker.speak(". ".join(stock_lines) + ".") - await self.capability_worker.speak( - "Say a stock name to check it, 'movers' for biggest movers, or stop." - ) - while True: - reply = await self.capability_worker.user_response() - if self._is_exit(reply): - break - r = reply.lower() - if any(kw in r for kw in ("mover", "moving", "gainer", "loser")): - await self._handle_movers(data) - else: - ticker = self._resolve_ticker(reply) - if ticker: - await self._speak_single_stock(ticker, data) - else: - await self.capability_worker.speak( - "Say a stock name, 'movers', or 'stop'." - ) + async def _handle_compare(self, data: dict | None = None): + if data is None: + data = self._load_data() + holdings = data.get("holdings", []) + if not holdings: + await self.capability_worker.speak("No stocks in your portfolio yet.") + return + + cache = data.get("price_cache", {}) + lines = [] + no_data = [] + + for h in holdings: + ticker = h["ticker"] + name = h.get("name", ticker) + q = cache.get(ticker) + if not q or not q.get("prev_close"): + no_data.append(name) + continue + + price = q["price"] + prev_close = q["prev_close"] + change_pct = q.get("change_pct", 0) + day_dollar = abs(price - prev_close) + direction = "up" if change_pct >= 0 else "down" + pct_str = f"{abs(change_pct):.0f}" if abs(change_pct) >= 1 else "less than 1" + lines.append(f"{name} {direction} {pct_str}% (${day_dollar:,.0f})") + + if not lines: + await self.capability_worker.speak( + "No comparison data yet — say 'my portfolio' first to fetch current prices." + ) + return + + await self.capability_worker.speak("Today versus yesterday: " + ", ".join(lines) + ".") + if no_data: + await self.capability_worker.speak(f"No data for: {', '.join(no_data)}.") async def _speak_single_stock(self, ticker: str, data: dict): cache = data.get("price_cache", {}) @@ -897,6 +946,8 @@ async def _run(self): await self._handle_portfolio() elif intent == "CHECK": await self._handle_check(trigger_text) + elif intent == "COMPARE": + await self._handle_compare() elif intent == "MOVERS": await self._handle_movers() elif intent == "ADD": From 346af806fc984c4e9aaca4eb517462eaeec95016 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Tue, 19 May 2026 21:15:11 +0500 Subject: [PATCH 18/18] Add UPDATE/MARKET intents, LLM hub router, chunked voice output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New UPDATE intent: bought-more (weighted avg recalc), sold-some (share reduction or remove-if-zero), correct/overwrite — handles all position change flows in one conversational handler - New MARKET intent: live S&P 500, Nasdaq, Dow Jones pulse via same Finnhub feed - Hub navigation loop now uses LLM sub-router (_classify_hub_action) instead of keyword matching — consistent with top-level router - Hub prompt and snapshot updated to surface 'market' as a nav option - ADD flow: instead of dead-ending on existing stock, offers to update the position immediately via _handle_update - Chunked voice output for BREAKDOWN and COMPARE: 4 stocks per speak, paginate with "Want to hear the rest?" — prevents wall-of-text TTS - README: example conversation section, updated trigger phrases and feature descriptions for UPDATE and MARKET --- community/portfolio-monitor/README.md | 54 +++++- community/portfolio-monitor/main.py | 230 ++++++++++++++++++++++++-- 2 files changed, 267 insertions(+), 17 deletions(-) diff --git a/community/portfolio-monitor/README.md b/community/portfolio-monitor/README.md index 14b4e3ff..037e75dc 100644 --- a/community/portfolio-monitor/README.md +++ b/community/portfolio-monitor/README.md @@ -17,9 +17,11 @@ Just add your stocks once and it handles the rest: morning open summary, live pr - `check Apple` / `how's Tesla` / `how's NVDA doing` - `compare my stocks` / `day over day` / `versus yesterday` / `how did my stocks do` - `add a stock` / `add to portfolio` / `log a stock` +- `update my position` / `bought more` / `I sold` / `add more shares` / `sold some` - `remove from portfolio` / `remove a stock` - `set a stock alert` / `price alert` - `biggest movers` / `what's moving` / `gainers today` / `losers today` +- `how are the markets` / `market update` / `market pulse` / `market today` - `clear my portfolio` / `wipe my portfolio` ## Features @@ -36,12 +38,14 @@ Just add your stocks once and it handles the rest: morning open summary, live pr - Each alert fires at most once per day per direction per stock **Interactive Queries** -- PORTFOLIO: opens with a quick snapshot (total value, today's P&L, overall P&L), then offers navigation — say 'breakdown', 'compare', 'movers', or a stock name -- BREAKDOWN: full per-stock detail — price, day change %, position value, and overall P&L +- PORTFOLIO: opens with a quick snapshot (total value, today's P&L, overall P&L), then offers navigation — say 'breakdown', 'compare', 'movers', 'market', or a stock name +- BREAKDOWN: full per-stock detail — price, day change %, position value, and overall P&L (chunked at 4 stocks, asks if you want more) - COMPARE: day-over-day view — each stock's price move and dollar change vs yesterday's close - CHECK: current price, day change, and position P&L for a specific stock; follow-up loop to check multiple stocks back-to-back - MOVERS: biggest gainer and loser in your portfolio today -- ADD: add a stock by name or ticker — specify shares and avg cost in one shot or via follow-up prompts; loop to add multiple stocks +- MARKET: live pulse on S&P 500, Nasdaq, and Dow Jones +- ADD: add a stock by name or ticker — specify shares and avg cost in one shot or via follow-up prompts; loop to add multiple stocks; offers UPDATE if stock already exists +- UPDATE: modify an existing position — bought more shares (recalculates weighted avg cost), sold some (reduces share count or removes if fully sold), or correct/overwrite values - SET_ALERT: set drop/rise percentage thresholds per stock; loop to set alerts for multiple stocks - REMOVE: remove a stock from your portfolio (with confirmation) - CLEAR: wipe the entire portfolio (with confirmation) @@ -53,8 +57,52 @@ Just add your stocks once and it handles the rest: morning open summary, live pr - On-demand price fetch if cache is empty; TTL-aware refresh so data is always current - Market-hours-aware ET timezone detection (DST handled, no external library) +## Example Conversation + +> **You:** my portfolio +> +> **Assistant:** Portfolio at $47,200 — down $380 today, up $3,100 (7%) overall. Say 'breakdown' for full detail, 'compare' for day-over-day, 'movers' for biggest movers, 'market' for market indices, or a stock name. +> +> **You:** market +> +> **Assistant:** Markets today: S&P 500 down 0.8%, Nasdaq down 1.2%, Dow Jones down 0.5%. +> +> **You:** how's Apple doing +> +> **Assistant:** Apple is at $189, down 1% today. Your 20 shares are worth $3,780 — up $620 (20%) on your position. +> +> **You:** breakdown +> +> **Assistant:** Apple: $189, down 1% today, up $620 (20%) overall. Tesla: $242, down 2% today, down $180 (4%) overall. Nvidia: $875, up 3% today, up $4,100 (68%) overall. Microsoft: $415, flat today, up $900 (12%) overall. Want to hear the rest? +> +> **You:** yes +> +> **Assistant:** Amazon: $198, down 0% today, up $260 (7%) overall. + +--- + +> **You:** I bought more Tesla +> +> **Assistant:** Tesla: 10 shares at $220 average, total cost $2,200. Say 'bought more', 'sold some', or 'correct' to overwrite. +> +> **You:** bought more +> +> **Assistant:** How many shares did you buy, and at what price? Say both — like '5 at 210'. +> +> **You:** 5 at 245 +> +> **Assistant:** Updated — Tesla now 15 shares at $228 average. + +--- + +> **[Background alert, proactive]** +> +> **Assistant:** Heads up — Nvidia is down 5% today. Your position is up $3,200. Say 'portfolio monitor' to review. + ## Notes - Price alerts are based on the day's change percentage (vs previous close), not vs your avg cost - The background daemon only runs while OpenHome is active — not a 24/7 service - Supports 50+ major US companies by name out of the box; any ticker symbol works directly +- Trigger phrases for UPDATE: `update my position`, `bought more`, `I sold`, `add more shares`, `sold some` +- Trigger phrases for MARKET: `how are the markets`, `market update`, `market pulse`, `market today` diff --git a/community/portfolio-monitor/main.py b/community/portfolio-monitor/main.py index 2d47bcd2..d08129e5 100644 --- a/community/portfolio-monitor/main.py +++ b/community/portfolio-monitor/main.py @@ -21,12 +21,22 @@ "clear my portfolio", "wipe my portfolio", "compare my stocks", "compare my portfolio", "day over day", "how did my stocks do", "stock comparison", "versus yesterday", + "update my position", "bought more", "i sold", "add more shares", + "change my position", "update my stock", "sold some", + "how are the markets", "market update", "market pulse", + "what's the market doing", "market today", "market check", "check apple", "check tesla", "check nvidia", "check amazon", "check microsoft", "check google", "check meta", "check netflix", "how's apple", "how's tesla", "how's nvidia", "how's amazon", "how's microsoft", "how's google", "how's meta", "how's netflix", } +_MARKET_INDICES = [ + ("SPY", "S&P 500"), + ("QQQ", "Nasdaq"), + ("DIA", "Dow Jones"), +] + TICKER_MAP = { "apple": "AAPL", "tesla": "TSLA", @@ -105,9 +115,12 @@ ) _VALID_INTENTS = frozenset({ - "PORTFOLIO", "CHECK", "COMPARE", "MOVERS", "ADD", "SET_ALERT", "REMOVE", "CLEAR" + "PORTFOLIO", "CHECK", "COMPARE", "MOVERS", "ADD", "UPDATE", + "SET_ALERT", "REMOVE", "CLEAR", "MARKET" }) +_HUB_ACTIONS = frozenset({"BREAKDOWN", "COMPARE", "MOVERS", "MARKET", "CHECK", "UNKNOWN"}) + def _empty_data() -> dict: return { @@ -188,10 +201,12 @@ def _classify_intent(self, text: str) -> str: "CHECK — price or status of a specific stock\n" "COMPARE — day-over-day: how each stock moved vs yesterday's close\n" "MOVERS — biggest gainer and loser in the portfolio today\n" - "ADD — add a stock to the portfolio\n" + "ADD — add a new stock to the portfolio\n" + "UPDATE — modify an existing position: bought more shares, sold some, or correct avg cost\n" "SET_ALERT — set a price drop or rise alert for a stock\n" "REMOVE — remove a stock from the portfolio\n" - "CLEAR — wipe the entire portfolio\n\n" + "CLEAR — wipe the entire portfolio\n" + "MARKET — broad market overview: S&P 500, Nasdaq, Dow Jones\n\n" "Reply with ONLY the intent label.\n" f"User input: {text.strip() or '(portfolio update)'}" ) @@ -200,6 +215,24 @@ def _classify_intent(self, text: str) -> str: except Exception: return "PORTFOLIO" + def _classify_hub_action(self, text: str) -> str: + try: + raw = self.capability_worker.text_to_text_response( + "The user is inside a portfolio dashboard. Route their request:\n" + "BREAKDOWN — full per-stock detail list\n" + "COMPARE — day-over-day comparison vs yesterday's close\n" + "MOVERS — biggest gainer and loser today\n" + "MARKET — broad market indices (S&P 500, Nasdaq, Dow)\n" + "CHECK — specific stock price or status\n" + "UNKNOWN — none of the above\n\n" + "Reply with ONLY the label.\n" + f"User input: {text}" + ) + result = raw.strip().upper().split()[0].strip(".,") + return result if result in _HUB_ACTIONS else "UNKNOWN" + except Exception: + return "UNKNOWN" + def _resolve_ticker_cheap(self, text: str) -> str | None: """Static-only ticker resolution — safe to call from does_match (no LLM).""" if not text: @@ -409,27 +442,37 @@ async def _handle_portfolio(self): ) await self.capability_worker.speak( "Say 'breakdown' for full detail, 'compare' for day-over-day, " - "'movers' for biggest movers, or a stock name." + "'movers' for biggest movers, 'market' for market indices, or a stock name." ) while True: reply = await self.capability_worker.user_response() if self._is_exit(reply): break - r = reply.lower() - if any(kw in r for kw in ("breakdown", "full", "detail", "all stocks", "list")): + action = self._classify_hub_action(reply) + if action == "BREAKDOWN": await self._speak_portfolio_breakdown(data) - elif any(kw in r for kw in ("compare", "yesterday", "day over", "vs yesterday", "comparison")): + elif action == "COMPARE": await self._handle_compare(data) - elif any(kw in r for kw in ("mover", "moving", "gainer", "loser", "biggest")): + elif action == "MOVERS": await self._handle_movers(data) + elif action == "MARKET": + await self._handle_market() + elif action == "CHECK": + ticker = self._resolve_ticker(reply) + if ticker: + await self._speak_single_stock(ticker, data) + else: + await self.capability_worker.speak( + "Which stock? Say a company name or ticker symbol." + ) else: ticker = self._resolve_ticker(reply) if ticker: await self._speak_single_stock(ticker, data) else: await self.capability_worker.speak( - "Say 'breakdown', 'compare', 'movers', a stock name, or stop." + "Say 'breakdown', 'compare', 'movers', 'market', a stock name, or stop." ) async def _speak_portfolio_breakdown(self, data: dict): @@ -462,8 +505,17 @@ async def _speak_portfolio_breakdown(self, data: dict): f"{pos_dir} ${abs(position_pnl):,.0f} ({abs(position_pnl_pct):.0f}%) overall" ) - if stock_lines: - await self.capability_worker.speak(". ".join(stock_lines) + ".") + if not stock_lines: + return + chunk_size = 4 + for i in range(0, len(stock_lines), chunk_size): + chunk = stock_lines[i:i + chunk_size] + await self.capability_worker.speak(". ".join(chunk) + ".") + if i + chunk_size < len(stock_lines): + await self.capability_worker.speak("Want to hear the rest?") + reply = await self.capability_worker.user_response() + if self._is_exit(reply) or not _AFFIRMATIVE_PATTERN.search(reply or ""): + break async def _handle_compare(self, data: dict | None = None): if data is None: @@ -499,7 +551,16 @@ async def _handle_compare(self, data: dict | None = None): ) return - await self.capability_worker.speak("Today versus yesterday: " + ", ".join(lines) + ".") + chunk_size = 4 + for i in range(0, len(lines), chunk_size): + chunk = lines[i:i + chunk_size] + prefix = "Today versus yesterday: " if i == 0 else "Continuing: " + await self.capability_worker.speak(prefix + ", ".join(chunk) + ".") + if i + chunk_size < len(lines): + await self.capability_worker.speak("Want to hear the rest?") + reply = await self.capability_worker.user_response() + if self._is_exit(reply) or not _AFFIRMATIVE_PATTERN.search(reply or ""): + break if no_data: await self.capability_worker.speak(f"No data for: {', '.join(no_data)}.") @@ -652,6 +713,137 @@ async def _handle_movers(self, data: dict | None = None): await self.capability_worker.speak(" ".join(parts)) + async def _handle_market(self): + await self.capability_worker.speak("Checking market indices...") + parts = [] + for ticker, label in _MARKET_INDICES: + quote = self._fetch_quote(ticker) + if quote: + change_pct = quote.get("change_pct", 0) + direction = "up" if change_pct >= 0 else "down" + pct_str = f"{abs(change_pct):.1f}" if abs(change_pct) >= 0.1 else "flat" + parts.append(f"{label} {direction} {pct_str}%") + if not parts: + await self.capability_worker.speak( + "Couldn't fetch market data right now. Try again in a moment." + ) + return + await self.capability_worker.speak("Markets today: " + ", ".join(parts) + ".") + + async def _handle_update(self, trigger_text: str, ticker: str | None = None): + if ticker is None: + ticker = self._resolve_ticker(trigger_text) + if not ticker: + await self.capability_worker.speak("Which stock do you want to update?") + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + ticker = self._resolve_ticker(reply) + if not ticker: + await self.capability_worker.speak( + "I couldn't identify that stock. Try using the ticker symbol." + ) + return + + data = self._load_data() + holding = next((h for h in data.get("holdings", []) if h["ticker"] == ticker), None) + if not holding: + name = self._resolve_company_name(ticker) + await self.capability_worker.speak( + f"{name} isn't in your portfolio. Say 'add a stock' to add it." + ) + return + + name = holding.get("name", ticker) + shares = holding["shares"] + avg_cost = holding["avg_cost"] + await self.capability_worker.speak( + f"{name}: {shares:g} shares at ${avg_cost:,.0f} average, " + f"total cost ${shares * avg_cost:,.0f}. " + "Say 'bought more', 'sold some', or 'correct' to overwrite." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + r = reply.lower() + + if any(kw in r for kw in ("bought", "more", "buy", "added", "purchase")): + await self.capability_worker.speak( + "How many shares did you buy, and at what price? Say both — like '5 at 210'." + ) + details = await self.capability_worker.user_response() + if self._is_exit(details): + return + nums = [float(n.replace(",", "")) for n in re.findall(r'[\d,]+\.?\d*', details) if n] + if len(nums) < 2 or nums[0] <= 0 or nums[1] <= 0: + await self.capability_worker.speak( + "I need both the number of shares and the price." + ) + return + new_shares, new_price = nums[0], nums[1] + total_shares = shares + new_shares + new_avg = (shares * avg_cost + new_shares * new_price) / total_shares + holding["shares"] = total_shares + holding["avg_cost"] = round(new_avg, 2) + self._save_data(data) + await self.capability_worker.speak( + f"Updated — {name} now {total_shares:g} shares at ${new_avg:,.0f} average." + ) + + elif any(kw in r for kw in ("sold", "sell", "sale", "reduced")): + await self.capability_worker.speak("How many shares did you sell?") + details = await self.capability_worker.user_response() + if self._is_exit(details): + return + nums = [float(n.replace(",", "")) for n in re.findall(r'[\d,]+\.?\d*', details) if n] + if not nums or nums[0] <= 0: + await self.capability_worker.speak("I need to know how many shares you sold.") + return + sold = nums[0] + remaining = shares - sold + if remaining <= 0: + confirmed = await self.capability_worker.run_confirmation_loop( + f"That would leave zero shares. Remove {name} from your portfolio?" + ) + if confirmed: + data["holdings"] = [h for h in data["holdings"] if h["ticker"] != ticker] + data.get("alert_thresholds", {}).pop(ticker, None) + data.get("price_cache", {}).pop(ticker, None) + self._save_data(data) + await self.capability_worker.speak(f"Removed {name} from your portfolio.") + else: + holding["shares"] = remaining + self._save_data(data) + await self.capability_worker.speak( + f"Updated — {name} now {remaining:g} shares remaining." + ) + + elif any(kw in r for kw in ("correct", "overwrite", "set", "change", "update")): + await self.capability_worker.speak( + "What's the correct number of shares and average cost? " + "Say both — like '10 at 175'." + ) + details = await self.capability_worker.user_response() + if self._is_exit(details): + return + nums = [float(n.replace(",", "")) for n in re.findall(r'[\d,]+\.?\d*', details) if n] + if len(nums) < 2 or nums[0] <= 0 or nums[1] <= 0: + await self.capability_worker.speak( + "I need both the number of shares and the price." + ) + return + holding["shares"] = nums[0] + holding["avg_cost"] = nums[1] + self._save_data(data) + await self.capability_worker.speak( + f"Updated — {name} corrected to {nums[0]:g} shares at ${nums[1]:,.0f} average." + ) + + else: + await self.capability_worker.speak( + "Say 'bought more', 'sold some', or 'correct' to update the position." + ) + async def _handle_add(self, trigger_text: str): while True: trigger_clean = trigger_text.lower().strip() @@ -750,10 +942,16 @@ async def _handle_add(self, trigger_text: str): (h for h in data.get("holdings", []) if h["ticker"] == ticker), None ) if existing: + ex_name = existing.get("name", ticker) await self.capability_worker.speak( - f"{existing.get('name', ticker)} is already in your portfolio — " - f"{existing['shares']:g} shares at ${existing['avg_cost']:,.0f} average." + f"{ex_name} is already in your portfolio — " + f"{existing['shares']:g} shares at ${existing['avg_cost']:,.0f} average. " + "Want to update this position?" ) + confirm = await self.capability_worker.user_response() + if _AFFIRMATIVE_PATTERN.search(confirm or ""): + await self._handle_update("", ticker=ticker) + return else: if name is None: name = self._resolve_company_name(ticker) @@ -952,12 +1150,16 @@ async def _run(self): await self._handle_movers() elif intent == "ADD": await self._handle_add(trigger_text) + elif intent == "UPDATE": + await self._handle_update(trigger_text) elif intent == "SET_ALERT": await self._handle_set_alert(trigger_text) elif intent == "REMOVE": await self._handle_remove(trigger_text) elif intent == "CLEAR": await self._handle_clear() + elif intent == "MARKET": + await self._handle_market() else: await self.capability_worker.speak( "I can show your portfolio, check a stock, track movers, "