Skip to content

Portfolio Monitor ability#271

Open
hassan1731996 wants to merge 21 commits into
openhome-dev:devfrom
hassan1731996:feature/portfolio-monitor
Open

Portfolio Monitor ability#271
hassan1731996 wants to merge 21 commits into
openhome-dev:devfrom
hassan1731996:feature/portfolio-monitor

Conversation

@hassan1731996
Copy link
Copy Markdown
Contributor

@hassan1731996 hassan1731996 commented May 16, 2026

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.

What's in this PR

Core features

  • Background daemon polls every 5 min during market hours, sleeps 30 min outside — no wasted API calls
  • Price cache with 3-min TTL prevents redundant fetches
  • Morning open summary, live price alerts (per-direction, once per day), end-of-day wrap-up
  • LLM intent router handles natural language reliably for all intents

Interactive intents

  • PORTFOLIO — snapshot (value, day P&L, overall P&L) → hub navigation
  • CHECK — specific stock with position P&L, follow-up loop
  • COMPARE — day-over-day vs yesterday's close, chunked at 4 stocks
  • MOVERS — biggest gainer and loser in your portfolio
  • MARKET — live S&P 500, Nasdaq, Dow Jones pulse
  • ADD — one-shot or prompted; offers UPDATE if stock already exists
  • UPDATE — bought more (weighted avg cost recalc), sold some (share reduction or full remove), correct/overwrite
  • SET_ALERT — drop/rise % thresholds per stock, loop for multiple
  • REMOVE — with confirmation
  • CLEAR — with confirmation

Voice UX

  • Hub navigation uses LLM sub-router — handles any phrasing consistently
  • BREAKDOWN and COMPARE chunk at 4 stocks per speak, paginate with "Want to hear the rest?"
  • All add/check/alert flows loop so you can do multiple in one conversation

Setup

  • Finnhub API key (free, 60 calls/min) — required
  • Alpha Vantage key (optional fallback, 25 calls/day free)
  • Both added in OpenHome Settings → API Keys

What changed since initial review

  • Replaced all keyword-matching intent routing with LLM router — more reliable on natural phrasing
  • Added COMPARE intent with day-over-day view
  • Rewrote portfolio hub as a feature-centric flow with 2-line snapshot + navigation
  • Added UPDATE intent (bought more / sold some / correct) — handles all position change flows
  • Added MARKET intent — S&P 500, Nasdaq, Dow via same Finnhub feed
  • Hub loop now uses LLM sub-router (_classify_hub_action) for consistency
  • ADD flow no longer dead-ends on existing stock — offers UPDATE instead
  • Chunked voice output for BREAKDOWN and COMPARE (4 stocks, paginated)
  • TOCTOU-safe storage (try update, fallback to create)
  • does_match is fully synchronous — no LLM, no I/O
  • resume_normal_flow in finally block, daemon startup only
  • API keys via get_api_keys() at runtime, not hardcoded
  • README updated with example conversation, trigger phrases for all intents

Files

  • community/portfolio-monitor/main.py — foreground skill
  • community/portfolio-monitor/background.py — background daemon
  • community/portfolio-monitor/README.md — setup, trigger phrases, example conversation

github-actions Bot and others added 11 commits April 21, 2026 05:10
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.
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.
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.
…ruption

- 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
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
Logs holding count on every wake cycle so we can tell whether the daemon
reached its polling loop or hung silently inside _load_data().
…sleep

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.
…uting

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.
…th design

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).
- 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
@hassan1731996 hassan1731996 requested review from a team as code owners May 16, 2026 20:08
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 16, 2026

🔀 Branch Merge Check

PR direction: feature/portfolio-monitordev

Passedfeature/portfolio-monitordev is a valid merge direction

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 16, 2026

✅ Community PR Path Check — Passed

All changed files are inside the community/ folder. Looks good!

@github-actions github-actions Bot added the community-ability Community-contributed ability label May 16, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 16, 2026

✅ Ability Validation Passed

📋 Validating: community/portfolio-monitor
  ✅ All checks passed!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 16, 2026

🔍 Lint Results

__init__.py — Empty as expected

Files linted: community/portfolio-monitor/background.py community/portfolio-monitor/main.py

✅ Flake8 — Passed

✅ All checks passed!

hassan1731996 and others added 3 commits May 17, 2026 01:16
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.
Copy link
Copy Markdown
Contributor

@uzair401 uzair401 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Portfolio Monitor — Issues to Address

Tested end-to-end. The architecture (interactive skill + background daemon) is solid, but a handful of issues need addressing before merge. Listed roughly by impact:

  1. One-shot handler + narrow does_match lets the agent LLM hallucinate ability behavior. _run() handles a single intent then calls resume_normal_flow(), so follow-ups like "add stock", "google", "also add nvidia" don't re-pass does_match and the agent LLM invents fake confirmations ("Great choice! Google is now in your portfolio") while no state changes — daemon logs show 0 holding(s) throughout. Fix: expand HOTWORDS, drop the "stock" not in t exclusion in the add regex, and make _handle_add / _handle_set_alert conversational with explicit-exit loops. Pattern reference: official/audius-music-dj.

  2. File I/O doesn't persist across triggers. _save_data does delete_file → write_file with a bare except — any silent failure loses the file. Reproduced: added stocks, retriggered, "No stocks yet." Switch to Context Storage (get_single_key / create_key / update_key) — same API audius-music-dj uses for favorites and global_context. Docs: capability-worker.md.

  3. Hardcoded API keys (FINNHUB_KEY = "your_finnhub_api_key" etc.) with README telling users to edit main.py. Use the SDK pattern — single-key call returning the value directly:

    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 ""

    Add an upfront check in _run() that speaks a clear message when no keys are configured.
    Docs reference: - https://docs.openhome.com/building-abilities/how-to-build#custom-api-keys-third-party-services

  4. Silent correctness bugs:

    • main.py:387(position_pnl / position_cost * 100) if position_cost else 0 is a dead expression; per-stock P&L % is never spoken.
    • main.py:470pos_pnl / pos_value should be pos_pnl / pos_cost. A stock that doubled reports "50%" instead of "100%".
    • _handle_set_alert resets the per-ticker dict to {} before setting either threshold — setting drop and rise in separate sessions loses the earlier one. Use setdefault(ticker, {}) + is not None checks.
    • does_match calls _resolve_ticker which falls through to _resolve_ticker_llmtext_to_text_response fires on every utterance, burning LLM quota. does_match must stay synchronous and cheap.
  5. README claims that don't hold up:

    • Daemon caps at 10 holdings (MAX_API_CALLS_PER_POLL) — stocks past index 10 never refresh or alert.
    • _handle_portfolio only fetches when ticker is absent from cache — stale entries are never refreshed during a session.
    • EOD summary reads from the cache; misses closing-bell moves. Force a fresh fetch in the EOD window.
    • _handle_check skips saving cache for non-portfolio stocks — re-hits API every time.

Let's bring this ability into full compliance with the SDK and voice UX requirements. Once fixed, we'd like to consider it for marketplace release — please update main.py and background.py accordingly to the best of your knowledge and best abilities practices.

- 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)
- 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
@hassan1731996 hassan1731996 force-pushed the feature/portfolio-monitor branch from 062edda to 13731a6 Compare May 19, 2026 09:46
@hassan1731996
Copy link
Copy Markdown
Contributor Author

hassan1731996 commented May 19, 2026

Hey @uzair401 - appreciate the thorough review, really helpful catching these before merge.

Went through everything you flagged and pushed fixes for all of it:

The LLM hallucination issue was the biggest one - _handle_add, _handle_check, and _handle_set_alert are all conversational now with explicit while True loops and exit detection, so the user stays in the skill for follow-ups instead of falling back to the agent. Also dropped the "stock" not in t exclusion and expanded the hotwords so routing is more reliable.

Switched the whole persistence layer over to Context Storage (get_single_key / create_key / update_key) in both files. The old file I/O approach was definitely the root cause of stocks not surviving across triggers.

API keys are now loaded at runtime via get_api_keys() - nothing hardcoded. Added an upfront check in _run() that tells the user exactly what to do if the Finnhub key is missing, and updated the README to point to Settings → API Keys instead of telling people to edit source files.

Fixed all four of the silent correctness bugs - the dead P&L expression, the pos_value vs pos_cost denominator, the _handle_set_alert reset issue (now uses setdefault(ticker, {}) + is not None), and does_match no longer touches the LLM at all (static map + regex only).

On the daemon side: EOD summary now forces a fresh fetch before computing totals, _handle_portfolio does TTL-aware cache refreshes (not just missing-key checks), _handle_check saves the cache after a fresh fetch, and MAX_API_CALLS_PER_POLL is raised to 50 so larger portfolios don't silently stall.

Let me know if anything else needs attention.

@hassan1731996 hassan1731996 requested a review from uzair401 May 19, 2026 09:51
Copy link
Copy Markdown
Contributor

@uzair401 uzair401 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @hassan1731996,
This is a great ability and your contribution is much appreciated — I'd like to push it a bit further so it's marketplace-ready. A few directions I'd like us to take it:

Make it a one-stop place for stocks. Beyond just looking things up, it should also track and monitor the user's portfolio, provide live market insights, show current stock status, and do a basic day-over-day comparison (no need to go deeper than that for now).

Replace regex-based routing with an LLM intent router. Regex routing tends to be fragile for an ability this complex. Using the LLM as the intent router will route user requests much more reliably.

Restructure main.py to be more feature-centric. When the ability is triggered, the user should be able to check their portfolio, check current stock status, and view the previous day's comparison — all from one entry point.
Tighten intent handling. The check / move (or change) / delete flows need to work properly and consistently through the LLM router.

Once these are in, we'll do rigorous testing before submitting for marketplace approval. Thanks again for the work on this!

- 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
@hassan1731996
Copy link
Copy Markdown
Contributor Author

hassan1731996 commented May 19, 2026

Hey @uzair401 - pushed another round of changes based on your second review.

LLM intent router is in. _classify_intent now routes through text_to_text_response with a clear prompt covering all 8 intents. The only thing still handled by regex is CLEAR and REMOVE - those are unambiguous and destructive, so there's no reason to burn an LLM call on them. Everything else ("what happened to my Google position", "how did my stocks do today") gets routed correctly now.

Day-over-day compare is a proper feature now. Saying "compare my stocks", "day over day", or "versus yesterday" triggers _handle_compare, which reads prev_close from the cached quote data and speaks each holding's move - direction, percent, and dollar change. Added the trigger phrases to HOTWORDS and the README.

Feature-centric entry point - _handle_portfolio is now a hub. First thing you hear is a tight two-line snapshot: total value, today's P&L, overall P&L. Then it prompts: say 'breakdown' for full detail, 'compare' for day-over-day, 'movers', or a stock name. The full per-stock breakdown is still there - just moved to _speak_portfolio_breakdown and called on demand. This way the first response is fast and you can drill into whatever you actually want.

Let me know how testing goes.

@hassan1731996 hassan1731996 requested a review from uzair401 May 19, 2026 11:50
Copy link
Copy Markdown
Contributor

@uzair401 uzair401 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @hassan1731996 for the quick turnaround on the requested changes. There are still a few improvements that need to be made, and I'll keep the PR open until those changes are in. I'll work through these, and once they're finalized by our reviewer and QA team, I'll share an update along with the revised code and updated README. After that, we'll proceed with marketplace approval. Thanks again for contributing to OpenHome!

@hassan1731996
Copy link
Copy Markdown
Contributor Author

Thanks @hassan1731996 for the quick turnaround on the requested changes. There are still a few improvements that need to be made, and I'll keep the PR open until those changes are in. I'll work through these, and once they're finalized by our reviewer and QA team, I'll share an update along with the revised code and updated README. After that, we'll proceed with marketplace approval. Thanks again for contributing to OpenHome!

Hey @uzair401 - glad the changes landed well. Happy to keep iterating if there's anything specific you want me to adjust on my end in the meantime. Looking forward to seeing what the QA pass surfaces - and appreciate you taking it the rest of the way to marketplace. Exciting milestone for this one.

- 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
@hassan1731996
Copy link
Copy Markdown
Contributor Author

hassan1731996 commented May 19, 2026

Few more additions in this push:

UPDATE intent - full position management in one flow: bought more shares (recalculates weighted average cost), sold some (reduces share count, or removes the stock entirely if you sell all of it with confirmation), and correct/overwrite if the numbers are just wrong. Reachable directly via voice ("I bought more Tesla", "sold some Apple") or from within the ADD flow when a stock already exists.

MARKET intent - "how are the markets" pulls live S&P 500, Nasdaq, and Dow Jones from the same Finnhub feed. Works both as a standalone trigger and as a navigation option inside the portfolio hub.

Hub navigation → LLM sub-router - unified the portfolio dashboard's follow-up loop with the same LLM routing approach used at the top level. Both now go through the same pattern so phrasing is handled consistently throughout.

Chunked voice output - BREAKDOWN and COMPARE speak 4 stocks at a time and ask "Want to hear the rest?" before continuing. Keeps things natural for larger portfolios instead of reading everything out at once.

README example conversation - added a full walkthrough: portfolio hub, market check, per-stock query, breakdown pagination, position update, and a background alert firing.

Let me know if you have any other thoughts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-ability Community-contributed ability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants