diff --git a/CLAUDE.md b/CLAUDE.md index 6453171..b3b4486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,13 +17,17 @@ dotnet build MarketExtension.sln ``` Deploy the MSIX package, then reload Command Palette to pick up changes. +> Debug builds are clean. A **Release** build on an ARM64 box needs a pinned RID, e.g. +> `dotnet build MarketExtension/MarketExtension.csproj -c Release -r win-arm64` (the release +> pipeline pins RIDs already, so this only matters for local Release builds). + ## Reference Library (`reference/`) Real-world implementations from the **AdbExtension** this project was scaffolded from live in `reference/` — **not compiled** (outside the project folder). Consult them before -writing a new command or page; copy and adapt rather than reinventing. The starters in -`MarketExtension/` (`SampleCommand`, `SampleListPage`, `ProcessHelper`) are minimal, -build-verified versions of these patterns. See `reference/README.md` for the full index; +writing a new command or page; copy and adapt rather than reinventing. The live code +(`Pages/SearchPage.cs`, `Pages/PricedListPage.cs`, `Pages/FavoritesDockPage.cs`, +`Helpers/ProcessHelper.cs`) shows these patterns in use. See `reference/README.md` for the full index; the highest-value examples: | Example | Demonstrates | @@ -38,9 +42,986 @@ the highest-value examples: - New commands → `MarketExtension/Commands/`, extend `InvokableCommand` - New pages → `MarketExtension/Pages/`, extend `ListPage` or `DynamicListPage` -- All external process execution goes through `ProcessHelper.Run()` — never use `Process` directly in command files -- Error toasts use `CommandResult.KeepOpen()` so the user can read them; success toasts use the default `Dismiss()` -- Icons: `new IconInfo("https://github.com/favicon.ico")` per project preference +- All external process execution goes through `ProcessHelper` (`Run()` for captured CLI calls; `OpenUrl()` to launch a URL/file in the OS default handler) — never use `Process` directly in command files +- Error toasts use `CommandResult.KeepOpen()` so the user can read them; one-shot success toasts use the + default `Dismiss()`. Exception: in-place list mutations (watchlist/favorites add/remove — see + `Commands/MembershipCommands.cs`) toast **and** `KeepOpen()`, so the user gets explicit confirmation and can keep editing +- Icons: **instrument** rows / dock buttons / the detail header show the real asset logo via + `AssetIconResolver.Resolve(...)` (Elbstream CDN — see "Asset logos (done)"), with a per-category Segoe + glyph fallback; the **extension entry, Settings, and all page chrome** (list/search/dock headers) use the + bundled app logo via `IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png")` — a full-bleed + charcoal squircle with its own background, so one file covers both light/dark themes (replaced the old + `github.com/favicon.ico` placeholder across all 7 spots). The MSIX **tile/Store** assets in `Assets\` + (`StoreLogo`/`Square*Logo`/`SmallTile`/`LargeTile`/`Wide*`/`SplashScreen`, generated by VS's Manifest + Designer → Visual Assets from a square source — turn **off** "Apply recommended padding" or already-padded + art double-insets and renders small) are a **separate** system from this in-CmdPal icon + +## Market Data Architecture (the core of this app) + +Live quotes flow through three explicitly-named layers + a coordinator. **Keep this layering and +naming for new code.** + +``` +ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merges)→ IReadOnlyList + └─(page maps via UiQuote.From)→ UiQuote (rendered) + +ApiFinnhubCandleDto ─(provider maps)→ DomainCandleSeries ─(repository routes)→ DomainCandleSeries + └─(page maps via UiCandleSeries.From)→ UiCandleSeries (SVG chart) +``` + +The **candle/chart history** flow (added for the symbol-detail chart) mirrors the quote flow through +the same three layers and the same provider seam — see the "Symbol detail + live chart" section. + +**Naming convention:** +- `Api*` — raw provider DTOs (provider-specific), e.g. `ApiFinnhubQuoteDto`. +- `Domain*` — provider-agnostic, **no formatting**; what every provider AND the repository return (`DomainQuote`, `DomainInstrument`, `DomainCandleSeries`). +- `Ui*` — presentation; the ONLY place `FormatPrice()`/`FormatChange()`/SVG live (`UiQuote`, `UiCandleSeries`). +- `AssetCategory`, `ChartRange`, `CandleInterval` (enums) stay **unprefixed** — shared vocabulary across all layers. + +**File map:** + +| File | Role | +|---|---| +| `Data/MarketRepository.cs` | **The coordinator the UI depends on.** Routes each `DomainInstrument` to the first `IMarketDataProvider` whose `Supports(AssetCategory)` matches, fans out concurrently, merges into one order-preserving list. Also `SearchAsync(query)` — fans out the free-text lookup to every provider and merges/dedupes by symbol — and `GetCandlesAsync(instrument, range)` — routes the chart history to the first supporting provider (no fan-out; one instrument). No provider for a category → `IsValid:false` quote / invalid candle series. **`ActiveProviders()`**: if any provider is `IsExclusive` (the mock in Demo mode), ALL three operations route to the exclusive set only — so an exclusive source wins everywhere, including the search fan-out. | +| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + **`bool IsExclusive`** (default `false`; true → MarketRepository routes every operation to this provider alone — how the Demo-mode mock takes precedence everywhere) + `GetQuotesAsync(instruments, ct)` + `SearchAsync(query, ct)` (free-text symbol lookup → `DomainInstrument`s, **identity only, no prices**; a provider that can't search returns `[]`) + `GetCandlesAsync(instrument, ChartRange, ct)` (price history for the detail chart; **default interface method** returns an invalid series, so non-candle providers opt out for free). | +| `Data/TwelveData/TwelveDataMarketDataProvider.cs` | **Primary provider when its key is set** (`Supports` Stock+Crypto+Currency, **gated on `HasTwelveDataApiKey`** → first-match routing falls back to Finnhub/Frankfurter when unset). One API for all three classes; **`/time_series` candles are free-tier**, so charts render on a free key. `GetQuotesAsync` **batches all symbols into one `/quote`** call (tight 8/min limit); `SearchAsync` = `/symbol_search` (results normalized back to neutral symbols). See Twelve Data specifics. | +| `Data/Finnhub/FinnhubMarketDataProvider.cs` | Stock+Crypto provider (`Supports` Stock+Crypto), used when no Twelve Data key is set. Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | +| `Data/Frankfurter/FrankfurterMarketDataProvider.cs` | FX provider (`Supports` Currency). **Keyless** ECB daily rates via Frankfurter (`https://api.frankfurter.dev/v1/`). Splits a 6-letter pair (`EURUSD` → base `EUR`/quote `USD`), calls the time-series endpoint for both quotes (latest vs previous fixing = **day-over-day** change) and candles (daily closes). `SearchAsync` = local filter over the catalog's FX pairs (no Frankfurter free-text endpoint exists). See Frankfurter specifics. | +| `Data/Frankfurter/ApiFrankfurterDto.cs` | Raw time-series DTO (`ApiFrankfurterSeriesDto`: `rates` = date→{currency→rate}) **+ the flat `/latest` DTO (`ApiFrankfurterLatestDto`: `rates` = currency→rate)** used by `CurrencyConverter` + the source-gen `FrankfurterJsonContext`. | +| `Data/MockMarketDataProvider.cs` | The **Demo-mode** data source — quotes/candles for every asset class so the whole UI works with no key/network. Registered **first**; `Supports()` is **gated on the `DemoMode` setting** and **`IsExclusive`** also returns it, so in demo mode the repository routes **everything (quotes, candles, search)** to the mock alone — it wins everywhere and no live provider is hit. Off → serves nothing, live providers take over (`SearchAsync` self-gates to `[]` so it stays out of the live search fan-out). **Serves ANY symbol:** a small hand-tuned `Seed` (headline symbols, incl. GBP `HSBA.L`) overrides on top of `SynthesizeQuote` — a stable-hash, category-/currency-inferred quote for any other ticker, so an off-seed real holding still prices. **Search** matches a curated **`Catalog`** of ~120 *real* instruments (never fabricated symbols — a faked match could be persisted and break when demo is off). See "Demo mode (offline testing)". | +| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` defaults — the **first-run seed** for `WatchlistStore` (no longer always-shown; removable once seeded). | +| `Settings/WatchlistStore.cs` | JSON-persisted tracked instruments, each carrying **two independent flags** `InWatchlist`/`IsFavorite` (favorites = the dock subset). Stores **full `DomainInstrument` identity** so searched non-catalog symbols re-price; an entry with both flags false is dropped. Source-gen `WatchlistItem`/`WatchlistJsonContext` → `market_watchlist.json`; seeds `InstrumentCatalog` on first run, else migrates the legacy `market_favorites.json` (old pins → watchlisted **and** favorited). Exposes its `Watchlist`/`Favorites` subsets as **observable `StateFlow`s** (each mutation calls `PublishState()` → re-publishes both); pages and the dock **subscribe** and re-render themselves. Replaces the old `FavoritesStore` + `Watchlist.cs`. | +| `Helpers/StateFlow.cs` | A tiny **Kotlin-StateFlow analog**, now a **thin wrapper over `System.Reactive`'s `BehaviorSubject`** (the migration off the old hand-rolled version is **done** — AOT/trim is off, so the Rx dependency is taken warning-free; see the Rx done bullet). Used by the **state holders** (`WatchlistStore`, `MarketSettingsManager`) — observable *state with a current value*, which `IObservable` (no `Value`) and a bare `BehaviorSubject` (no read-only face) can't express alone. Public API unchanged: `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed; a `Subscribe(onNext, replayOnSubscribe:false)` overload opts out of the replay via `Skip(1)` — used by the priced pages' secondary flows, e.g. `HasAnyApiKey`) and writable `MutableStateFlow` (`Update`). `SetValue` does **source-side** distinct-until-changed (an equal value isn't pushed, so re-publishing an unchanged subset doesn't wake its subscribers); `BehaviorSubject.OnNext` fans handlers out **outside** its lock (handlers re-read the store safely). The old hand-rolled **subscriber-count seam** (`OnActive`/`OnInactive` + the refcount + a custom `Subscription` wrapper) was **removed** once its only user, `PollTicker`, went pure Rx — Rx's `Publish().RefCount()` is the proper home for that, and Rx subscriptions are already idempotent on dispose. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | +| `Helpers/PollTicker.cs` | The **live-price poll ticker** — now **pure Rx** (no longer a `StateFlow`). A process-wide singleton `IObservable` = `Observable.Generate(...)` (self-rescheduling timer; per-step delay re-read from `MarketSettingsManager` each iteration so interval/on-off applies without reload, `0` = off idles on a 30 s re-check and the tick is filtered out) wrapped in **`Publish().RefCount()`** — the WhileSubscribed seam that starts the loop on the first subscriber and tears it down on the last (Generate never completes, so disposal is silent and resubscribe restarts cleanly). `Defer`/`Finally` log the start/stop at the refcount edges. Surfaces attach via the **guarded** `PollTicker.Subscribe(onTick)` (the raw stream is private): the handler is wrapped in try/catch (swallow + `Log.Error`) so a throwing tick handler can't trip Rx's `SafeObserver` into tearing down the shared multicast stream (which would kill polling for every surface). Handlers still offload heavy work via `Task.Run`. | +| `Helpers/HttpRetry.cs` | **Shared 429 back-off** at the HTTP seam. `SendAsync(send, tag, ct)` takes a request **thunk** (each retry must re-issue a fresh `HttpResponseMessage`) and, on HTTP `429`, honors a short `Retry-After` else backs off `1s`→`2s` (max 3 attempts, **bails** if the wait would exceed an `8s` cap — per-minute windows don't clear in seconds, so hammering only burns quota). Returns the final response (success / non-429 error / surviving 429) so callers inspect status exactly as before — a **drop-in** at each `GetAsync`. Also the single choke point that feeds `RateLimitSignal` (2xx → off, surviving 429 → on). Used by Finnhub + Twelve Data (not keyless Frankfurter). | +| `Helpers/RateLimitSignal.cs` | Process-wide **"are we throttled" flag** — a `MutableStateFlow` behind a read-only `StateFlow` (same state-holder idiom as `WatchlistStore`/`MarketSettingsManager`). `ReportRateLimited()`/`ReportSuccess()` flip it (distinct-until-changed); priced surfaces **subscribe** and re-render. Intentionally **global, not per-symbol** (a free-tier limit is key-wide). | +| `Helpers/RateLimitHint.cs` | The **rate-limited banner row** (parallels `ApiKeyHint.StatusRow()`): `Row()` returns an amber "Rate-limited — showing last known prices" `ListItem` (Enter = **`NoOpCommand`**, purely informational — it's the default-selected first row, so it must not navigate) when `RateLimitSignal` is set **and** the `ShowRateLimitErrors` setting is on, else `null`. Pinned to the **top** so it's seen without scrolling: `PricedListPage.GetItems` inserts it at index 0; `SearchPage.SearchItems` inserts it at index 1 (just under the Enter-to-search action, which must stay first). | +| `Data/Finnhub/ApiFinnhubQuoteDto.cs` | Raw `/quote` DTO + the **single** `FinnhubJsonContext` (all `[JsonSerializable]` live here — see AOT/trim gotcha). | +| `Data/Finnhub/ApiFinnhubSearchDto.cs` | Raw `/search` DTOs (`ApiFinnhubSearchDto` / `...ResultDto`); registered on `FinnhubJsonContext` in the quote file. | +| `Data/Finnhub/ApiFinnhubCandleDto.cs` | Raw `/stock/candle` + `/crypto/candle` DTO (parallel `c/h/l/o/t/v` arrays + `s` status); registered on `FinnhubJsonContext` in the quote file. **Premium** (free key → 403). | +| `Data/TwelveData/ApiTwelveDataQuoteDto.cs` | Raw `/quote` DTO + the **single** `TwelveDataJsonContext` (all `[JsonSerializable]` here, incl. the keyed `Dictionary` batch response; `NumberHandling=AllowReadingFromString` since TD encodes numbers as JSON strings). | +| `Data/TwelveData/ApiTwelveDataCandleDto.cs` | Raw `/time_series` DTOs (`ApiTwelveDataTimeSeriesDto` + per-bar `ApiTwelveDataValueDto`, **newest-first** — provider reverses to oldest-first); registered on `TwelveDataJsonContext`. **Free tier** (unlike Finnhub candles). | +| `Data/TwelveData/ApiTwelveDataSearchDto.cs` | Raw `/symbol_search` DTOs (`data[]` of symbol/instrument_name/instrument_type); registered on `TwelveDataJsonContext`. | +| `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the quote model layers. `DomainQuote` now carries a **`Currency`** (ISO-4217 native currency of the price, default `"USD"`); `UiQuote.FormatPrice` renders stock/crypto in that native currency's symbol (FX-rate prices stay raw 4-decimal). See "Multi-currency portfolio conversion (done)". | +| `Models/ChartRange.cs` | `ChartRange` (1D/1W/1M/1Y/5Y) **+ `CandleInterval`** enums + neutral helpers (`Label`/`Lookback`/`Interval`/`FromLabel`). Provider-agnostic — **no resolution tokens here** (those live in the provider). | +| `Models/DomainCandleSeries.cs` | Domain history: `Symbol`, `Range`, ordered `CandlePoint`s (`Time`+`Close`), `IsValid`; `Invalid(...)` factory + `First/LastClose`. No formatting. | +| `Models/UiCandleSeries.cs` | Ui projection of a `DomainCandleSeries`: `IsUp`, `FormatPrice`, `FormatRangeChange` (Robinhood-style net change over the selected range), `ChartImageUrl()`. The ONLY place chart formatting/SVG live. | +| `Helpers/ChartHelper.cs` | Ports Perf Monitor's **SVG-sparkline-as-`data:`-URI** (pure `System.Xml.Linq`, AOT-safe), generalized to plot N points + normalize Y to the series min/max, recolored green/red. Draws a **faint quarter gridline box** (0/¼/½/¾/1 on both axes) so the scale reads at a glance. **No numeric tick labels:** the host rasterizes the data-URI through Direct2D's SVG engine (`ID2D1SvgDocument`), which renders lines/polylines/rects/gradients but **silently drops ``** — so axis numbers aren't possible on this surface (the grid is decorative only). Grid uses a theme-neutral mid-gray (static image can't read the host theme). Only caller: `UiCandleSeries`. | +| `Helpers/AssetIconResolver.cs` | Instrument identity → row/dock `IconInfo`. Builds **Elbstream** logo URLs by category (`/logos/symbol/{t}`, `/logos/crypto/{c}`, FX pair → base currency's flag `/logos/country/{iso2}` via a currency→country map, `?format=png`); Segoe-glyph fallback for unmapped currencies/unknown. **Zero API calls** (the host fetches the URL). Also the shared `AttributionRow()` factory (the required Elbstream credit). See "Asset logos (done)". | +| `Helpers/CurrencyConverter.cs` | Process-wide singleton that converts money between currencies via Frankfurter's keyless **`/latest`** spot rates (same ECB source as the FX provider). Two-phase for the sync-render constraint: **`PrimeAsync(preferred, natives)`** fetches every not-yet-fresh `native→preferred` rate in **one batched call** (base=preferred → invert each) and caches per ordered pair with a **~1 h TTL** (negative results cached too); **`TryGetRate(from, to)`** is the synchronous cache read used while rendering (`from==to → 1`, unknown/unsupported → `null`). Used only by `PortfolioPage` (off the price-load path via `OnPriceCacheUpdated`). | +| `Helpers/CurrencyFormat.cs` | UI helper: render a `decimal` as money in an ISO-4217 currency — the home for the per-currency **symbol** (`$`/`€`/`£`/`¥`/…) + decimal-place rules that replaced the hardcoded `$`. `Format`/`FormatSigned` (sign-before-symbol for P&L), culture-invariant; unknown code → trailing code (`1,234.56 SGD`); JPY/KRW render 0-decimal. Used by `UiQuote`, `UiPosition`, `UiPortfolio`. | +| `Helpers/CurrencyHelper.cs` | Currency conventions the **providers** apply when stamping `DomainQuote.Currency`: `NormalizeStockQuote` folds London's **GBX/GBp pence → GBP** (price & change ÷100; the 100× trap) and upper-cases the code (null → `USD`); `QuoteCurrencyOfPair` returns an FX pair's quote (2nd) currency (`EURUSD → USD`). One home for the pence rule across Twelve Data + Finnhub. | +| `Pages/SymbolDetailPage.cs` | Shared per-symbol screen: nested `SymbolChartForm : FormContent` (adaptive-card chart + range tabs) + the list-management command bar. Flicker on range switch is fixed; ⚠️ the Enter-steals-focus bug is an **open known limitation** — see the chart section. | + +**To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, +and `SearchAsync` — return `[]` if it can't search; optionally **override `GetCandlesAsync`** to serve +chart history, else the default invalid-series opt-out applies), map its `Api*` DTO → `DomainQuote` +(and → `DomainCandleSeries` for candles), and register it in `MarketExtensionCommandsProvider`: +`new MarketRepository(new FinnhubMarketDataProvider(), new YourProvider())`. **Zero UI changes.** For +candles, the provider also translates the neutral `ChartRange.Interval`/`Lookback` into its own API +tokens — Finnhub's `ToFinnhubResolution` (`5/30/60/D/W`) is the model; `TwelveDataMarketDataProvider` +maps the same `CandleInterval` to `5min/30min/1h/1day/1week`. **Worked examples:** +`FrankfurterMarketDataProvider` (keyless FX) and `TwelveDataMarketDataProvider` (an all-three-classes +source, primary when its key is set) are both exactly this — added behind the seam with zero +UI/repository changes (only a registration line + a settings key). + +**Twelve Data specifics (primary provider):** +- Base `https://api.twelvedata.com/`, key via `apikey=` query param (per-provider setting + `TwelveDataApiKey` — see API Key). **One source for stocks + crypto + forex.** Registered FIRST so it's + primary, but `Supports()` is **gated on the key** → a no-key install falls through to Finnhub/Frankfurter. +- Free tier **~8 credits/min, ~800/day** (1 credit/symbol) — tighter per-minute than Finnhub. So + `GetQuotesAsync` **batches every instrument into ONE `/quote` call** (comma-joined). Response shape + differs by count: **>1 symbol** → object keyed by symbol (`Dictionary`); + **1 symbol** → a bare quote object. A global error (bad key/429) can come back as a bare + `{"status":"error"}` even on HTTP 200 → the parse degrades to all-invalid (kept off the error path). +- Symbol formats: stock `AAPL`, crypto `BTC/USD`, FX `EUR/USD`. `ToTwelveDataSymbol` maps our neutral + symbols (bare ticker / bare coin `BTC`→`BTC/USD` / 6-letter `EURUSD`→`EUR/USD`); `SearchAsync` + normalizes results **back** (crypto → base coin, FX → 6-letter) so they round-trip and match the icon + resolver + watchlist storage. +- **Candles are FREE-tier** — the headline win: `GET /time_series?symbol=&interval=&outputsize=` renders + the symbol-detail chart on a free key (Finnhub's candles are premium → 403). `interval` via + `ToTwelveDataInterval`; `outputsize` sized per range. Values arrive **newest-first** → the provider + reverses to oldest-first. Numbers are JSON **strings** → `NumberHandling=AllowReadingFromString`. +- **Search** `/symbol_search?symbol=` returns identity only across all three classes; `instrument_type` + → `AssetCategory` (equity-ish → Stock, "Digital Currency" → Crypto, "Physical Currency" → Currency). + +**Finnhub specifics:** +- Base `https://finnhub.io/api/v1`, endpoint `/quote`. Free tier ~60 calls/min, ~300/day. +- **No forex on the free tier** — `OANDA:*` returns 403. FX is therefore routed to the keyless + `FrankfurterMarketDataProvider` (ECB rates) rather than Finnhub; see Frankfurter specifics below. +- Symbol formats: stock = bare (`AAPL`), crypto = `BINANCE:{SYM}USDT`, FX = `OANDA:{BASE}_{QUOTE}`. +- An all-zero `/quote` response = invalid/unknown symbol → map to `IsValid:false`. +- **Candles (OHLCV) — PREMIUM** (free key → 403): `GET /stock/candle?symbol=&resolution=&from=&to=` + (crypto: `/crypto/candle`, same shape). `resolution` ∈ `1,5,15,30,60,D,W,M`; `from`/`to` are **UNIX + seconds**. Response = parallel arrays `c/h/l/o/t/v` + `s` (`ok`|`no_data`). Daily is split-adjusted; + intraday is unadjusted and **only ~1 month per call**. `FinnhubMarketDataProvider.GetCandlesAsync` + maps each `ChartRange`: 1D→`5`/1d, 1W→`30`/7d, 1M→`60`/31d, 1Y→`D`/365d, 5Y→`W`/5y (keeping + 1D/1W/1M inside the intraday cap). 403/429/`no_data` → invalid series → the chart shows an + "unavailable" message rather than blanking. +- **Symbol search** `/search?q=&exchange=US` (free tier): returns identity only (`symbol`/`description`/ + `type`), **no prices**. `symbol` is the canonical id for `/quote`; `displaySymbol` is for UI. Scoped to + **US equities** and mapped to `AssetCategory.Stock` — Finnhub's canonical `symbol` only round-trips + through `ToFinnhubSymbol(Stock)` for plain US tickers; crypto/FX search needs symbol-format + reconciliation (deferred). UI is **Enter-only** (the synthetic "Search Finnhub for …" item), never + per-keystroke, to protect the rate limit. A name can list across exchanges → dedupe by symbol. + +**Frankfurter specifics (FX):** +- Base `https://api.frankfurter.dev/v1/` (the old `api.frankfurter.app` 301-redirects here). **Keyless, + no documented rate limit** → no key short-circuit, no `Has…Key` gate. +- Wraps the **ECB daily reference rates**, so data is **daily only** (one fixing per business day, ~16:00 + CET). Implications baked into the provider: a quote's **change is day-over-day** (latest vs previous + fixing — the FX analog of Finnhub's `d`), and charts plot **daily closes** (the 1D/1W tabs show recent + daily fixings, not intraday bars — the candle lookback is floored at 7 days so short ranges still draw). +- **One endpoint for both flows** — the time series `GET /{from}..{to}?base={BASE}&symbols={QUOTE}` → + `{ "rates": { "2024-06-03": { "USD": 1.0842 }, … } }`. Quotes fetch a ~10-day window (≥2 fixings across + weekends) and take the last two points; candles fetch the range's lookback. Any base works (`base=USD` + for USDJPY is fine — Frankfurter cross-converts via EUR). +- Pairs are the neutral **6-letter `BASE+QUOTE`** (`EURUSD`); the provider splits 3+3. `SearchAsync` is a + **local filter over `InstrumentCatalog`'s FX pairs** (Frankfurter has no symbol-search endpoint), so the + catalog is the single source of truth for which pairs are seeded *and* searchable. +- Icons: an FX pair shows its **base currency's country flag** (Elbstream `/logos/country/{iso2}`, EUR→`eu`) + via the `AssetIconResolver` currency→country map; unmapped currencies fall back to the bank glyph. + +**AOT/trim (intentionally OFF):** this is a hobby project that does **not** trim or Native-AOT compile — +it ships **self-contained single-file JIT** (see `MarketExtension.csproj`; same shape AdbExtension +publishes to the Store), and enabling AOT/trim is a **deliberate non-goal**. The trim/AOT analyzers + the +CsWinRT AOT optimizer were **removed**, so reflection-based code (e.g. `System.Reactive`, reflection-based +`JsonSerializer`) now builds **warning-free** — there is no `IL2026`/`IL3050` enforcement anymore. (To +revive AOT readiness, restore `true` + the CsWinRT optimizer lines from git history.) +The existing source-gen `JsonSerializerContext`s are kept as a **convention, not a build requirement**: +`FinnhubJsonContext` (quotes + search + candles), `FrankfurterJsonContext` (FX time-series), +`TwelveDataJsonContext` (quotes + candles + search; `NumberHandling=AllowReadingFromString` for TD's +string-encoded numbers, and the keyed `Dictionary` batch response is registered too), and +`WatchlistJsonContext` (watchlist items + legacy migration) all still work and **don't need changing** — +new JSON may use either source-gen or plain reflection. +⚠️ **Gotcha (only while a context stays source-gen):** the JSON source generator does **not** support +`[JsonSerializable]` attributes split across multiple `partial` declarations of one context — it emits a +colliding hintName (e.g. `FinnhubJsonContext.Decimal.g.cs`) and the *whole* generator silently fails, +cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context in the build. Keep all +`[JsonSerializable]` for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). + +## API Key (runtime setting — no build-time key) + +- **Twelve Data key (primary provider)** — `TwelveDataApiKey`/`HasTwelveDataApiKey`, same + `market.settings.json`. When set, Twelve Data serves stocks/crypto/FX **and free charts**; its + `Supports()` is gated on the key, so an unset key **falls back** to Finnhub/Frankfurter (no + regression). Read per request (a key change applies without a reload); `SearchAsync` returns `[]` and + quote/candle fetches short-circuit to "no data" when unset. Having **both** TD and Finnhub keys set is + harmless but redundant: TD (registered first) wins all pricing/candles; Finnhub then only contributes + extra `/search` hits (search fans out to every provider and dedupes by symbol). +- The Finnhub key is provided **exclusively at runtime** via the extension's settings + (`Settings/MarketSettingsManager.cs`, exposed as `FinnhubApiKey`/`HasFinnhubApiKey`, persisted to + `…/Microsoft.CmdPal/market.settings.json`). There is **no built-in/baked key** — the old + `secrets.props` + csproj `GenerateSecrets` + `Secrets.FinnhubApiKey` machinery was removed. +- `FinnhubMarketDataProvider` reads `MarketSettingsManager.Instance.FinnhubApiKey` on each request + (so a key change applies without a reload) and **short-circuits to "no data"** (invalid quotes / + empty search / invalid candle series, with a `Log.Warn`) when no key is set, rather than firing + keyless requests that would just 401. +- **Per-provider naming on purpose:** keys are named per provider (`FinnhubApiKey`, not a generic + `ApiKey`) because future providers (e.g. a forex source) each get their own key setting here. + +## Logging + +- `Log.Info/Warn/Error(tag, message)` (`Helpers/Log.cs`) — tagged by component (`Finnhub`, + `Repository`, `ComServer`, `Startup`). +- `Info`/`Warn`/`Error` are all `[Conditional("DEBUG")]` → **compiled out of Release** (the MSIX ships + silent, with **no telemetry channel** — nothing leaves the machine). Sentry was removed entirely. +- Watch live (Debug builds): VS **Debug → Attach to Process → `MarketExtension.exe`** (Managed) → + Output window; or Sysinternals **DebugView** ("Capture Global Win32"). **NEVER log the API token.** + +## Current Status / Next Steps + +- **Done (this round): removed Sentry entirely — the app ships with NO telemetry.** The optional crash + reporter (SDK init in `Program.cs`, the `CaptureException`/`CaptureMessage` calls in `Log.Error`, the + `Sentry` package in the csproj + `Directory.Packages.props`) is **gone**. Rationale: it was a one-off in + this extension — **no** other CmdPal extension uses Sentry (0 references across the whole PowerToys repo; + none in the AdbExtension this was scaffolded from), and a market app that holds the user's API key + shouldn't carry anything that *looks* like it could exfiltrate it (even though the audit found it never + did — keys live only in request query strings handed to plain, un-instrumented `HttpClient`s, and no + `Log.Error` ever interpolated a URL/key). `Log.Error` is now `[Conditional("DEBUG")]` like `Info`/`Warn`, + so all logging is Debug-only and a Release MSIX emits nothing off-machine. To re-add telemetry later, + restore the package + a `SentrySdk.Init` and add a `BeforeSend` scrubber for `token=`/`apikey=`. Build + clean (0 warnings). +- **Done (this round): Demo mode now applies INSTANTLY (no waiting for the next refresh) + no rate-limit + banner while demoing.** Flipping the `Demo mode` toggle used to be **pull-style** — surfaces only picked up + the new data source on their next price refresh / reopen, so a hidden priced page (a long-lived singleton + whose `_priceCache` survives navigation) would repaint **stale, old-source** prices until the interval + elapsed. Now `MarketSettingsManager` exposes an observable **`DemoModeChanged`** (`StateFlow`, + published from the existing `SettingsChanged` handler, distinct-until-changed so it fires only on a real + flip) and every surface subscribes and resets the moment it flips: + - `PricedListPage` — a **lifetime** subscription (NOT the visibility-scoped block), so even a hidden + singleton drops its price cache + freshness clock: re-fetches from the new source if visible, else the + cleared cache forces a full fetch on next open. + - `FavoritesDockPage` / `PortfolioDockPage` — visible-scoped re-price (hidden bands already refetch on open). + - `CurrencyConverter` — clears its rate cache so the next `PrimeAsync` repopulates from the correct source + (live ECB ↔ the static `DemoUsdPerUnit` table) instead of serving its <1h-TTL stale entries. + - `SearchPage` / `MarketsPage` — re-list so the status row swaps (demo ↔ missing-key) at once. + + No reload / no process restart (an out-of-proc extension can't drive CmdPal's own Reload anyway; this hits + the same goal reactively). **Also fixed:** the rate-limited banner no longer shows in demo mode — + `RateLimitHint.Row()` now guards on `DemoMode` (the authoritative gate), and `RateLimitSignal` **clears its + flag when demo mode turns on** (offline nothing calls `ReportSuccess()`, so a 429 set earlier would + otherwise linger and reappear when demo is turned back off). Build clean (0 warnings). See "Demo mode + (offline testing)". +- **Done (this round): Demo mode (offline testing).** A **`Demo mode` toggle setting** (default off) makes the + whole app serve built-in sample data with **no API key and no network** — for trying out the extension or + testing UI work (like the cost-basis/total-return + multi-currency paths) without burning real-API budget. + `MockMarketDataProvider` is registered **first** and, in demo mode, declares itself **`IsExclusive`** — a new + default member on `IMarketDataProvider` that makes `MarketRepository` route **every** operation (quotes, + candles, **and the search fan-out**) to it alone, so the mock takes precedence **everywhere** and **zero** + network is hit even if live API keys are set. Off → it's not exclusive and `Supports()` is false, so the + live providers take over unchanged. `CurrencyConverter` checks the same setting and fills its cache from a + **static FX table** instead of calling Frankfurter `/latest`, so even cross-currency portfolio conversion is + fully offline. The mock seed now carries each instrument's real name/category/**native + currency** (incl. a GBP London stock `HSBA.L`) so search is correctly typed and conversion has a non-USD + holding to exercise. The list screens show an explicit blue **"Demo mode — showing sample data"** status row + while it's on (replacing the red missing-key nudge), so it's obvious the prices are simulated, not live — + `ApiKeyHint` became a unified `StatusRow()` (demo row → missing-key row → null). **(Update: flipping the + toggle now applies immediately across the app — see the "applies INSTANTLY" bullet above; this entry's + original "next refresh/reopen" timing is superseded.)** Build clean (0 warnings). ✅ **Live-verified**: flipping the toggle + swaps to sample data across the app, the blue status row shows, and the mock takes precedence everywhere + (even with live keys set). See "Demo mode (offline testing)". +- **Done (this round): cost-basis / total-return reporting** — the last open portfolio wishlist item. The + optional `CostBasis` (average price paid per unit, in the holding's **native** currency) was already stored + but unsurfaced; it's now **captured and displayed** end-to-end with **no provider/repository/model-shape + changes** (only behavior + UI): + - **Capture** — `Pages/SetQuantityPage.cs` gained a second optional `Input.Number` ("Average cost per unit"); + the editor prefills it from the current holding and maps **0/blank → null** (not recorded → total return + hidden). `PortfolioStore.SetPosition` now applies the cost basis **verbatim** (a value sets it, null + clears) — the old preserve-on-null rule is gone, because the editor is the sole caller and round-trips the + existing basis itself (so a quantity-only edit still preserves it). New `PortfolioStore.GetPosition(symbol)` + returns the full holding (quantity + basis) for rendering. + - **Compute** (only place the math/formatting lives) — `UiPosition` exposes `TotalCost`/`TotalReturn`/ + `TotalReturnPercent` (native **and** converted via the same `RateToPreferred` as daily P&L; percent = + `(price − basis)/basis`, currency-independent) + `FormatTotalReturn()`, shown only when a positive basis + exists. `UiPortfolio.From` rolls **converted** cost + return up across the counted holdings that **have a + basis** (percent = `return/cost` over exactly that subset), gated by `HasCostBasis` + a + `FormatTotalReturnNote()`. + - **Display** — Portfolio rows append "Total ▲ +$… (+…%)" after daily P&L when a basis is set; the totals + summary row **and** the Portfolio dock band append the total-return note to their subtitle. Holdings with + no basis (or unpriced/unconvertible) simply contribute nothing to the return rollup. Build clean (0 + warnings). ✅ **Live-verified** (via Demo mode: the GBP `HSBA.L` holding with a cost basis shows the + converted total return on the per-row subtitle, the totals summary, and the dock band). See + "Portfolio screen + dock band (done)". +- **Done (this round): the Portfolio dock band.** A second Dock band next to the favorites band, showing the + portfolio's **total value + daily P&L rolled up into the `PortfolioCurrency`** as a single summary button + (clicking it opens the full `PortfolioPage`). New `Pages/PortfolioDockPage.cs` (a `ListPage`, modeled on + `FavoritesDockPage`): same `INotifyItemsChanged` visible-lifecycle hook, subscribes to + `PortfolioStore.Positions` (replay paints on open; any add/edit/remove repaints — reference-equality flow + so even a quantity-only edit re-emits) **and** `PollTicker` (silent live re-price), disposing both on hide. + It prices the holdings, **`await`s `CurrencyConverter.PrimeAsync`** for the native→preferred FX rates + before rolling up (so the converted total lands in one paint — no progressive native-only frame needed on a + one-row band), then composes `UiPortfolio` exactly like `PortfolioPage.LeadingRows`. Same keep-last-good + guard as the favorites band. Registered as a second `CommandItem` in `GetDockBands()` with its own non-empty + `Id` (`com.costafotiadis.market.dock.portfolio`). No provider/repository/model changes. Build clean (0 + warnings). ✅ **Live-verified** (band renders the total + P&L; icon shows). ⚠️ **Glyph-icon gotcha hit:** + the Bank summary-icon glyph (U+E825) is a Segoe MDL2 private-use char — the Write tool **silently dropped** + it (saved as `""` → blank icon); fixed by re-adding it and byte-checking (real glyph = `EE A0 A5`). Prefer a + C# Unicode escape for new glyph icons. See "Portfolio screen + dock band (done)". +- **Done (this round): multi-currency portfolio conversion** — the headline portfolio wishlist item. The + app no longer assumes USD: `DomainQuote` carries an ISO-4217 **`Currency`** (providers stamp it — Twelve + Data from the batched `/quote` field, Finnhub via a cached `/stock/profile2` lookup, Frankfurter from the + pair's quote currency; crypto→USD, **GBX/pence→GBP ÷100**), and the **Portfolio screen** values each + holding in its native currency **and** converts into the user's `PortfolioCurrency` via the keyless + Frankfurter `/latest` rates (new `Helpers/CurrencyConverter.cs`, primed off the price-load path through a + new `PricedListPage.OnPriceCacheUpdated` hook; synchronous `TryGetRate` at render time). Totals roll up in + the preferred currency with proper symbols (`Helpers/CurrencyFormat.cs` replaces the hardcoded `$`); + unconvertible holdings are excluded and surfaced. `UiQuote.FormatPrice` is currency-aware app-wide too + (a London stock shows `£2.50`). New files: `Helpers/CurrencyConverter.cs`, `CurrencyFormat.cs`, + `CurrencyHelper.cs`, `ApiFinnhubProfileDto.cs`, `ApiFrankfurterLatestDto`. Build clean (0 warnings). + ✅ **Live-verified**: 10 sh SPY (USD) with GBP preferred → ~£5,571 (USD value × ~0.76, a real conversion). + See "Multi-currency portfolio conversion (done)". + +- **Done (this round): rate-limit (429) back-off + a "rate-limited" banner.** Two pieces behind the + existing provider seam — **no model-layer change**: + - **Back-off** — a shared `Helpers/HttpRetry.cs` wraps every `Http.GetAsync` in the two **keyed** HTTP + providers (Finnhub + Twelve Data; Frankfurter is keyless / no documented limit, so it's left out). On + an HTTP `429` it honors a short `Retry-After`, else backs off `1s`→`2s` (max 3 attempts), bailing + immediately if the wait would exceed an `8s` cap — because the free tiers are **per-minute** windows, a + few-second retry rarely clears an exhausted minute, so hammering would just burn more quota. Plain + `async`/`Task.Delay`, **not** Rx — wrapping these Task-based calls in observables just to use + `RetryWhen` would add ceremony for no gain (the CLAUDE.md Rx framing oversold this one). + - **Banner** — a process-wide `Helpers/RateLimitSignal.cs` (`StateFlow`, the same state-holder + idiom as `WatchlistStore`): `HttpRetry` flips it **on** when a 429 survives back-off and **off** on any + 2xx, at that single choke point. The Twelve Data provider additionally reports it when TD encodes a + `429` in a **200-OK body** (`{"code":429,…}`), which an HTTP-status check alone misses. The priced + pages + Search **subscribe** (replay:false) and render `Helpers/RateLimitHint.cs` — an amber + "Rate-limited — showing last known prices" row (Enter is a **no-op** — `NoOpCommand`, purely + informational; it's the default-selected first row so it must not steal Enter), pinned to the **top** + (priced pages index 0; Search index 1, just under the Enter-to-search action) so it's seen without + scrolling — so the user knows *why* prices look stale instead of just seeing the keep-last-good values. + A new **`Show rate-limit warnings`** toggle setting (`ShowRateLimitErrors`, default on) hides the banner + entirely for users who'd rather not see it. It's deliberately + **global, not per-symbol** (a free-tier limit is key-wide). The **dock band contributes to the signal** + (its fetches go through the same providers) but doesn't render the banner — too cramped, and a + pseudo-button warning would look out of place. Build clean (0 warnings). ✅ **Live-verified**: exhausted + a free key → the amber banner appeared, then cleared on recovery. +- **Done (this round): Twelve Data provider — now the primary source.** Added + `Data/TwelveData/TwelveDataMarketDataProvider.cs` behind the existing `IMarketDataProvider` seam with + **zero UI/repository changes** — one API covering **stocks + crypto + forex**, registered FIRST and + **gated on its key** so it's primary when set and **falls back** to Finnhub/Frankfurter when not. The + headline win: **`/time_series` candles are free-tier**, so the symbol-detail chart now renders real + history on a **free** key (Finnhub's candles are premium → 403). `GetQuotesAsync` **batches** all + symbols into one `/quote` call (tight ~8/min free limit); `GetCandlesAsync` reverses TD's newest-first + values; `SearchAsync` via `/symbol_search`, normalizing results back to neutral symbols. New + `TwelveDataApiKey` setting + source-gen `TwelveDataJsonContext` (`AllowReadingFromString`). Build is + clean (AOT/trim). ✅ Verified live end-to-end against a real Twelve Data key (quotes, batched `/quote`, + and free-tier `/time_series` charts). See "Twelve Data specifics". +- **Done:** layered data architecture; live Finnhub provider; `MarketRepository` coordinator; + **runtime API key + refresh-interval settings** (`MarketSettingsManager`; key is settings-only, + no baked-in key); tagged logging; **Enter-only Finnhub `/search`**; **persistent + watchlist + favorites** as two independent flags (`WatchlistStore`); the **three-screen UX** + (Markets Search / Watchlist / Favorites — see below); and the **observable (StateFlow) data layer** — + the store exposes `Watchlist`/`Favorites` as `StateFlow`s, all surfaces subscribe and re-render + themselves, the membership commands no longer thread a manual refresh callback, and **dock refresh on + favorites change is now live** (push, not poll). Priced pages **reconcile locally** on membership + change (drop removed rows free, fetch only new symbols). See `Helpers/StateFlow.cs`. +- **Done (this round):** the **symbol-detail price chart with 1D / 1W / 1M / 1Y / 5Y range tabs** — real + Finnhub candle history behind a provider-agnostic seam (`GetCandlesAsync`, `ChartRange` + `CandleInterval`, + `DomainCandleSeries` / `UiCandleSeries`, ported `Helpers/ChartHelper.cs`, `ApiFinnhubCandleDto`). The + Finnhub candle endpoint is **premium-gated**, so on the Finnhub path real charts need a paid key (free + key → "requires paid plan"; the mock provider draws synthetic candles for offline preview). **Update: + the Twelve Data provider (now primary) serves candles on the FREE tier**, so a free Twelve Data key + renders real charts — the premium gate now applies only to the Finnhub fallback path. The + **range-switch flicker is fixed**; the + **Enter-steals-focus bug is UNSOLVED** and left as a documented known limitation (two fixes tried and + abandoned — see the "Symbol detail + live chart" section). +- **Done (this round):** **live price polling** — prices now auto-refresh on a timer while a priced + surface is visible (Markets Watchlist / Favorites + the Favorites dock band). Realized via + `Helpers/PollTicker.cs` (a singleton ticker — originally a `StateFlow` on the `OnActive`/`OnInactive` + subscriber-count seam, **now pure Rx** `Observable.Generate(...).Publish().RefCount()`), and per-surface + **silent** `PollRefresh()` methods that re-price in place with **no spinner/flicker** (cache not + cleared, prices swap when the fetch lands). Honors the existing interval setting (default 10 min, + `0` = off, applied without reload). A **keep-last-good guard** in `PricedListPage.LoadQuotes(silent)` + stops a transient bad poll (e.g. a 429 mapped to an invalid quote) from blanking a price that was + fine. The chart now live-refreshes its visible range on the same ticker (see below). See "Live price polling (done)". +- **Done (this round): FX provider (keyless Frankfurter)** — forex is now live behind the existing + `IMarketDataProvider` seam with **zero UI/repository changes**. `Data/Frankfurter/FrankfurterMarketDataProvider.cs` + (`Supports(Currency)`) wraps the ECB daily reference rates via Frankfurter (`api.frankfurter.dev`, **no + key**): it splits a 6-letter pair, uses one time-series endpoint for both quotes (latest vs previous + fixing → **day-over-day** change) and candles (daily closes). `InstrumentCatalog` re-adds `EURUSD` / + `GBPUSD` / `USDJPY`; the provider is registered alongside Finnhub in `MarketExtensionCommandsProvider`; + `AssetIconResolver` now shows the base currency's **country flag** (EUR→`eu`). FX "search" is a local + filter over the catalog's pairs (Frankfurter has no symbol endpoint). Verified end-to-end against the + live API. See "Frankfurter specifics (FX)". (Caveats: ECB is **daily-only** — no intraday FX bars; FX + pairs reach existing installs via the catalog seed / local search, since Finnhub `/search` is US-equity.) +- **Done (this round): symbol-detail chart live refresh** — the open chart now re-fetches its **visible + range** on each `PollTicker` tick (not just one fetch per tab tap), sharing the same ticker + + `Publish().RefCount()` lifecycle as the priced list pages and dock. `SymbolDetailPage` subscribes the chart in its + visible-lifecycle hook (`replayOnSubscribe:false`, so opening the page doesn't double-fetch — `Start()` + already did the first load) and disposes it on hide with the rest. `SymbolChartForm.PollRefresh()` + re-prices in place: **silent** (no "Loading…" write, so no flicker), **bypasses the per-range cache** so + the on-screen range actually refreshes (then refreshes that cache entry), **reuses the generation guard** + so a concurrent tab tap still wins, and **keeps the last good chart** if a poll returns empty (the chart's + analog of `PricedListPage`'s keep-last-good). Caveat: on the **Finnhub fallback** path candles are + premium-gated, so a free Finnhub key just re-fetches a 403 each tick; with **Twelve Data** primary its + free-tier candles refresh for real. +- **Done (this round): the Portfolio screen.** **Markets Portfolio** now tracks real holdings (a quantity + per symbol), priced live, with a totals summary on top and **daily P&L** per holding — behind the existing + data seam with **no provider/repository changes**. New: `Settings/PortfolioStore.cs` (modeled on + `WatchlistStore`, `market_portfolio.json`, two flows `Positions`/`Instruments` — the latter + **reference-equality so quantity-only edits re-emit**), `Models/DomainPosition`/`UiPosition`/`UiPortfolio`, + `Pages/SetQuantityPage.cs` (an adaptive-card `Input.Number` editor), and `Commands/PortfolioCommands.cs`. + `PortfolioPage` is a **`PricedListPage` subclass** that reuses all the caching/polling plumbing; a new + `PricedListPage.LeadingRows(...)` hook (default empty, others unaffected) renders the totals row from the + full priced set. Per the user's choices this pass: **daily P&L only** (cost basis stored but no UI), + **no dock band that pass** (the Portfolio dock band shipped later — see its bullet above), and **all + add/edit/remove on the symbol detail page** (rows just open it, like every + other list). Build clean (0 warnings). ✅ **Live-verified**: added a holding, the total rolled up; + edit/remove and persistence work. See "Portfolio screen (DONE…)". +- **Deferred:** richer **per-symbol** error UX — a typed `QuoteStatus` (Ok/Invalid/RateLimited/NoKey) on + `DomainQuote` so each row could render its own state. Deliberately **not** built: the rate-limit signal + shipped this round is global (one throttle flag for the whole key) because a free-tier limit is key-wide, + not per-symbol, and a typed status would ripple through all four providers + `UiQuote` + the keep-last-good + guard for marginal gain. **(Done this round:** 429 **back-off + a "rate-limited" banner** — see the + dedicated bullet above; the keep-last-good guard is no longer the whole story.) + **(Resolved by the Twelve Data provider:** crypto + FX symbol search now works across all three classes + — TD's `/symbol_search` maps `instrument_type` → category and normalizes symbols back to neutral form, + and the repository fans search out to every provider. The original gap was Finnhub-only; it now persists + **only on the keyless fallback path** — without a TD key, Finnhub search is US-equities-only, Frankfurter + covers FX via a local catalog filter, and crypto search is unavailable.) +- **Done (this round): stale-revisit catch-up** — closes the "short visit" gap in live polling. The + priced pages are long-lived singletons whose `_priceCache` survives navigation, so revisiting an + unchanged set repainted cached prices with **no fetch**, while every revisit restarted the poll timer + from a full interval — so visits shorter than the interval never saw a refreshed price (a pinned dock + masked it). Now `PricedListPage` stamps a freshness clock (`_lastFullPriceTicks`, set on every + whole-set re-price; a partial add doesn't advance it) and, when a fully-cached set becomes visible, + fires **one silent catch-up re-price if the prices have aged ≥ one interval** (`RefreshStaleQuotes`). + Bounded to one fetch per revisit, only when stale, gated on `AutoRefreshEnabled` — liveness without + burning the Finnhub budget. This supersedes the old "poll-timer debounce" deferred item. +- **Done (this round): asset logos as row/dock icons** — every instrument surface (Markets Search results, + Watchlist, Favorites, the Favorites dock band, and the `SymbolDetailPage` header) now shows the + instrument's **real logo** instead of the generic favicon. Logos come from **Elbstream's keyless CDN** + addressed directly by symbol (`Helpers/AssetIconResolver.cs`) — **zero API calls / no cache / no DTO** + (the host fetches the URL). Elbstream is free **with attribution**, so a "Logos provided by Elbstream" + credit (clickable via the new `Commands/OpenUrlCommand.cs` + `ProcessHelper.OpenUrl`) appears on every + logo-bearing page; the dock band is an accepted gray area. Per-category Segoe glyph fallback for + Currency/unknown. See "Asset logos (done)". +- **Wishlist — original three DONE.** ~~**(1) multi-currency portfolio conversion**~~ — **DONE** (see + "Multi-currency portfolio conversion (done)"). ~~**(2) the Portfolio dock band**~~ — **DONE** (a one-line + total-value + daily-P&L strip next to the favorites band; see the "Done (this round): the Portfolio dock + band" bullet + "Portfolio screen + dock band (done)"). ~~**(3) cost-basis / total-return** reporting~~ — + **DONE** this round (the stored `CostBasis` is now captured in the editor and surfaced as total return on + the rows, totals summary, and dock band; see the "Done (this round): cost-basis / total-return reporting" + bullet above). +- **Done (this round): Demo mode now serves data for ANY symbol (not just the seeded handful).** Previously + `MockMarketDataProvider` only knew the ~10 symbols in its static `Seed` dictionary — any other ticker + returned an `IsValid:false` quote (blank row) and was absent from search, so a watchlist/portfolio of + off-seed symbols looked broken in demo mode. Fixed as **two deliberately-separate halves** (the split + matters — see the design note): + - **Quotes: synthesize for any requested symbol.** `GetQuotesAsync` now falls back to `SynthesizeQuote` + for any non-seeded instrument: a **stable FNV-1a hash** of the symbol (deterministic across refreshes + AND process restarts — unlike per-process-randomized `string.GetHashCode`) drives a category-appropriate + price (stock tens-to-hundreds, crypto a $1–$10k log spread, FX a realistic rate band) and a ±3% daily + move. **Native currency is inferred from the symbol shape** (crypto → USD; FX pair → quote currency via + `CurrencyHelper.QuoteCurrencyOfPair`; stock → exchange suffix, `.L`→GBP/`.DE`→EUR/`.T`→JPY/…, else USD), + with a per-currency magnitude scale so a yen stock reads in the thousands. `Seed` stays as nice-looking + hand-tuned overrides on top. **Candles** now anchor on the same synthesized price (was hardcoded 100) so + the chart agrees with the quote. This is the half that fixes off-seed real holdings looking broken. + - **Search: a curated REAL-ticker corpus, NOT free-text fabrication.** `SearchAsync` now matches over a + new `Catalog` dictionary (~120 genuine instruments — US + international stocks, crypto, FX; prefix-on- + symbol ranked, capped at 25) instead of just the seed. **Design note (why not fabricate search hits):** + a watchlist/portfolio persists the full `DomainInstrument` to JSON, so if demo search invented matches + for arbitrary free-text the user could add a **non-existent ticker** that becomes a permanent blank row + once demo mode is off (no clean migration path). The curated corpus keeps search feeling complete while + guaranteeing it can only ever surface real symbols. Build clean (0 warnings). ✅ **Live-verified** — + in Demo mode an off-seed real ticker (e.g. `GOOGL`, `7203.T`, `DOGE`) is found in search, prices, and + charts after adding. See "Demo mode (offline testing)". +- **Done (this round): migrated the observable layer to Rx.NET (`System.Reactive`).** Two parts: + - **`StateFlow` → thin wrapper over `BehaviorSubject`** (the blocker was gone once AOT/trim went off — + the trim/AOT analyzers were removed, so `System.Reactive` 6.0.1 is taken **warning-free**, CA1001 + suppressed on the process-lifetime singleton). The **public API is preserved** (`Value`, both `Subscribe` + overloads, `Update`, `InstrumentListComparer`), so the **state-holder consumers (`WatchlistStore`, + `MarketSettingsManager`) and every page/dock subscriber did not change** — + verified by a clean build (0 warnings). Mechanics preserved 1:1: `BehaviorSubject` gives value + + replay-on-subscribe + thread-safe fan-out; `replayOnSubscribe:false` → `Skip(1)`; `SetValue` keeps + **source-side** distinct-until-changed (equal value not pushed → unchanged subset doesn't wake + subscribers); handlers fan out **outside** the subject's lock (so the store re-read is deadlock-free). + - **`PollTicker` → pure Rx** (`Observable.Generate(...).Publish().RefCount()`), since it was the one + consumer that was a *poor* `StateFlow` fit (an event stream abusing `StateFlow` + a counter, only + to borrow the refcount seam). It no longer inherits `StateFlow`; `RefCount()` provides the + WhileSubscribed lifecycle, deleting the `CancellationTokenSource`, the lock, the `OnActive`/`OnInactive` + overrides, and the CA1001 suppression. The 3 subscribers dropped `, replayOnSubscribe: false` (a pure + event stream has no replay to suppress). Net effect: with no consumer left, `StateFlow`'s entire + subscriber-count seam (`OnActive`/`OnInactive` + the hand-rolled refcount + custom `Subscription` + wrapper) was **deleted** — `StateFlow` is now a genuinely thin `BehaviorSubject` wrapper. + - **Wiring:** `System.Reactive` added to `Directory.Packages.props` (CPM) + a `PackageReference` in the + csproj. **This was the swap only** — the payoff (Rx's **operator library**) lands with the deferred work + it unlocks: 429 **back-off** (`Retry`/`RetryWhen` + exponential delay), **debounced** search-as-you-type + (`Throttle` — would let `SearchPage` drop its Enter-only rate-limit guard), and multi-provider **merge** + in `MarketRepository` (`CombineLatest`/`Merge`). **Caveat:** Rx schedulers are mostly moot here (the + CmdPal host already marshals `RaiseItemsChanged`), so this bought **operators, not threading**. ✅ + **Live-verified**: opened a priced page, prices refreshed on the interval; hide/reshow confirmed the + `Publish().RefCount()` loop tears down and restarts. + +### Three-screen UX (done) + +Three top-level commands in `MarketExtensionCommandsProvider.TopLevelCommands()`, backed by **two +independent membership flags** per instrument in `WatchlistStore` — an instrument can be on either +list, both, or neither (so "watchlist" and "favorites" are genuinely separate sets, not subset/superset). +All three command titles share the **`Markets ` prefix** so they group together (and don't pollute the +namespace) when searching the Command Palette root: + +**Enter on any row opens the shared `Pages/SymbolDetailPage.cs`** — the per-symbol detail screen +(`ContentPage`; **now renders a live price chart with range tabs** — see the chart section), which is +**the single place for list management**. Its +command bar carries the add/remove-watchlist (**Enter**) and add/remove-favorite (**Ctrl+Enter**) +actions, labelled for the instrument's current state, and the page **subscribes to the two +`WatchlistStore` flows** so toggling there (the commands `KeepOpen`) flips the buttons + body in place. +List rows therefore carry **no context actions at all** — they only navigate into the detail page. The +screens: + +1. **Markets Search** (`Pages/SearchPage.cs`, default entry) — the Enter-only `/search` flow. On a + result: **Enter** → open detail page (where it's added to the watchlist/favorites). The subtitle + still reflects current membership. Empty box links to the other two screens. +2. **Markets Watchlist** (`Pages/WatchlistPage.cs`) — the tracked instruments, priced and grouped by + class; a ★ marks favorites. **Enter** → open detail page. +3. **Markets Favorites** (`Pages/FavoritesPage.cs`) — the curated subset; **only favorites render in + the dock band** (`FavoritesDockPage` subscribes to `WatchlistStore.Favorites`). **Enter** → open + detail page. Each **dock button also opens the detail page** when clicked. A pinned band now + updates **immediately** when favorites change anywhere (it subscribes to the flow while visible), not + just on reopen. + +Watchlist + Favorites share `Pages/PricedListPage.cs`, which **subscribes to its `StateFlow`** in the +`INotifyItemsChanged` add/remove lifecycle (StateFlow's replay-on-subscribe drives the first price load; +later membership pushes trigger a **local reconcile** — drop departed rows with no network call, fetch +only newly-added symbols) + local filter. A page can also observe secondary flows for re-render only via +`RelistTriggers` (Watchlist uses it to keep its ★ current when favorites change). The catalog seeds the +watchlist **once** on first run; thereafter every row is user-removable. The membership actions live in +`Commands/MembershipCommands.cs` +(`AddToWatchlist`/`RemoveFromWatchlist`/`AddToFavorites`/`RemoveFromFavorites`/`ToggleFavorite`) — each +just **mutates the store** (no manual UI callback: the flows re-render every live surface) and +**shows a confirmation toast** (e.g. "Added AAPL to watchlist") while **keeping the palette open** so +the user gets explicit feedback and can keep editing. + +### Live price polling (done) + +Priced surfaces used to fetch **once** when they became visible (the StateFlow replay-on-subscribe that +drives the first load). They now also auto-refresh on a timer while visible: **default 10 min, settings- +configurable** (0 = off). Built on the ticker's subscriber-count lifecycle (now Rx `Publish().RefCount()`). + +- **Where (in scope):** `Pages/PricedListPage.cs` (Markets Watchlist + Markets Favorites), + `Pages/FavoritesDockPage.cs`, and **the `SymbolDetailPage` chart** (its visible range re-fetches on each + tick via `SymbolChartForm.PollRefresh()` — see the chart section). `SearchPage` is exempt (identity-only + results, no prices). +- **The ticker = `Helpers/PollTicker.cs`** — **pure Rx** (originally `PollTicker : StateFlow`; the Rx + migration converted it). A process-wide singleton `IObservable` = `Observable.Generate(...)` + (a self-rescheduling timer that emits a tick after each per-step delay) wrapped in **`Publish().RefCount()`**. + `RefCount()` IS the OnActive/OnInactive seam: the Generate loop **starts on the first subscriber** (0→1) + and is **torn down on the last unsubscribe** (1→0), so polling runs only while something is watching. + Generate's `condition` is always true so the source **never completes** — teardown is therefore a silent + disposal (no OnCompleted/OnError reaches the shared subject), and a later resubscribe restarts cleanly + (so a rapid inactive→active bounce just reconnects; no two-loops hazard, no manual CTS/lock). The + `timeSelector` re-reads `MarketSettingsManager` each iteration (interval/on-off applies without a reload); + when off it idles on a 30 s re-check and a `.Where` drops the tick, so toggling it back on resumes. (The + old design used `StateFlow` + a tick counter because re-emitting the membership list would be + swallowed by distinct-until-changed; the Rx version sidesteps that entirely — a tick is a proper event, + not deduped state.) +- **No double-fetch on open.** The ticker is a **pure event stream** (not a `BehaviorSubject`), so there is + no replay to suppress: surfaces just `Subscribe(_ => PollRefresh())` and opening a page doesn't fire an + extra fetch (the membership flow's replay already paints the initial prices); the ticker only drives + *subsequent* refreshes. +- **Silent, flicker-free refresh.** Each surface's `PollRefresh()` re-prices **without** clearing its + cache or setting `IsLoading`, so the on-screen prices stay put and are swapped in place once the new + quotes land (contrast the manual "Refresh 🔄" row, which still clears + spins). `PricedListPage` + reuses its existing `LoadQuotes` + `_fetchGeneration` guard (so a membership change still wins); + `FavoritesDockPage` just re-runs `LoadQuotes` (which assigns `_quotes` without a pre-clear). +- **Keep-last-good guard.** `PricedListPage.LoadQuotes(…, silent: true)` won't overwrite a **valid** + cached `UiQuote` with an **invalid** poll result, so a transient bad poll (e.g. a 429 mapped to an + invalid quote) degrades gracefully instead of blanking a price that was fine. This is a first step, not + the full 429 story (back-off + an explicit "rate-limited" state remain deferred). +- **Shared-source refcount comes free.** `FavoritesPage` (via `PricedListPage`) and `FavoritesDockPage` + both subscribe to the **one** `PollTicker.Instance`: its refcount keeps the timer alive while *any* + priced surface is visible and stops it only when *all* are gone — a pinned dock keeps it warm. +- **Stale-revisit catch-up (the timer-reset fix).** Because the priced pages are long-lived singletons + whose `_priceCache` survives navigation, revisiting an unchanged set repaints cached prices with **no + fetch**, and each revisit restarts the `PollTicker` countdown from a full interval (it resets on the + 0→1 subscriber transition) — so visits shorter than the interval never refreshed (a pinned dock masked + it, since it holds the subscriber count above 0). `PricedListPage` now stamps `_lastFullPriceTicks` on + every whole-set re-price (a partial add of new symbols does **not** advance it, so the older rows keep + aging) and, when a fully-cached set becomes visible, `RefreshStaleQuotes()` fires **one silent catch-up + re-price if those prices have aged ≥ one `RefreshInterval`**. One fetch per revisit, only when stale, + gated on `AutoRefreshEnabled` (so 0 = off stays off) — maximal liveness without extra API calls. The + `FavoritesDockPage` doesn't need this: it refetches on every show (its favorites-flow replay calls + `RefreshQuotes`, which clears `_quotes`). +- **Settings.** `Settings/MarketSettingsManager.cs` (`JsonSettingsManager` singleton, + `…/market.settings.json`, wired via `Settings = MarketSettingsManager.Instance.Settings;`). The refresh + interval is a numeric `TextSetting` **in minutes, default 10** (0 = off), surfaced as `RefreshMinutes` / + `RefreshInterval` (TimeSpan) / `AutoRefreshEnabled`; the ticker reads these each tick. A + **`ToggleSetting` `Show rate-limit warnings`** (default on, surfaced as `ShowRateLimitErrors`) gates the + rate-limited banner — read pull-style by `RateLimitHint.Row()`, so toggling it applies on the next + re-render. A **`ToggleSetting` `Demo mode`** (default off, surfaced as `DemoMode`) switches the app to + built-in sample data with no key/network — see "Demo mode (offline testing)". (Same manager also holds the + API keys + the `PortfolioCurrency` choice — see the "API Key" section.) +- ⚠️ **Rate-limit tension (still real):** Finnhub free tier is **~60 calls/min AND ~300/day**, and + `GetQuotesAsync` issues **one `/quote` call per instrument**. Polling N instruments at interval T burns + the budget fast at a low T — e.g. 6 favorites every 60 s = 360 calls/hour, **exhausting ~300/day in + under an hour**. The **10-min default is gentler** (6 favorites = ~36 calls/hour). In place today: the + configurable interval + **Off** (0) escape, polling **only while visible**, the keep-last-good guard, + and now **429 back-off + a "rate-limited" banner** (`HttpRetry` / `RateLimitSignal` / `RateLimitHint` — + see the "Done (this round)" bullet). Note the back-off intentionally **bails fast** rather than retrying + hard, since these are per-minute windows; a batched-quote provider (Twelve Data already does this) is the + real relief. ⚠️ Note: each visible surface polls independently, so a pinned favorites + dock **and** an open favorites page both fetch favorites each tick (no shared-fetch dedup). This is an + **accepted trade-off, not a todo** — at most three priced surfaces exist (Watchlist / Favorites page / + Favorites dock) and the only real overlap is dock + Favorites page on one small set, so the worst case + is ~2–3× on a handful of symbols. A repository-level in-flight coalescing cache would close it cheaply + if it ever matters, but it's deliberately **not** planned. + +### Dock refresh on favorites change (done) + +A pinned `FavoritesDockPage` updates the instant a favorite changes anywhere, instead of going stale +until reopened. Implemented via the observable layer: + +- `WatchlistStore.Favorites` is a `StateFlow`; every favorite flip re-publishes it (`PublishState()`), + deduped by `InstrumentListComparer` so a watchlist-only change doesn't wake favorites subscribers. +- `FavoritesDockPage` **subscribes in its `add` accessor and disposes the `IDisposable` in `remove`** + (the Loaded/Unloaded lifecycle), calling `RefreshQuotes()` on each emission. Subscribing only while + visible keeps a hidden band from doing work and avoids leaks. +- Complementary to the (still-pending) polling: polling catches *price* drift on a timer; this pushes + *membership* changes immediately, and still works when polling is **Off**. + +### Portfolio screen + dock band (done) + +**Markets Portfolio** is live: a fourth screen off the Markets hub tracking actual *holdings* (a quantity +per symbol), priced live, with a **totals summary** pinned on top and **daily P&L** per holding. Reuses +the existing data seam with **no provider/repository changes** — `MarketRepository.GetQuotesAsync` already +returns per-instrument daily change. + +**Scope shipped:** quantity + **daily P&L** + **total return** (unrealized P&L vs. cost basis). The optional +per-unit `CostBasis` on `DomainPosition`/`PortfolioItem` is now captured in the editor and surfaced — see +"cost-basis / total-return" below; a holding with no basis simply omits the total-return figure. + +**Data model** (the usual `Api`/`Domain`/`Ui` layering; formatting only in `Ui*`): +- `Settings/PortfolioStore.cs` — JSON-persisted holdings, modeled on `WatchlistStore` (singleton, lock, + source-gen `PortfolioJsonContext` → `market_portfolio.json`). Each entry = symbol + name + category + + **quantity** (`decimal`, so crypto fractions work) + optional cost basis. **No first-run seed** (empty + by default). Exposes two `StateFlow`s: `Positions` (instrument + quantity — for rendering, the totals, + and the detail-page command bar) and `Instruments` (symbols to price — for the `PricedListPage`). Both + use **default reference equality, NOT `InstrumentListComparer`**, so EVERY mutation re-emits — including + a quantity-only edit that leaves the symbol set unchanged. That's what makes an edited quantity repaint: + the base reconcile finds nothing missing → no fetch → the re-render just reads the new quantity. +- `Models/DomainPosition.cs` — `DomainInstrument` + quantity + optional cost basis, **no prices**. +- `Models/UiPosition.cs` + `UiPortfolio.cs` — presentation: `UiPosition` combines a `DomainQuote` with the + quantity (and optional cost basis) → `MarketValue` (qty × price), `DailyPnL` (qty × `DomainQuote.Change`), + `TotalCost`/`TotalReturn`/`TotalReturnPercent` (qty × basis; unrealized P&L; `(price−basis)/basis`), + `Format*()` helpers (the only place position formatting lives, like `UiQuote`); `UiPortfolio.From(...)` + rolls up the value/daily-P&L totals plus the cost + total-return rollup over holdings that have a basis. + +**Daily P&L:** per position = `Quantity × DomainQuote.Change`. Total value = Σ market value; total daily +P&L = Σ daily P&L; aggregate **percent = `totalDailyPnL / (totalValue − totalDailyPnL)`** (vs. yesterday's +close) — not an average of the per-position percents (guards ÷0). `IsValid:false` holdings are excluded +from the totals (shown as unpriced rows). + +**Total return (cost basis):** per position = `MarketValue − Quantity × CostBasis` (the converted form when +the holding's currency differs from `PortfolioCurrency`); **percent = `(price − basis)/basis`**. Surfaced +only when a **positive** basis was recorded. The portfolio rollup sums **converted** cost + return across the +counted holdings that have a basis (aggregate **percent = `totalReturn / totalCost`**, ÷0-guarded); a holding +without a basis contributes to value/daily-P&L but not to the return rollup. Cost basis is stored **per unit +in the holding's native currency**, so it converts with the same `RateToPreferred` as the value. + +**Screen** (`Pages/PortfolioPage.cs`): a **`PricedListPage` subclass** (like Watchlist/Favorites), so it +inherits all the caching/polling/reconcile/keep-last-good plumbing — it just observes +`PortfolioStore.Instruments`. Per-row quantity is read from the store in `BuildRow` (the way `WatchlistPage` +reads `IsFavorite`). The **totals summary** is the first row, rendered via a new +`PricedListPage.LeadingRows(pricedQuotes)` **hook** (default empty → Watchlist/Favorites unaffected), fed +the **full unfiltered** priced set so the total reflects the whole portfolio even while search filters the +rows. Rows render "AAPL · 10 sh" → market value + daily P&L. + +**Management lives on the symbol detail page** (consistent with every other list — Portfolio rows just open +`SymbolDetailPage` on Enter, **no context actions**). `SymbolDetailPage.BuildCommands` gained portfolio +actions in the overflow: **Add to Portfolio** / **Edit holding** (both open `Pages/SetQuantityPage.cs`, +which auto-labels itself by current membership) plus **Remove from Portfolio** +(`Commands/PortfolioCommands.cs`) once held. The page also subscribes to `PortfolioStore.Positions`, so the +bar flips Add→Edit/Remove the instant a quantity is saved. + +**`Pages/SetQuantityPage.cs`** — the holding editor: a `ContentPage` whose single `FormContent` is an +adaptive card with a **quantity** `Input.Number` (required, > 0) and an optional **average-cost-per-unit** +`Input.Number` (the cost basis), both prefilled from the current holding (0 when adding). `SubmitForm` +validates quantity > 0 and maps a **0/blank cost basis → null** (cleared → total return hidden), then calls +`PortfolioStore.SetPosition(instrument, quantity, costBasis)` (which now applies the basis verbatim), toasts, +and `GoBack()`s to the detail page. The single-`FormContent` auto-focus quirk (that plagues the chart) is +**harmless/helpful here** — it drops the cursor straight into the quantity field. + +**The dock band — DONE** (`Pages/PortfolioDockPage.cs`): a second band in `GetDockBands()` next to favorites, +rendering **one summary button** — total value + daily P&L rolled up into the `PortfolioCurrency` (the same +totals `UiPortfolio` the screen pins on top); clicking it opens the full `PortfolioPage`. A `ListPage` modeled +on `FavoritesDockPage` with its own non-empty `Command.Id` (`com.costafotiadis.market.dock.portfolio` — see +`reference/dock-support.md`), registered as a second `CommandItem` in `MarketExtensionCommandsProvider.GetDockBands()`. +It inherits the favorites band's live-refresh story: the `INotifyItemsChanged` visible-lifecycle hook +subscribes to `PortfolioStore.Positions` (replay paints on open; add/edit/remove — incl. quantity-only edits, +since that flow is reference-equality — repaint at once) **and** `PollTicker` (silent live re-price), disposing +both on hide. Unlike the screen (which primes FX off the price-load path and paints native-only first), the +band **`await`s `CurrencyConverter.PrimeAsync`** inside its async load so the converted total is ready in a +single paint — a one-row band has no progressive-paint benefit. Same keep-last-good guard so a transient bad +poll doesn't drop a holding and make the total jump. Like the favorites band it **contributes to the +rate-limit signal but renders no banner** (too cramped). No provider/repository/model changes. +⚠️ **Icon gotcha:** the summary row uses the Segoe MDL2 Bank glyph (U+E825); the Write tool drops literal +private-use glyph chars (blank icon), so byte-check after editing (real glyph = `EE A0 A5`) or use a C# +Unicode escape. ✅ Live-verified: band shows the total + P&L + icon. + +**Cost-basis / total-return — DONE (this round).** The optional per-unit `CostBasis` is now captured in the +holding editor (`SetQuantityPage`) and surfaced as **total return** (unrealized P&L since purchase) on the +per-row subtitle ("Total ▲ +$… (+…%)"), the totals summary row, and the Portfolio dock band — via +`UiPosition.TotalCost/TotalReturn/TotalReturnPercent` and the `UiPortfolio` rollup (`HasCostBasis` + +`FormatTotalReturnNote()`). Multi-currency aware: the native basis converts into `PortfolioCurrency` with the +same FX rate as the value, so per-row returns sum to the total. `PortfolioStore.SetPosition` applies the +basis verbatim (null clears) and `GetPosition` returns the full holding for rendering. Holdings without a +recorded basis (or unpriced/unconvertible ones) contribute to value/daily-P&L but not the return rollup. ✅ +**Live-verified** via **Demo mode** (the GBP `HSBA.L` holding with a cost basis shows the converted total +return on the per-row subtitle, the totals summary, and the dock band). + +### Multi-currency portfolio conversion (done) + +**The `$`-everywhere assumption is gone.** Holdings priced in any currency are now valued in **both** their +native currency **and** the user's `PortfolioCurrency`, and the portfolio total is rolled up in the preferred +currency. Built behind the existing data seam with **no repository changes** — only `DomainQuote` gained a +field and `PortfolioPage` gained an FX-priming step. + +**1 — Capture native currency (static metadata, resolved once per provider seam).** `DomainQuote` carries an +ISO-4217 **`Currency`** (default `"USD"`, so every existing construction stays correct). Providers stamp it +when mapping their `Api*` DTO; the **`Helpers/CurrencyHelper.cs`** rules are shared so each provider does it +identically: +- **Twelve Data (primary):** added `currency` to `ApiTwelveDataQuoteDto` — it rides in the batched `/quote` + we already fetch (**zero extra calls**). Mapped per category: stock → the field (normalized), crypto → USD, + FX → the pair's quote currency. +- **Finnhub (fallback):** `/quote` has no currency, so stocks resolve via **`/stock/profile2?symbol=`**, + cached **per-symbol forever** (`StockCurrencyCache`) — one extra call per *new* stock symbol per session + (US tickers just confirm USD); crypto short-circuits to USD with no call; FX isn't served here. The USD + fallback on a failed profile2 is **cached too**, so a flaky profile2 can't double Finnhub's poll volume. +- **Frankfurter (FX):** stamps the pair's **quote currency** (`EURUSD → USD`, `USDJPY → JPY`). +- ⚠️ **GBX/pence trap handled:** `CurrencyHelper.NormalizeStockQuote` folds London's `GBp`/`GBX` (pence) to + **GBP**, dividing price *and* change by 100, so UK holdings aren't 100× too large. The Domain layer only + ever sees major-unit prices in a major-unit code. + +**2 — Convert via `Helpers/CurrencyConverter.cs`** (keyless Frankfurter `/latest`, the same ECB source as the +FX provider). Because the screen renders synchronously but FX is a fetch, it's two-phase: `PrimeAsync` fetches +every not-yet-fresh `native→preferred` rate in **one batched call** (cached per pair, ~1 h TTL, negatives +cached too); `TryGetRate` is the synchronous cache read used while building rows (`from==to → 1`, unknown → +`null`). An all-USD portfolio with USD preferred does **zero** network (rate 1 short-circuit). + +**3 — Display (`UiPosition`/`UiPortfolio`, the only place the formatting lives).** `UiPosition` takes the +preferred currency + the `native→preferred` rate and exposes native value/P&L **and** their converted forms; +a row shows e.g. `£75.00 (≈$98.70)` and its P&L in the converted currency (so per-row P&L sums to the total). +`UiPortfolio.From(positions, preferred)` sums the **converted** values and formats the total via +`CurrencyFormat` with the preferred currency's symbol. `Helpers/CurrencyFormat.cs` owns the per-currency +symbol/decimals (replacing the hardcoded `$#,##0.00`). + +**Wiring (`PortfolioPage` + one base hook).** Rates are primed off the price-load path: `PricedListPage` gained +a `protected virtual void OnPriceCacheUpdated()` hook (fired whenever the price cache changes; default no-op, +so Watchlist/Favorites are unaffected) + a `SnapshotPricedQuotes()` accessor. `PortfolioPage` overrides the +hook to `PrimeAsync` the current currencies then re-render, and reads `TryGetRate` synchronously in +`LeadingRows`/`BuildRow`. First paint shows native-only, then swaps in converted values when the rates land +(progressive, no flicker). Beyond the portfolio, `UiQuote.FormatPrice` is now currency-aware too, so a London +stock shows `£2.50` on the Watchlist/dock instead of `$2.50`. + +**Excluded from the total** (and surfaced as "*N* not converted" on the summary): a holding whose native +currency the ECB set can't convert. `IsValid:false` (unpriced) holdings remain excluded as before. + +**Caveats / not done.** Reporting currency is cached **in-memory per session** (Finnhub `StockCurrencyCache`), +not persisted with the instrument — a reload re-resolves it (one profile2 call per stock). Changing the +`PortfolioCurrency` setting applies on the **next price refresh/revisit** (pull-style, like the other +settings), not instantly. ✅ **Live-verified** end-to-end: a USD holding (10 sh SPY) with **GBP** preferred +rolled up to ~£5,571 — i.e. the USD value × ~0.76, a genuine conversion, not a relabel. Still worth a spot +check: the **GBX/pence** path (a London `.L` stock → ÷100 → GBP) and a **non-USD native** holding priced by +Finnhub via profile2. Build clean (0 warnings). + +### Demo mode (offline testing) (done) + +A **`Demo mode` toggle** in Settings (`MarketSettingsManager`, key `demoMode`, default **off**, exposed as +`DemoMode`) puts the whole app on **built-in sample data with no API key and no network** — for demoing the +extension or testing UI changes without spending real-API budget. It exercises every path: quotes, candles, +search, multi-currency conversion, and total return. + +**How it routes (no reload needed).** The mock takes precedence **everywhere** via a general "exclusive +provider" seam, not a separate build path: +- `IMarketDataProvider` gained a default member **`bool IsExclusive => false`**. `MockMarketDataProvider` + returns `MarketSettingsManager.Instance.DemoMode`. `MarketRepository.ActiveProviders()` returns **only the + exclusive providers when any exist**, else the full set — and quotes, candles, **and the search fan-out** + all route through that active set. So in demo mode the mock is the *sole* source for **every** operation; no + live provider is consulted at all (this closes the old search-leak that `Supports()` couldn't gate, since + search fans out to all providers). +- `MockMarketDataProvider` is also registered **first** with `Supports(category)` gated on `DemoMode` (so when + demo is off it's neither exclusive nor selectable → the real providers serve unchanged), and `SearchAsync` + still self-gates to `[]` when off (off → it's back in the normal search fan-out, so it must stay quiet). +- `CurrencyConverter.PrimeAsync` checks the same setting and, when on, fills its rate cache from a **static + `DemoUsdPerUnit` table** (USD value per unit → native/preferred ratio) instead of calling Frankfurter + `/latest`. So even cross-currency portfolio totals/returns are fully offline; `TryGetRate` reads the cache + exactly as for live rates. +- `ApiKeyHint` is now a unified **`StatusRow()`** (demo on → a blue "Demo mode — showing sample data" row so + it's obvious prices are simulated; else no key → the red "No API key set" nudge; else null). Both rows' + Enter opens Settings (to turn demo off, or add a key). The hub, Search, Watchlist and Favorites append it. + +Flipping the toggle applies **immediately** across the app — unlike `PortfolioCurrency` / +`ShowRateLimitErrors`, which stay pull-style. `MarketSettingsManager` publishes an observable +**`DemoModeChanged`** (`StateFlow`) from its `SettingsChanged` handler, and every surface subscribes +and resets at once: the priced pages drop their price cache (a **lifetime** subscription, so even a hidden +singleton page invalidates — re-fetching if visible, else on next open), the dock bands re-price, +`CurrencyConverter` clears its rate cache (next prime refills from the static demo table vs live ECB), and +the hub/Search re-list so the status row swaps. No reload, no process restart. The **rate-limited banner is +also suppressed in demo mode**: `RateLimitHint.Row()` guards on `DemoMode`, and `RateLimitSignal` clears its +flag when demo turns on (offline, nothing would otherwise clear a 429 set before demo mode). The +quote/candle/search **routing** itself is still read pull-style per request (`MockMarketDataProvider.Supports` +/ `IsExclusive` and `CurrencyConverter` check `DemoMode` each call) — the `DemoModeChanged` broadcast is what +makes the already-rendered surfaces re-fetch through that routing the instant the toggle flips. + +**Coverage — any symbol** (`MockMarketDataProvider`). Two sources back demo data: +- **`Seed`** — hand-tuned, nice-looking prices for the headline symbols: AAPL/MSFT/NVDA (USD), **HSBA.L (GBP, + a London stock)**, BTC/ETH/SOL (USD), EURUSD/GBPUSD/USDJPY. Used as quote *overrides*; the GBP stock gives + multi-currency conversion + total return a non-USD holding to exercise. +- **`SynthesizeQuote`** — for any **non-seeded** symbol, a stable-hash quote (deterministic across refreshes + and process restarts) with native **currency inferred from the symbol shape** (crypto→USD; FX pair→quote + currency; stock→exchange suffix `.L`→GBP/`.DE`→EUR/`.T`→JPY/… else USD) and a category-appropriate price + magnitude. So an off-seed *real* holding added while live still prices in demo mode instead of showing a + blank row. Candles anchor on the same price (seed or synthesized) — a deterministic per-`(symbol,range)` + random walk. + +**Search** matches a curated **`Catalog`** of ~120 *real* instruments (US + international stocks, crypto, FX; +prefix-on-symbol ranked, capped at 25) — each typed with its real category. It deliberately does **NOT** +fabricate matches for free-text: a watchlist/portfolio persists the full `DomainInstrument` to JSON, so a +faked symbol would become a permanent blank row once demo mode is off. Curated corpus = search feels complete +but can only ever surface genuine tickers. + +**Network in demo mode: none.** Quotes, candles, search, and FX conversion are *all* served from the mock / +the static rate table — the `IsExclusive` routing means even a user with live API keys set hits **zero** +network for market data while demo mode is on. (Logos are still fetched by the host from Elbstream's CDN — +that's the host rendering an `IconInfo` URL, not an app data call, and is unaffected.) ✅ **Live-verified**: +toggling demo mode swaps the whole app to sample data, the mock wins everywhere even with live keys set, and +the blue "Demo mode" status row makes it obvious. + +### Symbol detail + live chart — for ANY instrument (CHART DONE; flicker fixed, Enter/focus bug open) + +**App-wide, not portfolio-specific.** The shared `Pages/SymbolDetailPage.cs` opens from any row (Enter) +and every dock-button click, and **now renders a real price chart with Robinhood-style range tabs +(1D / 1W / 1M / 1Y / 5Y)**. + +**What was built (and how it differs from the original plan):** the original sketch sampled the live +price into a rolling buffer (Perf Monitor's model). That can't produce *retrospective* 1W/1Y/5Y history, +so we instead fetch **real candle history** from Finnhub's premium `/stock/candle` (+ `/crypto/candle`) +through a new provider seam — see the Finnhub candle spec + the candle layer files above. Concretely: +- `ChartRange` + `CandleInterval` (neutral), `DomainCandleSeries`/`CandlePoint`, `UiCandleSeries`, + `Helpers/ChartHelper.cs` (ported SVG sparkline), `ApiFinnhubCandleDto`, and + `IMarketDataProvider.GetCandlesAsync` → `MarketRepository.GetCandlesAsync` → + `FinnhubMarketDataProvider.GetCandlesAsync` (+ `MockMarketDataProvider` synthetic series). +- The page body is a nested `SymbolChartForm : FormContent`: an adaptive card binding the SVG chart as + an `Image`, with the 5 range tabs as `Action.Submit` buttons → `SubmitForm` switches range, fetches + (per-range in-memory cache + a generation guard against fast taps), and updates `DataJson` to repaint. + The first fetch fires from the page's "became visible" hook (the `INotifyItemsChanged.add`), so list + rows building a `SymbolDetailPage` per item never trigger a fetch. Header price/%change reflect the + **selected range** (last vs. first close), Robinhood-style — derived from the series, no extra `/quote`. +- **Gating:** this premium limit is **Finnhub-only**. With **Twelve Data** primary (its `/time_series` + candles are free-tier), a free Twelve Data key renders real charts; only the **Finnhub fallback** path + 403s on a free key → the card shows "requires a paid Finnhub plan". To preview rendering with no key at + all, turn on **Demo mode** in Settings (the `DemoMode` toggle → `MockMarketDataProvider` draws synthetic + candles; see "Demo mode (offline testing)"). + +**State of the two UI issues: flicker FIXED; the Enter/focus bug is UNSOLVED (left as-is, documented).** + +1. **Flicker on range switch — ✅ FIXED.** Each `DataJson` write rebuilds the whole card + (`ContentFormViewModel.RenderCard` → `AdaptiveCard.FromJsonString`; `ContentFormControl.DisplayCard` + clears + re-adds children), and `Load()` used to write `DataJson` **twice** per tap (a "Loading…" + state, then the result) → a double teardown/rebuild flash. **Fix:** `Load()` now only paints the + "Loading…" card when **no chart is on screen yet** (the very first load); on later range switches it + leaves the prior chart up and swaps it in place once the fetch lands. Tracked via `_displaySeries` + (the currently-painted series; a real chart present ⇒ skip the loading write). Cached ranges already + single-write. + +2. **Enter activates "1D" instead of the primary command — ❌ UNSOLVED.** Root cause: with a single + `FormContent`, CmdPal sets `OnlyControlOnPage = true` (`ContentPageViewModel`, `= (content.Count == 1)`) + and **programmatically focuses the card's first focusable element** on load (`ContentFormControl. + OnFrameworkElementLoaded` → `FindFirstFocusableElement().Focus()`) — the 1D `Action.Submit`. So Enter + hits that tab, and with focus trapped in the card **Ctrl+Enter can't reach the secondary command + either**. There is **no host API to suppress the auto-focus** — content count is the only documented + lever. **Two fixes were tried and abandoned:** + - **Option A — return a 2nd content item** (a hint-caption `MarkdownContent`) so `content.Count == 2` + → `OnlyControlOnPage` should be false → focus should stay in the search box. The CmdPal **source + reads this way**, but **in practice it did NOT help**: focus still landed on the card's first action + (Enter still activated a tab) **and Ctrl+Enter stopped working too** — strictly worse. Why the + observed behavior diverges from the source reading is unexplained (a lead for future investigation; + not chased now). Reverted. + - **Option C — lead the card with membership `Action.Submit` buttons** so the auto-focused first + element is the watchlist toggle (Enter then add/removes). It worked but was rejected as too hacky and + it splits list management away from the command bar (inconsistent with every other surface). + **Current state:** back to the post-flicker-fix single-content card; membership stays on the command + bar; the focus bug is **accepted as a known limitation**. Possible real fixes if revisited: a host API + to set/suppress the focused element (doesn't exist today — would need an upstream CmdPal change), or + moving range switching onto the command bar so the card has no focusable element at all. + +For reference, the Perf Monitor source this chart was ported from +(`C:\Users\jarla\code\PowerToys\src\modules\cmdpal\ext\Microsoft.CmdPal.Ext.PerformanceMonitor\`): + +**How Performance Monitor does it (the pattern to copy):** +- A band button's `Command` is itself an **`IContentPage`** (`WidgetPage : OnLoadContentPage`), so + clicking the button **navigates into** the page. Same trick works for a list row — point the row's + command at the content page. +- The page renders an **Adaptive Card** via a `FormContent` (`TemplateJson` = an adaptive-card template + + `DataJson` = bound values); `GetContent()` returns `[formContent]`. +- **The chart is an inline SVG embedded as a data URI** — `ChartHelper.CreateImageUrl(values, type)` + returns `"data:image/svg+xml;utf8," + …`, where the `` is just two ``s (the + line + a gradient-fill loop) and a border ``, built with `System.Xml.Linq`. **No + System.Drawing/SkiaSharp** → a dependency-free, reflection-free SVG (now just good hygiene, not a hard + requirement since AOT/trim is off — see the AOT/trim note). The card + template binds that data-URI as an `Image`. `MaxChartValues = 34` is a rolling window of the last 34 samples. +- Live update reuses the same Loaded/Unloaded lifecycle as our pages: `OnLoadBasePage` turns the + `ItemsChanged` add/remove into `Loaded()`/`Unloaded()`; each data tick re-renders `DataJson` and calls + `RaiseItemsChanged()` so the card repaints. A `PushActivate`/`PopActivate` refcount keeps the source + alive while either the band button or the open chart needs it. + +**Where to look (exact files):** +- `…/PerformanceMonitor/PerformanceWidgetsPage.cs` — `WidgetPage : OnLoadContentPage`, `GetContent()` → + `FormContent`; `LoadContentData()` sets `cpuGraphUrl = CreateCPUImageUrl()`; the `Updated → + RaiseItemsChanged` repaint; the `PushActivate`/`PopActivate` refcount. +- `…/PerformanceMonitor/OnLoadStaticPage.cs` — `OnLoadContentPage` / `OnLoadBasePage`: the `ItemsChanged` + add/remove ⇒ `Loaded()`/`Unloaded()` (the generalized form of our on-load hook). +- `…/PerformanceMonitor/DevHome/Helpers/ChartHelper.cs` — **the file to port**: `CreateImageUrl` → + `CreateChart(List, type)` building the SVG polyline/rect; `ChartHeight`/`ChartWidth`, `MaxChartValues`. +- `…/PerformanceMonitor/DevHome/Helpers/CPUStats.cs` — `CreateCPUImageUrl()` + the `CpuChartValues` + rolling `List` buffer (the sampling model). +- `…/PerformanceMonitor/DevHome/Templates/*.json` — the adaptive-card templates that place the + `${…graphUrl}` image; copy the shape. +- MediaControls `…/Pages/DockHeadItem.cs` — the event-driven content-band variant. + +**Refinements still open (flicker is fixed; the Enter/focus bug above is still open):** +- **Gridlines — ✅ DONE; numeric axis labels NOT POSSIBLE.** The chart draws a faint quarter-gridline box + (the "normal graph" look) so the scale reads at a glance. **Numeric axis ticks were attempted and + abandoned:** the SVG is rasterized by Direct2D's `ID2D1SvgDocument`, which **does not render ``** + (it silently drops it — lines/polylines/rects/gradients are fine), so `` price/time labels never + appeared (verified: gridlines showed, numbers didn't). The standard workaround — moving labels into the + adaptive card around the image (a left price column + a bottom time `ColumnSet`) — was deliberately **not** + pursued (alignment with the stretched image is fragile; deemed not worth the hack). Grid is decorative + only, theme-neutral gray. (This is the same ceiling Perf Monitor's chart hits — it has no labels either.) +- **Active-tab highlight:** the tabs don't visually mark the selected range (adaptive cards can't easily + restyle a button by bound data); the header caption names it instead. +- **Keyboard range switching:** you must Tab into the card to reach the tabs — no arrow-key switch from + the search box. Entangled with the open Enter/focus bug: any real fix there (e.g. moving range + switching onto the command bar, or host support for the focused element) would address this too. +- **Hover/scrub readout is NOT possible** on this surface: CmdPal extensions are out-of-process and the + chart is a static image — no pointer/hover events cross the boundary (Perf Monitor's graph has the same + ceiling; the content types are all declarative: Markdown / PlainText / Image / Form / Tree). Closest + approximation = bake High/Low/Open/Close text + on-chart min/max labels (the `o/h/l/v` arrays are + already parsed in `ApiFinnhubCandleDto`, just not yet promoted past `Close` into `CandlePoint`). +- **Live auto-refresh — ✅ DONE.** The open chart now re-fetches its **visible range** (not just 1D) on + each `PollTicker` tick and repaints the `FormContent` in place, sharing the one ticker + + `Publish().RefCount()` lifecycle with the priced list pages. `SymbolDetailPage` subscribes the chart in its + visible-lifecycle hook (`replayOnSubscribe:false` — no double-fetch on open) and `SymbolChartForm. + PollRefresh()` does the silent re-price: bypasses the per-range cache so the on-screen range actually + refreshes, reuses the generation guard so a tab tap still wins, and keeps the last good chart on a + transient empty poll. On the Finnhub fallback path candles are premium-gated (a free key re-fetches a + 403 each tick); with Twelve Data primary its free-tier candles refresh for real. See the "Done (this + round): symbol-detail chart live refresh" bullet in Current Status. +- Not yet wired to a future `PortfolioPage` (doesn't exist yet). + +### Asset logos as icons, app-wide (done) + +Every instrument row, dock button, and the `SymbolDetailPage` header shows the instrument's **real logo** +(AAPL's apple, BTC's coin) instead of the generic github-favicon. Page **chrome** icons (list/search page +headers) still use the favicon. + +**Shipped approach — Elbstream CDN, addressed by symbol (zero API calls).** Because `new IconInfo(url)` +makes the **CmdPal host** fetch the image, the logo URL is built *directly from the symbol* — so there is +**no API call, no caching layer, and no DTO**. `Helpers/AssetIconResolver.cs` is the single place the +convention lives: +- `Stock → https://api.elbstream.com/logos/symbol/{ticker}?format=png` +- `Crypto → https://api.elbstream.com/logos/crypto/{sym}?format=png` +- `Currency → https://api.elbstream.com/logos/country/{iso2}?format=png` — an FX pair shows its **base + currency's country flag** (a small currency→ISO-3166 map, `EUR→eu`/`USD→us`/`JPY→jp`/…; the `eu` flag + covers the Euro). Unmapped currencies and unknown categories fall back to a Segoe MDL2 glyph. + +Our neutral `DomainInstrument.Symbol` matches Elbstream's identifiers directly (`AAPL`, `BTC`), so no +provider-specific symbol translation is needed. `format` ∈ svg/png/webp/jpg (default svg, PNG auto-fallback +when no SVG); `size` defaults to 100px. A **miss returns HTTP 404** (verified — not a placeholder image), so +an off-catalog/obscure symbol shows the host's empty-icon slot; the glyph fallback only covers whole +categories, **not** per-symbol misses (a true glyph-on-miss would need a per-symbol HEAD probe + +swap-on-arrival — deferred to keep the zero-call simplicity). + +**Attribution (required).** Elbstream is free **with attribution**: a clearly-visible link back, min 12pt, +on any page where a logo shows — `Logos provided by Elbstream`. Placed as: +- a trailing clickable "Logos provided by Elbstream" row on the priced list pages (`PricedListPage.GetItems`, + beside Refresh) and on Search results (`SearchPage.SearchItems`, only when results are present); +- a subtle `TextBlock` line **inside** the `SymbolDetailPage` adaptive-card template (deliberately NOT a 2nd + `IContent` item — that would re-trigger the `OnlyControlOnPage` auto-focus bug); +- the **dock band is an accepted gray area** (no room for a link; arguably a "band," not a "page"). Strict- + compliance escape if ever needed: keep the dock's generic icon so no un-attributed logo surface exists. + +The credit row is clickable via `Commands/OpenUrlCommand.cs` → `ProcessHelper.OpenUrl(url)` (a shell launch, +not a captured `Run`). The shared row factory is `AssetIconResolver.AttributionRow()`. + +**Wired in:** `SearchPage.BuildResultItem`, `WatchlistPage.BuildRow`, `FavoritesPage.BuildRow`, +`FavoritesDockPage.GetItems`, and the `SymbolDetailPage` ctor all set `Icon = AssetIconResolver.Resolve(...)`. + +**Alternatives considered (why not):** Finnhub `/stock/profile2` (official `logo` URL, reuses our key, no +attribution) — but stocks-only and spends the tight ~300/day budget (one call per symbol, even with a +forever cache). Logo.dev (Clearbit's official successor, 500K/mo free) — needs a token → a new settings +field. Bundled static assets (e.g. `spothq/cryptocurrency-icons`) — offline/AOT-safe but only covers what +you ship. ⚠️ **Clearbit's free logo API was permanently sunset Dec 2025 — do not use it.** Elbstream won as +the only keyless, symbol-addressable source covering stocks + crypto + forex from one place; its attribution +requirement was the accepted trade-off. + +**Future polish:** a glyph-on-miss probe if blank slots on obscure symbols become annoying. (FX flags are +now wired — Elbstream `/logos/country/{iso2}` keyed off the base currency. **Portfolio logos are DONE** — +`PortfolioPage.BuildRow` resolves the per-holding logo via the same `AssetIconResolver.Resolve(...)` call, +and it inherits the Elbstream attribution row from `PricedListPage.GetItems`; the totals summary row and the +Portfolio dock band keep the Bank glyph on purpose, since those are rolled-up summaries, not instruments.) ## CommandPalette Toolkit — Quick Reference @@ -77,7 +1058,7 @@ The fix: re-implement `INotifyItemsChanged` on the page class and intercept the **Critical:** use `INotifyItemsChanged.ItemsChanged`, not `IListPage.ItemsChanged` — `ItemsChanged` lives on `INotifyItemsChanged`, and the class must re-list the interface to override base class dispatch. ```csharp -// For pages that load data async (see Pages/SampleListPage.cs, reference/pages/AdbExtensionPage.cs): +// For pages that load data async (see Pages/PricedListPage.cs, reference/pages/AdbExtensionPage.cs): internal sealed partial class MyPage : DynamicListPage, INotifyItemsChanged { private event TypedEventHandler? _itemsChanged; @@ -110,6 +1091,18 @@ internal sealed partial class MyPage : ListPage, INotifyItemsChanged Do **not** call `IsLoading = true` + `Task.Run(Load)` from the constructor — the event fires before the framework subscribes and the signal is lost. +> **`ContentPage` focus gotcha:** if `GetContent()` returns a **single** `FormContent`, the host marks it +> `OnlyControlOnPage = true` and **auto-focuses the card's first focusable element** (e.g. the first +> `Action.Submit`) on load — so Enter activates that card button instead of the page's primary (Enter) +> command — and with focus trapped in the card, Ctrl+Enter can't reach the secondary command either. +> Content count is the **only** documented lever (no host API suppresses the auto-focus). In theory: +> (a) return **≥2 content items** so `OnlyControlOnPage` is false and focus stays in the search box; or +> (b) **make the first focusable element the action you WANT Enter to fire** by ordering the card so that +> button comes first. ⚠️ The symbol-detail chart tried **both and neither shipped** — (a) did **not** +> actually keep focus in the search box in practice (Enter still hit a tab, Ctrl+Enter broke), and (b) +> was too hacky. It currently ships with the bug **unfixed**; full trace in the "Symbol detail + live +> chart" section of this file. Treat (a) as suspect until someone reproduces it working. + ### DynamicListPage Pattern ```csharp @@ -204,6 +1197,10 @@ internal sealed partial class MyCommand : InvokableCommand // Run any external process. Reads BOTH stdout and stderr before WaitForExit (prevents // pipe deadlocks). stderr is non-empty only on failure (non-zero exit). ProcessHelper.Run(string fileName, string arguments, out string stdout, out string stderr); + +// Launch a URL / file / protocol with the OS default handler (UseShellExecute=true). Fire-and-forget; +// no output captured. Used by Commands/OpenUrlCommand.cs (the Elbstream attribution row). +ProcessHelper.OpenUrl(string url); ``` For richer process handling (output parsing into records, etc.) see `reference/helpers/AdbHelper.cs`. @@ -242,10 +1239,14 @@ This reads the version from the csproj automatically, builds x64 + ARM64, bundle ## One-time setup (this extension's own identity) -This was scaffolded from AdbExtension. Already changed: COM GUID (`6b38c9aa-bbee-45e9-81e9-cf25707910e7`), namespace/assembly (`MarketExtension`), package identity (`CostaFotiadis.MarketExtension`), version (`0.0.1.0`). Still TODO before shipping: +This was scaffolded from AdbExtension. Already changed: COM GUID (`6b38c9aa-bbee-45e9-81e9-cf25707910e7`), namespace/assembly (`MarketExtension`), package identity (`CostaFotiadis.MarketsExtensionforCommandPalette`), version (`0.0.1.0`). Still TODO before shipping: - **Partner Center**: reserve the app name; confirm `Identity Name` + `Publisher` in `Package.appxmanifest`/csproj match what it assigns. - **GitHub secrets**: add `SIGNING_CERT_PFX` + `SIGNING_CERT_PASSWORD` (see `MarketExtension/create-signing-cert.ps1`). Template clones do not carry secrets. -- **Assets**: replace the art in `MarketExtension/Assets/` (still the AdbExtension tiles/logos). -- **Sentry**: set your own DSN in `Program.cs` or leave it empty to disable. +- ~~**Assets**: replace the art in `MarketExtension/Assets/`~~ — **DONE.** New `$ >_` charcoal-squircle + logo: in-CmdPal icon wired in code (`markets_logo_base_square.png` via `IconHelpers.FromRelativePath`, + all 7 chrome spots), and the MSIX tile/Store PNGs regenerated from the square source via VS's Visual + Assets generator. No ``/`.ico` (matches AdbExtension — the deployed app icon comes from + the manifest assets; the bare exe keeps the default apphost icon, which is fine). +- **API key**: none needed at build time — each user pastes their own Finnhub key into the extension's settings at runtime (see the "API Key" section above). - **Description**: update the placeholder description in `Package.appxmanifest`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 3bf9eb5..3a9ffa8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,8 +11,8 @@ - + diff --git a/MarketExtension/Assets/LargeTile.scale-100.png b/MarketExtension/Assets/LargeTile.scale-100.png index d41d6f8..9d52dd4 100644 Binary files a/MarketExtension/Assets/LargeTile.scale-100.png and b/MarketExtension/Assets/LargeTile.scale-100.png differ diff --git a/MarketExtension/Assets/LargeTile.scale-125.png b/MarketExtension/Assets/LargeTile.scale-125.png index b4e1f2a..585d1cf 100644 Binary files a/MarketExtension/Assets/LargeTile.scale-125.png and b/MarketExtension/Assets/LargeTile.scale-125.png differ diff --git a/MarketExtension/Assets/LargeTile.scale-150.png b/MarketExtension/Assets/LargeTile.scale-150.png index b101cad..5c87b4d 100644 Binary files a/MarketExtension/Assets/LargeTile.scale-150.png and b/MarketExtension/Assets/LargeTile.scale-150.png differ diff --git a/MarketExtension/Assets/LargeTile.scale-200.png b/MarketExtension/Assets/LargeTile.scale-200.png index 93ffa16..65f34f1 100644 Binary files a/MarketExtension/Assets/LargeTile.scale-200.png and b/MarketExtension/Assets/LargeTile.scale-200.png differ diff --git a/MarketExtension/Assets/LargeTile.scale-400.png b/MarketExtension/Assets/LargeTile.scale-400.png index 67c2b92..b9de743 100644 Binary files a/MarketExtension/Assets/LargeTile.scale-400.png and b/MarketExtension/Assets/LargeTile.scale-400.png differ diff --git a/MarketExtension/Assets/SmallTile.scale-100.png b/MarketExtension/Assets/SmallTile.scale-100.png index 3e386d1..b911b3a 100644 Binary files a/MarketExtension/Assets/SmallTile.scale-100.png and b/MarketExtension/Assets/SmallTile.scale-100.png differ diff --git a/MarketExtension/Assets/SmallTile.scale-125.png b/MarketExtension/Assets/SmallTile.scale-125.png index 1101d49..3016007 100644 Binary files a/MarketExtension/Assets/SmallTile.scale-125.png and b/MarketExtension/Assets/SmallTile.scale-125.png differ diff --git a/MarketExtension/Assets/SmallTile.scale-150.png b/MarketExtension/Assets/SmallTile.scale-150.png index bc7f36e..f81d507 100644 Binary files a/MarketExtension/Assets/SmallTile.scale-150.png and b/MarketExtension/Assets/SmallTile.scale-150.png differ diff --git a/MarketExtension/Assets/SmallTile.scale-200.png b/MarketExtension/Assets/SmallTile.scale-200.png index f3c4125..3a69e63 100644 Binary files a/MarketExtension/Assets/SmallTile.scale-200.png and b/MarketExtension/Assets/SmallTile.scale-200.png differ diff --git a/MarketExtension/Assets/SmallTile.scale-400.png b/MarketExtension/Assets/SmallTile.scale-400.png index 5e5e442..ad5150b 100644 Binary files a/MarketExtension/Assets/SmallTile.scale-400.png and b/MarketExtension/Assets/SmallTile.scale-400.png differ diff --git a/MarketExtension/Assets/SplashScreen.scale-100.png b/MarketExtension/Assets/SplashScreen.scale-100.png index 00b19d2..4c1117b 100644 Binary files a/MarketExtension/Assets/SplashScreen.scale-100.png and b/MarketExtension/Assets/SplashScreen.scale-100.png differ diff --git a/MarketExtension/Assets/SplashScreen.scale-125.png b/MarketExtension/Assets/SplashScreen.scale-125.png index 5926411..ada2074 100644 Binary files a/MarketExtension/Assets/SplashScreen.scale-125.png and b/MarketExtension/Assets/SplashScreen.scale-125.png differ diff --git a/MarketExtension/Assets/SplashScreen.scale-150.png b/MarketExtension/Assets/SplashScreen.scale-150.png index 61904a9..36df2ac 100644 Binary files a/MarketExtension/Assets/SplashScreen.scale-150.png and b/MarketExtension/Assets/SplashScreen.scale-150.png differ diff --git a/MarketExtension/Assets/SplashScreen.scale-200.png b/MarketExtension/Assets/SplashScreen.scale-200.png index 7ecef08..ccc4179 100644 Binary files a/MarketExtension/Assets/SplashScreen.scale-200.png and b/MarketExtension/Assets/SplashScreen.scale-200.png differ diff --git a/MarketExtension/Assets/SplashScreen.scale-400.png b/MarketExtension/Assets/SplashScreen.scale-400.png index 6c98f09..4cc9d8f 100644 Binary files a/MarketExtension/Assets/SplashScreen.scale-400.png and b/MarketExtension/Assets/SplashScreen.scale-400.png differ diff --git a/MarketExtension/Assets/Square150x150Logo.scale-100.png b/MarketExtension/Assets/Square150x150Logo.scale-100.png index 8c10bc0..595c9b7 100644 Binary files a/MarketExtension/Assets/Square150x150Logo.scale-100.png and b/MarketExtension/Assets/Square150x150Logo.scale-100.png differ diff --git a/MarketExtension/Assets/Square150x150Logo.scale-125.png b/MarketExtension/Assets/Square150x150Logo.scale-125.png index f9def74..8ceee73 100644 Binary files a/MarketExtension/Assets/Square150x150Logo.scale-125.png and b/MarketExtension/Assets/Square150x150Logo.scale-125.png differ diff --git a/MarketExtension/Assets/Square150x150Logo.scale-150.png b/MarketExtension/Assets/Square150x150Logo.scale-150.png index 4cecc1a..46c3598 100644 Binary files a/MarketExtension/Assets/Square150x150Logo.scale-150.png and b/MarketExtension/Assets/Square150x150Logo.scale-150.png differ diff --git a/MarketExtension/Assets/Square150x150Logo.scale-200.png b/MarketExtension/Assets/Square150x150Logo.scale-200.png index 72a4ace..58c6522 100644 Binary files a/MarketExtension/Assets/Square150x150Logo.scale-200.png and b/MarketExtension/Assets/Square150x150Logo.scale-200.png differ diff --git a/MarketExtension/Assets/Square150x150Logo.scale-400.png b/MarketExtension/Assets/Square150x150Logo.scale-400.png index d36d22b..e5c0d87 100644 Binary files a/MarketExtension/Assets/Square150x150Logo.scale-400.png and b/MarketExtension/Assets/Square150x150Logo.scale-400.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-16.png b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-16.png index 2222078..6a9ebf1 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-16.png and b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-16.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-24.png b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-24.png index 2dcbbae..b10227d 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-24.png and b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-24.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png index 4d8daee..5e3b48e 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png and b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-256.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-32.png b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-32.png index b68ddbe..1e878d5 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-32.png and b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-32.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-48.png b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-48.png index f01f34c..5175ad2 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-48.png and b/MarketExtension/Assets/Square44x44Logo.altform-lightunplated_targetsize-48.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-16.png b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-16.png index 2222078..6a9ebf1 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-16.png and b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-16.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-256.png b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-256.png index 4d8daee..5e3b48e 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-256.png and b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-256.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-32.png b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-32.png index b68ddbe..1e878d5 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-32.png and b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-32.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-48.png b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-48.png index f01f34c..5175ad2 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-48.png and b/MarketExtension/Assets/Square44x44Logo.altform-unplated_targetsize-48.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.scale-100.png b/MarketExtension/Assets/Square44x44Logo.scale-100.png index 49ccde0..344d710 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.scale-100.png and b/MarketExtension/Assets/Square44x44Logo.scale-100.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.scale-125.png b/MarketExtension/Assets/Square44x44Logo.scale-125.png index a5e3512..95a12c2 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.scale-125.png and b/MarketExtension/Assets/Square44x44Logo.scale-125.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.scale-150.png b/MarketExtension/Assets/Square44x44Logo.scale-150.png index fe3f239..7565ece 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.scale-150.png and b/MarketExtension/Assets/Square44x44Logo.scale-150.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.scale-200.png b/MarketExtension/Assets/Square44x44Logo.scale-200.png index 8bae6d5..1b29543 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.scale-200.png and b/MarketExtension/Assets/Square44x44Logo.scale-200.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.scale-400.png b/MarketExtension/Assets/Square44x44Logo.scale-400.png index 58cffef..34ffded 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.scale-400.png and b/MarketExtension/Assets/Square44x44Logo.scale-400.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.targetsize-16.png b/MarketExtension/Assets/Square44x44Logo.targetsize-16.png index be4c987..6a9ebf1 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.targetsize-16.png and b/MarketExtension/Assets/Square44x44Logo.targetsize-16.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.targetsize-24.png b/MarketExtension/Assets/Square44x44Logo.targetsize-24.png index 1834b4b..b10227d 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.targetsize-24.png and b/MarketExtension/Assets/Square44x44Logo.targetsize-24.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/MarketExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png index 2dcbbae..b10227d 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and b/MarketExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.targetsize-256.png b/MarketExtension/Assets/Square44x44Logo.targetsize-256.png index fb43fd4..5e3b48e 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.targetsize-256.png and b/MarketExtension/Assets/Square44x44Logo.targetsize-256.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.targetsize-32.png b/MarketExtension/Assets/Square44x44Logo.targetsize-32.png index 79dd3d3..1e878d5 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.targetsize-32.png and b/MarketExtension/Assets/Square44x44Logo.targetsize-32.png differ diff --git a/MarketExtension/Assets/Square44x44Logo.targetsize-48.png b/MarketExtension/Assets/Square44x44Logo.targetsize-48.png index c8f1c65..5175ad2 100644 Binary files a/MarketExtension/Assets/Square44x44Logo.targetsize-48.png and b/MarketExtension/Assets/Square44x44Logo.targetsize-48.png differ diff --git a/MarketExtension/Assets/StoreLogo.scale-100.png b/MarketExtension/Assets/StoreLogo.scale-100.png index 5ccdb7f..0f4c25e 100644 Binary files a/MarketExtension/Assets/StoreLogo.scale-100.png and b/MarketExtension/Assets/StoreLogo.scale-100.png differ diff --git a/MarketExtension/Assets/StoreLogo.scale-125.png b/MarketExtension/Assets/StoreLogo.scale-125.png index f66da5d..c376662 100644 Binary files a/MarketExtension/Assets/StoreLogo.scale-125.png and b/MarketExtension/Assets/StoreLogo.scale-125.png differ diff --git a/MarketExtension/Assets/StoreLogo.scale-150.png b/MarketExtension/Assets/StoreLogo.scale-150.png index b05f94b..f65ebc7 100644 Binary files a/MarketExtension/Assets/StoreLogo.scale-150.png and b/MarketExtension/Assets/StoreLogo.scale-150.png differ diff --git a/MarketExtension/Assets/StoreLogo.scale-200.png b/MarketExtension/Assets/StoreLogo.scale-200.png index 36e099a..139b8df 100644 Binary files a/MarketExtension/Assets/StoreLogo.scale-200.png and b/MarketExtension/Assets/StoreLogo.scale-200.png differ diff --git a/MarketExtension/Assets/StoreLogo.scale-400.png b/MarketExtension/Assets/StoreLogo.scale-400.png index bc99757..4997806 100644 Binary files a/MarketExtension/Assets/StoreLogo.scale-400.png and b/MarketExtension/Assets/StoreLogo.scale-400.png differ diff --git a/MarketExtension/Assets/Wide310x150Logo.scale-100.png b/MarketExtension/Assets/Wide310x150Logo.scale-100.png index 26db210..806aa1c 100644 Binary files a/MarketExtension/Assets/Wide310x150Logo.scale-100.png and b/MarketExtension/Assets/Wide310x150Logo.scale-100.png differ diff --git a/MarketExtension/Assets/Wide310x150Logo.scale-125.png b/MarketExtension/Assets/Wide310x150Logo.scale-125.png index c364be3..83c4896 100644 Binary files a/MarketExtension/Assets/Wide310x150Logo.scale-125.png and b/MarketExtension/Assets/Wide310x150Logo.scale-125.png differ diff --git a/MarketExtension/Assets/Wide310x150Logo.scale-150.png b/MarketExtension/Assets/Wide310x150Logo.scale-150.png index 90c0b5f..841e0e6 100644 Binary files a/MarketExtension/Assets/Wide310x150Logo.scale-150.png and b/MarketExtension/Assets/Wide310x150Logo.scale-150.png differ diff --git a/MarketExtension/Assets/Wide310x150Logo.scale-200.png b/MarketExtension/Assets/Wide310x150Logo.scale-200.png index 00b19d2..4c1117b 100644 Binary files a/MarketExtension/Assets/Wide310x150Logo.scale-200.png and b/MarketExtension/Assets/Wide310x150Logo.scale-200.png differ diff --git a/MarketExtension/Assets/Wide310x150Logo.scale-400.png b/MarketExtension/Assets/Wide310x150Logo.scale-400.png index 7ecef08..ccc4179 100644 Binary files a/MarketExtension/Assets/Wide310x150Logo.scale-400.png and b/MarketExtension/Assets/Wide310x150Logo.scale-400.png differ diff --git a/MarketExtension/Assets/markets_logo_base_square.png b/MarketExtension/Assets/markets_logo_base_square.png new file mode 100644 index 0000000..cc7453a Binary files /dev/null and b/MarketExtension/Assets/markets_logo_base_square.png differ diff --git a/MarketExtension/Commands/MembershipCommands.cs b/MarketExtension/Commands/MembershipCommands.cs new file mode 100644 index 0000000..6856de4 --- /dev/null +++ b/MarketExtension/Commands/MembershipCommands.cs @@ -0,0 +1,95 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// The watchlist/favorites membership actions. These now live solely on the per-symbol SymbolDetailPage +// (the single place to manage membership); list rows just navigate into it. Each one just mutates +// WatchlistStore for one instrument; the store re-publishes its observable subsets, so every live page, +// the dock, and the detail page's own command bar re-render themselves \u2014 no manual refresh callback is +// threaded through any more. It also pops a confirmation toast ("Added AAPL to watchlist") and keeps the +// palette open: the silent re-render alone left users unsure the action took, and KeepOpen lets them keep +// editing. Adapted from reference/commands/ToggleFavoriteCommand.cs. +internal abstract partial class MembershipCommand : InvokableCommand +{ + // Segoe MDL2 glyphs: Add, Delete, FavoriteStar (outline), FavoriteStarFill. + protected const string AddGlyph = "\uE710"; + protected const string DeleteGlyph = "\uE74D"; + protected const string StarGlyph = "\uE734"; + protected const string StarFillGlyph = "\uE735"; + + protected readonly DomainInstrument Instrument; + + protected MembershipCommand(DomainInstrument instrument) => Instrument = instrument; + + // Performs the mutation and returns the confirmation message to toast (so toggles can report + // exactly what they did). + protected abstract string Apply(); + + public override ICommandResult Invoke() + { + var message = Apply(); + return CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); + } +} + +internal sealed partial class AddToWatchlistCommand : MembershipCommand +{ + public AddToWatchlistCommand(DomainInstrument instrument) : base(instrument) + { + Name = Resources.Cmd_AddToWatchlist; + Icon = new IconInfo(AddGlyph); + } + + protected override string Apply() + { + WatchlistStore.Instance.AddToWatchlist(Instrument); + return Strings.Format(Resources.Toast_AddedToWatchlist, Instrument.Symbol); + } +} + +internal sealed partial class RemoveFromWatchlistCommand : MembershipCommand +{ + public RemoveFromWatchlistCommand(DomainInstrument instrument) : base(instrument) + { + Name = Resources.Cmd_RemoveFromWatchlist; + Icon = new IconInfo(DeleteGlyph); + } + + protected override string Apply() + { + WatchlistStore.Instance.RemoveFromWatchlist(Instrument.Symbol); + return Strings.Format(Resources.Toast_RemovedFromWatchlist, Instrument.Symbol); + } +} + +internal sealed partial class AddToFavoritesCommand : MembershipCommand +{ + public AddToFavoritesCommand(DomainInstrument instrument) : base(instrument) + { + Name = Resources.Cmd_AddToFavorites; + Icon = new IconInfo(StarGlyph); + } + + protected override string Apply() + { + WatchlistStore.Instance.AddToFavorites(Instrument); + return Strings.Format(Resources.Toast_AddedToFavorites, Instrument.Symbol); + } +} + +internal sealed partial class RemoveFromFavoritesCommand : MembershipCommand +{ + public RemoveFromFavoritesCommand(DomainInstrument instrument) : base(instrument) + { + Name = Resources.Cmd_RemoveFromFavorites; + Icon = new IconInfo(StarFillGlyph); + } + + protected override string Apply() + { + WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); + return Strings.Format(Resources.Toast_RemovedFromFavorites, Instrument.Symbol); + } +} diff --git a/MarketExtension/Commands/OpenUrlCommand.cs b/MarketExtension/Commands/OpenUrlCommand.cs new file mode 100644 index 0000000..ece75e0 --- /dev/null +++ b/MarketExtension/Commands/OpenUrlCommand.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Opens a URL in the user's default browser. Used by the Elbstream attribution row (see +// AssetIconResolver.AttributionRow). Keeps the palette open so the user returns to their list after the +// browser launches. +internal sealed partial class OpenUrlCommand : InvokableCommand +{ + private readonly string _url; + + public OpenUrlCommand(string url, string name) + { + _url = url; + Name = name; + } + + public override ICommandResult Invoke() + { + try + { + ProcessHelper.OpenUrl(_url); + return CommandResult.KeepOpen(); + } + catch (Exception ex) + { + return CommandResult.ShowToast(new ToastArgs + { + Message = Strings.Format(Resources.Toast_OpenUrlFailed, _url, ex.Message), + Result = CommandResult.KeepOpen(), + }); + } + } +} diff --git a/MarketExtension/Commands/PortfolioCommands.cs b/MarketExtension/Commands/PortfolioCommands.cs new file mode 100644 index 0000000..ed3a04c --- /dev/null +++ b/MarketExtension/Commands/PortfolioCommands.cs @@ -0,0 +1,34 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Removes a holding from the portfolio. Like the watchlist/favorites membership commands, it just +// mutates the store (PortfolioStore re-publishes its flows, so the Portfolio screen and the detail +// page's command bar re-render themselves) and pops a confirmation toast while keeping the palette open +// so the user knows it took and can keep editing. Adding/editing a holding is a page (SetQuantityPage), +// not a one-shot command, because it needs a quantity input. +internal sealed partial class RemoveFromPortfolioCommand : InvokableCommand +{ + private const string DeleteGlyph = "\uE74D"; // Segoe MDL2 Delete + + private readonly DomainInstrument _instrument; + + public RemoveFromPortfolioCommand(DomainInstrument instrument) + { + _instrument = instrument; + Name = Resources.Cmd_RemoveFromPortfolio; + Icon = new IconInfo(DeleteGlyph); + } + + public override ICommandResult Invoke() + { + PortfolioStore.Instance.Remove(_instrument.Symbol); + return CommandResult.ShowToast(new ToastArgs + { + Message = Strings.Format(Resources.Toast_RemovedFromPortfolio, _instrument.Symbol), + Result = CommandResult.KeepOpen(), + }); + } +} diff --git a/MarketExtension/Commands/ToggleFavoriteCommand.cs b/MarketExtension/Commands/ToggleFavoriteCommand.cs deleted file mode 100644 index 7d58f00..0000000 --- a/MarketExtension/Commands/ToggleFavoriteCommand.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace MarketExtension; - -// Stars/unstars an instrument by symbol, then asks the host page to re-list so the row jumps -// in/out of the Favorites section. Adapted from reference/commands/ToggleFavoriteCommand.cs. -internal sealed partial class ToggleFavoriteCommand : InvokableCommand -{ - private readonly string _symbol; - private readonly Action _refresh; - - public ToggleFavoriteCommand(string symbol, Action refresh) - { - _symbol = symbol; - _refresh = refresh; - Name = FavoritesStore.Instance.IsFavorite(symbol) ? "Remove from Favorites" : "Add to Favorites"; - Icon = new IconInfo(FavoritesStore.Instance.IsFavorite(symbol) ? "" : ""); // FavoriteStarFill / FavoriteStar - } - - public override ICommandResult Invoke() - { - FavoritesStore.Instance.Toggle(_symbol); - _refresh(); - return CommandResult.KeepOpen(); - } -} diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubCandleDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubCandleDto.cs new file mode 100644 index 0000000..6fecfde --- /dev/null +++ b/MarketExtension/Data/Finnhub/ApiFinnhubCandleDto.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Finnhub's GET /stock/candle (and /crypto/candle — identical shape) +// response. Internal to the Finnhub provider, which maps it into a DomainCandleSeries. The arrays are +// column-oriented and parallel by index: Close[i]/Timestamps[i] are candle i's close and time. Status +// is "ok" or "no_data". Open/High/Low/Volume are parsed but currently unused (kept for a future OHLC +// tooltip). Premium endpoint — a free-tier key gets a 403 (handled in the provider). +// +// Registered on FinnhubJsonContext via the SINGLE [JsonSerializable] declaration in +// ApiFinnhubQuoteDto.cs — never a second partial declaration (see the source-gen gotcha in CLAUDE.md). +internal sealed record ApiFinnhubCandleDto( + [property: JsonPropertyName("c")] IReadOnlyList? Close, + [property: JsonPropertyName("h")] IReadOnlyList? High, + [property: JsonPropertyName("l")] IReadOnlyList? Low, + [property: JsonPropertyName("o")] IReadOnlyList? Open, + [property: JsonPropertyName("t")] IReadOnlyList? Timestamps, + [property: JsonPropertyName("v")] IReadOnlyList? Volume, + [property: JsonPropertyName("s")] string? Status); diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubProfileDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubProfileDto.cs new file mode 100644 index 0000000..ed0a233 --- /dev/null +++ b/MarketExtension/Data/Finnhub/ApiFinnhubProfileDto.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the slice of Finnhub's GET /stock/profile2?symbol= response we care about — the instrument's +// reporting `currency`. Finnhub's /quote (c/d/dp/pc) carries NO currency, so the provider resolves it +// here, ONCE per symbol (currency is static metadata), and caches it forever. US tickers come back "USD"; +// a paid plan's non-US listings report their local currency (e.g. "GBp" for London → normalized to GBP). +// +// Registered on FinnhubJsonContext in ApiFinnhubQuoteDto.cs (the single context declaration) per the +// CLAUDE.md source-gen gotcha — NOT in its own context here. +internal sealed record ApiFinnhubProfileDto( + [property: JsonPropertyName("currency")] string? Currency, + [property: JsonPropertyName("ticker")] string? Ticker); diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs new file mode 100644 index 0000000..b78a1fb --- /dev/null +++ b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Finnhub's GET /quote response. Internal to the Finnhub provider, +// which maps it into a DomainQuote. Fields are nullable because Finnhub returns null for +// change/percent on instruments without a previous close; the provider coalesces to 0. +// +// Source-generated (de)serialization keeps the AOT/trim build clean: reflection-based JSON would +// trip ILLinkTreatWarningsAsErrors. +internal sealed record ApiFinnhubQuoteDto( + [property: JsonPropertyName("c")] decimal? Current, // current price + [property: JsonPropertyName("d")] decimal? Change, // absolute change + [property: JsonPropertyName("dp")] decimal? ChangePercent, // percent change + [property: JsonPropertyName("pc")] decimal? PreviousClose); // previous close + +[JsonSerializable(typeof(ApiFinnhubQuoteDto))] +[JsonSerializable(typeof(ApiFinnhubSearchDto))] // /search response — see ApiFinnhubSearchDto.cs +[JsonSerializable(typeof(ApiFinnhubCandleDto))] // /stock/candle + /crypto/candle — see ApiFinnhubCandleDto.cs +[JsonSerializable(typeof(ApiFinnhubProfileDto))] // /stock/profile2 (native currency) — see ApiFinnhubProfileDto.cs +internal sealed partial class FinnhubJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubSearchDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubSearchDto.cs new file mode 100644 index 0000000..2872792 --- /dev/null +++ b/MarketExtension/Data/Finnhub/ApiFinnhubSearchDto.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Finnhub's GET /search (symbol lookup) response. Internal to the +// Finnhub provider, which maps each result into a provider-agnostic DomainInstrument. All fields +// are nullable because Finnhub omits them for sparse matches; the provider skips results without a +// symbol. +// +// Source-generated (de)serialization keeps the AOT/trim build clean — reflection-based JSON would +// trip ILLinkTreatWarningsAsErrors. Registration lives on the single FinnhubJsonContext +// declaration in ApiFinnhubQuoteDto.cs: the JSON source generator does NOT support [JsonSerializable] +// attributes split across multiple partial declarations (it emits colliding hintNames and fails), +// so all serializable types for that context must be attributed in one place. +internal sealed record ApiFinnhubSearchDto( + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("result")] IReadOnlyList? Result); + +internal sealed record ApiFinnhubSearchResultDto( + [property: JsonPropertyName("description")] string? Description, // e.g. "APPLE INC" + [property: JsonPropertyName("displaySymbol")] string? DisplaySymbol, // UI symbol, e.g. "AAPL" + [property: JsonPropertyName("symbol")] string? Symbol, // canonical id for /quote + [property: JsonPropertyName("type")] string? Type); // e.g. "Common Stock" diff --git a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs new file mode 100644 index 0000000..1bc2387 --- /dev/null +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// Live market data from Finnhub (https://finnhub.io). One IMarketDataProvider source: it formats +// each neutral DomainInstrument into Finnhub's symbol syntax, calls /quote, and maps the raw +// ApiFinnhubQuoteDto into a DomainQuote. Nothing outside this class references Finnhub, so a +// different provider can replace it without touching the repository or the UI. +internal sealed class FinnhubMarketDataProvider : IMarketDataProvider +{ + // The Finnhub key, sourced exclusively from the extension's settings (no built-in/baked key). + // Read from MarketSettingsManager on each access so a key change applies on the next request + // without a reload; empty until the user sets one. + private static string ApiKey => MarketSettingsManager.Instance.FinnhubApiKey; + private static bool HasApiKey => MarketSettingsManager.Instance.HasFinnhubApiKey; + + // Reuse a single client (creating one per request exhausts sockets). AOT-safe. + private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://finnhub.io/api/v1/") }; + + // symbol → raw native-currency code (e.g. "USD", "GBp"), resolved once via /stock/profile2 and cached + // for the process lifetime. Currency is static metadata, so this is one extra call per NEW stock symbol + // (trivial against the ~300/day budget) — and most are "USD". Only successful resolutions are cached, so + // a transient profile2 failure retries on the next poll rather than pinning the wrong currency. + private static readonly ConcurrentDictionary StockCurrencyCache = + new(StringComparer.OrdinalIgnoreCase); + + // Finnhub's free tier serves stocks and crypto; forex (OANDA:*) is premium-gated. + public bool Supports(AssetCategory category) => category is AssetCategory.Stock or AssetCategory.Crypto; + + public async Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) + { + // No key configured (settings is the only source) — return everything as unavailable rather + // than firing keyless /quote calls that would just 401. + if (!HasApiKey) + { + Log.Warn("Finnhub", "no API key set — skipping quotes (set one in extension settings)"); + return instruments.Select(Invalid).ToArray(); + } + + // One /quote call per instrument, fanned out. A handful of symbols stays well under the + // free-tier 60 calls/minute limit. + return await Task.WhenAll(instruments.Select(i => FetchQuoteAsync(i, ct))).ConfigureAwait(false); + } + + // Max search results surfaced. Finnhub can return dozens for a common name; we cap to keep the + // list usable (one /search call regardless — the cap is purely a UI trim). + private const int MaxSearchResults = 25; + + public async Task> SearchAsync(string query, CancellationToken ct = default) + { + query = query?.Trim() ?? string.Empty; + if (query.Length == 0) + return []; + + if (!HasApiKey) + { + Log.Warn("Finnhub", "no API key set — skipping search (set one in extension settings)"); + return []; + } + + try + { + // exchange=US keeps results to plain US tickers whose canonical `symbol` round-trips + // through ToFinnhubSymbol(Stock) for the later /quote (see InstrumentCatalog note). We + // therefore treat every match as a Stock; crypto/FX search would need symbol-format + // reconciliation and is deferred. + // NEVER log the full URL — it carries the API token. Log the query only. + Log.Info("Finnhub", $"GET /search q={query}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync($"search?q={Uri.EscapeDataString(query)}&exchange=US&token={ApiKey}", c), + "Finnhub", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("Finnhub", $"search '{query}' <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + // e.g. 429 rate-limited. Degrade to no results rather than throwing. + Log.Warn("Finnhub", $"search '{query}': non-success status {(int)response.StatusCode} — no results"); + return []; + } + + var dto = JsonSerializer.Deserialize(json, FinnhubJsonContext.Default.ApiFinnhubSearchDto); + if (dto?.Result is not { Count: > 0 } results) + return []; + + // Map to neutral identities, dropping entries without a usable symbol, deduping by + // symbol (a name can list across exchanges), and capping for the UI. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var instruments = new List(); + foreach (var r in results) + { + var symbol = r.Symbol; + if (string.IsNullOrWhiteSpace(symbol) || !seen.Add(symbol)) + continue; + + var name = string.IsNullOrWhiteSpace(r.Description) ? symbol : r.Description!; + instruments.Add(new DomainInstrument(symbol, name, AssetCategory.Stock)); + if (instruments.Count >= MaxSearchResults) + break; + } + + Log.Info("Finnhub", $"search '{query}' -> {instruments.Count} result(s)"); + return instruments; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("Finnhub", $"search '{query}': request failed", ex); + return []; + } + } + + private static async Task FetchQuoteAsync(DomainInstrument instrument, CancellationToken ct) + { + var symbol = ToFinnhubSymbol(instrument); + try + { + // NEVER log the full URL — it carries the API token. Log the symbol only. + Log.Info("Finnhub", $"GET /quote symbol={symbol}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync($"quote?symbol={Uri.EscapeDataString(symbol)}&token={ApiKey}", c), + "Finnhub", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("Finnhub", $"{symbol} <- {(int)response.StatusCode} {response.StatusCode} body={json}"); + + if (!response.IsSuccessStatusCode) + { + // e.g. 403 premium-gated (forex), 429 rate-limited. + Log.Warn("Finnhub", $"{symbol}: non-success status {(int)response.StatusCode} — treating as unavailable"); + return Invalid(instrument); + } + + var dto = JsonSerializer.Deserialize(json, FinnhubJsonContext.Default.ApiFinnhubQuoteDto); + + // Finnhub returns all-zero fields for unknown/unavailable symbols (not an HTTP error). + var price = dto?.Current ?? 0m; + if (price == 0m) + { + Log.Warn("Finnhub", $"{symbol}: all-zero quote — treating as invalid symbol"); + return Invalid(instrument); + } + + // Finnhub's /quote carries no currency — resolve the native currency (and fold any pence quote + // to major units) before building the domain quote. Crypto is USD-quoted; stocks look up + // /stock/profile2 (cached). This is the only place we await a second call, and only for stocks. + var (currency, normPrice, normChange) = + await ResolveCurrencyAsync(instrument, price, dto!.Change ?? 0m, ct).ConfigureAwait(false); + var quote = new DomainQuote( + instrument.Symbol, instrument.Name, instrument.Category, + normPrice, normChange, dto.ChangePercent ?? 0m, IsValid: true, Currency: currency); + Log.Info("Finnhub", $"{symbol} -> price={quote.Price} change%={quote.ChangePercent} ccy={currency}"); + return quote; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Network / HTTP / parse failure: degrade to an invalid quote rather than breaking the + // whole page. Richer error/rate-limit UX is deferred. + Log.Error("Finnhub", $"{symbol}: request failed", ex); + return Invalid(instrument); + } + } + + public async Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + if (!HasApiKey) + { + Log.Warn("Finnhub", "no API key set — skipping candles (set one in extension settings)"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + var symbol = ToFinnhubSymbol(instrument); + // Crypto candles come from the parallel /crypto/candle endpoint (same args + response shape). + var endpoint = instrument.Category == AssetCategory.Crypto ? "crypto/candle" : "stock/candle"; + var resolution = ToFinnhubResolution(range.Interval()); + var to = DateTimeOffset.UtcNow; + var fromUnix = (to - range.Lookback()).ToUnixTimeSeconds(); + var toUnix = to.ToUnixTimeSeconds(); + + try + { + // NEVER log the full URL — it carries the API token. Log symbol/resolution/range only. + Log.Info("Finnhub", $"GET /{endpoint} symbol={symbol} resolution={resolution} range={range}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync( + $"{endpoint}?symbol={Uri.EscapeDataString(symbol)}&resolution={resolution}" + + $"&from={fromUnix}&to={toUnix}&token={ApiKey}", c), + "Finnhub", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("Finnhub", $"{symbol} candles <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + // 403 = premium-gated (candles are a paid endpoint), 429 = rate-limited, ... + Log.Warn("Finnhub", $"{symbol} candles: non-success status {(int)response.StatusCode} — no chart data"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + var dto = JsonSerializer.Deserialize(json, FinnhubJsonContext.Default.ApiFinnhubCandleDto); + if (dto is not { Status: "ok" } || + dto.Close is not { Count: > 0 } closes || + dto.Timestamps is not { Count: > 0 } times) + { + Log.Warn("Finnhub", $"{symbol} candles: status={dto?.Status ?? "null"} — no chart data"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + // The c/t arrays are parallel by index; guard against a ragged response. + var count = Math.Min(closes.Count, times.Count); + var points = new List(count); + for (var i = 0; i < count; i++) + points.Add(new CandlePoint(DateTimeOffset.FromUnixTimeSeconds(times[i]), closes[i])); + + Log.Info("Finnhub", $"{symbol} candles -> {points.Count} pts"); + return new DomainCandleSeries(instrument.Symbol, range, points, IsValid: true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("Finnhub", $"{symbol} candles: request failed", ex); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + } + + private static DomainQuote Invalid(DomainInstrument i) => + new(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false); + + // Resolve the native currency (and major-unit price/change) for a Finnhub quote. Crypto is USD (we + // price against USD and Finnhub serves no FX here); a stock looks up its reporting currency via + // /stock/profile2 and normalizes any pence quote (London GBp/GBX → GBP, ÷100). + private static async Task<(string Currency, decimal Price, decimal Change)> ResolveCurrencyAsync( + DomainInstrument instrument, decimal price, decimal change, CancellationToken ct) + { + if (instrument.Category != AssetCategory.Stock) + return ("USD", price, change); + + var raw = await GetStockCurrencyCodeAsync(instrument.Symbol, ct).ConfigureAwait(false); + return CurrencyHelper.NormalizeStockQuote(raw, price, change); + } + + // The raw reporting-currency code for a stock symbol, cached forever. A US-only (free) key returns + // "USD" for everything anyway; only a paid key's non-US listings make this matter. profile2 is on the + // free tier and reliable, so we cache EVERY result — including a USD fallback when it's unavailable — to + // bound this to exactly one extra call per symbol per session (doubling Finnhub's poll volume by + // retrying a flaky profile2 every tick would be worse than a rare, reload-healed USD mislabel). + private static async Task GetStockCurrencyCodeAsync(string symbol, CancellationToken ct) + { + var key = symbol.ToUpperInvariant(); + if (StockCurrencyCache.TryGetValue(key, out var cached)) + return cached; + + var resolved = await FetchProfileCurrencyAsync(key, ct).ConfigureAwait(false) ?? "USD"; + StockCurrencyCache[key] = resolved; + return resolved; + } + + private static async Task FetchProfileCurrencyAsync(string symbol, CancellationToken ct) + { + try + { + // NEVER log the full URL — it carries the API token. Log the symbol only. + Log.Info("Finnhub", $"GET /stock/profile2 symbol={symbol}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync($"stock/profile2?symbol={Uri.EscapeDataString(symbol)}&token={ApiKey}", c), + "Finnhub", ct).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + Log.Warn("Finnhub", $"profile2 {symbol}: non-success status {(int)response.StatusCode} — defaulting USD"); + return null; + } + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var dto = JsonSerializer.Deserialize(json, FinnhubJsonContext.Default.ApiFinnhubProfileDto); + return string.IsNullOrWhiteSpace(dto?.Currency) ? null : dto!.Currency; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("Finnhub", $"profile2 {symbol}: request failed", ex); + return null; + } + } + + // Neutral CandleInterval -> Finnhub's resolution token. The candle analog of ToFinnhubSymbol — + // the ONLY place these provider-specific tokens live. A different provider maps the same + // CandleInterval to its own vocabulary (e.g. Twelve Data: 5min/30min/1h/1day/1week). + private static string ToFinnhubResolution(CandleInterval interval) => interval switch + { + CandleInterval.FiveMin => "5", + CandleInterval.ThirtyMin => "30", + CandleInterval.Hourly => "60", + CandleInterval.Daily => "D", + CandleInterval.Weekly => "W", + _ => "D", + }; + + // Neutral ticker -> Finnhub symbol syntax. Keeps InstrumentCatalog provider-agnostic. + // Stock AAPL -> AAPL + // Crypto BTC -> BINANCE:BTCUSDT + // Currency EURUSD -> OANDA:EUR_USD (premium on the free tier; see InstrumentCatalog) + private static string ToFinnhubSymbol(DomainInstrument instrument) => instrument.Category switch + { + AssetCategory.Stock => instrument.Symbol.ToUpperInvariant(), + AssetCategory.Crypto => $"BINANCE:{instrument.Symbol.ToUpperInvariant()}USDT", + AssetCategory.Currency => $"OANDA:{instrument.Symbol.ToUpperInvariant().Insert(3, "_")}", + _ => instrument.Symbol, + }; +} diff --git a/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs b/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs new file mode 100644 index 0000000..a79712d --- /dev/null +++ b/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Frankfurter's time-series response +// GET /v1/{from}..{to}?base=EUR&symbols=USD +// { "amount":1.0, "base":"EUR", "start_date":"…", "end_date":"…", +// "rates": { "2024-06-03": { "USD": 1.0842 }, … } } +// `rates` is keyed by ISO date, each mapping a currency code → that day's rate (per 1 base unit). +// Internal to FrankfurterMarketDataProvider, which maps it into DomainQuotes / DomainCandleSeries. +// One DTO covers both flows: a short window backs the day-over-day quote, a wider one backs the chart. +// +// Source-generated (de)serialization keeps the AOT/trim build clean. Per the CLAUDE.md gotcha, the +// single [JsonSerializable] attribute lives on one partial declaration of this context. +internal sealed record ApiFrankfurterSeriesDto( + [property: JsonPropertyName("amount")] decimal Amount, + [property: JsonPropertyName("base")] string? Base, + [property: JsonPropertyName("start_date")] string? StartDate, + [property: JsonPropertyName("end_date")] string? EndDate, + [property: JsonPropertyName("rates")] Dictionary>? Rates); + +// Api layer: the raw shape of Frankfurter's LATEST (spot) response +// GET /v1/latest?base=USD&symbols=GBP,JPY +// { "amount":1.0, "base":"USD", "date":"…", "rates": { "GBP": 0.78, "JPY": 156.2 } } +// Here `rates` is a flat currency → rate map (per 1 base unit), NOT nested by date like the series above. +// Used by CurrencyConverter to convert holding values into the user's PortfolioCurrency. +internal sealed record ApiFrankfurterLatestDto( + [property: JsonPropertyName("amount")] decimal Amount, + [property: JsonPropertyName("base")] string? Base, + [property: JsonPropertyName("date")] string? Date, + [property: JsonPropertyName("rates")] Dictionary? Rates); + +// ⚠️ Keep ALL [JsonSerializable] on this single declaration — splitting them across partials silently +// breaks the JSON source generator (see CLAUDE.md AOT/trim gotcha). +[JsonSerializable(typeof(ApiFrankfurterSeriesDto))] +[JsonSerializable(typeof(ApiFrankfurterLatestDto))] +internal sealed partial class FrankfurterJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs b/MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs new file mode 100644 index 0000000..5d82ab2 --- /dev/null +++ b/MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// Live foreign-exchange rates from Frankfurter (https://frankfurter.dev) — a free, keyless wrapper +// over the European Central Bank's daily reference rates. One IMarketDataProvider source: it splits a +// neutral 6-letter pair (EURUSD) into base/quote currencies, calls Frankfurter's time-series endpoint, +// and maps the response into DomainQuotes / DomainCandleSeries. Nothing outside this class references +// Frankfurter, so it slots in beside FinnhubMarketDataProvider with no repository or UI changes — it's +// the keyless FX provider the seam was designed for (see CLAUDE.md "To add a provider"). +// +// ECB publishes one fixing per business day, so FX here is DAILY ONLY: +// • a quote's "change" is day-over-day (latest vs previous fixing — the FX analog of Finnhub's `d`); +// • charts plot daily closes, so the intraday 1D/1W tabs show the most recent daily fixings, not bars. +// There's no API key and no documented rate limit, so (unlike Finnhub) there's no key short-circuit. +internal sealed class FrankfurterMarketDataProvider : IMarketDataProvider +{ + // Reuse a single client (creating one per request exhausts sockets). The trailing slash matters: + // relative request paths resolve under /v1/. AOT-safe. + private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.frankfurter.dev/v1/") }; + + public bool Supports(AssetCategory category) => category is AssetCategory.Currency; + + public async Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) => + await Task.WhenAll(instruments.Select(i => FetchQuoteAsync(i, ct))).ConfigureAwait(false); + + // Frankfurter has no free-text symbol endpoint, so "search" is a local filter over the catalog's + // FX pairs (identity only, no network call) — enough to make the pairs findable from Markets Search + // for installs whose watchlist predates them. (Crypto/Finnhub-side FX search is still deferred.) + public Task> SearchAsync(string query, CancellationToken ct = default) + { + query = query?.Trim() ?? string.Empty; + IReadOnlyList matches = query.Length == 0 + ? [] + : [.. InstrumentCatalog.All.Where(i => + i.Category == AssetCategory.Currency && + (i.Symbol.Contains(query, StringComparison.OrdinalIgnoreCase) || + i.Name.Contains(query, StringComparison.OrdinalIgnoreCase)))]; + return Task.FromResult(matches); + } + + public async Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + if (!TrySplitPair(instrument.Symbol, out var baseCur, out var quoteCur)) + { + Log.Warn("Frankfurter", $"{instrument.Symbol}: not a 6-letter FX pair — no chart data"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + // ECB is daily-only, so a sub-week window yields too few points to draw a line. Floor the + // lookback at 7 days so even the 1D/1W tabs render a short daily series rather than a blank chart. + var lookback = range.Lookback() < TimeSpan.FromDays(7) ? TimeSpan.FromDays(7) : range.Lookback(); + var series = await FetchSeriesAsync(baseCur, quoteCur, lookback, ct).ConfigureAwait(false); + if (series is not { Count: > 0 }) + { + Log.Warn("Frankfurter", $"{instrument.Symbol} candles: no data for {range}"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + var points = series.Select(p => new CandlePoint(p.Date, p.Rate)).ToList(); + Log.Info("Frankfurter", $"{instrument.Symbol} candles -> {points.Count} pts"); + return new DomainCandleSeries(instrument.Symbol, range, points, IsValid: true); + } + + private static async Task FetchQuoteAsync(DomainInstrument instrument, CancellationToken ct) + { + if (!TrySplitPair(instrument.Symbol, out var baseCur, out var quoteCur)) + { + Log.Warn("Frankfurter", $"{instrument.Symbol}: not a 6-letter FX pair — treating as unavailable"); + return Invalid(instrument); + } + + // A ~10-day window guarantees at least two business-day fixings across weekends/holidays, so we + // can derive a day-over-day change. One call returns both the latest rate and the prior one. + var series = await FetchSeriesAsync(baseCur, quoteCur, TimeSpan.FromDays(10), ct).ConfigureAwait(false); + if (series is not { Count: > 0 }) + return Invalid(instrument); + + var price = series[^1].Rate; + var prev = series.Count >= 2 ? series[^2].Rate : price; + var change = price - prev; + var pct = prev != 0m ? change / prev * 100m : 0m; + + // An FX pair's price is denominated in its QUOTE (second) currency — EURUSD in USD, USDJPY in JPY — + // so a notional holding of the pair values in that currency (and converts from it on the Portfolio). + var quote = new DomainQuote( + instrument.Symbol, instrument.Name, instrument.Category, price, change, pct, IsValid: true, + Currency: quoteCur); + Log.Info("Frankfurter", $"{instrument.Symbol} -> rate={price} change%={pct} ccy={quoteCur}"); + return quote; + } + + // Fetch the daily rate of quoteCur (per 1 baseCur) over [now-lookback, now], ascending by date. + // Returns null on any HTTP / parse failure so the caller can degrade to an invalid quote/series. + private static async Task?> FetchSeriesAsync( + string baseCur, string quoteCur, TimeSpan lookback, CancellationToken ct) + { + var to = DateTimeOffset.UtcNow; + var from = to - lookback; + // ISO dates carry no colons, and "{from}..{to}" is a single path segment (not a "../" dot-segment), + // so it resolves cleanly under the /v1/ base address. + var path = $"{from:yyyy-MM-dd}..{to:yyyy-MM-dd}?base={baseCur}&symbols={quoteCur}"; + try + { + Log.Info("Frankfurter", $"GET /{from:yyyy-MM-dd}..{to:yyyy-MM-dd} {baseCur}->{quoteCur}"); + using var response = await Http.GetAsync(path, ct).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("Frankfurter", $"{baseCur}{quoteCur} <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + Log.Warn("Frankfurter", $"{baseCur}{quoteCur}: non-success {(int)response.StatusCode} — no data"); + return null; + } + + var dto = JsonSerializer.Deserialize(json, FrankfurterJsonContext.Default.ApiFrankfurterSeriesDto); + if (dto?.Rates is not { Count: > 0 } rates) + return null; + + // `rates` is keyed by ISO date; ISO dates sort lexically = chronologically. Pull our quote + // currency out of each day's bucket, skipping any malformed entry. + var points = new List<(DateTimeOffset Date, decimal Rate)>(rates.Count); + foreach (var kv in rates.OrderBy(r => r.Key, StringComparer.Ordinal)) + { + if (kv.Value.TryGetValue(quoteCur, out var rate) && + DateTimeOffset.TryParse( + kv.Key, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + points.Add((date, rate)); + } + return points; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("Frankfurter", $"{baseCur}{quoteCur}: request failed", ex); + return null; + } + } + + // A neutral FX pair is 6 letters: base (first 3) + quote (last 3), e.g. EURUSD -> EUR, USD. + private static bool TrySplitPair(string symbol, out string baseCur, out string quoteCur) + { + baseCur = quoteCur = string.Empty; + if (string.IsNullOrWhiteSpace(symbol) || symbol.Length != 6) + return false; + baseCur = symbol[..3].ToUpperInvariant(); + quoteCur = symbol[3..].ToUpperInvariant(); + return true; + } + + private static DomainQuote Invalid(DomainInstrument i) => + new(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false); +} diff --git a/MarketExtension/Data/IMarketDataProvider.cs b/MarketExtension/Data/IMarketDataProvider.cs index 78e5ec4..f1f0fe6 100644 --- a/MarketExtension/Data/IMarketDataProvider.cs +++ b/MarketExtension/Data/IMarketDataProvider.cs @@ -1,14 +1,40 @@ using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace MarketExtension; -// The single seam between the UI and the data source. Today MockMarketDataProvider returns -// static seed data; swapping in a real market-data API later means replacing the -// implementation here and nowhere else. -// -// Kept synchronous to match the existing load convention (the page calls this inside -// Task.Run, the same way the reference AdbExtensionPage wraps AdbHelper.GetInstalledPackages). +// One market-data source (Finnhub, a future forex source, the offline mock, ...). Each provider +// declares which asset classes it can serve and maps its own Api* response into provider-agnostic +// DomainQuotes. MarketRepository coordinates one or more of these; the UI depends on the +// repository, not on a provider directly. internal interface IMarketDataProvider { - IReadOnlyList GetQuotes(); + // Which asset classes this source can price — used by MarketRepository to route instruments. + bool Supports(AssetCategory category); + + // When true, this provider is the SOLE active source: MarketRepository routes EVERY operation — + // quotes, candles, AND the search fan-out — to exclusive providers only and ignores the rest. This is + // how a provider takes precedence everywhere, including search (which Supports() can't gate, since the + // repository fans search out to all providers). MockMarketDataProvider sets this from the Demo-mode + // setting so demo data wins across the board. Default false → normal first-match-by-Supports routing; a + // default member so existing providers opt out for free. + bool IsExclusive => false; + + Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default); + + // Look up instruments by free-text query (symbol or name). Returns identity only — no prices, + // so a search costs one call regardless of how many matches come back. A provider that can't + // search returns an empty list. MarketRepository fans out and merges across providers. + Task> SearchAsync(string query, CancellationToken ct = default); + + // Historical price candles for one instrument over a ChartRange (backs the detail-page chart). + // Provider-agnostic in and out: the provider translates ChartRange.Interval into its own + // resolution token and ChartRange.Lookback into a from/to window, and maps its Api* candle DTO + // into a DomainCandleSeries. A provider that can't serve candles keeps this default (an invalid, + // empty series). MarketRepository routes by asset class, exactly like GetQuotesAsync. + Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + => Task.FromResult(DomainCandleSeries.Invalid(instrument.Symbol, range)); } diff --git a/MarketExtension/Data/InstrumentCatalog.cs b/MarketExtension/Data/InstrumentCatalog.cs new file mode 100644 index 0000000..3526613 --- /dev/null +++ b/MarketExtension/Data/InstrumentCatalog.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace MarketExtension; + +// The static set of instruments the extension shows. Provider-agnostic (neutral tickers); +// whichever IMarketDataProvider is active turns these into live Quotes. This is app config, +// not provider state, so the pages pass it in. Later this gives way to a user-pinned list. +// +// FX pairs are served by FrankfurterMarketDataProvider (keyless ECB daily rates) — Finnhub's free +// tier gates OANDA:* /quote behind a paid plan, so forex is routed to Frankfurter via the +// IMarketDataProvider seam. Pairs use the neutral 6-letter form BASE+QUOTE (EURUSD), which the +// provider splits into base/quote currencies. +internal static class InstrumentCatalog +{ + public static readonly IReadOnlyList All = + [ + // Stocks + new("AAPL", "Apple Inc.", AssetCategory.Stock), + new("MSFT", "Microsoft Corp.", AssetCategory.Stock), + new("NVDA", "NVIDIA Corp.", AssetCategory.Stock), + + // Crypto + new("BTC", "Bitcoin", AssetCategory.Crypto), + new("ETH", "Ethereum", AssetCategory.Crypto), + new("SOL", "Solana", AssetCategory.Crypto), + + // Currency (Forex) — priced via Frankfurter (keyless ECB daily fixings). + new("EURUSD", "Euro / US Dollar", AssetCategory.Currency), + new("GBPUSD", "British Pound / US Dollar", AssetCategory.Currency), + new("USDJPY", "US Dollar / Japanese Yen", AssetCategory.Currency), + ]; +} diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs new file mode 100644 index 0000000..1ae253a --- /dev/null +++ b/MarketExtension/Data/MarketRepository.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// The coordinator the UI depends on. Holds an ordered set of IMarketDataProviders (one per data +// source) and presents them as a single source of quotes: each instrument is routed to the first +// provider that supports its asset class, every provider's batch is fetched concurrently, and the +// results are merged into one list in the original instrument order. Instruments no provider can +// serve become invalid placeholders. Adding a data source = pass another provider to the +// constructor; nothing else changes. +internal sealed class MarketRepository(params IMarketDataProvider[] providers) +{ + // The providers that should serve right now. When any provider declares itself exclusive (e.g. the + // mock in Demo mode), ONLY those serve — every operation routes to them alone, so the exclusive source + // takes precedence everywhere, including the search fan-out that Supports() can't gate. Otherwise the + // full ordered set, routed normally by first-match Supports. + private IReadOnlyList ActiveProviders() + { + var exclusive = providers.Where(p => p.IsExclusive).ToList(); + return exclusive.Count > 0 ? exclusive : providers; + } + + public async Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) + { + // Route each instrument to the first ACTIVE provider that can serve its asset class. + var active = ActiveProviders(); + var batches = new Dictionary>(); + var unserviceable = new List(); + foreach (var instrument in instruments) + { + var provider = active.FirstOrDefault(p => p.Supports(instrument.Category)); + if (provider is null) + { + unserviceable.Add(instrument); + continue; + } + + if (!batches.TryGetValue(provider, out var batch)) + batches[provider] = batch = []; + batch.Add(instrument); + } + + // One batched call per provider, fanned out concurrently. + var results = await Task.WhenAll( + batches.Select(kv => kv.Key.GetQuotesAsync(kv.Value, ct))).ConfigureAwait(false); + + // Merge into a symbol -> quote lookup; instruments no provider serves become invalid + // placeholders so the row renders as unavailable rather than vanishing. + var bySymbol = results.SelectMany(r => r).ToDictionary(q => q.Symbol); + foreach (var instrument in unserviceable) + bySymbol[instrument.Symbol] = + new DomainQuote(instrument.Symbol, instrument.Name, instrument.Category, 0m, 0m, 0m, IsValid: false); + + // Preserve the caller's instrument order. + var ordered = instruments.Select(i => bySymbol[i.Symbol]).ToList(); + Log.Info("Repository", + $"{instruments.Count} instrument(s) via {batches.Count} provider(s); " + + $"{ordered.Count(q => q.IsValid)} valid, {unserviceable.Count} unserviceable"); + return ordered; + } + + // Free-text instrument lookup. Fans out to every provider and merges their matches into one + // list, deduped by symbol and preserving first-seen (provider) order. Identity only — callers + // fetch quotes separately, so a search stays one call per provider regardless of match count. + public async Task> SearchAsync( + string query, CancellationToken ct = default) + { + var results = await Task.WhenAll( + ActiveProviders().Select(p => p.SearchAsync(query, ct))).ConfigureAwait(false); + + var merged = results + .SelectMany(r => r) + .GroupBy(i => i.Symbol, System.StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + + Log.Info("Repository", $"search '{query}' -> {merged.Count} merged result(s)"); + return merged; + } + + // Historical candles for one instrument over a ChartRange — routed to the first provider that + // supports its asset class (mirrors GetQuotesAsync). No provider → an invalid series so the chart + // renders an "unavailable" state rather than throwing. + public async Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + var provider = ActiveProviders().FirstOrDefault(p => p.Supports(instrument.Category)); + if (provider is null) + { + Log.Info("Repository", $"candles {instrument.Symbol} {range}: no provider for {instrument.Category}"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + var series = await provider.GetCandlesAsync(instrument, range, ct).ConfigureAwait(false); + Log.Info("Repository", + $"candles {instrument.Symbol} {range} -> {(series.HasData ? $"{series.Points.Count} pts" : "no data")}"); + return series; + } +} diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index 1f40f4f..4671589 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -1,27 +1,387 @@ +using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace MarketExtension; -// Placeholder data source. Returns a fixed set of plausible quotes across all three -// categories so the UI (and later the Dock band) has something realistic to render before -// the real API exists. Replace this class with an API-backed IMarketDataProvider later. +// The "Demo mode" data source. Returns deterministic prices/candles for the requested instruments so the +// whole UI works with no API key and no network. Registered FIRST in the repository and, in Demo mode, +// declared EXCLUSIVE (IsExclusive) so it takes precedence EVERYWHERE — the repository routes quotes, candles, +// AND the search fan-out to it alone, so no live provider is consulted at all (closing the search-leak that +// Supports() can't gate). Off, it's neither exclusive nor (via Supports) selectable, so live installs fall +// straight through to the real providers; flipping the setting applies without a reload. +// +// Two data sources back this: +// * Seed — a handful of headline symbols with hand-tuned, nice-looking prices (used as quote overrides). +// * Catalog — a curated corpus of REAL instruments (identity only) that backs demo-mode SEARCH. Search +// returns ONLY real tickers from here — deliberately NOT free-text fabrication — so a user can never add +// a non-existent symbol to their watchlist/portfolio (those persist to JSON and would show a permanent +// blank row once demo mode is off). Prices for any tracked symbol (catalog or not) are SYNTHESIZED at +// quote time from a stable hash of the symbol, with native currency inferred from the symbol shape — so +// an off-seed real holding added while live still prices in demo mode instead of looking broken. internal sealed class MockMarketDataProvider : IMarketDataProvider { - public IReadOnlyList GetQuotes() => - [ - // Stocks - new Quote("AAPL", "Apple Inc.", AssetCategory.Stock, 189.20m, 2.24m, 1.20m), - new Quote("MSFT", "Microsoft Corp.", AssetCategory.Stock, 421.10m, 1.68m, 0.40m), - new Quote("NVDA", "NVIDIA Corp.", AssetCategory.Stock, 118.45m, -3.10m, -2.55m), - - // Crypto - new Quote("BTC", "Bitcoin", AssetCategory.Crypto, 64210.00m, -512.00m, -0.80m), - new Quote("ETH", "Ethereum", AssetCategory.Crypto, 3420.50m, 45.20m, 1.34m), - new Quote("SOL", "Solana", AssetCategory.Crypto, 148.30m, -2.10m, -1.40m), - - // Currency - new Quote("EURUSD", "Euro / US Dollar", AssetCategory.Currency, 1.0832m, -0.0011m, -0.10m), - new Quote("GBPUSD", "Pound / US Dollar", AssetCategory.Currency, 1.2715m, 0.0024m, 0.19m), - new Quote("USDJPY", "US Dollar / Yen", AssetCategory.Currency, 157.420m, 0.310m, 0.20m), - ]; + private readonly record struct SeedQuote( + string Name, AssetCategory Category, decimal Price, decimal Change, decimal Pct, string Currency); + + // Hand-tuned prices for the headline symbols — these override synthesis so the most-seen instruments + // look "real" (e.g. AAPL at 189.20, a GBP London stock, a JPY-quoted FX pair). Every key here is also in + // Catalog so it remains searchable. + private static readonly Dictionary Seed = new(StringComparer.OrdinalIgnoreCase) + { + ["AAPL"] = new("Apple Inc.", AssetCategory.Stock, 189.20m, 2.24m, 1.20m, "USD"), + ["MSFT"] = new("Microsoft Corp.", AssetCategory.Stock, 421.10m, 1.68m, 0.40m, "USD"), + ["NVDA"] = new("NVIDIA Corp.", AssetCategory.Stock, 118.45m, -3.10m, -2.55m, "USD"), + // A London-listed stock priced in GBP — gives multi-currency conversion + total return a non-USD + // native to exercise offline (the live GBX/pence path is normalized to GBP by the real providers). + ["HSBA.L"] = new("HSBC Holdings plc", AssetCategory.Stock, 6.50m, 0.08m, 1.25m, "GBP"), + ["BTC"] = new("Bitcoin", AssetCategory.Crypto, 64210.00m, -512.00m, -0.80m, "USD"), + ["ETH"] = new("Ethereum", AssetCategory.Crypto, 3420.50m, 45.20m, 1.34m, "USD"), + ["SOL"] = new("Solana", AssetCategory.Crypto, 148.30m, -2.10m, -1.40m, "USD"), + ["EURUSD"] = new("Euro / US Dollar", AssetCategory.Currency, 1.0832m, -0.0011m, -0.10m, "USD"), + ["GBPUSD"] = new("British Pound / US Dollar", AssetCategory.Currency, 1.2715m, 0.0024m, 0.19m, "USD"), + ["USDJPY"] = new("US Dollar / Japanese Yen", AssetCategory.Currency, 157.420m, 0.310m, 0.20m, "JPY"), + }; + + private readonly record struct CatalogEntry(string Name, AssetCategory Category); + + // The real-instrument corpus backing demo-mode search. Identity only (name + category); prices are + // synthesized. Curated, not generated, so search can ONLY surface genuine tickers. Includes every Seed + // symbol plus a broad spread of well-known stocks (US + international), crypto, and FX pairs. + private static readonly Dictionary Catalog = new(StringComparer.OrdinalIgnoreCase) + { + // ── US large-cap stocks (USD) ── + ["AAPL"] = new("Apple Inc.", AssetCategory.Stock), + ["MSFT"] = new("Microsoft Corp.", AssetCategory.Stock), + ["NVDA"] = new("NVIDIA Corp.", AssetCategory.Stock), + ["GOOGL"] = new("Alphabet Inc. Class A", AssetCategory.Stock), + ["GOOG"] = new("Alphabet Inc. Class C", AssetCategory.Stock), + ["AMZN"] = new("Amazon.com Inc.", AssetCategory.Stock), + ["META"] = new("Meta Platforms Inc.", AssetCategory.Stock), + ["TSLA"] = new("Tesla Inc.", AssetCategory.Stock), + ["AVGO"] = new("Broadcom Inc.", AssetCategory.Stock), + ["JPM"] = new("JPMorgan Chase & Co.", AssetCategory.Stock), + ["V"] = new("Visa Inc.", AssetCategory.Stock), + ["MA"] = new("Mastercard Inc.", AssetCategory.Stock), + ["UNH"] = new("UnitedHealth Group Inc.", AssetCategory.Stock), + ["XOM"] = new("Exxon Mobil Corp.", AssetCategory.Stock), + ["JNJ"] = new("Johnson & Johnson", AssetCategory.Stock), + ["WMT"] = new("Walmart Inc.", AssetCategory.Stock), + ["PG"] = new("Procter & Gamble Co.", AssetCategory.Stock), + ["HD"] = new("Home Depot Inc.", AssetCategory.Stock), + ["COST"] = new("Costco Wholesale Corp.", AssetCategory.Stock), + ["ORCL"] = new("Oracle Corp.", AssetCategory.Stock), + ["NFLX"] = new("Netflix Inc.", AssetCategory.Stock), + ["AMD"] = new("Advanced Micro Devices Inc.", AssetCategory.Stock), + ["CRM"] = new("Salesforce Inc.", AssetCategory.Stock), + ["ADBE"] = new("Adobe Inc.", AssetCategory.Stock), + ["INTC"] = new("Intel Corp.", AssetCategory.Stock), + ["CSCO"] = new("Cisco Systems Inc.", AssetCategory.Stock), + ["PEP"] = new("PepsiCo Inc.", AssetCategory.Stock), + ["KO"] = new("Coca-Cola Co.", AssetCategory.Stock), + ["MCD"] = new("McDonald's Corp.", AssetCategory.Stock), + ["DIS"] = new("Walt Disney Co.", AssetCategory.Stock), + ["BA"] = new("Boeing Co.", AssetCategory.Stock), + ["NKE"] = new("Nike Inc.", AssetCategory.Stock), + ["PFE"] = new("Pfizer Inc.", AssetCategory.Stock), + ["BAC"] = new("Bank of America Corp.", AssetCategory.Stock), + ["WFC"] = new("Wells Fargo & Co.", AssetCategory.Stock), + ["GS"] = new("Goldman Sachs Group Inc.", AssetCategory.Stock), + ["MS"] = new("Morgan Stanley", AssetCategory.Stock), + ["ABBV"] = new("AbbVie Inc.", AssetCategory.Stock), + ["LLY"] = new("Eli Lilly & Co.", AssetCategory.Stock), + ["TMO"] = new("Thermo Fisher Scientific Inc.", AssetCategory.Stock), + ["ACN"] = new("Accenture plc", AssetCategory.Stock), + ["TXN"] = new("Texas Instruments Inc.", AssetCategory.Stock), + ["QCOM"] = new("Qualcomm Inc.", AssetCategory.Stock), + ["INTU"] = new("Intuit Inc.", AssetCategory.Stock), + ["IBM"] = new("International Business Machines Corp.", AssetCategory.Stock), + ["GE"] = new("General Electric Co.", AssetCategory.Stock), + ["CAT"] = new("Caterpillar Inc.", AssetCategory.Stock), + ["UPS"] = new("United Parcel Service Inc.", AssetCategory.Stock), + ["RTX"] = new("RTX Corp.", AssetCategory.Stock), + ["LMT"] = new("Lockheed Martin Corp.", AssetCategory.Stock), + ["SBUX"] = new("Starbucks Corp.", AssetCategory.Stock), + ["NOW"] = new("ServiceNow Inc.", AssetCategory.Stock), + ["UBER"] = new("Uber Technologies Inc.", AssetCategory.Stock), + ["PYPL"] = new("PayPal Holdings Inc.", AssetCategory.Stock), + ["SHOP"] = new("Shopify Inc.", AssetCategory.Stock), + ["PLTR"] = new("Palantir Technologies Inc.", AssetCategory.Stock), + ["COIN"] = new("Coinbase Global Inc.", AssetCategory.Stock), + ["SNOW"] = new("Snowflake Inc.", AssetCategory.Stock), + + // ── International stocks (non-USD; native currency inferred from the exchange suffix) ── + ["HSBA.L"] = new("HSBC Holdings plc", AssetCategory.Stock), // London (GBP) + ["BP.L"] = new("BP plc", AssetCategory.Stock), + ["SHEL.L"] = new("Shell plc", AssetCategory.Stock), + ["VOD.L"] = new("Vodafone Group plc", AssetCategory.Stock), + ["AZN.L"] = new("AstraZeneca plc", AssetCategory.Stock), + ["GSK.L"] = new("GSK plc", AssetCategory.Stock), + ["ULVR.L"] = new("Unilever plc", AssetCategory.Stock), + ["RIO.L"] = new("Rio Tinto plc", AssetCategory.Stock), + ["BARC.L"] = new("Barclays plc", AssetCategory.Stock), + ["LLOY.L"] = new("Lloyds Banking Group plc", AssetCategory.Stock), + ["SAP.DE"] = new("SAP SE", AssetCategory.Stock), // Frankfurt (EUR) + ["SIE.DE"] = new("Siemens AG", AssetCategory.Stock), + ["VOW3.DE"] = new("Volkswagen AG", AssetCategory.Stock), + ["BMW.DE"] = new("Bayerische Motoren Werke AG", AssetCategory.Stock), + ["BAS.DE"] = new("BASF SE", AssetCategory.Stock), + ["ALV.DE"] = new("Allianz SE", AssetCategory.Stock), + ["MC.PA"] = new("LVMH Moët Hennessy Louis Vuitton SE", AssetCategory.Stock), // Paris (EUR) + ["OR.PA"] = new("L'Oréal SA", AssetCategory.Stock), + ["AIR.PA"] = new("Airbus SE", AssetCategory.Stock), + ["SAN.PA"] = new("Sanofi SA", AssetCategory.Stock), + ["ASML.AS"] = new("ASML Holding NV", AssetCategory.Stock), // Amsterdam (EUR) + ["INGA.AS"] = new("ING Groep NV", AssetCategory.Stock), + ["NESN.SW"] = new("Nestlé SA", AssetCategory.Stock), // Zurich (CHF) + ["ROG.SW"] = new("Roche Holding AG", AssetCategory.Stock), + ["NOVN.SW"] = new("Novartis AG", AssetCategory.Stock), + ["7203.T"] = new("Toyota Motor Corp.", AssetCategory.Stock), // Tokyo (JPY) + ["6758.T"] = new("Sony Group Corp.", AssetCategory.Stock), + ["9984.T"] = new("SoftBank Group Corp.", AssetCategory.Stock), + ["0700.HK"] = new("Tencent Holdings Ltd.", AssetCategory.Stock), // Hong Kong (HKD) + ["9988.HK"] = new("Alibaba Group Holding Ltd.", AssetCategory.Stock), + ["RY.TO"] = new("Royal Bank of Canada", AssetCategory.Stock), // Toronto (CAD) + ["TD.TO"] = new("Toronto-Dominion Bank", AssetCategory.Stock), + ["BHP.AX"] = new("BHP Group Ltd.", AssetCategory.Stock), // Sydney (AUD) + ["CBA.AX"] = new("Commonwealth Bank of Australia", AssetCategory.Stock), + + // ── Crypto (USD) ── + ["BTC"] = new("Bitcoin", AssetCategory.Crypto), + ["ETH"] = new("Ethereum", AssetCategory.Crypto), + ["SOL"] = new("Solana", AssetCategory.Crypto), + ["XRP"] = new("XRP", AssetCategory.Crypto), + ["ADA"] = new("Cardano", AssetCategory.Crypto), + ["DOGE"] = new("Dogecoin", AssetCategory.Crypto), + ["DOT"] = new("Polkadot", AssetCategory.Crypto), + ["AVAX"] = new("Avalanche", AssetCategory.Crypto), + ["LINK"] = new("Chainlink", AssetCategory.Crypto), + ["LTC"] = new("Litecoin", AssetCategory.Crypto), + ["BCH"] = new("Bitcoin Cash", AssetCategory.Crypto), + ["MATIC"] = new("Polygon", AssetCategory.Crypto), + ["UNI"] = new("Uniswap", AssetCategory.Crypto), + ["ATOM"] = new("Cosmos", AssetCategory.Crypto), + ["XLM"] = new("Stellar", AssetCategory.Crypto), + ["TRX"] = new("TRON", AssetCategory.Crypto), + ["NEAR"] = new("NEAR Protocol", AssetCategory.Crypto), + ["APT"] = new("Aptos", AssetCategory.Crypto), + ["ARB"] = new("Arbitrum", AssetCategory.Crypto), + ["OP"] = new("Optimism", AssetCategory.Crypto), + + // ── FX pairs (native currency = the pair's quote currency) ── + ["EURUSD"] = new("Euro / US Dollar", AssetCategory.Currency), + ["GBPUSD"] = new("British Pound / US Dollar", AssetCategory.Currency), + ["USDJPY"] = new("US Dollar / Japanese Yen", AssetCategory.Currency), + ["USDCHF"] = new("US Dollar / Swiss Franc", AssetCategory.Currency), + ["AUDUSD"] = new("Australian Dollar / US Dollar", AssetCategory.Currency), + ["USDCAD"] = new("US Dollar / Canadian Dollar", AssetCategory.Currency), + ["NZDUSD"] = new("New Zealand Dollar / US Dollar", AssetCategory.Currency), + ["EURGBP"] = new("Euro / British Pound", AssetCategory.Currency), + ["EURJPY"] = new("Euro / Japanese Yen", AssetCategory.Currency), + ["GBPJPY"] = new("British Pound / Japanese Yen", AssetCategory.Currency), + ["EURCHF"] = new("Euro / Swiss Franc", AssetCategory.Currency), + ["USDCNY"] = new("US Dollar / Chinese Yuan", AssetCategory.Currency), + ["USDHKD"] = new("US Dollar / Hong Kong Dollar", AssetCategory.Currency), + ["USDSGD"] = new("US Dollar / Singapore Dollar", AssetCategory.Currency), + ["USDMXN"] = new("US Dollar / Mexican Peso", AssetCategory.Currency), + }; + + // Gated on Demo mode: when off this provider serves nothing (Supports false → the repository's + // first-match routing skips it for quotes/candles and falls through to the real providers); when on it + // serves every asset class. + public bool Supports(AssetCategory category) => MarketSettingsManager.Instance.DemoMode; + + // In Demo mode, be the SOLE active source so the mock wins everywhere — including the search fan-out the + // repository would otherwise send to live providers too (which Supports() can't gate). Off → not + // exclusive, so the real providers serve as normal. + public bool IsExclusive => MarketSettingsManager.Instance.DemoMode; + + public Task> SearchAsync(string query, CancellationToken ct = default) + { + // Self-gate on Demo mode: when ON the mock is exclusive, so the repository sends search to it alone; + // when OFF it's still in the repository's search fan-out (exclusivity doesn't apply), so returning [] + // keeps the catalog from polluting live results. + query = MarketSettingsManager.Instance.DemoMode ? query?.Trim() ?? string.Empty : string.Empty; + IReadOnlyList matches = query.Length == 0 + ? [] + // Match on symbol OR name against the curated real-instrument corpus — never fabricate a symbol. + // Prefix-on-symbol hits rank first (so "AAP" surfaces AAPL ahead of name-only matches), then + // alphabetical; capped so a broad query ("a") returns a sane page like the live providers do. + : [.. Catalog + .Where(kv => kv.Key.Contains(query, StringComparison.OrdinalIgnoreCase) + || kv.Value.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(kv => kv.Key.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .ThenBy(kv => kv.Key, StringComparer.Ordinal) + .Take(25) + .Select(kv => new DomainInstrument(kv.Key, kv.Value.Name, kv.Value.Category))]; + return Task.FromResult(matches); + } + + public Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) + { + IReadOnlyList quotes = + [ + // Seed override (nice hand-tuned price + native currency) for the headline symbols; otherwise + // SYNTHESIZE a stable, plausible quote for ANY symbol so an off-seed real holding (added while + // live) still prices in demo mode instead of showing a blank row. + .. instruments.Select(i => Seed.TryGetValue(i.Symbol, out var s) + ? new DomainQuote(i.Symbol, i.Name, i.Category, s.Price, s.Change, s.Pct, IsValid: true, + Currency: s.Currency) + : SynthesizeQuote(i)), + ]; + return Task.FromResult(quotes); + } + + public Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + // A deterministic random walk anchored on the symbol's price (seed override, else the synthesized + // price, so the chart agrees with the quote) so the chart renders offline / on a free key. Point + // count varies by range for a believable shape; seeded by (symbol, range) so the same chart is + // stable across re-fetches. + var basePrice = Seed.TryGetValue(instrument.Symbol, out var s) && s.Price > 0 + ? s.Price + : SynthesizeQuote(instrument).Price; + var count = range switch + { + ChartRange.OneDay => 78, + ChartRange.OneWeek => 65, + ChartRange.OneMonth => 120, + ChartRange.OneYear => 252, + ChartRange.FiveYear => 260, + _ => 100, + }; + + var rng = new Random(HashCode.Combine(instrument.Symbol, range)); + var step = range.Lookback() / count; + var now = DateTimeOffset.UtcNow; + var price = (double)basePrice * 0.92; // start below the seed so the walk trends toward it + var points = new List(count); + for (var i = 0; i < count; i++) + { + price *= 1 + ((rng.NextDouble() - 0.48) * 0.02); // small per-step move, slight upward drift + points.Add(new CandlePoint(now - (step * (count - i)), (decimal)price)); + } + + IReadOnlyList pts = points; + return Task.FromResult(new DomainCandleSeries(instrument.Symbol, range, pts, IsValid: true)); + } + + // Build a stable, plausible quote for a non-seeded instrument. Native currency is inferred from the + // symbol shape; price/change are derived from a stable hash of the symbol so they're identical across + // refreshes AND process restarts (unlike string.GetHashCode, which is per-process randomized). + private static DomainQuote SynthesizeQuote(DomainInstrument instrument) + { + var currency = InferCurrency(instrument.Symbol, instrument.Category); + var (price, change, pct) = SynthesizePrice(instrument.Symbol, instrument.Category, currency); + var name = string.IsNullOrWhiteSpace(instrument.Name) ? instrument.Symbol : instrument.Name; + return new DomainQuote(instrument.Symbol, name, instrument.Category, price, change, pct, + IsValid: true, Currency: currency); + } + + // Native currency by category + symbol shape: crypto is quoted in USD; an FX pair's price is in its + // quote (2nd) currency; a stock's currency comes from its exchange suffix (.L → GBP, .DE → EUR, …), + // defaulting to USD for a plain ticker. + private static string InferCurrency(string symbol, AssetCategory category) => category switch + { + AssetCategory.Crypto => "USD", + AssetCategory.Currency => CurrencyHelper.QuoteCurrencyOfPair(symbol), + _ => ExchangeSuffixCurrency(symbol), + }; + + // Map a stock's exchange suffix (the bit after the last '.') to its trading currency. Covers the + // exchanges in Catalog plus a few common others; unknown/suffix-less → USD. + private static string ExchangeSuffixCurrency(string symbol) + { + var dot = symbol.LastIndexOf('.'); + if (dot < 0 || dot == symbol.Length - 1) + return "USD"; + return symbol[(dot + 1)..].ToUpperInvariant() switch + { + "L" => "GBP", + "DE" or "F" or "PA" or "AS" or "MI" or "MC" or "BR" or "LS" or "VI" or "HE" or "IR" => "EUR", + "SW" => "CHF", + "T" => "JPY", + "HK" => "HKD", + "TO" or "V" => "CAD", + "AX" => "AUD", + "NZ" => "NZD", + "SS" or "SZ" => "CNY", + "KS" or "KQ" => "KRW", + "BO" or "NS" => "INR", + "ST" => "SEK", + "OL" => "NOK", + "CO" => "DKK", + "SA" => "BRL", + "MX" => "MXN", + "JO" => "ZAR", + "TA" => "ILS", + _ => "USD", + }; + } + + // Deterministic price + day-change for a symbol. Magnitude is category-appropriate (stock tens-to-low- + // hundreds, crypto a wide $1–$10k log spread, FX a realistic rate band) and the daily move is ±3%. + // Two independent hash draws (one for level, one for the move) keep price and change uncorrelated. + private static (decimal Price, decimal Change, decimal Pct) SynthesizePrice( + string symbol, AssetCategory category, string currency) + { + var level = Unit(symbol); + var move = Unit(symbol + "|chg"); + + decimal price; + int changeDecimals; + switch (category) + { + case AssetCategory.Crypto: + price = (decimal)Math.Round(Math.Pow(10, level * 4), 2); // ~$1 .. ~$10,000 + changeDecimals = 2; + break; + case AssetCategory.Currency: + price = currency == "JPY" + ? (decimal)Math.Round(90 + (level * 80), 3) // ~90 .. 170 (yen pairs) + : (decimal)Math.Round(0.6 + (level * 1.2), 4); // ~0.60 .. 1.80 + changeDecimals = currency == "JPY" ? 3 : 4; + break; + default: // Stock — scaled so non-USD prices read at a believable magnitude (e.g. JPY in thousands) + price = (decimal)Math.Round((15 + (level * 485)) * StockCurrencyScale(currency), 2); + changeDecimals = 2; + break; + } + + var pct = (decimal)Math.Round((move - 0.5) * 6, 2); // -3.00% .. +3.00% + var change = Math.Round(price * pct / 100m, changeDecimals); + return (price, change, pct); + } + + // Rough order-of-magnitude factor so a synthesized stock price reads plausibly in its native currency + // (a yen stock in the thousands, a won stock in the tens of thousands). Currencies near USD scale = 1. + private static double StockCurrencyScale(string currency) => currency switch + { + "JPY" => 30, + "KRW" => 200, + "HKD" => 8, + "INR" => 80, + _ => 1, + }; + + // A deterministic [0,1) fraction from a stable 32-bit FNV-1a hash of the input (24 high-quality bits). + private static double Unit(string s) => (Fnv1a(s) & 0xFFFFFF) / (double)0x1000000; + + private static uint Fnv1a(string s) + { + unchecked + { + uint hash = 2166136261; + foreach (var c in s) + { + hash ^= c; + hash *= 16777619; + } + return hash; + } + } } diff --git a/MarketExtension/Data/TwelveData/ApiTwelveDataCandleDto.cs b/MarketExtension/Data/TwelveData/ApiTwelveDataCandleDto.cs new file mode 100644 index 0000000..7ad91ae --- /dev/null +++ b/MarketExtension/Data/TwelveData/ApiTwelveDataCandleDto.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Twelve Data's GET /time_series response. Internal to the Twelve Data +// provider, which maps it into a DomainCandleSeries. Unlike Finnhub's column-oriented candle arrays, +// Twelve Data returns a row per bar in `values` (newest-first — the provider reverses to chronological +// order). Status is "ok" or "error" (with code/message). Open/High/Low/Volume are parsed but currently +// unused (kept for a future OHLC tooltip, mirroring ApiFinnhubCandleDto). Unlike Finnhub's candles this +// endpoint is on the FREE tier, so a free key renders real charts. +// +// Numbers arrive as JSON strings; the context's NumberHandling = AllowReadingFromString (in +// ApiTwelveDataQuoteDto.cs) parses them into decimal?. Registered on TwelveDataJsonContext via the +// SINGLE [JsonSerializable] declaration in ApiTwelveDataQuoteDto.cs — never a second partial (see the +// source-gen gotcha in CLAUDE.md). +internal sealed record ApiTwelveDataTimeSeriesDto( + [property: JsonPropertyName("values")] IReadOnlyList? Values, + [property: JsonPropertyName("status")] string? Status, + [property: JsonPropertyName("code")] int? Code, + [property: JsonPropertyName("message")] string? Message); + +internal sealed record ApiTwelveDataValueDto( + [property: JsonPropertyName("datetime")] string? Datetime, // "2021-09-16 15:59:00" or "2021-09-16" + [property: JsonPropertyName("open")] decimal? Open, + [property: JsonPropertyName("high")] decimal? High, + [property: JsonPropertyName("low")] decimal? Low, + [property: JsonPropertyName("close")] decimal? Close, + [property: JsonPropertyName("volume")] decimal? Volume); diff --git a/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs b/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs new file mode 100644 index 0000000..f1cdea7 --- /dev/null +++ b/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Twelve Data's GET /quote response. Internal to the Twelve Data +// provider, which maps it into a DomainQuote. A successful quote carries close/change/percent_change; +// a per-symbol failure (unknown ticker, symbol not on the plan) comes back as the same object with +// status="error" + code/message instead, so those fields are nullable and the provider treats a +// missing/zero close as invalid. +// +// Twelve Data encodes numbers as JSON strings ("148.85001"); the context's +// NumberHandling = AllowReadingFromString lets them deserialize straight into decimal? — no manual +// parsing. Source-generated (de)serialization keeps the AOT/trim build clean. +// +// ⚠️ Per the CLAUDE.md source-gen gotcha, ALL [JsonSerializable] for this context live on the SINGLE +// partial declaration below (splitting across partials silently breaks the whole generator). The +// candle and search DTOs (separate files) are registered here, not in their own files. +internal sealed record ApiTwelveDataQuoteDto( + [property: JsonPropertyName("symbol")] string? Symbol, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("close")] decimal? Close, // current price + [property: JsonPropertyName("change")] decimal? Change, // absolute day change + [property: JsonPropertyName("percent_change")] decimal? PercentChange, // percent day change + [property: JsonPropertyName("currency")] string? Currency, // native currency of the quote (e.g. "USD", "GBp") + [property: JsonPropertyName("status")] string? Status, // "error" on a per-symbol failure + [property: JsonPropertyName("code")] int? Code, // error code when status=="error" + [property: JsonPropertyName("message")] string? Message); + +[JsonSourceGenerationOptions(NumberHandling = JsonNumberHandling.AllowReadingFromString)] +[JsonSerializable(typeof(ApiTwelveDataQuoteDto))] +[JsonSerializable(typeof(Dictionary))] // multi-symbol /quote response (keyed by symbol) +[JsonSerializable(typeof(ApiTwelveDataTimeSeriesDto))] // /time_series — see ApiTwelveDataCandleDto.cs +[JsonSerializable(typeof(ApiTwelveDataSearchDto))] // /symbol_search — see ApiTwelveDataSearchDto.cs +internal sealed partial class TwelveDataJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Data/TwelveData/ApiTwelveDataSearchDto.cs b/MarketExtension/Data/TwelveData/ApiTwelveDataSearchDto.cs new file mode 100644 index 0000000..f04808b --- /dev/null +++ b/MarketExtension/Data/TwelveData/ApiTwelveDataSearchDto.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of Twelve Data's GET /symbol_search response. Internal to the Twelve Data +// provider, which maps each result into a provider-agnostic DomainInstrument — deriving the asset +// class from instrument_type and normalizing the symbol back to our neutral convention (crypto -> bare +// base coin, FX -> 6-letter pair) so it round-trips through the later quote/candle fetch. Fields are +// nullable because Twelve Data omits them for sparse matches; the provider skips results without a symbol. +// +// Registered on TwelveDataJsonContext via the SINGLE [JsonSerializable] declaration in +// ApiTwelveDataQuoteDto.cs — never a second partial (see the source-gen gotcha in CLAUDE.md). +internal sealed record ApiTwelveDataSearchDto( + [property: JsonPropertyName("data")] IReadOnlyList? Data, + [property: JsonPropertyName("status")] string? Status); + +internal sealed record ApiTwelveDataSearchResultDto( + [property: JsonPropertyName("symbol")] string? Symbol, // e.g. "AAPL", "BTC/USD", "EUR/USD" + [property: JsonPropertyName("instrument_name")] string? InstrumentName, // e.g. "Apple Inc" + [property: JsonPropertyName("instrument_type")] string? InstrumentType, // e.g. "Common Stock", "Digital Currency" + [property: JsonPropertyName("exchange")] string? Exchange, + [property: JsonPropertyName("currency")] string? Currency); diff --git a/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs new file mode 100644 index 0000000..9ddba53 --- /dev/null +++ b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// Live market data from Twelve Data (https://twelvedata.com). One IMarketDataProvider source covering +// stocks, crypto AND forex from a single API — it formats each neutral DomainInstrument into Twelve +// Data's symbol syntax, calls /quote (batched), /time_series and /symbol_search, and maps the raw +// Api* DTOs into DomainQuote / DomainCandleSeries / DomainInstrument. Nothing outside this class +// references Twelve Data, so it slots in beside Finnhub/Frankfurter with no repository or UI changes. +// +// Why it's the primary provider when configured: unlike Finnhub, Twelve Data's /time_series (candle) +// endpoint is on the FREE tier, so a free key renders real symbol-detail charts. Registered FIRST in +// MarketExtensionCommandsProvider; Supports() is gated on having a key (below), so with no key set the +// repository's first-match routing falls through to Finnhub (stocks/crypto) / Frankfurter (FX) — no +// regression for users without a Twelve Data key. Set a key and Twelve Data transparently takes over. +// +// Rate limit: the free tier is ~8 credits/minute and ~800/day (one credit per symbol). That tight +// per-minute cap is why GetQuotesAsync batches every instrument into a SINGLE /quote call rather than +// fanning out one request per symbol (which a sizable watchlist would blow on each refresh). +internal sealed class TwelveDataMarketDataProvider : IMarketDataProvider +{ + // The Twelve Data key, sourced exclusively from the extension's settings (no built-in/baked key). + // Read from MarketSettingsManager on each access so a key change applies on the next request + // without a reload; empty until the user sets one. + private static string ApiKey => MarketSettingsManager.Instance.TwelveDataApiKey; + private static bool HasApiKey => MarketSettingsManager.Instance.HasTwelveDataApiKey; + + // Reuse a single client (creating one per request exhausts sockets). The trailing slash matters: + // relative request paths resolve under the host root. AOT-safe. + private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.twelvedata.com/") }; + + // Max search results surfaced (also the /symbol_search outputsize). Keeps the list usable. + private const int MaxSearchResults = 25; + + // Twelve Data covers all three asset classes — but only claim them when a key is configured, so the + // repository's first-match routing falls through to Finnhub/Frankfurter when no key is set. + public bool Supports(AssetCategory category) => + HasApiKey && category is AssetCategory.Stock or AssetCategory.Crypto or AssetCategory.Currency; + + public async Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) + { + if (instruments.Count == 0) + return []; + + // No key configured (settings is the only source) — return everything as unavailable rather + // than firing keyless /quote calls that would just error. + if (!HasApiKey) + { + Log.Warn("TwelveData", "no API key set — skipping quotes (set one in extension settings)"); + return instruments.Select(Invalid).ToArray(); + } + + // Map each instrument to its Twelve Data symbol, deduped (two instruments shouldn't collide on + // a TD symbol in practice). The response is keyed by these symbols, so we match back by them. + var tdSymbols = new HashSet(instruments.Select(ToTwelveDataSymbol), StringComparer.OrdinalIgnoreCase); + + try + { + // Batch every symbol into ONE /quote call (comma-joined). One credit per symbol either way, + // but a single request stays under the tight 8-requests/minute free cap. The comma separates + // values; the "/" inside crypto/FX symbols is escaped per value. + var joined = string.Join(",", tdSymbols.Select(Uri.EscapeDataString)); + // NEVER log the full URL — it carries the API key. Log the symbols only. + Log.Info("TwelveData", $"GET /quote symbols={string.Join(",", tdSymbols)}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync($"quote?symbol={joined}&apikey={ApiKey}", c), "TwelveData", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("TwelveData", $"quote <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + // e.g. 429 rate-limited, 401 bad key. Degrade to all-unavailable rather than throwing. + Log.Warn("TwelveData", $"quote: non-success status {(int)response.StatusCode} — treating as unavailable"); + return instruments.Select(Invalid).ToArray(); + } + + var quotes = ParseQuotes(json, tdSymbols); + + return instruments.Select(i => + { + var tdSymbol = ToTwelveDataSymbol(i); + if (quotes.TryGetValue(tdSymbol, out var dto) && dto.Close is > 0m && + !string.Equals(dto.Status, "error", StringComparison.OrdinalIgnoreCase)) + { + var (currency, price, change) = ResolveCurrency(i, dto.Close.Value, dto.Change ?? 0m, dto.Currency); + var quote = new DomainQuote( + i.Symbol, i.Name, i.Category, + price, change, dto.PercentChange ?? 0m, IsValid: true, Currency: currency); + Log.Info("TwelveData", $"{tdSymbol} -> price={quote.Price} change%={quote.ChangePercent} ccy={currency}"); + return quote; + } + + Log.Warn("TwelveData", $"{tdSymbol}: no valid quote in response — treating as invalid"); + return Invalid(i); + }).ToArray(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Network / HTTP failure: degrade to invalid quotes rather than breaking the whole page. + Log.Error("TwelveData", "quote request failed", ex); + return instruments.Select(Invalid).ToArray(); + } + } + + // Normalize Twelve Data's /quote response into a symbol -> DTO map. The shape depends on count: + // a single symbol returns a bare quote object; multiple symbols return an object keyed by symbol. + private static Dictionary ParseQuotes( + string json, HashSet requestedSymbols) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + if (requestedSymbols.Count == 1) + { + var dto = JsonSerializer.Deserialize(json, TwelveDataJsonContext.Default.ApiTwelveDataQuoteDto); + if (dto is not null) + { + ReportIfRateLimited(dto); // a single-symbol error object parses straight into the DTO + result[requestedSymbols.First()] = dto; + } + } + else + { + var map = JsonSerializer.Deserialize( + json, TwelveDataJsonContext.Default.DictionaryStringApiTwelveDataQuoteDto); + if (map is not null) + foreach (var kv in map) + result[kv.Key] = kv.Value; + } + } + catch (JsonException ex) + { + // A global error (bad key, rate limit) can come back as a bare {"code":…,"status":"error"} + // object even on HTTP 200, which won't shape-match the multi-symbol map. Surface a 429 to the + // banner, then degrade to "no quotes" (all invalid) rather than throwing — keeps it out of the + // error path. + ReportIfGlobalRateLimit(json); + Log.Warn("TwelveData", $"quote: could not parse response ({ex.Message}) — treating as unavailable"); + } + return result; + } + + // Twelve Data signals throttling with code 429 — even inside a 200-OK body — so flip the rate-limit + // banner when we see it (HttpRetry only catches a real HTTP 429). + private static void ReportIfRateLimited(ApiTwelveDataQuoteDto dto) + { + if (dto.Code == 429) + RateLimitSignal.Instance.ReportRateLimited(); + } + + // The multi-symbol path's global-error case: the bare error object failed to deserialize as the keyed + // map, so re-parse it as a single quote DTO purely to read its code (and ignore if it isn't one). + private static void ReportIfGlobalRateLimit(string json) + { + try + { + var error = JsonSerializer.Deserialize(json, TwelveDataJsonContext.Default.ApiTwelveDataQuoteDto); + if (error?.Code == 429) + RateLimitSignal.Instance.ReportRateLimited(); + } + catch (JsonException) { /* not a recognizable error object — ignore */ } + } + + public async Task> SearchAsync(string query, CancellationToken ct = default) + { + query = query?.Trim() ?? string.Empty; + if (query.Length == 0) + return []; + + if (!HasApiKey) + { + Log.Warn("TwelveData", "no API key set — skipping search (set one in extension settings)"); + return []; + } + + try + { + // NEVER log the full URL — it carries the API key. Log the query only. + Log.Info("TwelveData", $"GET /symbol_search q={query}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync( + $"symbol_search?symbol={Uri.EscapeDataString(query)}&outputsize={MaxSearchResults}&apikey={ApiKey}", c), + "TwelveData", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("TwelveData", $"search '{query}' <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + Log.Warn("TwelveData", $"search '{query}': non-success status {(int)response.StatusCode} — no results"); + return []; + } + + var dto = JsonSerializer.Deserialize(json, TwelveDataJsonContext.Default.ApiTwelveDataSearchDto); + if (dto?.Data is not { Count: > 0 } rows) + return []; + + // Map to neutral identities: derive the asset class from instrument_type, normalize the + // symbol back to our convention so it round-trips through ToTwelveDataSymbol (and matches the + // icon resolver + watchlist storage), dedupe by symbol, and cap for the UI. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var instruments = new List(); + foreach (var r in rows) + { + if (string.IsNullOrWhiteSpace(r.Symbol)) + continue; + + var category = ToCategory(r.InstrumentType); + var symbol = NormalizeSymbol(r.Symbol!, category); + // FX results must be a clean 6-letter pair to split downstream; skip bare currencies. + if (string.IsNullOrEmpty(symbol) || + (category == AssetCategory.Currency && symbol.Length != 6) || + !seen.Add(symbol)) + continue; + + var name = string.IsNullOrWhiteSpace(r.InstrumentName) ? symbol : r.InstrumentName!; + instruments.Add(new DomainInstrument(symbol, name, category)); + if (instruments.Count >= MaxSearchResults) + break; + } + + Log.Info("TwelveData", $"search '{query}' -> {instruments.Count} result(s)"); + return instruments; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("TwelveData", $"search '{query}': request failed", ex); + return []; + } + } + + public async Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + if (!HasApiKey) + { + Log.Warn("TwelveData", "no API key set — skipping candles (set one in extension settings)"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + var symbol = ToTwelveDataSymbol(instrument); + var interval = ToTwelveDataInterval(range.Interval()); + var outputsize = OutputSize(range); + + try + { + // NEVER log the full URL — it carries the API key. Log symbol/interval/range only. + Log.Info("TwelveData", $"GET /time_series symbol={symbol} interval={interval} range={range}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync( + $"time_series?symbol={Uri.EscapeDataString(symbol)}&interval={interval}" + + $"&outputsize={outputsize}&apikey={ApiKey}", c), + "TwelveData", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("TwelveData", $"{symbol} candles <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + Log.Warn("TwelveData", $"{symbol} candles: non-success status {(int)response.StatusCode} — no chart data"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + var dto = JsonSerializer.Deserialize(json, TwelveDataJsonContext.Default.ApiTwelveDataTimeSeriesDto); + if (dto is not { Status: "ok" } || dto.Values is not { Count: > 0 } values) + { + Log.Warn("TwelveData", $"{symbol} candles: status={dto?.Status ?? "null"} — no chart data"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + // Twelve Data returns values newest-first; the chart (and the header's last-vs-first change) + // needs them oldest-first. Walk in reverse, dropping any point missing a close or datetime. + var points = new List(values.Count); + for (var i = values.Count - 1; i >= 0; i--) + { + var v = values[i]; + if (v.Close is { } close && + DateTimeOffset.TryParse( + v.Datetime, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var time)) + points.Add(new CandlePoint(time, close)); + } + + if (points.Count == 0) + { + Log.Warn("TwelveData", $"{symbol} candles: no usable points — no chart data"); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + + Log.Info("TwelveData", $"{symbol} candles -> {points.Count} pts"); + return new DomainCandleSeries(instrument.Symbol, range, points, IsValid: true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("TwelveData", $"{symbol} candles: request failed", ex); + return DomainCandleSeries.Invalid(instrument.Symbol, range); + } + } + + private static DomainQuote Invalid(DomainInstrument i) => + new(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false); + + // Resolve the native currency (and major-unit price/change) for a quote. Per category: + // * Currency — the pair's quote currency (EURUSD → USD), never the `currency` field. + // * Crypto — always USD; we price against USD (BTC/USD), and TD's `currency` adds nothing. + // * Stock — the /quote `currency` field, normalized (handles London's GBp/GBX pence → GBP, ÷100). + private static (string Currency, decimal Price, decimal Change) ResolveCurrency( + DomainInstrument instrument, decimal price, decimal change, string? rawCurrency) => + instrument.Category switch + { + AssetCategory.Currency => (CurrencyHelper.QuoteCurrencyOfPair(instrument.Symbol), price, change), + AssetCategory.Crypto => ("USD", price, change), + _ => CurrencyHelper.NormalizeStockQuote(rawCurrency, price, change), + }; + + // Twelve Data's instrument_type -> our neutral asset class. Equity-ish types (Common Stock, ETF, + // Index, ADR, ...) all price as stocks; only crypto and forex map elsewhere. + private static AssetCategory ToCategory(string? instrumentType) => instrumentType?.ToLowerInvariant() switch + { + "digital currency" or "cryptocurrency" or "crypto" => AssetCategory.Crypto, + "physical currency" or "forex" or "currency" => AssetCategory.Currency, + _ => AssetCategory.Stock, + }; + + // Provider symbol -> our neutral DomainInstrument.Symbol, so search results round-trip through + // ToTwelveDataSymbol and match the icon resolver + watchlist storage: + // Stock AAPL -> AAPL + // Crypto BTC/USD -> BTC (our crypto symbol is the bare base coin) + // Currency EUR/USD -> EURUSD (6-letter pair, matching Frankfurter's convention) + private static string NormalizeSymbol(string providerSymbol, AssetCategory category) + { + var s = providerSymbol.Trim().ToUpperInvariant(); + return category switch + { + AssetCategory.Crypto => s.Contains('/') ? s[..s.IndexOf('/')] : s, + AssetCategory.Currency => s.Replace("/", string.Empty), + _ => s, + }; + } + + // Neutral ticker -> Twelve Data symbol syntax. The mirror of NormalizeSymbol; keeps the catalog + // and watchlist provider-agnostic. + // Stock AAPL -> AAPL + // Crypto BTC -> BTC/USD + // Currency EURUSD -> EUR/USD + private static string ToTwelveDataSymbol(DomainInstrument instrument) + { + var s = instrument.Symbol.ToUpperInvariant(); + return instrument.Category switch + { + AssetCategory.Stock => s, + AssetCategory.Crypto => $"{s}/USD", + AssetCategory.Currency => s.Length == 6 ? $"{s[..3]}/{s[3..]}" : s, + _ => s, + }; + } + + // Neutral CandleInterval -> Twelve Data's interval token. The candle analog of ToTwelveDataSymbol — + // the ONLY place these provider-specific tokens live (cf. Finnhub's 5/30/60/D/W). + private static string ToTwelveDataInterval(CandleInterval interval) => interval switch + { + CandleInterval.FiveMin => "5min", + CandleInterval.ThirtyMin => "30min", + CandleInterval.Hourly => "1h", + CandleInterval.Daily => "1day", + CandleInterval.Weekly => "1week", + _ => "1day", + }; + + // Bars to request per range. Twelve Data returns the most-recent N at the given interval, so this + // sizes each window; all are well under TD's 5000 max. + private static int OutputSize(ChartRange range) => range switch + { + ChartRange.OneDay => 78, // ~1 trading day of 5-min bars + ChartRange.OneWeek => 70, // ~1 week of 30-min bars + ChartRange.OneMonth => 160, // ~1 month of hourly bars + ChartRange.OneYear => 365, // ~1 year of daily bars + ChartRange.FiveYear => 260, // ~5 years of weekly bars + _ => 100, + }; +} diff --git a/MarketExtension/Helpers/ApiKeyHint.cs b/MarketExtension/Helpers/ApiKeyHint.cs new file mode 100644 index 0000000..5794deb --- /dev/null +++ b/MarketExtension/Helpers/ApiKeyHint.cs @@ -0,0 +1,83 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// The status nudge for the list screens — one row, appended at the bottom, that explains the current data +// state. Two cases, in priority order: +// * Demo mode ON → a blue "Demo mode — showing sample data" row, so it's obvious prices are simulated +// (not live). Takes priority because in demo mode there's data regardless of keys. +// * No API key set → a red "No API key set" row: with NEITHER a Twelve Data nor a Finnhub key, stocks and +// crypto can't be priced or searched (only keyless Frankfurter FX still works), so the app looks +// broken/empty with no explanation. +// Otherwise null (a key is set and we're live → no nudge). The hub, Search, Watchlist and Favorites append +// StatusRow() when non-null; Enter on either row navigates straight into the extension's Settings page (to +// add a key, or to turn Demo mode off). +internal static class ApiKeyHint +{ + private const string WarningGlyph = ""; // Segoe MDL2 Warning + private const string DemoGlyph = ""; // Segoe MDL2 Info + + // Windows "attention" red — used for the missing-key tag so the row reads as a problem, not a normal item. + // A ListItem has no per-row text-color lever; a colored Tag (pill) is the only way to tint a row. + private static OptionalColor WarningRed => ColorHelpers.FromRgb(0xD1, 0x34, 0x38); + + // Windows accent blue — informational, distinct from the red "problem" tag: demo mode is a deliberate + // state, not a misconfiguration. + private static OptionalColor DemoBlue => ColorHelpers.FromRgb(0x00, 0x78, 0xD4); + + // Built once and reused: the toolkit's navigable settings form over our settings singleton. Both status + // rows point their Enter command at this, so the user lands directly on the relevant setting. + private static IContentPage? _settingsPage; + + private static IContentPage SettingsPage => + _settingsPage ??= MarketSettingsManager.Instance.Settings.SettingsPage; + + // True when at least one pricing-provider key is set. Frankfurter (FX) is keyless and needs no key, + // but stocks/crypto require a Twelve Data or Finnhub key — so "no key at all" means most of the app + // shows no data. Reads the observable flag's current value; the surfaces also Subscribe to it so the + // hint appears/disappears reactively the instant a key is added/cleared (MarketSettingsManager.HasAnyApiKey). + private static bool HasAnyKey => MarketSettingsManager.Instance.HasAnyApiKey.Value; + + // The status row, or null when a key is set and demo mode is off (live → no nudge). Callers append it + // only when non-null: + // + // if (ApiKeyHint.StatusRow() is { } hint) items.Add(hint); + public static IListItem? StatusRow() + { + if (MarketSettingsManager.Instance.DemoMode) + return DemoRow(); + return HasAnyKey ? null : MissingKeyRow(); + } + + // "Demo mode — showing sample data": surfaced while the Demo-mode setting is on so the user knows the + // prices are simulated. Enter → Settings (to turn it off). + private static ListItem DemoRow() => + new ListItem(SettingsPage) + { + Title = Resources.Status_Demo_Title, + Subtitle = Resources.Status_Demo_Subtitle, + Icon = new IconInfo(DemoGlyph), + Tags = [new Tag(Resources.Status_Demo_Tag) + { + Foreground = DemoBlue, + ToolTip = Resources.Status_Demo_Tooltip, + }], + }; + + // "No API key set": surfaced when neither pricing key is configured (and demo mode is off). Enter → + // Settings (to add a key). + private static ListItem MissingKeyRow() => + new ListItem(SettingsPage) + { + Title = Resources.Status_NoKey_Title, + Subtitle = Resources.Status_NoKey_Subtitle, + Icon = new IconInfo(WarningGlyph), + Tags = [new Tag(Resources.Status_NoKey_Tag) + { + Foreground = WarningRed, + ToolTip = Resources.Status_NoKey_Tooltip, + }], + }; +} diff --git a/MarketExtension/Helpers/AssetIconResolver.cs b/MarketExtension/Helpers/AssetIconResolver.cs new file mode 100644 index 0000000..a68d051 --- /dev/null +++ b/MarketExtension/Helpers/AssetIconResolver.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Resolves an instrument's identity to a row/dock IconInfo. This is the single place the logo-URL +// convention lives. Logos come from Elbstream's keyless CDN (https://elbstream.com), addressed +// directly by symbol — so there's no API call, no caching layer, and no DTO: new IconInfo(url) just +// hands the URL to the CmdPal host, which fetches the image itself. +// +// Stocks/crypto resolve to their company/coin logo; an FX pair resolves to its BASE currency's +// country flag (Elbstream /logos/country/{iso2} — EUR maps to the EU flag). Currencies we don't map +// and unknown categories fall back to a per-category Segoe MDL2 glyph. Elbstream is free WITH +// ATTRIBUTION — every surface that shows a logo must carry the credit (see AttributionRow + its +// placements in the priced/search pages and the detail card). +internal static class AssetIconResolver +{ + private const string Base = "https://api.elbstream.com/logos"; + + // Per-category fallback glyphs (Segoe MDL2 Assets), used only where no remote logo is fetched. + private const string CurrencyGlyph = ""; // Bank + private const string FallbackGlyph = ""; // Market + private const string LinkGlyph = ""; // Link (attribution row) + + // Elbstream's neutral identifiers match our DomainInstrument.Symbol directly (AAPL, BTC), so no + // provider-specific symbol translation is needed. format=png keeps icons raster (host SVG support is + // uncertain); size defaults to 100px, fine for a list icon. + public static IconInfo Resolve(string symbol, AssetCategory category) => category switch + { + AssetCategory.Stock => new IconInfo($"{Base}/symbol/{Uri.EscapeDataString(symbol)}?format=png"), + AssetCategory.Crypto => new IconInfo($"{Base}/crypto/{Uri.EscapeDataString(symbol)}?format=png"), + AssetCategory.Currency => ResolveCurrencyIcon(symbol), + _ => new IconInfo(FallbackGlyph), + }; + + // An FX pair (EURUSD) → its BASE currency's country flag (EUR → "eu"). Unknown currencies fall back + // to the generic bank glyph rather than a 404 empty slot. + private static IconInfo ResolveCurrencyIcon(string symbol) => + symbol is { Length: 6 } && CurrencyCountry.TryGetValue(symbol[..3].ToUpperInvariant(), out var iso2) + ? new IconInfo($"{Base}/country/{iso2}?format=png") + : new IconInfo(CurrencyGlyph); + + // Currency code → ISO-3166 alpha-2 country code for Elbstream's flag CDN. Covers the + // ECB-published currencies Frankfurter can serve; extend as new pairs are added to the catalog. + private static readonly Dictionary CurrencyCountry = new(StringComparer.Ordinal) + { + ["USD"] = "us", ["EUR"] = "eu", ["GBP"] = "gb", ["JPY"] = "jp", ["CHF"] = "ch", + ["AUD"] = "au", ["CAD"] = "ca", ["NZD"] = "nz", ["CNY"] = "cn", ["HKD"] = "hk", + ["SGD"] = "sg", ["SEK"] = "se", ["NOK"] = "no", ["DKK"] = "dk", ["PLN"] = "pl", + ["ZAR"] = "za", ["MXN"] = "mx", ["INR"] = "in", ["BRL"] = "br", ["KRW"] = "kr", + }; + + public static IconInfo Resolve(DomainInstrument instrument) => Resolve(instrument.Symbol, instrument.Category); + + public static IconInfo Resolve(UiQuote quote) => Resolve(quote.Symbol, quote.Category); + + // The Elbstream attribution row, appended to every list page that shows logos. Clickable → opens + // elbstream.com. A list-item title renders at the host's standard (well above 12pt) size, satisfying + // Elbstream's "clearly visible, min 12pt" requirement. + public static IListItem AttributionRow() => + new ListItem(new OpenUrlCommand("https://elbstream.com", Resources.Attribution_OpenName)) + { + Title = Resources.Attribution_Title, + Subtitle = Resources.Attribution_Subtitle, + Icon = new IconInfo(LinkGlyph), + }; +} diff --git a/MarketExtension/Helpers/ChartHelper.cs b/MarketExtension/Helpers/ChartHelper.cs new file mode 100644 index 0000000..b78f209 --- /dev/null +++ b/MarketExtension/Helpers/ChartHelper.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Xml.Linq; + +namespace MarketExtension; + +// Builds a price sparkline as an inline SVG embedded in a data: URI — the same AOT/trim-safe trick +// PowerToys' Performance Monitor uses for its CPU/RAM graphs (pure System.Xml.Linq, no +// System.Drawing/Skia). Ported from that extension's ChartHelper and generalized for price series: +// it plots every point across the width and normalizes Y to the series' own min/max (prices aren't +// 0-100 percentages), recoloring green/red by overall direction. Draws a faint background grid so the +// scale reads at a glance. +// +// NOTE: no axis NUMBER labels — the host rasterizes this data: URI through Direct2D's SVG engine +// (ID2D1SvgDocument), which renders lines/polylines/rects/gradients but NOT (text is silently +// dropped). So the grid is decorative only; numeric ticks aren't possible on this surface. The only +// caller is the Ui layer (UiCandleSeries) — this is a low-level string builder, like string.Format. +internal static class ChartHelper +{ + // Intrinsic SVG size; the adaptive-card Image stretches it to the card width keeping this 3:1 + // aspect. Larger than Perf Monitor's tile since this is a full detail-page chart. + private const int ChartHeight = 200; + private const int ChartWidth = 600; + private const int Padding = 6; // keep the line + grid frame off the edges + + private const string UpLineStyle = "fill:none;stroke:rgb(48,209,88);stroke-width:2"; + private const string DownLineStyle = "fill:none;stroke:rgb(255,69,58);stroke-width:2"; + private const string FillStyle = "fill:url(#g);stroke:transparent"; + + private const string UpStop1 = "stop-color:rgb(48,209,88);stop-opacity:0.35"; + private const string UpStop2 = "stop-color:rgb(48,209,88);stop-opacity:0.02"; + private const string DownStop1 = "stop-color:rgb(255,69,58);stop-opacity:0.35"; + private const string DownStop2 = "stop-color:rgb(255,69,58);stop-opacity:0.02"; + + // A neutral mid-gray that stays legible on both the light and dark CmdPal themes — the SVG is a + // static data: URI image and can't read the host theme (same trade-off the fixed green/red line + // already makes). Quarter lines (0/¼/½/¾/1 on both axes) form a 4×4 box, the 0/1 lines its frame. + private const string GridStyle = "stroke:rgb(136,136,136);stroke-width:1;stroke-opacity:0.18"; + private static readonly double[] GridFractions = [0, 0.25, 0.5, 0.75, 1.0]; + + public static string CreateImageUrl(IReadOnlyList closes, bool up) => + "data:image/svg+xml;utf8," + CreateChart(closes, up); + + public static string CreateChart(IReadOnlyList closes, bool up) + { + // + // ... (faint background grid) + // + // (gradient under the line) + // (the price line) + // + var svg = new XElement( + "svg", + new XAttribute("height", ChartHeight), + new XAttribute("width", ChartWidth)); + + // Grid first so the line + fill draw on top of it. + AddGrid(svg); + + var line = BuildLinePoints(closes); + if (line.Length > 0) + { + // Close the loop down to the baseline so the gradient fills the area under the line. + var fill = new StringBuilder(line.ToString()); + fill.Append(CultureInfo.InvariantCulture, $" {ChartWidth - Padding},{ChartHeight - Padding}"); + fill.Append(CultureInfo.InvariantCulture, $" {Padding},{ChartHeight - Padding}"); + + svg.Add(CreateGradient(up)); + svg.Add(new XElement( + "polyline", + new XAttribute("points", fill.ToString()), + new XAttribute("style", FillStyle))); + svg.Add(new XElement( + "polyline", + new XAttribute("points", line.ToString()), + new XAttribute("style", up ? UpLineStyle : DownLineStyle))); + } + + return svg.ToString(SaveOptions.DisableFormatting); + } + + // Faint quarter gridlines (horizontal + vertical), framing the plot like a normal graph. + private static void AddGrid(XElement svg) + { + double innerW = ChartWidth - (2 * Padding); + double innerH = ChartHeight - (2 * Padding); + + foreach (var f in GridFractions) + { + var y = Padding + (innerH * f); + svg.Add(GridLine(Padding, y, ChartWidth - Padding, y)); + + var x = Padding + (innerW * f); + svg.Add(GridLine(x, Padding, x, ChartHeight - Padding)); + } + } + + private static XElement GridLine(double x1, double y1, double x2, double y2) => + new( + "line", + new XAttribute("x1", x1.ToString("0.##", CultureInfo.InvariantCulture)), + new XAttribute("y1", y1.ToString("0.##", CultureInfo.InvariantCulture)), + new XAttribute("x2", x2.ToString("0.##", CultureInfo.InvariantCulture)), + new XAttribute("y2", y2.ToString("0.##", CultureInfo.InvariantCulture)), + new XAttribute("style", GridStyle)); + + // Map the close series to "x,y x,y ..." spread across the chart width, with Y normalized to the + // series' own min..max so the line uses the full height. + private static StringBuilder BuildLinePoints(IReadOnlyList closes) + { + var points = new StringBuilder(); + if (closes.Count == 0) + return points; + + var min = closes.Min(); + var max = closes.Max(); + var span = max - min; + + double innerW = ChartWidth - (2 * Padding); + double innerH = ChartHeight - (2 * Padding); + + for (var i = 0; i < closes.Count; i++) + { + var x = closes.Count == 1 + ? Padding + (innerW / 2) + : Padding + (innerW * i / (closes.Count - 1)); + + // 0 (=min) sits at the bottom, 1 (=max) at the top; a flat series rides mid-height. + var norm = span == 0m ? 0.5 : (double)((closes[i] - min) / span); + var y = Padding + (innerH * (1 - norm)); + + points.Append(CultureInfo.InvariantCulture, $"{x:0.##},{y:0.##} "); + } + + points.Remove(points.Length - 1, 1); // trailing space + return points; + } + + private static XElement CreateGradient(bool up) => + new( + "defs", + new XElement( + "linearGradient", + new XAttribute("id", "g"), + new XAttribute("x1", "0%"), + new XAttribute("x2", "0%"), + new XAttribute("y1", "0%"), + new XAttribute("y2", "100%"), + new XElement( + "stop", + new XAttribute("offset", "0%"), + new XAttribute("style", up ? UpStop1 : DownStop1)), + new XElement( + "stop", + new XAttribute("offset", "100%"), + new XAttribute("style", up ? UpStop2 : DownStop2)))); +} diff --git a/MarketExtension/Helpers/CurrencyConverter.cs b/MarketExtension/Helpers/CurrencyConverter.cs new file mode 100644 index 0000000..5c1a5e6 --- /dev/null +++ b/MarketExtension/Helpers/CurrencyConverter.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// Converts money between currencies using Frankfurter's keyless spot rates (the same ECB daily fixings the +// FX provider wraps). The Portfolio screen uses it to roll holdings priced in various native currencies up +// into the user's single PortfolioCurrency. +// +// Two-phase, because the screen renders synchronously but FX is a network fetch: +// * PrimeAsync(preferred, natives) — fetch (and cache) every native→preferred rate that isn't already +// fresh, in ONE batched Frankfurter call. Called off the price-load path (see PricedListPage's +// OnPriceCacheUpdated hook), then the page re-renders. +// * TryGetRate(from, to) — a synchronous cache read used while building the rows. Returns the rate, or +// null when it isn't known yet OR the currency pair isn't ECB-supported (the row then shows native +// value only and is excluded from the converted total). from == to short-circuits to 1. +// +// Rates are cached per ordered pair with a modest TTL: ECB publishes once per business day, so anything +// fresher than ~1h is pointless to refetch, and a stale entry simply re-primes on the next price refresh. +// Negative results (a currency ECB doesn't cover) are cached too, so we don't refetch them every tick. +internal sealed class CurrencyConverter +{ + public static readonly CurrencyConverter Instance = new(); + + // Keyless, no documented rate limit — same host as FrankfurterMarketDataProvider. Single reused client. + private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.frankfurter.dev/v1/") }; + + // Refetch a cached rate only once it's older than this. ECB is daily, so an hour is plenty fresh. + private static readonly long TtlMs = (long)TimeSpan.FromHours(1).TotalMilliseconds; + + // Key = "FROM>TO" (ISO codes, upper-cased). Value carries a nullable rate (null = resolved-as- + // unsupported) plus the tick it was stored at, for TTL expiry. + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + // Demo-mode rate table: the USD value of 1 unit of each currency, for computing native→preferred as a + // ratio with no network. Approximate mid-2025 levels — enough to exercise conversion, not to be precise. + // Covers the PortfolioCurrency choices plus the common native quote currencies; a code that's missing + // resolves to null (unsupported), mirroring how ECB-uncovered currencies behave live. + private static readonly Dictionary DemoUsdPerUnit = new(StringComparer.Ordinal) + { + ["USD"] = 1m, ["EUR"] = 1.08m, ["GBP"] = 1.27m, ["JPY"] = 0.0063m, ["CHF"] = 1.12m, + ["AUD"] = 0.66m, ["CAD"] = 0.73m, ["NZD"] = 0.61m, ["CNY"] = 0.138m, ["HKD"] = 0.128m, + ["SGD"] = 0.74m, ["SEK"] = 0.094m, ["NOK"] = 0.092m, ["DKK"] = 0.145m, ["PLN"] = 0.25m, + ["ZAR"] = 0.054m, ["MXN"] = 0.058m, ["INR"] = 0.012m, ["BRL"] = 0.18m, ["KRW"] = 0.00073m, + }; + + // native→preferred under demo mode = (USD per native) / (USD per preferred). Null when either side + // isn't in the table, matching the live "ECB doesn't cover it" → null contract. + private static decimal? DemoRate(string from, string to) => + DemoUsdPerUnit.TryGetValue(from, out var f) && DemoUsdPerUnit.TryGetValue(to, out var t) && t != 0m + ? f / t + : null; + + private CurrencyConverter() + { + // Demo mode swaps the rate source (live ECB fixings ↔ the static DemoUsdPerUnit table), so a flip + // invalidates every cached pair — drop them so the next PrimeAsync repopulates from the now-correct + // source. Without this, fresh entries (<1h TTL) from the old source would linger and PrimeAsync would + // skip them as "already fresh". replay:false — nothing is cached at construction anyway. + MarketSettingsManager.Instance.DemoModeChanged + .Subscribe(_ => _cache.Clear(), replayOnSubscribe: false); + } + + private readonly record struct CachedRate(decimal? Rate, long Tick); + + private static string Key(string from, string to) => $"{from}>{to}"; + + private bool TryGetFresh(string key, out decimal? rate) + { + rate = null; + if (_cache.TryGetValue(key, out var entry) && Environment.TickCount64 - entry.Tick < TtlMs) + { + rate = entry.Rate; + return true; + } + return false; + } + + // Synchronous cache read for render time. Units of `to` per 1 unit of `from`, or null when unknown/ + // unsupported. Same currency → exactly 1 (no fetch, no rounding). + public decimal? TryGetRate(string from, string to) + { + var f = (from ?? string.Empty).Trim().ToUpperInvariant(); + var t = (to ?? string.Empty).Trim().ToUpperInvariant(); + if (f.Length == 0 || t.Length == 0) + return null; + if (f == t) + return 1m; + return TryGetFresh(Key(f, t), out var rate) ? rate : null; + } + + // Ensure native→preferred rates are cached for every `native`, fetching the not-yet-fresh ones in a + // single Frankfurter call. Safe to call often: fresh entries (and same-currency natives) are skipped, + // so a no-op call does no network. Failures are left uncached so the next call retries. + public async Task PrimeAsync(string preferred, IEnumerable natives, CancellationToken ct = default) + { + var to = (preferred ?? string.Empty).Trim().ToUpperInvariant(); + if (to.Length == 0) + return; + + // Distinct native codes that still need fetching: not the preferred currency itself, and not + // already fresh in the cache. + var needed = natives + .Select(n => (n ?? string.Empty).Trim().ToUpperInvariant()) + .Where(n => n.Length > 0 && n != to) + .Distinct(StringComparer.Ordinal) + .Where(n => !TryGetFresh(Key(n, to), out _)) + .ToList(); + + if (needed.Count == 0) + return; + + // Demo mode: fill the cache from the static table (no network), then return. TryGetRate reads the + // cache exactly as it does for live rates, so the rest of the app is none the wiser. + if (MarketSettingsManager.Instance.DemoMode) + { + var stamp = Environment.TickCount64; + foreach (var native in needed) + _cache[Key(native, to)] = new CachedRate(DemoRate(native, to), stamp); + return; + } + + try + { + // base=preferred → the response gives preferred→native rates; we invert each to native→preferred. + // One call covers every needed native. + var symbols = string.Join(",", needed); + Log.Info("Convert", $"GET /latest base={to} symbols={symbols}"); + using var response = await Http.GetAsync($"latest?base={to}&symbols={symbols}", ct).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("Convert", $"latest base={to} <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + Log.Warn("Convert", $"latest base={to}: non-success {(int)response.StatusCode} — leaving rates uncached"); + return; + } + + var dto = JsonSerializer.Deserialize(json, FrankfurterJsonContext.Default.ApiFrankfurterLatestDto); + var rates = dto?.Rates; + var now = Environment.TickCount64; + foreach (var native in needed) + { + // preferred→native present and non-zero → native→preferred = 1/that. Missing (ECB doesn't + // cover it) → cache a null so we don't keep retrying within the TTL. + decimal? nativeToPreferred = + rates is not null && rates.TryGetValue(native, out var prefToNative) && prefToNative != 0m + ? 1m / prefToNative + : null; + _cache[Key(native, to)] = new CachedRate(nativeToPreferred, now); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("Convert", $"latest base={to}: request failed", ex); + // Leave uncached so a later prime retries. + } + } +} diff --git a/MarketExtension/Helpers/CurrencyFormat.cs b/MarketExtension/Helpers/CurrencyFormat.cs new file mode 100644 index 0000000..7d8725f --- /dev/null +++ b/MarketExtension/Helpers/CurrencyFormat.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace MarketExtension; + +// UI helper: render a decimal amount as money in a given ISO-4217 currency. This is where the per-currency +// SYMBOL ("$", "€", "£", "¥") and decimal-place rules live, so the Ui layer no longer hardcodes "$". +// Used by UiQuote (instrument price), UiPosition (holding value / P&L) and UiPortfolio (totals). +// +// Formatting is deliberately simple and culture-INVARIANT (the app formats numbers invariantly everywhere +// else): "", e.g. "$1,234.56", "£2.50", "¥156,000". A code we don't have a glyph +// for falls back to a trailing code: "1,234.56 SGD". Most-zero-decimal currencies (JPY/KRW) render with no +// fractional part. Signed variants put the sign before the symbol ("+$12.05" / "-$8.40") for P&L. +internal static class CurrencyFormat +{ + // ISO-4217 code → display symbol. Covers the currencies offered as PortfolioCurrency plus the common + // native quote currencies; anything missing falls back to the bare code. + private static readonly Dictionary Symbols = new(System.StringComparer.OrdinalIgnoreCase) + { + ["USD"] = "$", ["EUR"] = "€", ["GBP"] = "£", ["JPY"] = "¥", ["CNY"] = "¥", + ["AUD"] = "A$", ["CAD"] = "C$", ["NZD"] = "NZ$", ["HKD"] = "HK$", ["SGD"] = "S$", + ["CHF"] = "CHF ", ["SEK"] = "kr ", ["NOK"] = "kr ", ["DKK"] = "kr ", + ["PLN"] = "zł ", ["ZAR"] = "R ", ["MXN"] = "Mex$", ["INR"] = "₹", ["BRL"] = "R$", + ["KRW"] = "₩", + }; + + // Currencies conventionally shown without a fractional part. + private static int Decimals(string code) => + code is "JPY" or "KRW" ? 0 : 2; + + // "$1,234.56" / "¥156,000" / "1,234.56 SGD" — an unsigned money amount. + public static string Format(decimal amount, string code) + { + var number = amount.ToString(NumberPattern(code), CultureInfo.InvariantCulture); + return Symbols.TryGetValue(code, out var symbol) + ? symbol + number + : $"{number} {code.ToUpperInvariant()}"; + } + + // "+$12.05" / "-$8.40" / "+156 JPY" — a signed money amount (the sign precedes the symbol). Used for + // P&L, where the leading +/- reads more naturally than a parenthesized negative. + public static string FormatSigned(decimal amount, string code) + { + var sign = amount < 0m ? "-" : "+"; + return sign + Format(System.Math.Abs(amount), code); + } + + private static string NumberPattern(string code) => + Decimals(code) == 0 ? "#,##0" : "#,##0.00"; +} diff --git a/MarketExtension/Helpers/CurrencyHelper.cs b/MarketExtension/Helpers/CurrencyHelper.cs new file mode 100644 index 0000000..0a782ac --- /dev/null +++ b/MarketExtension/Helpers/CurrencyHelper.cs @@ -0,0 +1,60 @@ +using System; + +namespace MarketExtension; + +// Small currency conventions shared by the providers when they stamp DomainQuote.Currency. Two jobs: +// +// * NormalizeStockQuote — turn a raw provider currency code (which may be a MINOR unit) plus its +// price/change into a clean ISO-4217 code with price/change in MAJOR units. The case that matters: +// London-listed stocks are quoted in PENCE while the reported currency is "GBp"/"GBX", so a price of +// 250 means £2.50 — a 100× factor that would otherwise make UK holdings 100× too large. We fold that +// here so the Domain layer only ever sees major-unit prices in a major-unit currency code. +// * QuoteCurrencyOfPair — the currency an FX pair's price is denominated in: the QUOTE (second) currency +// of a 6-letter BASE+QUOTE pair (EURUSD → USD, USDJPY → JPY), so a notional FX holding values correctly. +// +// Keeping the GBX rule in ONE place means every provider that maps a stock quote (Twelve Data, Finnhub) +// normalizes it identically. +internal static class CurrencyHelper +{ + // The minor-unit codes we know to be 1/100 of their major unit. "GBp" (pence, lowercase p) is the + // common one; "GBX" is the same thing under the exchange's alias. Compared case-sensitively for "GBp" + // (so it isn't confused with "GBP" pounds) and case-insensitively for "GBX". + private static bool IsPence(string code) => + code == "GBp" || string.Equals(code, "GBX", StringComparison.OrdinalIgnoreCase); + + // Normalize a stock's raw (code, price, change) into a major-unit (code, price, change). A null/blank + // code defaults to USD (the overwhelming common case and the app's prior assumption). Pence-quoted + // codes divide price AND change by 100 and report GBP; everything else just upper-cases the code. + // Percent change is unit-free, so it never needs adjusting. + public static (string Currency, decimal Price, decimal Change) NormalizeStockQuote( + string? rawCurrency, decimal price, decimal change) + { + var code = rawCurrency?.Trim(); + if (string.IsNullOrEmpty(code)) + return ("USD", price, change); + + if (IsPence(code)) + return ("GBP", price / 100m, change / 100m); + + return (code.ToUpperInvariant(), price, change); + } + + // Resolve just the ISO code from a raw provider currency (e.g. for a price already in major units, or + // when we only need the code). Mirrors NormalizeStockQuote's code rule. + public static string NormalizeCode(string? rawCurrency) + { + var code = rawCurrency?.Trim(); + if (string.IsNullOrEmpty(code)) + return "USD"; + return IsPence(code) ? "GBP" : code.ToUpperInvariant(); + } + + // The currency an FX pair is priced in = its quote (second) currency. A neutral pair is 6 letters + // (BASE+QUOTE); anything else falls back to USD rather than throwing. + public static string QuoteCurrencyOfPair(string pairSymbol) + { + if (string.IsNullOrWhiteSpace(pairSymbol) || pairSymbol.Length != 6) + return "USD"; + return pairSymbol[3..].ToUpperInvariant(); + } +} diff --git a/MarketExtension/Helpers/HttpRetry.cs b/MarketExtension/Helpers/HttpRetry.cs new file mode 100644 index 0000000..65a27fd --- /dev/null +++ b/MarketExtension/Helpers/HttpRetry.cs @@ -0,0 +1,79 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// Shared back-off for the HTTP providers' rate limits. Wraps a re-issuable request (a thunk, because each +// retry must send a FRESH HttpResponseMessage) and, on a 429 Too Many Requests, waits then retries a few +// times before giving up. The final response (a success, a non-429 error, or a surviving 429) is returned to +// the caller, which disposes it via `using` and inspects status exactly as before — so adding back-off was a +// drop-in at each GetAsync call site. +// +// It also feeds RateLimitSignal at this single choke point: a 2xx flips the banner OFF (we're not throttled), +// a surviving 429 flips it ON. (Twelve Data can additionally signal a 429 inside a 200-OK body; the TD +// provider detects that case itself — see TwelveDataMarketDataProvider.ParseQuotes.) +// +// Why so few retries / a short cap: the free-tier limits are PER-MINUTE windows, so a 1–2s back-off rarely +// clears an exhausted minute — hammering would just burn more of the quota. So we honor a short Retry-After +// when the server sends one, otherwise back off 1s then 2s, and bail straight to the banner if a (Retry-After) +// wait would exceed MaxDelay. The next poll tick / user action recovers once the window rolls over; meanwhile +// the keep-last-good guard holds the last prices and the banner explains the staleness. +internal static class HttpRetry +{ + private const int MaxAttempts = 3; // initial try + up to 2 retries (1s, 2s) + private static readonly TimeSpan MaxDelay = TimeSpan.FromSeconds(8); // don't block a refresh longer than this + + public static async Task SendAsync( + Func> send, string tag, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(send); + + for (var attempt = 1; ; attempt++) + { + var response = await send(ct).ConfigureAwait(false); + + if (response.StatusCode != HttpStatusCode.TooManyRequests) + { + if (response.IsSuccessStatusCode) + RateLimitSignal.Instance.ReportSuccess(); // a real success clears the banner + return response; + } + + // 429. Work out whether another attempt is worth it before disposing this response. + var delay = RetryAfter(response) ?? TimeSpan.FromSeconds(1 << (attempt - 1)); // 1s, 2s, 4s, ... + if (attempt >= MaxAttempts || delay > MaxDelay) + { + Log.Warn(tag, $"429 — giving up after {attempt} attempt(s) (next wait {delay.TotalSeconds:F0}s) — rate-limited"); + RateLimitSignal.Instance.ReportRateLimited(); + return response; // hand back the 429 so the caller degrades exactly as before + } + + response.Dispose(); // discard this 429 before re-issuing + Log.Warn(tag, $"429 — backing off {delay.TotalSeconds:F0}s before retry {attempt + 1}/{MaxAttempts}"); + await Task.Delay(delay, ct).ConfigureAwait(false); + } + } + + // The server's Retry-After, if present and sane: a delta (seconds) directly, or an HTTP date converted to + // a wait from now. Null when absent so the caller falls back to exponential back-off. + private static TimeSpan? RetryAfter(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + if (retryAfter is null) + return null; + + if (retryAfter.Delta is { } delta && delta > TimeSpan.Zero) + return delta; + + if (retryAfter.Date is { } date) + { + var wait = date - DateTimeOffset.UtcNow; + return wait > TimeSpan.Zero ? wait : null; + } + + return null; + } +} diff --git a/MarketExtension/Helpers/Log.cs b/MarketExtension/Helpers/Log.cs index cec219c..d46a62e 100644 --- a/MarketExtension/Helpers/Log.cs +++ b/MarketExtension/Helpers/Log.cs @@ -1,37 +1,29 @@ using System; using System.Diagnostics; -using System.IO; namespace MarketExtension; +// Lightweight tagged logger. +// +// All three levels write to the Debug stream (OutputDebugString) — watch them live in the VS +// Output window or Sysinternals DebugView. They are all marked [Conditional("DEBUG")], so in a +// Release build the calls (and their argument evaluation) are compiled away entirely: a shipped +// MSIX emits nothing here and has NO telemetry channel — no overhead, and nothing leaves the +// machine. Format: "[time] [LEVEL] [Tag] message". The is a component name (e.g. "Finnhub", +// "Repository"). NEVER pass secrets (API tokens) into a message. internal static class Log { - private static readonly string _logPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "MarketExtension", "logs", $"marketextension-{DateTime.Now:yyyy-MM-dd}.log"); + [Conditional("DEBUG")] + public static void Info(string tag, string message) => Write("INFO", tag, message); - static Log() - { - try { Directory.CreateDirectory(Path.GetDirectoryName(_logPath)!); } - catch { /* if we can't create the log dir, file writes will just fail silently */ } - } + [Conditional("DEBUG")] + public static void Warn(string tag, string message) => Write("WARN", tag, message); - public static void Info(string message) => Write("INFO", message, writeToFile: false); + [Conditional("DEBUG")] + public static void Error(string tag, string message, Exception? ex = null) => + Write("ERROR", tag, ex is null ? message : $"{message} — {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); - public static void Error(string message, Exception? ex = null) - { - var text = ex is null ? message : $"{message} — {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"; - Write("ERROR", text, writeToFile: true); - } - - private static void Write(string level, string message, bool writeToFile) - { - var line = $"[{DateTime.Now:HH:mm:ss.fff}] [{level}] {message}"; - Trace.WriteLine(line); - if (writeToFile) - { - try { File.AppendAllText(_logPath, line + Environment.NewLine); } - catch { /* don't crash the app if logging fails */ } - } - } + [Conditional("DEBUG")] + private static void Write(string level, string tag, string message) => + Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{level}] [{tag}] {message}"); } diff --git a/MarketExtension/Helpers/PollTicker.cs b/MarketExtension/Helpers/PollTicker.cs new file mode 100644 index 0000000..23b167b --- /dev/null +++ b/MarketExtension/Helpers/PollTicker.cs @@ -0,0 +1,81 @@ +using System; +using System.Reactive.Linq; + +namespace MarketExtension; + +// The live-price poll ticker — emits a tick WHILE at least one priced surface is subscribed and stops +// when the last one unsubscribes. Priced surfaces (PricedListPage, FavoritesDockPage, the SymbolDetailPage +// chart) subscribe and re-price their current set on each tick. +// +// Pure Rx (no longer a StateFlow): +// * Observable.Generate drives a self-rescheduling timer whose per-step delay is re-read from +// MarketSettingsManager each iteration, so an interval / on-off change applies without a reload. When +// auto-refresh is off it idles on a short re-check and the tick is filtered out (Where), so toggling +// it back on later resumes on the next iteration. +// * Publish().RefCount() IS the WhileSubscribed / RefCount() seam the old OnActive/OnInactive hooks +// hand-rolled: the Generate loop starts on the first subscriber (0 -> 1) and is torn down on the last +// unsubscribe (1 -> 0). Generate's condition is always true so the source never completes — disposal +// is therefore silent (no OnCompleted/OnError reaches the shared subject), so a later resubscribe +// cleanly restarts the loop. Defer logs the start; Finally logs the stop, both at the refcount edges. +// +// This replaces the hand-rolled StateFlow + CancellationTokenSource version: the tick-counter/dedup +// workaround, the manual subscriber-count refcount, the CTS lifecycle + lock, and the CA1001 suppression +// are all now Rx's job. (The tick value restarts from 0 on each active cycle — only ever used for a Debug +// log line, never read by subscribers.) +// +// Subscribe via PollTicker.Subscribe(onTick): the handler is guarded so a synchronous throw can't escape +// into the shared stream. This matters because the published stream is multicast — an unguarded throw +// would be caught by Rx's SafeObserver, which DISPOSES that subscription AND rethrows back through the +// Publish subject, terminating the stream for EVERY surface (polling dead until reload). The guard +// swallows + logs (Log.Error) instead. Handlers should still offload heavy work (all PollRefresh()s Task.Run), +// but they no longer have to be throw-proof. +// +// ⚠️ That guard covers the SUBSCRIBER side only. The SOURCE operators below (timeSelector, Where, iterate, +// Do) are NOT guarded: a throw in any of them OnErrors the Publish subject, poisoning it for every surface +// (polling dead until reload — the exact failure mode above). They are safe today because they only do +// non-throwing MarketSettingsManager property reads; keep them that way. Any new logic here that could +// throw (e.g. parsing a malformed setting) must guard itself or use .Catch/.Retry. +internal static class PollTicker +{ + // How long to idle before re-checking settings while auto-refresh is off, so the user can turn it on + // mid-view and have polling resume without reloading the extension. + private static readonly TimeSpan OffRecheckInterval = TimeSpan.FromSeconds(30); + + // One process-wide ticker shared by every priced surface, so the timer lives while ANY of them is + // visible and goes quiet only when they all hide (a pinned dock band keeps it warm). Private so every + // subscription goes through the guarded Subscribe below — no surface can attach an unguarded handler. + private static readonly IObservable Ticks = + Observable.Defer(() => + { + Log.Info("Poll", "Poll loop started — a priced surface became visible"); + return Observable + .Generate( + initialState: 0L, + condition: _ => true, + iterate: tick => tick + 1, + resultSelector: tick => tick, + timeSelector: _ => + { + // Re-read live each iteration so an interval/on-off change applies without a reload. + var settings = MarketSettingsManager.Instance; + return settings.AutoRefreshEnabled ? settings.RefreshInterval : OffRecheckInterval; + }) + .Where(_ => MarketSettingsManager.Instance.AutoRefreshEnabled) + .Do(tick => Log.Info("Poll", $"Tick #{tick} — signalling visible surfaces to refresh")); + }) + .Finally(() => Log.Info("Poll", "Poll loop stopped — all priced surfaces hidden")) + .Publish() + .RefCount(); + + // Run onTick on every poll tick while subscribed. The handler is wrapped so a throw is contained + // (swallowed + logged) and can't tear down the shared stream — see the type comment above. + public static IDisposable Subscribe(Action onTick) + { + ArgumentNullException.ThrowIfNull(onTick); + return Ticks.Subscribe(_ => + { + try { onTick(); } + catch (Exception ex) { Log.Error("Poll", "tick handler threw — continuing poll loop", ex); } + }); + } +} diff --git a/MarketExtension/Helpers/ProcessHelper.cs b/MarketExtension/Helpers/ProcessHelper.cs index 2fd84e5..49ddb06 100644 --- a/MarketExtension/Helpers/ProcessHelper.cs +++ b/MarketExtension/Helpers/ProcessHelper.cs @@ -34,4 +34,9 @@ public static void Run(string fileName, string arguments, out string stdout, out ? (string.IsNullOrWhiteSpace(errText) ? $"{fileName} exited with code {process.ExitCode}" : errText.Trim()) : string.Empty; } + + // Opens a URL (or file/protocol) with the OS default handler. Unlike Run, this is a fire-and-forget + // shell launch — no output is captured (UseShellExecute can't redirect streams). + public static void OpenUrl(string url) + => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } diff --git a/MarketExtension/Helpers/RateLimitHint.cs b/MarketExtension/Helpers/RateLimitHint.cs new file mode 100644 index 0000000..6d38f34 --- /dev/null +++ b/MarketExtension/Helpers/RateLimitHint.cs @@ -0,0 +1,46 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// An advisory "we're being rate-limited" banner for the priced screens, shown while RateLimitSignal is set +// (a request stayed 429 after backing off — see HttpRetry) AND the user hasn't hidden it via the "Show +// rate-limit warnings" setting (MarketSettingsManager.ShowRateLimitErrors). It tells the user the prices on +// screen are the last known ones (the keep-last-good guard holds them) and will refresh once the window +// clears, instead of leaving them to guess why values look stale. Enter does NOTHING (a NoOpCommand): it's +// the default-selected first row, so it must not steal Enter or navigate away — it's purely informational. +// Parallels ApiKeyHint.StatusRow(). +internal static class RateLimitHint +{ + private const string WarningGlyph = ""; // Segoe MDL2 Warning + + // Amber (caution) — distinct from ApiKeyHint's red (an actual misconfiguration); rate-limiting is + // transient, not broken. A colored Tag is the only per-row tint a ListItem allows. + private static OptionalColor CautionAmber => ColorHelpers.FromRgb(0xC8, 0x7C, 0x00); + + // The banner row, or null when we're not throttled (or the user hid it via the "Show rate-limit + // warnings" setting, or demo mode is on). Callers append it only when non-null: + // + // if (RateLimitHint.Row() is { } banner) items.Add(banner); + // + // Demo mode makes no live requests, so throttling is meaningless there — and a signal set before demo + // was turned on would otherwise never clear (nothing calls ReportSuccess() offline). Guard on it here so + // the banner can never show while demoing, regardless of a stale signal. RateLimitSignal ALSO clears the + // flag when demo mode is enabled (so turning it back off doesn't flash the old banner). + public static IListItem? Row() => + !MarketSettingsManager.Instance.DemoMode && + RateLimitSignal.Instance.IsRateLimited.Value && MarketSettingsManager.Instance.ShowRateLimitErrors + ? new ListItem(new NoOpCommand()) // informational only — Enter must not navigate (it's the first row) + { + Title = Resources.RateLimit_Title, + Subtitle = Resources.RateLimit_Subtitle, + Icon = new IconInfo(WarningGlyph), + Tags = [new Tag(Resources.RateLimit_Tag) + { + Foreground = CautionAmber, + ToolTip = Resources.RateLimit_Tooltip, + }], + } + : null; +} diff --git a/MarketExtension/Helpers/RateLimitSignal.cs b/MarketExtension/Helpers/RateLimitSignal.cs new file mode 100644 index 0000000..b523b09 --- /dev/null +++ b/MarketExtension/Helpers/RateLimitSignal.cs @@ -0,0 +1,50 @@ +namespace MarketExtension; + +// A process-wide "are we currently being throttled" flag. Providers report through it at the HTTP seam +// (see HttpRetry): a request that stays 429 after backing off flips it ON; any request that ultimately +// succeeds flips it OFF. Priced surfaces subscribe to IsRateLimited and show an advisory banner row +// (RateLimitHint) so the user understands why prices look stale, instead of silently seeing the +// keep-last-good prices with no explanation. +// +// Modeled on the StateFlow state-holders (WatchlistStore, MarketSettingsManager): a single +// MutableStateFlow behind a read-only StateFlow face, distinct-until-changed so redundant +// reports don't wake subscribers. It is intentionally GLOBAL, not per-symbol: a free-tier rate limit +// applies to the whole API key, so the condition is shared across every instrument and surface — a global +// signal models that exactly (and avoids rippling a per-quote status through all four providers + UiQuote). +internal sealed class RateLimitSignal +{ + public static readonly RateLimitSignal Instance = new(); + + private readonly MutableStateFlow _isRateLimited = new(false); + + private RateLimitSignal() + { + // In demo mode no live request is ever made, so a "throttled" signal is meaningless — and because + // nothing calls ReportSuccess() offline, a signal set before demo mode was switched on would + // otherwise linger. Clear it the moment demo mode turns on, so we start clean and stay clean while + // demoing; turning demo back off leaves it cleared, so the banner only returns on a fresh live 429. + // replay:false — only react to a flip, not the value at construction. + MarketSettingsManager.Instance.DemoModeChanged.Subscribe( + demo => { if (demo) ReportSuccess(); }, replayOnSubscribe: false); + } + + // Observable "are recent requests being throttled" flag. Read .Value for a snapshot, or Subscribe to + // re-render when it flips (the priced pages do the latter to show/hide the banner reactively). + public StateFlow IsRateLimited => _isRateLimited; + + // A request was throttled even after HttpRetry backed off (or a provider saw an in-body 429). + // Distinct-until-changed means this only wakes subscribers on the false -> true edge. + public void ReportRateLimited() + { + if (_isRateLimited.Update(true)) + Log.Warn("RateLimit", "throttled — surfaces will show the rate-limited banner"); + } + + // A request ultimately succeeded, so we're not hard-throttled right now — clear the banner. Called by + // HttpRetry on any 2xx. Only emits on the true -> false edge. + public void ReportSuccess() + { + if (_isRateLimited.Update(false)) + Log.Info("RateLimit", "request succeeded — clearing the rate-limited banner"); + } +} diff --git a/MarketExtension/Helpers/StateFlow.cs b/MarketExtension/Helpers/StateFlow.cs new file mode 100644 index 0000000..8c7ecd4 --- /dev/null +++ b/MarketExtension/Helpers/StateFlow.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace MarketExtension; + +// A minimal StateFlow analog (Kotlin's StateFlow): holds a current value, REPLAYS it to every new +// subscriber the moment they subscribe, then pushes the new value on each change. Deduplicates with an +// equality comparer (distinct-until-changed). +// +// A thin wrapper over System.Reactive's BehaviorSubject, which already provides the current value, +// replay-on-subscribe, fan-out, and thread-safe, idempotent subscriptions. The hand-rolled version this +// replaced avoided System.Reactive only because the build was once AOT/trim-clean; that constraint is gone +// (AOT/trim is a deliberate non-goal now — see CLAUDE.md), so we take the dependency to unlock Rx's +// operator library for the deferred work (429 Retry/RetryWhen back-off, Throttle'd search, Merge'd +// providers). The PUBLIC API is unchanged, so the state holders (WatchlistStore, MarketSettingsManager) +// and their page/dock subscribers don't change. +// +// This is the read-only face consumers see (read Value / Subscribe). Only the writable MutableStateFlow +// subclass can change the value — mirroring Kotlin's MutableStateFlow/StateFlow split, so the store +// mutates while pages and the dock merely observe. (An earlier OnActive/OnInactive subscriber-count seam +// lived here too; it was removed once its only user, PollTicker, moved to pure Rx Publish().RefCount() — +// see PollTicker.cs.) +[SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", + Justification = "The BehaviorSubject is owned by process-lifetime singletons (WatchlistStore, " + + "MarketSettingsManager) whose flows are observed for the life of the process; it is " + + "intentionally never completed or disposed.")] +internal class StateFlow +{ + private readonly BehaviorSubject _subject; + private readonly IEqualityComparer _comparer; + + protected StateFlow(T initial, IEqualityComparer? comparer = null) + { + _subject = new BehaviorSubject(initial); + _comparer = comparer ?? EqualityComparer.Default; + } + + public T Value => _subject.Value; + + // Subscribe and immediately receive the current value (StateFlow replay — BehaviorSubject does this). + // Dispose to unsubscribe; Rx subscriptions are idempotent on dispose. Handlers run on whatever thread + // mutates the value (BehaviorSubject fans out OUTSIDE its internal lock) — the toolkit marshals the + // RaiseItemsChanged that handlers ultimately call. + public IDisposable Subscribe(Action onNext) => Subscribe(onNext, replayOnSubscribe: true); + + // As above, but pass replayOnSubscribe:false to suppress the initial replay and only receive *future* + // values (Skip(1) drops the value BehaviorSubject replays synchronously on subscribe). Used where a + // surface's initial paint is already driven by another flow's replay, so replaying here would just + // duplicate work — e.g. the priced pages re-rendering on a HasAnyApiKey change. + public IDisposable Subscribe(Action onNext, bool replayOnSubscribe) + { + ArgumentNullException.ThrowIfNull(onNext); + + // Guard the handler so a throw can never escape into Rx. Two reasons: + // 1. Symmetry: the replayOnSubscribe:false path runs through Skip(1) — an Rx operator whose + // SafeObserver DISPOSES the subscription if OnNext throws (silently unsubscribing the surface), + // while the raw-BehaviorSubject replay path does not. Guarding makes both paths behave the same + // (throw is non-fatal to the subscription), matching the old hand-rolled flow. + // 2. Isolation: BehaviorSubject fans out with a foreach, so one subscriber throwing would skip the + // rest for that emission. Swallowing here keeps subscribers independent. + // Errors are logged via Log.Error (Debug builds only), tagged StateFlow. + void Guarded(T value) + { + try { onNext(value); } + catch (Exception ex) { Log.Error("StateFlow", "subscriber handler threw", ex); } + } + + IObservable source = replayOnSubscribe ? _subject : _subject.Skip(1); + return source.Subscribe(Guarded); + } + + // Writable entry point for subclasses. Returns true if the value actually changed (i.e. listeners + // were notified). Distinct-until-changed at the source: an equal value is not pushed, so re-publishing + // an unchanged subset doesn't wake its subscribers. OnNext invokes handlers OUTSIDE the subject's + // lock — handlers re-read the store, which re-takes the store lock, with no lock held here. + protected bool SetValue(T value) + { + if (_comparer.Equals(_subject.Value, value)) return false; // distinct-until-changed + _subject.OnNext(value); + return true; + } +} + +// The writable side of a StateFlow — only the data owner (e.g. WatchlistStore) holds one of these and +// exposes it typed as the read-only StateFlow. +internal sealed class MutableStateFlow(T initial, IEqualityComparer? comparer = null) + : StateFlow(initial, comparer) +{ + public bool Update(T value) => SetValue(value); +} + +// Compares two instrument lists by their symbol sequence (order-sensitive, case-insensitive). Used as +// the dedup comparer for the watchlist/favorites flows so re-publishing both subsets after a mutation +// only notifies the one whose membership actually changed. +internal sealed class InstrumentListComparer : IEqualityComparer> +{ + public static readonly InstrumentListComparer Instance = new(); + + private InstrumentListComparer() { } + + public bool Equals(IReadOnlyList? x, IReadOnlyList? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null || x.Count != y.Count) return false; + + for (var i = 0; i < x.Count; i++) + if (!string.Equals(x[i].Symbol, y[i].Symbol, StringComparison.OrdinalIgnoreCase)) + return false; + + return true; + } + + public int GetHashCode(IReadOnlyList obj) + { + var hash = new HashCode(); + foreach (var instrument in obj) + hash.Add(instrument.Symbol, StringComparer.OrdinalIgnoreCase); + return hash.ToHashCode(); + } +} diff --git a/MarketExtension/Helpers/Strings.cs b/MarketExtension/Helpers/Strings.cs new file mode 100644 index 0000000..527f418 --- /dev/null +++ b/MarketExtension/Helpers/Strings.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Thin helpers around the strongly-typed Properties.Resources class (generated from +// Properties/Resources.resx by VS's PublicResXFileCodeGenerator — the same setup PowerToys' own CmdPal +// extensions use). Static UI strings are read directly off Resources.; this class only covers the +// two cases that can't be: a runtime-computed key, and filling a format template. +// +// English values live in the neutral Resources.resx, embedded as "MarketExtension.Properties.Resources". +// At runtime ResourceManager resolves the right culture from CultureInfo.CurrentUICulture via satellite +// assemblies. Shipping a language is purely additive: drop a Resources..resx with the same keys +// translated and the SDK compiles it into a satellite; missing keys fall back to English. No code changes. +// +// FAIL LOUD by design: a missing resource set or key is a real bug (packaging/embed broken, a typo, a +// forgotten resx entry), so surface it immediately rather than papering over it with a degraded UI. +internal static class Strings +{ + // Runtime-computed-key lookup. Static keys should use Resources. directly (compile-checked); + // this is only for keys built at runtime — e.g. the Currency_{code} portfolio-currency dropdown. + // Throws if the key isn't in the resx (or the set can't load) rather than rendering a key name. + public static string Get(string key) => + Resources.ResourceManager.GetString(key, CultureInfo.CurrentUICulture) + ?? throw new InvalidOperationException($"Missing resource string '{key}'"); + + // Fill a localized format template (pass it strongly-typed, e.g. Strings.Format(Resources.Toast_X, sym)). + // The template is localized; the args are already-formatted strings, so culture here is moot — the + // deliberate CultureInfo.Invariant number/currency formatting still happens in the Ui layer that + // produces them. A malformed template throws (FormatException) — a bad placeholder is a bug, not hidden. + public static string Format(string template, params object[] args) => + string.Format(CultureInfo.CurrentCulture, template, args); +} diff --git a/MarketExtension/MarketExtension.csproj b/MarketExtension/MarketExtension.csproj index eb96171..59f6c08 100644 --- a/MarketExtension/MarketExtension.csproj +++ b/MarketExtension/MarketExtension.csproj @@ -10,17 +10,40 @@ 10.0.26100.0 win-x64;win-arm64 -CostaFotiadis.MarketExtension +CostaFotiadis.MarketsExtensionforCommandPalette CN=3D57AA92-97A9-42D2-8CB0-4207D9145514 - 0.0.1.0 + 1.0.0.0 true enable + + + en-US + + + + PublicResXFileCodeGenerator + Resources.Designer.cs + + + Resources.resx + True + True + + + - + @@ -75,35 +98,26 @@ true + true - true - - true - 2 - - IL2081;$(WarningsNotAsErrors) - - - true - - - - - false - - true - true - true - false true + diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index 31b2790..345468b 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -1,13 +1,23 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; namespace MarketExtension; public partial class MarketExtensionCommandsProvider : CommandProvider { - // One shared data source for both the palette page and the dock band. Swap this single - // line for a real API-backed IMarketDataProvider later (see reference/dock-support.md). - private readonly IMarketDataProvider _dataProvider = new MockMarketDataProvider(); + // The repository coordinates all market-data providers; both the palette page and the dock band share + // this one instance. Routing is by asset class, first-match in registration order. MockMarketDataProvider + // is FIRST but gated on the Demo-mode setting (its Supports() is false unless Demo mode is on), so it + // wins everything when demoing and is otherwise skipped. Then Twelve Data (stocks + crypto + forex, plus + // free price charts) is primary WHEN its key is set — also gated on its key — so with no Twelve Data key + // the routing falls through to Finnhub (stocks + crypto) and Frankfurter (forex, keyless ECB rates). Add + // a provider here to extend coverage; gate its Supports() on whatever should make it active. + private readonly MarketRepository _repository = + new(new MockMarketDataProvider(), + new TwelveDataMarketDataProvider(), + new FinnhubMarketDataProvider(), + new FrankfurterMarketDataProvider()); private readonly ICommandItem[] _commands; private readonly ICommandItem[] _dockBands; @@ -15,20 +25,26 @@ public partial class MarketExtensionCommandsProvider : CommandProvider public MarketExtensionCommandsProvider() { Id = "com.costafotiadis.market"; - DisplayName = "Market Extension"; - Icon = new IconInfo("https://github.com/favicon.ico"); + DisplayName = Resources.Extension_DisplayName; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); - // To expose extension settings, add a JsonSettingsManager and assign: - // Settings = MySettingsManager.Instance.Settings; - // See reference/settings/AdbSettingsManager.cs for an example. + // Surface the extension's settings (Twelve Data + Finnhub API keys + price refresh interval) + // in the Command Palette Settings UI. See Settings/MarketSettingsManager.cs. + Settings = MarketSettingsManager.Instance.Settings; + // A single top-level "Markets" command that opens the MarketsPage hub. From there the user + // funnels into the three screens — Search, Watchlist, Favorites — which are otherwise + // unchanged. This keeps the Command Palette root to one entry instead of three. Favorites + // also feed the dock band below. _commands = [ - new CommandItem(new MarketsPage(_dataProvider)) { Title = "Markets" }, + new CommandItem(new MarketsPage(_repository)) { Title = Resources.Command_Markets }, ]; - // Dock band: a ticker strip of up to 3 favorited instruments. Pinned from the Dock. + // Dock bands, each pinnable from the Dock: a ticker strip of favorited instruments, and a + // one-line portfolio total (value + daily P&L in the preferred currency). _dockBands = [ - new CommandItem(new FavoritesDockPage(_dataProvider)) { Title = "Markets" }, + new CommandItem(new FavoritesDockPage(_repository)) { Title = Resources.Command_Markets }, + new CommandItem(new PortfolioDockPage(_repository)) { Title = Resources.Command_MarketsPortfolio }, ]; } diff --git a/MarketExtension/Models/AssetCategory.cs b/MarketExtension/Models/AssetCategory.cs new file mode 100644 index 0000000..e95a605 --- /dev/null +++ b/MarketExtension/Models/AssetCategory.cs @@ -0,0 +1,10 @@ +namespace MarketExtension; + +// The asset class shared across all three model layers (Api / Domain / Ui). It's the common +// vocabulary, not a model DTO, so it keeps its plain unprefixed name. +internal enum AssetCategory +{ + Stock, + Crypto, + Currency, +} diff --git a/MarketExtension/Models/ChartRange.cs b/MarketExtension/Models/ChartRange.cs new file mode 100644 index 0000000..d2e7f87 --- /dev/null +++ b/MarketExtension/Models/ChartRange.cs @@ -0,0 +1,85 @@ +using System; + +namespace MarketExtension; + +// Provider-agnostic shared vocabulary (like AssetCategory): the time window a price chart covers. +// It carries only neutral intent — a label, a lookback window, and a neutral CandleInterval. The +// translation to a specific data source's resolution token lives in that provider (e.g. +// FinnhubMarketDataProvider.ToFinnhubResolution), so a new provider plugs in without touching this. +internal enum ChartRange +{ + OneDay, + OneWeek, + OneMonth, + OneYear, + FiveYear, +} + +// Provider-agnostic bar size. Each provider maps this to its own API token (Finnhub: 5/30/60/D/W; +// a Twelve Data provider would map the same values to 5min/30min/1h/1day/1week). +internal enum CandleInterval +{ + FiveMin, + ThirtyMin, + Hourly, + Daily, + Weekly, +} + +internal static class ChartRangeExtensions +{ + // The five ranges, in tab order. + public static readonly ChartRange[] All = + [ + ChartRange.OneDay, + ChartRange.OneWeek, + ChartRange.OneMonth, + ChartRange.OneYear, + ChartRange.FiveYear, + ]; + + // Short tab label, e.g. "1D". Also the Action.Submit payload value (see FromLabel). + public static string Label(this ChartRange range) => range switch + { + ChartRange.OneDay => "1D", + ChartRange.OneWeek => "1W", + ChartRange.OneMonth => "1M", + ChartRange.OneYear => "1Y", + ChartRange.FiveYear => "5Y", + _ => "1D", + }; + + // How far back the chart looks. A provider computes its request window as from = now - Lookback. + public static TimeSpan Lookback(this ChartRange range) => range switch + { + ChartRange.OneDay => TimeSpan.FromDays(1), + ChartRange.OneWeek => TimeSpan.FromDays(7), + ChartRange.OneMonth => TimeSpan.FromDays(31), + ChartRange.OneYear => TimeSpan.FromDays(365), + ChartRange.FiveYear => TimeSpan.FromDays(365 * 5), + _ => TimeSpan.FromDays(1), + }; + + // The neutral bar size that reads well for this window — intraday for short ranges, coarser for + // long ones. Providers translate this to their own resolution token. + public static CandleInterval Interval(this ChartRange range) => range switch + { + ChartRange.OneDay => CandleInterval.FiveMin, + ChartRange.OneWeek => CandleInterval.ThirtyMin, + ChartRange.OneMonth => CandleInterval.Hourly, + ChartRange.OneYear => CandleInterval.Daily, + ChartRange.FiveYear => CandleInterval.Weekly, + _ => CandleInterval.Daily, + }; + + // Parse a tab label (the Action.Submit payload) back to a range; unknown -> 1D. + public static ChartRange FromLabel(string? label) => label switch + { + "1D" => ChartRange.OneDay, + "1W" => ChartRange.OneWeek, + "1M" => ChartRange.OneMonth, + "1Y" => ChartRange.OneYear, + "5Y" => ChartRange.FiveYear, + _ => ChartRange.OneDay, + }; +} diff --git a/MarketExtension/Models/DomainCandleSeries.cs b/MarketExtension/Models/DomainCandleSeries.cs new file mode 100644 index 0000000..a146e07 --- /dev/null +++ b/MarketExtension/Models/DomainCandleSeries.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace MarketExtension; + +// Domain layer: a provider-agnostic, presentation-free price history for one instrument over one +// ChartRange. Every IMarketDataProvider produces this shape (mapped from its own Api* candle DTO); +// no formatting lives here — see UiCandleSeries for the presentation projection. +internal sealed record DomainCandleSeries( + string Symbol, + ChartRange Range, + IReadOnlyList Points, + bool IsValid = true) +{ + // An empty, invalid series — what providers return when they can't serve candles (no provider + // for the asset class, premium-gated, "no_data", or a fetch failure). The UI renders this as an + // "unavailable" state rather than a blank chart. + public static DomainCandleSeries Invalid(string symbol, ChartRange range) => + new(symbol, range, [], IsValid: false); + + public bool HasData => IsValid && Points.Count > 0; + + public decimal FirstClose => Points.Count > 0 ? Points[0].Close : 0m; + public decimal LastClose => Points.Count > 0 ? Points[^1].Close : 0m; +} + +// One sampled point in a series. Close drives the sparkline; the timestamp is kept for future axis +// labels / tooltips. (Full OHLCV lives in the Api DTO and can be promoted here later if needed.) +internal readonly record struct CandlePoint(DateTimeOffset Time, decimal Close); diff --git a/MarketExtension/Models/DomainInstrument.cs b/MarketExtension/Models/DomainInstrument.cs new file mode 100644 index 0000000..ebb6160 --- /dev/null +++ b/MarketExtension/Models/DomainInstrument.cs @@ -0,0 +1,6 @@ +namespace MarketExtension; + +// Domain layer: the provider-agnostic identity of a tradable instrument — what to price, not how +// to fetch it. The Symbol is the neutral ticker the app knows ("BTC", "AAPL", "EURUSD"); each +// IMarketDataProvider translates it into its own API's symbol format. +internal sealed record DomainInstrument(string Symbol, string Name, AssetCategory Category); diff --git a/MarketExtension/Models/DomainPosition.cs b/MarketExtension/Models/DomainPosition.cs new file mode 100644 index 0000000..afab7aa --- /dev/null +++ b/MarketExtension/Models/DomainPosition.cs @@ -0,0 +1,11 @@ +namespace MarketExtension; + +// Domain layer: a provider-agnostic portfolio holding — an instrument plus HOW MUCH of it the user +// holds. No prices and no formatting live here (see UiPosition for the presentation projection); +// pricing happens later by routing the Instrument through MarketRepository, exactly like any other +// tracked instrument. Quantity is decimal so fractional crypto holdings (e.g. 0.5 BTC) work. +// +// CostBasis (the average price paid per unit, in the instrument's native currency) is optional: it's set +// from the holding editor and drives total-return (unrealized P&L) reporting in UiPosition/UiPortfolio. +// Null/absent means "not recorded" — total return is simply omitted for that holding. +internal sealed record DomainPosition(DomainInstrument Instrument, decimal Quantity, decimal? CostBasis = null); diff --git a/MarketExtension/Models/DomainQuote.cs b/MarketExtension/Models/DomainQuote.cs new file mode 100644 index 0000000..996a619 --- /dev/null +++ b/MarketExtension/Models/DomainQuote.cs @@ -0,0 +1,23 @@ +namespace MarketExtension; + +// Domain layer: a provider-agnostic, presentation-free quote. This is what every +// IMarketDataProvider produces (mapped from its Api* DTO) and what MarketRepository returns. +// No formatting lives here — see UiQuote for the presentation projection. +// +// Currency is the ISO-4217 code the Price/Change are quoted in (e.g. "USD", "GBP", "JPY") — the native +// currency of the instrument, NOT the user's reporting currency. It's STATIC metadata each provider +// resolves once: Twelve Data returns it on /quote, Finnhub via a cached /stock/profile2 lookup, +// Frankfurter from the pair's quote currency; crypto and US equities short-circuit to USD. Providers +// normalize minor-unit quotes (London's GBX/pence → GBP, dividing by 100) so Price is always in major +// units of Currency. Defaults to "USD" so every existing construction stays correct without change. +// PortfolioPage converts Price×Quantity from Currency into the user's PortfolioCurrency (via +// CurrencyConverter); nothing else interprets it beyond UiQuote picking the right currency symbol. +internal sealed record DomainQuote( + string Symbol, + string Name, + AssetCategory Category, + decimal Price, + decimal Change, + decimal ChangePercent, + bool IsValid = true, + string Currency = "USD"); diff --git a/MarketExtension/Models/Quote.cs b/MarketExtension/Models/Quote.cs deleted file mode 100644 index efd3b7f..0000000 --- a/MarketExtension/Models/Quote.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Globalization; - -namespace MarketExtension; - -internal enum AssetCategory -{ - Stock, - Crypto, - Currency, -} - -// A single market instrument snapshot. This is the model the UI renders today and the future -// Dock band will consume — keep it the shape both need. Today it is populated by -// MockMarketDataProvider; later by a real market-data API behind IMarketDataProvider. -internal sealed record Quote( - string Symbol, - string Name, - AssetCategory Category, - decimal Price, - decimal Change, - decimal ChangePercent) -{ - public bool IsUp => Change >= 0; - - // Currency pairs are quoted to 4 decimals (e.g. 1.0832); stocks/crypto as money. - public string FormatPrice() => Category == AssetCategory.Currency - ? Price.ToString("0.0000", CultureInfo.InvariantCulture) - : Price.ToString("$#,##0.00", CultureInfo.InvariantCulture); - - // e.g. "▲ +1.20%" / "▼ -0.80%" - public string FormatChange() => - $"{(IsUp ? "▲" : "▼")} {ChangePercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%"; -} diff --git a/MarketExtension/Models/UiCandleSeries.cs b/MarketExtension/Models/UiCandleSeries.cs new file mode 100644 index 0000000..a9a3bd8 --- /dev/null +++ b/MarketExtension/Models/UiCandleSeries.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using System.Linq; + +namespace MarketExtension; + +// UI layer: the presentation projection of a DomainCandleSeries — the ONLY place chart formatting +// and SVG generation live (the chart-series analog of UiQuote). Provider-independent: it renders the +// same regardless of which IMarketDataProvider produced the data. +internal sealed record UiCandleSeries(DomainCandleSeries Source) +{ + public static UiCandleSeries From(DomainCandleSeries series) => new(series); + + public string Symbol => Source.Symbol; + public ChartRange Range => Source.Range; + public bool HasData => Source.HasData; + + // Net direction over the selected window (last vs. first close). Up when flat or rising — drives + // the green/red color of both the change text and the chart line. + public bool IsUp => Source.LastClose >= Source.FirstClose; + + // Latest close in the window, formatted as money. A dash when there's no data. + public string FormatPrice() => !HasData + ? "—" + : Source.LastClose.ToString("$#,##0.00", CultureInfo.InvariantCulture); + + // Net change across the SELECTED range (like Robinhood — the % matches the chosen tab, not the + // trading day), e.g. "▲ +$3.20 (+1.71%) · 1M". Empty when there's no data. + public string FormatRangeChange() + { + if (!HasData) + return string.Empty; + + var first = Source.FirstClose; + var change = Source.LastClose - first; + var pct = first == 0m ? 0m : change / first * 100m; + var arrow = IsUp ? "▲" : "▼"; + var amount = change.ToString("+$#,##0.00;-$#,##0.00", CultureInfo.InvariantCulture); + var percent = pct.ToString("+0.00;-0.00", CultureInfo.InvariantCulture); + return $"{arrow} {amount} ({percent}%) · {Range.Label()}"; + } + + // The chart as an inline SVG data URI (empty when there's no data), recolored by direction. + public string ChartImageUrl() => !HasData + ? string.Empty + : ChartHelper.CreateImageUrl([.. Source.Points.Select(p => p.Close)], IsUp); +} diff --git a/MarketExtension/Models/UiPortfolio.cs b/MarketExtension/Models/UiPortfolio.cs new file mode 100644 index 0000000..f73ee0f --- /dev/null +++ b/MarketExtension/Models/UiPortfolio.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MarketExtension.Properties; + +namespace MarketExtension; + +// UI layer: the rolled-up totals across a set of UiPositions — total market value and today's P&L for the +// whole portfolio, expressed in the user's reporting (preferred) currency. Rendered as the summary row +// pinned to the top of the Portfolio screen. Like the other Ui* types, the only place its formatting lives. +// +// A holding counts toward the totals only when it's both VALID (priced) AND convertible into the preferred +// currency (UiPosition.CountsTowardTotal) — its CONVERTED value/P&L is summed. Two kinds of holding are +// therefore left out: an unpriced one (unknown symbol / fetch failure) and one whose native currency the FX +// provider can't convert. UnconvertedCount surfaces the latter so the user knows the total is partial; an +// all-USD portfolio with USD preferred converts trivially (rate 1) and excludes nothing. +internal sealed record UiPortfolio( + decimal TotalValue, + decimal TotalDailyPnL, + string Currency, + bool HasHoldings, + int UnconvertedCount, + decimal TotalCost, + decimal TotalReturn, + bool HasCostBasis) +{ + public static UiPortfolio From(IEnumerable positions, string preferredCurrency) + { + var all = positions.ToList(); + var counted = all.Where(p => p.CountsTowardTotal).ToList(); + var totalValue = counted.Sum(p => p.ConvertedMarketValue ?? 0m); + var totalPnL = counted.Sum(p => p.ConvertedDailyPnL ?? 0m); + // Priced but not convertible — present in the list, missing from the total. + var unconverted = all.Count(p => p.IsValid && !p.IsConverted); + // Total return rolls up only over counted holdings that actually carry a cost basis: a holding + // without one can't contribute a gain, so cost AND return are summed across the same subset (and the + // percent below is gain/cost over exactly those). HasCostBasis gates whether the figure is shown. + var withBasis = counted.Where(p => p.HasCostBasis).ToList(); + var totalCost = withBasis.Sum(p => p.ConvertedTotalCost ?? 0m); + var totalReturn = withBasis.Sum(p => p.ConvertedTotalReturn ?? 0m); + return new UiPortfolio( + totalValue, totalPnL, preferredCurrency, all.Count > 0, unconverted, + totalCost, totalReturn, withBasis.Count > 0); + } + + public bool IsUp => TotalDailyPnL >= 0; + public bool IsTotalReturnUp => TotalReturn >= 0m; + + // Aggregate daily percent measured against YESTERDAY'S close value (today's value minus today's + // gain), not an average of the per-position percents. Guards a zero denominator. + public decimal TotalDailyPnLPercent + { + get + { + var previousClose = TotalValue - TotalDailyPnL; + return previousClose == 0m ? 0m : TotalDailyPnL / previousClose * 100m; + } + } + + // Total return as a percentage of cost (gain / what was paid), over the holdings that have a basis. + // Guards a zero denominator. + public decimal TotalReturnPercent => TotalCost == 0m ? 0m : TotalReturn / TotalCost * 100m; + + public string FormatTotalValue() => CurrencyFormat.Format(TotalValue, Currency); + + // e.g. "▲ +$120.50 (+0.98%) today" / "▼ -$84.10 (-0.71%) today", in the preferred currency. + public string FormatTotalChange() + { + var amount = $"{(IsUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(TotalDailyPnL, Currency)}"; + var percent = TotalDailyPnLPercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture); + return Strings.Format(Resources.Ui_Portfolio_DailyChange, amount, percent); + } + + // A trailing total-return note for the summary subtitle, e.g. " · Total ▲ +$2,300.00 (+18.40%)", in the + // preferred currency. Empty when no holding has a recorded cost basis (so total return is unknown). + public string FormatTotalReturnNote() + { + if (!HasCostBasis) + return string.Empty; + var amount = $"{(IsTotalReturnUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(TotalReturn, Currency)}"; + var percent = TotalReturnPercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture); + return Strings.Format(Resources.Ui_Portfolio_TotalReturnNote, amount, percent); + } + + // A trailing note for the summary subtitle when some priced holdings couldn't be converted into the + // preferred currency (so the total visibly excludes them); empty when everything converted. + public string FormatUnconvertedNote() => UnconvertedCount switch + { + 0 => string.Empty, + 1 => Resources.Ui_Portfolio_Unconverted_One, + _ => Strings.Format(Resources.Ui_Portfolio_Unconverted_Many, UnconvertedCount), + }; +} diff --git a/MarketExtension/Models/UiPosition.cs b/MarketExtension/Models/UiPosition.cs new file mode 100644 index 0000000..23312e9 --- /dev/null +++ b/MarketExtension/Models/UiPosition.cs @@ -0,0 +1,132 @@ +using MarketExtension.Properties; + +namespace MarketExtension; + +// UI layer: the presentation projection of a priced holding — a DomainQuote combined with the quantity +// held, valued in BOTH the instrument's native currency and the user's reporting (preferred) currency. +// Like UiQuote, this is the ONLY place its formatting lives, so the Domain layer stays free of presentation +// concerns. The Portfolio screen renders these rows; UiPortfolio rolls a set of them up into totals. +// +// Two flavours of gain/loss are shown: +// * DAILY P&L — qty × the per-unit daily change (vs. yesterday's close); always available on a valid quote. +// * TOTAL RETURN — qty × (price − cost basis), the unrealized gain since purchase; shown only when the +// holding has a recorded CostBasis (per-unit price paid, in the instrument's NATIVE currency). A holding +// with no basis simply omits it. +// Multi-currency: +// * The native value/P&L/cost come straight from the quote (qty × price/change/basis in DomainQuote.Currency). +// * RateToPreferred is units of PreferredCurrency per 1 unit of the native currency (1 when they match, +// null when the FX rate isn't available — see CurrencyConverter). When known, the native amounts convert +// and the row shows native AND converted, and the holding counts toward the portfolio total. When null, +// the row shows native only and is excluded from the total (CountsTowardTotal == false). +internal sealed record UiPosition(DomainQuote Source, decimal Quantity, string PreferredCurrency, decimal? RateToPreferred, decimal? CostBasis = null) +{ + public static UiPosition From(DomainQuote quote, decimal quantity, string preferredCurrency, decimal? rateToPreferred, decimal? costBasis = null) + => new(quote, quantity, preferredCurrency, rateToPreferred, costBasis); + + public string Symbol => Source.Symbol; + public string Name => Source.Name; + public AssetCategory Category => Source.Category; + public bool IsValid => Source.IsValid; + public bool IsUp => Source.Change >= 0; + + public string NativeCurrency => Source.Currency; + public bool NeedsConversion => + !string.Equals(NativeCurrency, PreferredCurrency, System.StringComparison.OrdinalIgnoreCase); + public bool IsConverted => RateToPreferred.HasValue; + + // What the holding is worth right now (qty × live price) and today's gain/loss on it (qty × the + // per-unit daily change), in the NATIVE currency. Both are 0 on an invalid quote. + public decimal MarketValue => IsValid ? Quantity * Source.Price : 0m; + public decimal DailyPnL => IsValid ? Quantity * Source.Change : 0m; + + // The same values in the PREFERRED currency, or null when the rate is unavailable (or the quote invalid). + public decimal? ConvertedMarketValue => IsValid && RateToPreferred is { } r ? MarketValue * r : null; + public decimal? ConvertedDailyPnL => IsValid && RateToPreferred is { } r ? DailyPnL * r : null; + + // A holding contributes to the portfolio total only when it's priced AND convertible into the + // preferred currency (a USD holding with USD preferred trivially has rate 1). + public bool CountsTowardTotal => IsValid && RateToPreferred.HasValue; + + // Total-return (unrealized P&L since purchase). Only meaningful when a positive cost basis was recorded; + // a basis of 0/null means "unknown" and these stay null so the UI omits the figure. + public bool HasCostBasis => CostBasis is > 0m; + + // What was paid for the whole holding (qty × per-unit basis), native and converted. + public decimal? TotalCost => HasCostBasis ? Quantity * CostBasis : null; + public decimal? ConvertedTotalCost => TotalCost is { } c && RateToPreferred is { } r ? c * r : null; + + // Current value minus what was paid = the unrealized gain/loss since purchase, native and converted. + public decimal? TotalReturn => IsValid && TotalCost is { } c ? MarketValue - c : null; + public decimal? ConvertedTotalReturn => TotalReturn is { } tr && RateToPreferred is { } r ? tr * r : null; + + // Return as a percentage of cost: (price − basis) / basis. Currency-independent; null without a basis or + // a live price. Computed from the per-unit figures so it's exact regardless of quantity. + public decimal? TotalReturnPercent => IsValid && CostBasis is { } cb && cb > 0m + ? (Source.Price - cb) / cb * 100m + : null; + + public bool IsTotalReturnUp => (TotalReturn ?? 0m) >= 0m; + + // The quantity without trailing zeros (10, not 10.00; 0.5 stays 0.5). + public string FormatQuantity() => Quantity.ToString("0.########", System.Globalization.CultureInfo.InvariantCulture); + + // "AAPL · 10 sh" / "BTC · 0.5 units". Forex is shown in "units" too (a notional holding of a pair). + public string FormatHolding() => $"{Symbol} · {FormatQuantity()} {UnitLabel}"; + + private string UnitLabel => Category == AssetCategory.Stock + ? Resources.Ui_Position_Unit_Shares + : Resources.Ui_Position_Unit_Units; + + // Native value, with a converted approximation appended when the currencies differ and a rate is known: + // USD holding, USD preferred → "$1,234.56" + // GBP holding, USD preferred → "£75.00 (≈$95.20)" + // GBP holding, USD preferred, no rate → "£75.00" (excluded from the total) + public string FormatValue() + { + if (!IsValid) + return "—"; + + var native = CurrencyFormat.Format(MarketValue, NativeCurrency); + if (!NeedsConversion) + return native; + return ConvertedMarketValue is { } converted + ? $"{native} (≈{CurrencyFormat.Format(converted, PreferredCurrency)})" + : native; + } + + // e.g. "▲ +$12.05 (+1.20%)" / "▼ -$8.40 (-0.80%)"; empty for an invalid quote. Shown in the converted + // (preferred) currency when convertible — so it matches the portfolio total — else in the native one. + // The percent is currency-independent. + public string FormatDailyPnL() + { + if (!IsValid) + return string.Empty; + + var (amount, currency) = NeedsConversion && ConvertedDailyPnL is { } c + ? (c, PreferredCurrency) + : (DailyPnL, NativeCurrency); + + return $"{(IsUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(amount, currency)} " + + $"({Source.ChangePercent.ToString("+0.00;-0.00", System.Globalization.CultureInfo.InvariantCulture)}%)"; + } + + // e.g. "▲ +$420.00 (+12.00%)" — the unrealized gain/loss since purchase. Empty when no cost basis is + // recorded or the quote is invalid. Like FormatDailyPnL it shows the converted (preferred-currency) + // amount when convertible — so it matches the portfolio total — else the native one; the percent is + // currency-independent. The caller prefixes its own label ("Total") to distinguish it from daily P&L. + public string FormatTotalReturn() + { + if (!IsValid || TotalReturn is not { } native) + return string.Empty; + + var (amount, currency) = NeedsConversion && ConvertedTotalReturn is { } c + ? (c, PreferredCurrency) + : (native, NativeCurrency); + + var percent = TotalReturnPercent is { } p + ? $" ({p.ToString("+0.00;-0.00", System.Globalization.CultureInfo.InvariantCulture)}%)" + : string.Empty; + + return $"{(IsTotalReturnUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(amount, currency)}{percent}"; + } +} diff --git a/MarketExtension/Models/UiQuote.cs b/MarketExtension/Models/UiQuote.cs new file mode 100644 index 0000000..885e7f2 --- /dev/null +++ b/MarketExtension/Models/UiQuote.cs @@ -0,0 +1,31 @@ +using System.Globalization; + +namespace MarketExtension; + +// UI layer: the presentation projection of a DomainQuote. This is the ONLY place price/change +// formatting lives, so the Domain and Api layers stay free of UI concerns. Pages render UiQuote +// and filter/group on the pass-through identity members (Symbol / Name / Category). +internal sealed record UiQuote(DomainQuote Source) +{ + public static UiQuote From(DomainQuote quote) => new(quote); + + public string Symbol => Source.Symbol; + public string Name => Source.Name; + public AssetCategory Category => Source.Category; + public bool IsValid => Source.IsValid; + public bool IsUp => Source.Change >= 0; + + // Currency pairs are quoted to 4 decimals (e.g. 1.0832 — an exchange rate, not a money amount, so no + // symbol); stocks/crypto as money in their NATIVE currency (a London stock shows "£2.50", not "$2.50"). + // Invalid quotes (unknown symbol, unavailable, or a fetch failure) render as a dash. + public string FormatPrice() => !IsValid + ? "—" + : Category == AssetCategory.Currency + ? Source.Price.ToString("0.0000", CultureInfo.InvariantCulture) + : CurrencyFormat.Format(Source.Price, Source.Currency); + + // e.g. "▲ +1.20%" / "▼ -0.80%"; empty for invalid quotes. + public string FormatChange() => !IsValid + ? string.Empty + : $"{(IsUp ? "▲" : "▼")} {Source.ChangePercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%"; +} diff --git a/MarketExtension/Package.appxmanifest b/MarketExtension/Package.appxmanifest index fb67550..8216768 100644 --- a/MarketExtension/Package.appxmanifest +++ b/MarketExtension/Package.appxmanifest @@ -9,12 +9,12 @@ IgnorableNamespaces="uap uap3 rescap"> + Version="1.0.0.0" /> - Market Extension + Markets Extension for Command Palette Costa Fotiadis Assets\StoreLogo.png @@ -33,8 +33,8 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> @@ -44,8 +44,8 @@ - - + + @@ -53,8 +53,8 @@ + DisplayName="Markets Extension for Command Palette" + Description="Track market prices directly from Command Palette."> diff --git a/MarketExtension/Pages/DataSourcesPage.cs b/MarketExtension/Pages/DataSourcesPage.cs new file mode 100644 index 0000000..2793344 --- /dev/null +++ b/MarketExtension/Pages/DataSourcesPage.cs @@ -0,0 +1,62 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// A plain informational screen explaining where market data comes from and the keys-stay-on-your-machine +// model. Reached from the Markets hub. Pure static Markdown — no data fetch, no providers, no lifecycle. +internal sealed partial class DataSourcesPage : ContentPage +{ + private readonly MarkdownContent _content = new( + """ + # Data Sources + + This app shows market data for informational purposes only. It is **not + financial advice** and should not be relied on for trading or investment + decisions. Quotes, rates, and charts may be **delayed or inaccurate** + depending on the source. + + The app is **not affiliated with, + endorsed by, or connected to** Finnhub, Twelve Data, Frankfurter, the + European Central Bank, or any data provider. + + ## Where the data comes from + + Quotes, currency rates, and charts come from independent third-party + providers. What's available depends on which keys you've added in + Settings and the plan attached to each. + + Some data, such as currency reference rates, can come from a free + public source that needs no key. + + ## Your keys, your data + + The keys are yours. You create them with the provider; this app keeps + them on your own machine and sends each one + **directly to the provider it belongs to** — nothing is routed through + a server, because this app doesn't run one. + + Any agreement governing a key's use is **between you and that + provider**. This app is just the surface that displays the result, and + you are responsible for using your keys in line with whatever terms you + agreed to when you created them. + + ## What this app does and doesn't do + + **Does:** send your queries straight to the provider you've configured, + using your own key, and briefly caches results so it isn't making redundant requests. + + **Doesn't:** run a server, upload or proxy your keys, retain provider + data, or share anything with anyone but you. + """); + + public DataSourcesPage() + { + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + Title = Resources.Page_DataSources_Title; + Name = Resources.Action_Open; + } + + public override IContent[] GetContent() => [_content]; +} diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 45bbe26..52d7365 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -1,14 +1,17 @@ +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; +using MarketExtension.Properties; namespace MarketExtension; -// Backs a Command Palette Dock band: a strip showing up to MaxDockFavorites favorited -// instruments as ticker buttons (e.g. "AAPL ▲ +1.20%"). Returned from -// MarketExtensionCommandsProvider.GetDockBands() wrapped in a CommandItem. +// Backs a Command Palette Dock band: a strip showing every favorited +// instrument as ticker buttons (e.g. "AAPL ▲ +1.20%"); clicking one opens its SymbolDetailPage. +// Returned from MarketExtensionCommandsProvider.GetDockBands() wrapped in a CommandItem. // // Because the band's command is an IListPage, the host renders each item from GetItems() as // its own button within the one band (see reference/dock-support.md). We use the project's @@ -17,28 +20,57 @@ namespace MarketExtension; // API phase — see reference/dock-support.md for the OnLoad lifecycle to add then. internal sealed partial class FavoritesDockPage : ListPage, INotifyItemsChanged { - private const int MaxDockFavorites = 3; - - private readonly IMarketDataProvider _provider; - private Quote[]? _quotes; + private readonly MarketRepository _repository; + private UiQuote[]? _quotes; private event TypedEventHandler? _itemsChanged; + private IDisposable? _subscription; + private IDisposable? _pollSubscription; + private IDisposable? _demoModeSubscription; event TypedEventHandler INotifyItemsChanged.ItemsChanged { - add { _itemsChanged += value; RefreshQuotes(); } // fires when the band becomes visible - remove => _itemsChanged -= value; + add + { + _itemsChanged += value; + // Observe the favorites flow while the band is visible: its replay paints the band the moment + // it opens, and any later star/unstar from a palette page refreshes it at once (no waiting + // for a reopen). Disposed in `remove` so a hidden band does no work and doesn't leak. + _subscription = WatchlistStore.Instance.Favorites.Subscribe(_ => RefreshQuotes()); + // Live polling: each tick silently re-prices favorites in place (no spinner). The ticker is a + // pure event stream (no replay), so becoming visible doesn't double-fetch — the favorites + // subscription above already paints. + _pollSubscription = PollTicker.Subscribe(PollRefresh); + // Demo mode flips the data source — re-price from the new one at once if the band is open. A + // hidden band already re-fetches on its next open (the favorites-flow replay calls RefreshQuotes), + // so this only needs to cover a currently-visible/pinned band. replay:false — opening already + // paints via the favorites subscription above. + _demoModeSubscription = MarketSettingsManager.Instance.DemoModeChanged + .Subscribe(_ => RefreshQuotes(), replayOnSubscribe: false); + Log.Info("Poll", $"Dock: started polling [{string.Join(", ", WatchlistStore.Instance.Favorites.Value.Select(i => i.Symbol))}]"); + } + remove + { + _itemsChanged -= value; + _subscription?.Dispose(); + _subscription = null; + _pollSubscription?.Dispose(); + _pollSubscription = null; + _demoModeSubscription?.Dispose(); + _demoModeSubscription = null; + Log.Info("Poll", "Dock: stopped polling"); + } } - protected new void RaiseItemsChanged(int totalItems = -1) + private new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); - public FavoritesDockPage(IMarketDataProvider provider) + public FavoritesDockPage(MarketRepository repository) { - _provider = provider; + _repository = repository; Id = "com.costafotiadis.market.dock.favorites"; // dock bands require a non-empty command Id - Title = "Markets"; - Icon = new IconInfo("https://github.com/favicon.ico"); + Title = Resources.Command_Markets; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); } public override IListItem[] GetItems() @@ -46,29 +78,24 @@ public override IListItem[] GetItems() if (_quotes is null) return []; - var favorites = _quotes - .Where(q => FavoritesStore.Instance.IsFavorite(q.Symbol)) - .Take(MaxDockFavorites) - .ToArray(); + var favorites = _quotes; if (favorites.Length == 0) { return [new ListItem(new NoOpCommand { Id = "com.costafotiadis.market.dock.empty" }) { - Title = "No favorites yet", - Subtitle = "Star instruments in the Markets page", + Title = Resources.Favorites_Empty_Title, + Subtitle = Resources.Favorites_Empty_Subtitle, }]; } return favorites .Select(q => (IListItem)new ListItem( - new CopyTextCommand($"{q.Symbol} {q.FormatPrice()} ({q.FormatChange()})") - { - Id = $"com.costafotiadis.market.dock.copy.{q.Symbol}", - }) + new SymbolDetailPage(new DomainInstrument(q.Symbol, q.Name, q.Category), _repository)) { Title = $"{q.Symbol} {q.FormatChange()}", Subtitle = q.FormatPrice(), + Icon = AssetIconResolver.Resolve(q), }) .ToArray(); } @@ -78,12 +105,44 @@ internal void RefreshQuotes() _quotes = null; IsLoading = true; RaiseItemsChanged(0); - Task.Run(LoadQuotes); + Task.Run(() => LoadQuotes(silent: false)); + } + + // Live-poll refresh: re-price favorites WITHOUT clearing _quotes or showing a spinner, so the current + // prices stay on the band until LoadQuotes swaps the new ones in (no flicker). Skips work before the + // first paint — the favorites subscription already has a load in flight then. `silent: true` also keeps + // a transient bad poll from blanking a good price (see LoadQuotes). + internal void PollRefresh() + { + if (_quotes is null) + return; + Log.Info("Poll", $"Dock: re-pricing favorites [{string.Join(", ", WatchlistStore.Instance.Favorites.Value.Select(i => i.Symbol))}]"); + Task.Run(() => LoadQuotes(silent: true)); } - private void LoadQuotes() + // `silent` = a background poll (no spinner): in that mode, don't let a transient bad result (e.g. a + // Finnhub 429 mapped to an invalid quote) overwrite a price that was fine a moment ago. This mirrors + // PricedListPage.LoadQuotes' keep-last-good guard — without it a single failed poll would replace every + // UiQuote with an invalid one and the band would blank (symbol-only buttons) until the extension is + // reloaded, because a pinned dock never re-fetches except on a poll tick. + private async Task LoadQuotes(bool silent) { - _quotes = [.. _provider.GetQuotes()]; + // The dock shows only favorites — price exactly that subset (a snapshot of the flow's value). + var prior = _quotes; // on-screen prices, for the keep-last-good merge + IEnumerable fetched = + (await _repository.GetQuotesAsync(WatchlistStore.Instance.Favorites.Value)).Select(UiQuote.From); + + if (silent && prior is not null) + { + var lastGood = prior + .Where(q => q.IsValid) + .GroupBy(q => q.Symbol, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + fetched = fetched.Select(q => + !q.IsValid && lastGood.TryGetValue(q.Symbol, out var good) ? good : q); + } + + _quotes = [.. fetched]; IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs new file mode 100644 index 0000000..683132f --- /dev/null +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Top-level screen: the curated favorites — the same set the dock band shows — priced live. Enter +// opens the row's SymbolDetailPage, which is the single place to remove it from favorites (leaving +// it on the watchlist if it's tracked there). The row carries no context actions. +internal sealed partial class FavoritesPage : PricedListPage +{ + public FavoritesPage(MarketRepository repository) : base(repository, WatchlistStore.Instance.Favorites) + { + Title = Resources.Page_Favorites_Title; + Name = Resources.Action_Open; + PlaceholderText = Resources.Favorites_Placeholder; + } + + protected override IListItem BuildRow(UiQuote q) + { + var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); + + return new ListItem(new SymbolDetailPage(instrument, Repository)) + { + Title = $"★ {q.Symbol} · {q.Name}", + Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", + Icon = AssetIconResolver.Resolve(q), + }; + } + + protected override IListItem[] EmptyState() => + [ + new ListItem(new NoOpCommand()) + { + Title = Resources.Favorites_Empty_Title, + Subtitle = Resources.Favorites_Empty_Subtitle, + }, + ]; +} diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 41cdb89..3d51556 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -1,131 +1,125 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; +using MarketExtension.Properties; namespace MarketExtension; -// Top-level page: browse market instruments and star favorites. -// -// Structure mirrors reference/pages/AdbExtensionPage.cs — DynamicListPage with the -// INotifyItemsChanged on-load refresh hook (see CLAUDE.md), an async Task.Run load, and a -// trailing Refresh item. Data comes from IMarketDataProvider; today that is the mock, later -// a real API. Favorites are pinned into their own section at the top. -internal sealed partial class MarketsPage : DynamicListPage, INotifyItemsChanged +// The single top-level "Markets" command: a hub that funnels into the three market screens +// (Search / Watchlist / Favorites). Replaces the old three separate top-level commands, so the +// Command Palette root now carries one "Markets" entry instead of polluting it with three. Each row +// navigates into the existing page unchanged ("everything is the same after that"). The sub-pages are +// built once and reused, so their per-page price caches survive navigating in and out of the hub. +internal sealed partial class MarketsPage : ListPage, INotifyItemsChanged { - // Injected so the palette page and the dock band share one data source (the seam where the - // real API implementation drops in later — see reference/dock-support.md). - private readonly IMarketDataProvider _provider; - private Quote[]? _quotes; - + private const string SearchGlyph = "\uE721"; // Segoe MDL2 Search + private const string ListGlyph = "\uE8FD"; // Segoe MDL2 List + private const string StarFillGlyph = "\uE735"; // Segoe MDL2 FavoriteStarFill + private const string PortfolioGlyph = "\uE825"; // Segoe MDL2 Bank + private const string InfoGlyph = "\uE9F9"; // Segoe MDL2 ReportDocument + private const string SettingsGlyph = "\uE713"; // Segoe MDL2 Setting + + private readonly SearchPage _searchPage; + private readonly WatchlistPage _watchlistPage; + private readonly FavoritesPage _favoritesPage; + private readonly PortfolioPage _portfolioPage; + private readonly DataSourcesPage _dataSourcesPage; + private readonly IContentPage _settingsPage; + + // The hub is otherwise a static list, but the missing-key hint row must react when a key is + // added/cleared. Re-implement INotifyItemsChanged so we can subscribe to the key flow while visible + // (the add/remove accessors are CmdPal's de-facto Loaded/Unloaded hooks) and re-list on a flip. private event TypedEventHandler? _itemsChanged; + private readonly List _subscriptions = []; event TypedEventHandler INotifyItemsChanged.ItemsChanged { - add { _itemsChanged += value; RefreshQuotes(); } // fires every time the user navigates here - remove => _itemsChanged -= value; + add + { + _itemsChanged += value; + // replay:false \u2014 the framework's initial fetch already painted the rows (incl. the hint); + // we only need to re-list when the key presence actually flips. + _subscriptions.Add(MarketSettingsManager.Instance.HasAnyApiKey + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + // Same for demo mode: re-list so the status row swaps to/from the blue "Demo mode" row at once. + _subscriptions.Add(MarketSettingsManager.Instance.DemoModeChanged + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } } - protected new void RaiseItemsChanged(int totalItems = -1) + private new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); - public MarketsPage(IMarketDataProvider provider) + public MarketsPage(MarketRepository repository) { - _provider = provider; - Icon = new IconInfo("https://github.com/favicon.ico"); - Title = "Markets"; - Name = "Open"; - PlaceholderText = "Search symbol or name..."; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + Title = Resources.Page_Markets_Title; + Name = Resources.Action_Open; + PlaceholderText = Resources.Hub_Placeholder; + + _searchPage = new SearchPage(repository); + _watchlistPage = new WatchlistPage(repository); + _favoritesPage = new FavoritesPage(repository); + _portfolioPage = new PortfolioPage(repository); + _dataSourcesPage = new DataSourcesPage(); + + // The toolkit builds a navigable settings page straight from our settings (Finnhub API key + + // refresh interval) \u2014 the same form Command Palette shows in its own Settings UI. + _settingsPage = MarketSettingsManager.Instance.Settings.SettingsPage; } - public override void UpdateSearchText(string oldSearch, string newSearch) - => RaiseItemsChanged(0); - public override IListItem[] GetItems() { - if (_quotes is null) - return []; - - var source = string.IsNullOrEmpty(SearchText) - ? _quotes - : _quotes.Where(q => - q.Symbol.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || - q.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToArray(); - - if (source.Length == 0) - return [new ListItem(new NoOpCommand()) { Title = "No instruments match 😔" }]; - - var items = new List(); - - // Favorites first, in their own section (and not repeated in the category sections). - var favorites = source.Where(q => FavoritesStore.Instance.IsFavorite(q.Symbol)).ToArray(); - foreach (var q in favorites) - items.Add(BuildItem(q, "★ Favorites")); - - foreach (var category in new[] { AssetCategory.Stock, AssetCategory.Crypto, AssetCategory.Currency }) + var items = new List { - var inCategory = source.Where(q => q.Category == category && !FavoritesStore.Instance.IsFavorite(q.Symbol)); - foreach (var q in inCategory) - items.Add(BuildItem(q, SectionLabel(category))); - } - - items.Add(new ListItem(new RefreshCommand(this)) { Title = "Refresh 🔄" }); - - return items.ToArray(); - } - - private ListItem BuildItem(Quote q, string section) => - new(new ToggleFavoriteCommand(q.Symbol, () => RaiseItemsChanged(0))) - { - Title = $"{q.Symbol} · {q.Name}", - Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", - Section = section, - MoreCommands = - [ - new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, - ], + new ListItem(_searchPage) + { + Title = Resources.Nav_Search_Title, + Icon = new IconInfo(SearchGlyph), + }, + new ListItem(_watchlistPage) + { + Title = Resources.Nav_Watchlist_Title, + Subtitle = Resources.Nav_Watchlist_Subtitle, + Icon = new IconInfo(ListGlyph), + }, + new ListItem(_favoritesPage) + { + Title = Resources.Nav_Favorites_Title, + Subtitle = Resources.Nav_Favorites_Subtitle, + Icon = new IconInfo(StarFillGlyph), + }, + new ListItem(_portfolioPage) + { + Title = Resources.Nav_Portfolio_Title, + Icon = new IconInfo(PortfolioGlyph), + }, + new ListItem(_dataSourcesPage) + { + Title = Resources.Nav_DataSources_Title, + Icon = new IconInfo(InfoGlyph), + }, + new ListItem(_settingsPage) + { + Title = Resources.Nav_Settings_Title, + Icon = new IconInfo(SettingsGlyph), + }, }; - private static string SectionLabel(AssetCategory category) => category switch - { - AssetCategory.Stock => "Stocks", - AssetCategory.Crypto => "Crypto", - AssetCategory.Currency => "Currency", - _ => "Other", - }; - - internal void RefreshQuotes() - { - _quotes = null; - IsLoading = true; - RaiseItemsChanged(0); - Task.Run(LoadQuotes); - } - - private void LoadQuotes() - { - _quotes = [.. _provider.GetQuotes()]; - IsLoading = false; - RaiseItemsChanged(0); - } + // Surface the data state up front on the hub: no key → most of the app shows nothing; demo mode → + // the data is sample/simulated. + if (ApiKeyHint.StatusRow() is { } hint) + items.Add(hint); - private sealed partial class RefreshCommand : InvokableCommand - { - private readonly MarketsPage _page; - - public RefreshCommand(MarketsPage page) - { - _page = page; - Name = "Refresh"; - } - - public override ICommandResult Invoke() - { - _page.RefreshQuotes(); - return CommandResult.KeepOpen(); - } + return [.. items]; } } diff --git a/MarketExtension/Pages/PortfolioDockPage.cs b/MarketExtension/Pages/PortfolioDockPage.cs new file mode 100644 index 0000000..1bf740f --- /dev/null +++ b/MarketExtension/Pages/PortfolioDockPage.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Backs a second Command Palette Dock band (next to the favorites band): a single strip showing the +// portfolio's total value + today's P&L, rolled up into the user's PortfolioCurrency. Clicking it opens the +// full PortfolioPage. Returned from MarketExtensionCommandsProvider.GetDockBands() wrapped in a CommandItem. +// +// Unlike the favorites band (one button per instrument), this band renders ONE summary button — the same +// totals row the Portfolio screen pins on top — so the dock shows the bottom line at a glance. +// +// Live lifecycle mirrors FavoritesDockPage: the project's INotifyItemsChanged on-load refresh re-reads +// holdings + quotes every time the band becomes visible, it subscribes to PortfolioStore.Positions (so a +// quantity/membership change repaints at once) and to PollTicker (so prices refresh on the timer), and it +// disposes both when hidden so a hidden band does no work. Multi-currency: the band prices every holding, +// primes the native->preferred FX rates (CurrencyConverter / Frankfurter), and rolls them up via +// UiPortfolio — exactly like PortfolioPage, but it AWAITS the FX prime inside the async load so the +// converted total is ready in one paint (no progressive native-only first frame needed on a one-row band). +internal sealed partial class PortfolioDockPage : ListPage, INotifyItemsChanged +{ + private const string PortfolioGlyph = ""; // Segoe MDL2 Bank glyph U+E825 (matches PortfolioPage's summary row) + + private readonly MarketRepository _repository; + private UiQuote[]? _quotes; // last priced holdings, for the keep-last-good poll merge + private UiPortfolio? _portfolio; // the rolled-up total for rendering; null until first load lands + + private event TypedEventHandler? _itemsChanged; + private IDisposable? _subscription; + private IDisposable? _pollSubscription; + private IDisposable? _demoModeSubscription; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // Observe the holdings flow while the band is visible: its replay paints the band the moment it + // opens, and any later add/edit/remove from the detail page refreshes it at once. Positions uses + // reference equality, so even a quantity-only edit re-emits and re-rolls the total. + _subscription = PortfolioStore.Instance.Positions.Subscribe(_ => RefreshPortfolio()); + // Live polling: each tick silently re-prices holdings in place (no spinner). The ticker is a pure + // event stream (no replay), so becoming visible doesn't double-fetch — the positions subscription + // above already paints. + _pollSubscription = PollTicker.Subscribe(PollRefresh); + // Demo mode flips the data source — re-roll the total from the new one at once if the band is + // open. A hidden band already re-fetches on its next open (the positions-flow replay calls + // RefreshPortfolio), so this only needs to cover a currently-visible/pinned band. replay:false — + // opening already paints via the positions subscription above. + _demoModeSubscription = MarketSettingsManager.Instance.DemoModeChanged + .Subscribe(_ => RefreshPortfolio(), replayOnSubscribe: false); + Log.Info("Poll", $"PortfolioDock: started polling [{string.Join(", ", PortfolioStore.Instance.Positions.Value.Select(p => p.Instrument.Symbol))}]"); + } + remove + { + _itemsChanged -= value; + _subscription?.Dispose(); + _subscription = null; + _pollSubscription?.Dispose(); + _pollSubscription = null; + _demoModeSubscription?.Dispose(); + _demoModeSubscription = null; + Log.Info("Poll", "PortfolioDock: stopped polling"); + } + } + + private new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + public PortfolioDockPage(MarketRepository repository) + { + _repository = repository; + Id = "com.costafotiadis.market.dock.portfolio"; // dock bands require a non-empty command Id + Title = Resources.Command_MarketsPortfolio; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + } + + public override IListItem[] GetItems() + { + if (_quotes is null) + return []; // not loaded yet + + if (_portfolio is null || !_portfolio.HasHoldings) + { + return [new ListItem(new NoOpCommand { Id = "com.costafotiadis.market.dock.portfolio.empty" }) + { + Title = Resources.Portfolio_Empty_Title, + Subtitle = Resources.Portfolio_Empty_Subtitle, + }]; + } + + // The one summary button: total value as the title, today's P&L (and any "not converted" note) as the + // subtitle. Clicking opens the full Portfolio screen for the breakdown. + return + [ + new ListItem(new PortfolioPage(_repository)) + { + Title = Strings.Format(Resources.Portfolio_TotalsRow_Title, _portfolio.FormatTotalValue()), + Subtitle = _portfolio.FormatTotalChange() + _portfolio.FormatTotalReturnNote() + _portfolio.FormatUnconvertedNote(), + Icon = new IconInfo(PortfolioGlyph), + }, + ]; + } + + internal void RefreshPortfolio() + { + _quotes = null; + _portfolio = null; + IsLoading = true; + RaiseItemsChanged(0); + Task.Run(() => LoadPortfolio(silent: false)); + } + + // Live-poll refresh: re-price holdings WITHOUT clearing _quotes/_portfolio or showing a spinner, so the + // current total stays on the band until LoadPortfolio swaps the new one in (no flicker). Skips work + // before the first paint — the positions subscription already has a load in flight then. + internal void PollRefresh() + { + if (_quotes is null) + return; + Log.Info("Poll", $"PortfolioDock: re-pricing holdings [{string.Join(", ", PortfolioStore.Instance.Positions.Value.Select(p => p.Instrument.Symbol))}]"); + Task.Run(() => LoadPortfolio(silent: true)); + } + + // Price the current holdings, convert into the preferred currency, and roll up the total. `silent` = a + // background poll (no spinner): in that mode, don't let a transient bad result (e.g. a 429 mapped to an + // invalid quote) overwrite a price that was fine a moment ago, which would otherwise drop that holding + // from the total and make it jump. Same keep-last-good guard as FavoritesDockPage / PricedListPage. + private async Task LoadPortfolio(bool silent) + { + var positions = PortfolioStore.Instance.Positions.Value; // snapshot the holdings to price + if (positions.Count == 0) + { + _quotes = []; // loaded-but-empty (distinct from null = not-loaded), so GetItems shows the empty row + _portfolio = null; + IsLoading = false; + RaiseItemsChanged(0); + return; + } + + var instruments = positions.Select(p => p.Instrument).ToList(); + var prior = _quotes; // on-screen prices, for the keep-last-good merge + IEnumerable fetched = + (await _repository.GetQuotesAsync(instruments)).Select(UiQuote.From); + + if (silent && prior is not null) + { + var lastGood = prior + .Where(q => q.IsValid) + .GroupBy(q => q.Symbol, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + fetched = fetched.Select(q => + !q.IsValid && lastGood.TryGetValue(q.Symbol, out var good) ? good : q); + } + + var quotes = fetched.ToArray(); + _quotes = quotes; + + // Prime native->preferred FX rates before rolling up, so the converted total is ready this paint. + var preferred = MarketSettingsManager.Instance.PortfolioCurrency; + var natives = quotes + .Where(q => q.IsValid) + .Select(q => q.Source.Currency) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (natives.Length > 0) + await CurrencyConverter.Instance.PrimeAsync(preferred, natives); + + _portfolio = BuildPortfolio(positions, quotes, preferred); + IsLoading = false; + RaiseItemsChanged(0); + } + + // Zip each holding's quantity with its priced quote and the (cached) native->preferred rate, then roll + // the set up into the totals — the same composition PortfolioPage.LeadingRows does, just eager here. + private static UiPortfolio BuildPortfolio(IReadOnlyList positions, UiQuote[] quotes, string preferred) + { + var quoteBySymbol = quotes + .GroupBy(q => q.Symbol, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Source, StringComparer.OrdinalIgnoreCase); + + var uiPositions = new List(); + foreach (var p in positions) + if (quoteBySymbol.TryGetValue(p.Instrument.Symbol, out var quote)) + { + var rate = CurrencyConverter.Instance.TryGetRate(quote.Currency, preferred); + uiPositions.Add(UiPosition.From(quote, p.Quantity, preferred, rate, p.CostBasis)); + } + + return UiPortfolio.From(uiPositions, preferred); + } +} diff --git a/MarketExtension/Pages/PortfolioPage.cs b/MarketExtension/Pages/PortfolioPage.cs new file mode 100644 index 0000000..51a9e61 --- /dev/null +++ b/MarketExtension/Pages/PortfolioPage.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Top-level screen: the user's portfolio holdings, priced live, with a totals summary pinned on top. +// Reached from the Markets hub. Subclasses PricedListPage (like Watchlist/Favorites) to inherit all of +// its caching / polling / reconcile / keep-last-good plumbing — it just observes PortfolioStore.Instruments +// (the symbols to price) instead of a watchlist subset. +// +// Each row shows the holding (symbol · quantity) with its market value and today's P&L; Enter opens the +// shared SymbolDetailPage, which is where holdings are added/edited/removed (Add to Portfolio / Edit +// holding / Remove), consistent with every other list. The quantity for a row is read from PortfolioStore +// at render time — the same way WatchlistPage reads IsFavorite — and the totals summary is built from the +// full priced set via the LeadingRows hook. A quantity-only edit re-emits PortfolioStore.Instruments +// (default-equality flow), so the base re-renders with no fetch and the new quantity + total show at once. +// +// Multi-currency: holdings priced in various native currencies are converted into the user's +// PortfolioCurrency setting. Conversion rates are an async FX fetch (CurrencyConverter / Frankfurter), but +// GetItems is synchronous — so the rates are PRIMED off the price-load path (OnPriceCacheUpdated, the base's +// post-update hook) and read from the converter's cache while rendering. Until a rate lands a row shows its +// native value only and sits out of the total; a holding in a currency the FX provider can't convert stays +// that way (surfaced as "N not converted" on the summary). +internal sealed partial class PortfolioPage : PricedListPage +{ + private const string PortfolioGlyph = ""; // Segoe MDL2 Bank + + public PortfolioPage(MarketRepository repository) : base(repository, PortfolioStore.Instance.Instruments) + { + Title = Resources.Page_Portfolio_Title; + Name = Resources.Action_Open; + PlaceholderText = Resources.Portfolio_Placeholder; + } + + // The totals summary, pinned above the holdings. Built from the FULL priced set zipped with the current + // quantities, so it always reflects the WHOLE portfolio even while the search box filters rows below. + protected override IEnumerable LeadingRows(IReadOnlyList pricedQuotes) + { + var preferred = MarketSettingsManager.Instance.PortfolioCurrency; + var positions = new List(); + foreach (var q in pricedQuotes) + if (PortfolioStore.Instance.GetPosition(q.Symbol) is { } held) + positions.Add(MakePosition(q.Source, held.Quantity, held.CostBasis, preferred)); + + if (positions.Count == 0) + return []; + + var portfolio = UiPortfolio.From(positions, preferred); + return + [ + new ListItem(new NoOpCommand()) + { + Title = Strings.Format(Resources.Portfolio_TotalsRow_Title, portfolio.FormatTotalValue()), + Subtitle = portfolio.FormatTotalChange() + portfolio.FormatTotalReturnNote() + portfolio.FormatUnconvertedNote(), + Icon = new IconInfo(PortfolioGlyph), + }, + ]; + } + + protected override IListItem BuildRow(UiQuote q) + { + var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); + var held = PortfolioStore.Instance.GetPosition(q.Symbol); + var position = MakePosition(q.Source, held?.Quantity ?? 0m, held?.CostBasis, MarketSettingsManager.Instance.PortfolioCurrency); + + // Daily P&L always; total return appended ("Total ▲ …") only when a cost basis is recorded. + var subtitle = $"{position.FormatValue()} {position.FormatDailyPnL()}"; + var totalReturn = position.FormatTotalReturn(); + if (totalReturn.Length > 0) + subtitle += " " + Strings.Format(Resources.Portfolio_Row_TotalReturnPrefix, totalReturn); + + return new ListItem(new SymbolDetailPage(instrument, Repository)) + { + Title = position.FormatHolding(), + Subtitle = subtitle, + Icon = AssetIconResolver.Resolve(q), + }; + } + + // Build a UiPosition, looking up the (cached) native→preferred FX rate. A null rate means "not known + // yet / not convertible" — the position renders native-only and stays out of the total. costBasis is the + // per-unit price paid (null when none recorded), carried through for total-return reporting. + private static UiPosition MakePosition(DomainQuote quote, decimal qty, decimal? costBasis, string preferred) + { + var rate = CurrencyConverter.Instance.TryGetRate(quote.Currency, preferred); + return UiPosition.From(quote, qty, preferred, rate, costBasis); + } + + // After each price update, ensure the FX rates for every native currency now present are fetched into + // the converter's cache, then re-render so the converted values + total appear. Runs off the UI/render + // path; the converter skips currencies it already has fresh, so a steady portfolio does no extra network. + protected override void OnPriceCacheUpdated() + { + var preferred = MarketSettingsManager.Instance.PortfolioCurrency; + var natives = SnapshotPricedQuotes() + .Where(q => q.IsValid) + .Select(q => q.Source.Currency) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (natives.Length == 0) + return; + + Task.Run(async () => + { + await CurrencyConverter.Instance.PrimeAsync(preferred, natives); + RaiseItemsChanged(0); + }); + } + + protected override IListItem[] EmptyState() => + [ + new ListItem(new NoOpCommand()) + { + Title = Resources.Portfolio_Empty_Title, + Subtitle = Resources.Portfolio_Empty_Subtitle, + }, + ]; +} diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs new file mode 100644 index 0000000..131662d --- /dev/null +++ b/MarketExtension/Pages/PricedListPage.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Shared base for the two priced list screens (Watchlist, Favorites). Each OBSERVES a different +// WatchlistStore membership flow (passed to the ctor) and prices its instruments. Wiring: +// +// * The page subscribes to its instrument flow in the INotifyItemsChanged `add` accessor (CmdPal's +// de-facto "page became visible" hook) and disposes in `remove`. StateFlow replays the current set +// on subscribe, which drives the initial price load — so navigating here always re-prices. +// * When membership changes (a row added/removed from anywhere), the flow pushes the new set and the +// page reconciles LOCALLY against a per-symbol price cache: rows that left are dropped with no +// network call, and only newly-added symbols are fetched. This keeps edits cheap against the +// Finnhub rate limit and makes a removed row disappear at once (rather than lingering until reload). +// * The Refresh row (and, later, the live-poll timer) forces a full re-price of the whole set. +// +// Typing only re-filters the already-loaded quotes locally — these screens never hit the network on +// keystroke (only the SearchPage talks to /search, and only on Enter). +internal abstract partial class PricedListPage : DynamicListPage, INotifyItemsChanged +{ + protected readonly MarketRepository Repository; + + private readonly StateFlow> _instruments; + private readonly List _subscriptions = []; + + // Per-symbol price cache (key = WatchlistStore.Normalize(symbol)) + the current instrument set, so a + // membership change can reconcile without re-pricing instruments we already hold. Also guards the + // in-flight fetch token swap. + private readonly Lock _cacheLock = new(); + private readonly Dictionary _priceCache = []; + private IReadOnlyList _snapshot = []; + private bool _received; // have we processed the first flow emission yet? + private int _fetchGeneration; // bumped per fetch so a superseded one's late result is discarded + private long _lastFullPriceTicks; // Environment.TickCount64 at the last WHOLE-set re-price; 0 = never priced + + private event TypedEventHandler? _itemsChanged; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // Primary flow: drives which rows exist + their pricing (replays the current set at once). + _subscriptions.Add(_instruments.Subscribe(OnInstrumentsChanged)); + // Secondary flows: a change just re-renders (e.g. the Watchlist's ★ when favorites change). + foreach (var trigger in RelistTriggers) + _subscriptions.Add(trigger.Subscribe(_ => RaiseItemsChanged(0))); + // Live polling: while visible, each tick silently re-prices the set in place. The ticker is a + // pure event stream (no replay), so opening the page doesn't double-fetch (the membership replay + // above already did the first load); the ticker runs its timer only while a surface is subscribed. + _subscriptions.Add(PollTicker.Subscribe(PollRefresh)); + // Re-render when a key is added/cleared so the missing-key hint appears/disappears at once. + // replay:false — the membership flow above already drives the initial paint. + _subscriptions.Add(MarketSettingsManager.Instance.HasAnyApiKey + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + // Re-render when throttling starts/stops so the rate-limited banner appears/disappears at once. + // replay:false — same reason as above. + _subscriptions.Add(RateLimitSignal.Instance.IsRateLimited + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + Log.Info("Poll", $"{Title}: started polling [{string.Join(", ", _snapshot.Select(i => i.Symbol))}] " + + $"(every {MarketSettingsManager.Instance.RefreshMinutes} min, 0=off)"); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + Log.Info("Poll", $"{Title}: stopped polling [{string.Join(", ", _snapshot.Select(i => i.Symbol))}]"); + } + } + + protected new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + protected PricedListPage(MarketRepository repository, StateFlow> instruments) + { + Repository = repository; + _instruments = instruments; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + // Demo mode flips the data SOURCE, so every cached price is now wrong. Subscribe for the page's + // WHOLE life (not the visibility-scoped block above): these pages are long-lived singletons that + // reconcile against a surviving cache on reopen, so a HIDDEN page must drop its cache on a flip too — + // otherwise reopening it would repaint stale prices from the old source. replay:false so construction + // is a no-op (the cache is empty then anyway). Never disposed: the page lives for the whole process. + _ = MarketSettingsManager.Instance.DemoModeChanged + .Subscribe(_ => OnDataSourceChanged(), replayOnSubscribe: false); + } + + // The data source changed (demo mode toggled): the cached prices were produced by the other source and + // are now wrong. Drop them and reset the freshness clock. If we're visible, re-fetch from the new source + // right away; if hidden, the cleared cache makes the next open do a full fetch (OnInstrumentsChanged + // finds everything "missing"), so the flip applies the instant the page is next seen. + private void OnDataSourceChanged() + { + lock (_cacheLock) + { + _priceCache.Clear(); + _lastFullPriceTicks = 0; + } + + if (_itemsChanged is not null) // visible (has subscribers) → reprice now from the new source + RefreshQuotes(); + } + + // Additional flows whose changes should merely re-render this page (no re-pricing). Default: none. + // The Watchlist screen overrides this with the Favorites flow so its ★ marks stay current. + protected virtual IEnumerable>> RelistTriggers => []; + + // Render one priced row. + protected abstract IListItem BuildRow(UiQuote quote); + + // Shown when the underlying set is empty. + protected abstract IListItem[] EmptyState(); + + // Rows to render ABOVE the priced list — e.g. the Portfolio screen's totals summary. Receives the + // FULL priced set (NOT filtered by the search box) so a summary always reflects the whole list even + // while the user filters rows below it. Default: none, so Watchlist/Favorites are unaffected. + protected virtual IEnumerable LeadingRows(IReadOnlyList pricedQuotes) => []; + + // Fired after the price cache changes (a fresh fetch landed, or a revisit found it fully cached), on + // whatever thread did the update, just before the list re-renders. Default: nothing. The Portfolio + // screen overrides it to (re)fetch FX conversion rates for the currencies now present, then re-render + // once they land. Kept off the synchronous GetItems path so a keystroke never triggers a network call. + protected virtual void OnPriceCacheUpdated() { } + + // A snapshot of the currently-priced quotes, in set order (used by OnPriceCacheUpdated overrides to see + // which currencies need converting). Same projection GetItems renders from. + protected IReadOnlyList SnapshotPricedQuotes() + { + lock (_cacheLock) + return [.. _snapshot + .Select(i => _priceCache.GetValueOrDefault(WatchlistStore.Normalize(i.Symbol))) + .OfType()]; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + => RaiseItemsChanged(0); + + public override IListItem[] GetItems() + { + if (!_received) + return []; // framework's pre-subscribe fetch — nothing to show yet + + if (_snapshot.Count == 0) + return ApiKeyHint.StatusRow() is { } emptyHint ? [.. EmptyState(), emptyHint] : EmptyState(); + + UiQuote[] cached; + lock (_cacheLock) + cached = [.. _snapshot + .Select(i => _priceCache.GetValueOrDefault(WatchlistStore.Normalize(i.Symbol))) + .OfType()]; + + if (cached.Length == 0) + return []; // first prices still loading — let the spinner show + + var rows = new List(); + rows.AddRange(LeadingRows(cached)); // e.g. the Portfolio totals summary — computed from the full set + rows.AddRange(cached.Where(Matches).Select(BuildRow)); + rows.Add(new ListItem(new RefreshCommand(this)) { Title = Resources.Action_Refresh + " 🔄" }); + rows.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (rows above show logos) + if (ApiKeyHint.StatusRow() is { } hint) // no key → explain blanks; demo mode → flag sample data + rows.Add(hint); + if (RateLimitHint.Row() is { } banner) // throttled → surface at the TOP so it's seen without scrolling + rows.Insert(0, banner); + return [.. rows]; + } + + // Instant, no-API filter over the already-loaded quotes. + private bool Matches(UiQuote q) => + string.IsNullOrEmpty(SearchText) || + q.Symbol.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || + q.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase); + + // Reaction to a membership change (and to the initial replay): drop departed rows from the cache for + // free, then fetch only the symbols we don't yet have. + private void OnInstrumentsChanged(IReadOnlyList instruments) + { + _snapshot = instruments; + _received = true; + + List missing; + lock (_cacheLock) + { + var keep = instruments.Select(i => WatchlistStore.Normalize(i.Symbol)).ToHashSet(); + foreach (var stale in _priceCache.Keys.Where(k => !keep.Contains(k)).ToList()) + _priceCache.Remove(stale); + missing = [.. instruments.Where(i => !_priceCache.ContainsKey(WatchlistStore.Normalize(i.Symbol)))]; + } + + if (missing.Count == 0) + { + IsLoading = false; + OnPriceCacheUpdated(); // re-prime conversion rates (e.g. preferred currency may have changed) + RaiseItemsChanged(0); // removals reflected immediately, no network + RefreshStaleQuotes(); // ...but if the cached prices have aged past the interval, quietly re-price + return; + } + + RaiseItemsChanged(0); // show what's cached now (departed rows already gone) while we fetch + Fetch(missing); + } + + // Full re-price of the current set — the manual Refresh row. Clears the cache so every symbol is + // re-fetched, showing a spinner while it loads. + internal void RefreshQuotes() + { + lock (_cacheLock) _priceCache.Clear(); + Fetch(_snapshot); + } + + // Live-poll re-price: re-fetch the CURRENT set without clearing the cache or showing a spinner, so the + // prices on screen stay put and are swapped in place once the new quotes land (no flicker). Reuses the + // generation guard so a membership change still wins; `silent` also keeps a transient bad poll from + // blanking a good price (see LoadQuotes). + internal void PollRefresh() + { + var snapshot = _snapshot; + if (!_received || snapshot.Count == 0) + return; + + int generation; + lock (_cacheLock) + generation = ++_fetchGeneration; + + Log.Info("Poll", $"{Title}: re-pricing [{string.Join(", ", snapshot.Select(i => i.Symbol))}]"); + Task.Run(() => LoadQuotes(snapshot, generation, silent: true)); + } + + // Catch-up for the "short visit" gap in live polling. These page instances are long-lived singletons + // whose _priceCache survives navigation, so revisiting an UNCHANGED set repaints cached prices and fires + // NO fetch (nothing missing) — and every revisit restarts the poll timer from a full interval (PollTicker + // resets the countdown on its 0->1 subscriber transition). So a user whose visits are each shorter than + // the interval would never see a refreshed price. Fix: when we become visible with a fully-cached set, if + // those prices have aged past one refresh interval, kick a single SILENT re-price. Bounded to one fetch + // per revisit and only when actually stale — liveness without burning the Finnhub budget. Gated on + // AutoRefreshEnabled so "auto-refresh off" (interval 0) stays truly off. + private void RefreshStaleQuotes() + { + var settings = MarketSettingsManager.Instance; + if (!settings.AutoRefreshEnabled || _lastFullPriceTicks == 0) + return; + + var age = TimeSpan.FromMilliseconds(Environment.TickCount64 - _lastFullPriceTicks); + if (age < settings.RefreshInterval) + return; + + Log.Info("Poll", $"{Title}: cached prices aged {age.TotalMinutes:F1} min (>= interval) — silent catch-up re-price"); + PollRefresh(); + } + + // Fetch quotes for the given instruments and merge them into the cache. Tags the fetch with a + // generation so a full refresh that supersedes it discards this one's late result instead of it + // clobbering newer data. + private void Fetch(IReadOnlyList instruments) + { + if (instruments.Count == 0) + { + IsLoading = false; + RaiseItemsChanged(0); + return; + } + + // A whole-set fetch (initial load or the manual Refresh row) advances the freshness clock; a partial + // fetch of only newly-added symbols does NOT, so the older rows keep aging toward a catch-up re-price. + bool wholeSet = instruments.Count == _snapshot.Count; + + int generation; + lock (_cacheLock) + generation = ++_fetchGeneration; + + IsLoading = true; + RaiseItemsChanged(0); + Task.Run(() => LoadQuotes(instruments, generation, markFresh: wholeSet)); + } + + // `silent` = a background poll (no spinner): in that mode, don't let a transient bad result (e.g. a + // Finnhub 429 mapped to an invalid quote) overwrite a price that was fine a moment ago. + // `markFresh` = this fetch covered the WHOLE current set, so stamp the freshness clock that + // RefreshStaleQuotes reads (a partial add passes false — see Fetch). + private async Task LoadQuotes( + IReadOnlyList instruments, int generation, bool silent = false, bool markFresh = true) + { + var quotes = await Repository.GetQuotesAsync(instruments); + + lock (_cacheLock) + { + if (generation != _fetchGeneration) + return; // a newer full refresh took over — drop this stale result + + // Only cache symbols still in the set: a removal during the fetch must not re-add them. + var keep = _snapshot.Select(i => WatchlistStore.Normalize(i.Symbol)).ToHashSet(); + foreach (var quote in quotes) + { + var key = WatchlistStore.Normalize(quote.Symbol); + if (!keep.Contains(key)) + continue; + + var ui = UiQuote.From(quote); + if (silent && !ui.IsValid && _priceCache.TryGetValue(key, out var prev) && prev.IsValid) + continue; // keep the last good price through a transient bad poll + _priceCache[key] = ui; + } + } + + if (markFresh) + _lastFullPriceTicks = Environment.TickCount64; // whole set re-priced — reset the staleness clock + + IsLoading = false; + OnPriceCacheUpdated(); // prices changed — let a subclass refresh anything derived (e.g. FX rates) + RaiseItemsChanged(0); + } + + private sealed partial class RefreshCommand : InvokableCommand + { + private readonly PricedListPage _page; + + public RefreshCommand(PricedListPage page) + { + _page = page; + Name = Resources.Action_Refresh; + } + + public override ICommandResult Invoke() + { + _page.RefreshQuotes(); + return CommandResult.KeepOpen(); + } + } +} diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs new file mode 100644 index 0000000..20b57e4 --- /dev/null +++ b/MarketExtension/Pages/SearchPage.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Default entry screen: the Enter-only online symbol-search flow. Typing never hits the network +// (free-tier rate limits) — it only changes what the synthetic "Search markets for ..." item will +// look up. The query fans out to every provider via MarketRepository.SearchAsync (provider-agnostic). +// Results are price-less identities; Enter on a result opens its SymbolDetailPage, which is +// the single place to add it to the watchlist or favorites. The subtitle still reflects current +// membership so the user can see at a glance what a result is already on. +// Lifted from the search half of the old MarketsPage. +// +// Subscribes to the WatchlistStore membership flows while visible (the INotifyItemsChanged add/remove +// lifecycle) so a row's membership subtitle/★ refreshes the instant it's added — no manual callback. +// Their replay-on-subscribe also paints the initial list; the async /search completion re-lists too. +internal sealed partial class SearchPage : DynamicListPage, INotifyItemsChanged +{ + private const string SearchGlyph = "\uE721"; // Segoe MDL2 Search + private const string ListGlyph = "\uE8FD"; // Segoe MDL2 List + private const string StarFillGlyph = "\uE735"; // Segoe MDL2 FavoriteStarFill + + private readonly MarketRepository _repository; + private readonly WatchlistPage _watchlistPage; + private readonly FavoritesPage _favoritesPage; + + // Last executed online search and the exact query it belongs to. Results render only while + // SearchText still equals _searchedQuery, so they vanish as the user edits the query and reappear + // (without a new call) if they type it back. + private IReadOnlyList? _searchResults; + private string _searchedQuery = string.Empty; + + private event TypedEventHandler? _itemsChanged; + private readonly List _subscriptions = []; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // Results are identity-only (no prices), but each row's subtitle/★ reflects current + // membership — so re-render whenever the watchlist or favorites change (e.g. right after an + // Enter/Ctrl+Enter add). StateFlow's replay also fires the initial list on subscribe, which + // replaces the old explicit fire-on-subscribe. + _subscriptions.Add(WatchlistStore.Instance.Watchlist.Subscribe(_ => RaiseItemsChanged(0))); + _subscriptions.Add(WatchlistStore.Instance.Favorites.Subscribe(_ => RaiseItemsChanged(0))); + // Re-render when a key is added/cleared so the missing-key hint appears/disappears at once. + // replay:false — the membership replay above already paints the initial list. + _subscriptions.Add(MarketSettingsManager.Instance.HasAnyApiKey + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + // Re-render when demo mode toggles so the status row (demo vs missing-key) flips at once. The next + // /search already routes to the new source; this just keeps the row in sync without a re-search. + _subscriptions.Add(MarketSettingsManager.Instance.DemoModeChanged + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + // Re-render when throttling starts/stops so the rate-limited banner appears/disappears at once. + _subscriptions.Add(RateLimitSignal.Instance.IsRateLimited + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } + } + + private new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + public SearchPage(MarketRepository repository) + { + _repository = repository; + _watchlistPage = new WatchlistPage(repository); + _favoritesPage = new FavoritesPage(repository); + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + Title = Resources.Page_Search_Title; + Name = Resources.Action_Search; + PlaceholderText = Resources.Search_Placeholder; + } + + // Typing only re-lists — never calls the API. The online search is Enter-only. + public override void UpdateSearchText(string oldSearch, string newSearch) + => RaiseItemsChanged(0); + + public override IListItem[] GetItems() + => string.IsNullOrWhiteSpace(SearchText) ? HomeItems() : SearchItems(); + + // Empty box: quick links into the other two screens (also top-level commands). + private IListItem[] HomeItems() + { + var items = new List + { + new ListItem(_watchlistPage) + { + Title = Resources.Nav_Watchlist_Title, + Subtitle = Resources.Nav_Watchlist_Subtitle, + Icon = new IconInfo(ListGlyph), + }, + new ListItem(_favoritesPage) + { + Title = Resources.Nav_Favorites_Title, + Subtitle = Resources.Nav_Favorites_Subtitle, + Icon = new IconInfo(StarFillGlyph), + }, + }; + + if (ApiKeyHint.StatusRow() is { } hint) // no key → nudge to add one; demo mode → flag sample data + items.Add(hint); + + return [.. items]; + } + + // Non-empty box: the Enter-to-search action, then the last online search's results (if they + // still belong to the current query). + private IListItem[] SearchItems() + { + var items = new List + { + // First item → pressing Enter runs the one /search call (see RunSearchCommand). + new ListItem(new RunSearchCommand(this)) + { + Title = Strings.Format(Resources.Search_Action_Title, SearchText), + Subtitle = Resources.Search_Action_Subtitle, + Icon = new IconInfo(SearchGlyph), + }, + }; + + if (_searchResults is not null && + string.Equals(_searchedQuery, SearchText, StringComparison.OrdinalIgnoreCase)) + { + if (_searchResults.Count == 0) + { + items.Add(new ListItem(new NoOpCommand()) + { + Title = Strings.Format(Resources.Search_NoMatches, SearchText), + Section = Resources.Search_ResultsSection, + }); + } + + foreach (var instrument in _searchResults) + items.Add(BuildResultItem(instrument)); + + if (_searchResults.Count > 0) + items.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (results show logos) + } + + if (ApiKeyHint.StatusRow() is { } hint) // no key → explain empty search; demo mode → flag sample data + items.Add(hint); + if (RateLimitHint.Row() is { } banner) // throttled → just under the search action, seen without scrolling + items.Insert(1, banner); + + return [.. items]; + } + + // A price-less search result. Enter opens its detail page (the single place to add it to the + // watchlist or favorites). The subtitle reflects current membership so the user can see at a + // glance what the row is already on. + private ListItem BuildResultItem(DomainInstrument instrument) + { + var inWatchlist = WatchlistStore.Instance.IsInWatchlist(instrument.Symbol); + var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); + + return new ListItem(new SymbolDetailPage(instrument, _repository)) + { + Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", + Subtitle = MembershipSubtitle(inWatchlist, isFavorite), + Section = Resources.Search_ResultsSection, + Icon = AssetIconResolver.Resolve(instrument), + }; + } + + private static string MembershipSubtitle(bool inWatchlist, bool isFavorite) => (inWatchlist, isFavorite) switch + { + (true, true) => Resources.Membership_WatchlistFavorite, + (true, false) => Resources.Membership_Watchlist, + (false, true) => Resources.Membership_Favorite, + (false, false) => Resources.Membership_None, + }; + + // Runs the single online /search call for the current SearchText, then re-lists with results. + internal void RunSearch() + { + var query = SearchText; + if (string.IsNullOrWhiteSpace(query)) + return; + + IsLoading = true; + RaiseItemsChanged(0); + Task.Run(() => SearchOnline(query)); + } + + private async Task SearchOnline(string query) + { + var results = await _repository.SearchAsync(query); + _searchResults = results; + _searchedQuery = query; + IsLoading = false; + RaiseItemsChanged(0); + } + + private sealed partial class RunSearchCommand : InvokableCommand + { + private readonly SearchPage _page; + + public RunSearchCommand(SearchPage page) + { + _page = page; + Name = Resources.Action_Search; + Icon = new IconInfo(SearchGlyph); + } + + public override ICommandResult Invoke() + { + _page.RunSearch(); + return CommandResult.KeepOpen(); + } + } +} diff --git a/MarketExtension/Pages/SetQuantityPage.cs b/MarketExtension/Pages/SetQuantityPage.cs new file mode 100644 index 0000000..89ff91e --- /dev/null +++ b/MarketExtension/Pages/SetQuantityPage.cs @@ -0,0 +1,154 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// The holding editor for one instrument — the single place to add a holding to the portfolio or change +// how much of it is held (and, optionally, what was paid for it). Opened from the symbol detail page's +// command bar: "Add to Portfolio" when the instrument isn't held yet, "Edit holding" when it is. The body +// is a single adaptive-card FormContent with a quantity input and an optional average-cost-per-unit input +// (the cost basis, used for total-return reporting); Save writes the holding to PortfolioStore and navigates +// back to the detail page, whose PortfolioStore subscription then flips its command bar (Add → Edit/Remove). +// +// The single-FormContent auto-focus quirk that plagues SymbolDetailPage (the host focuses the card's first +// focusable element) is HARMLESS here — even helpful: it drops the cursor straight into the quantity field. +internal sealed partial class SetQuantityPage : ContentPage +{ + private const string AddGlyph = "\uE710"; // Segoe MDL2 Add + private const string EditGlyph = "\uE70F"; // Segoe MDL2 Edit + + private readonly QuantityForm _form; + + public SetQuantityPage(DomainInstrument instrument) + { + var editing = PortfolioStore.Instance.Contains(instrument.Symbol); + _form = new QuantityForm(instrument); + // Stable, unique per symbol — also satisfies the non-empty Id a page used as a command needs. + Id = $"com.costafotiadis.market.portfolio.setquantity.{instrument.Symbol}"; + Icon = new IconInfo(editing ? EditGlyph : AddGlyph); + Title = editing + ? Strings.Format(Resources.SetQty_Title_Edit, instrument.Symbol) + : Strings.Format(Resources.SetQty_Title_Add, instrument.Symbol); + Name = editing ? Resources.SetQty_Name_Edit : Resources.SetQty_Name_Add; + } + + public override IContent[] GetContent() => [_form]; + + // The adaptive-card form: a number input prefilled with the current holding (0 when adding). SubmitForm + // validates and writes to the store. FormContent : BaseObservable, so this renders in place like the + // chart card (TemplateJson + DataJson), the same mechanism used across the app. + private sealed partial class QuantityForm : FormContent + { + private readonly DomainInstrument _instrument; + + public QuantityForm(DomainInstrument instrument) + { + _instrument = instrument; + TemplateJson = Template; + DataJson = BuildData(); + } + + private string BuildData() + { + var existing = PortfolioStore.Instance.GetPosition(_instrument.Symbol); + var data = new JsonObject + { + ["symbol"] = _instrument.Symbol, + ["name"] = _instrument.Name, + // Pre-fill with the current holding (0 for a new one). Input.Number binds a number; keep it + // decimal so the prefill round-trips exactly (no binary-float artifacts). + ["quantity"] = existing?.Quantity ?? 0m, + // Average cost per unit. 0 means "not recorded" (a fresh holding, or one with no basis) and + // is treated as cleared on submit — so total return is simply omitted until a real price is set. + ["costBasis"] = existing?.CostBasis ?? 0m, + // Localized card labels, bound by the template below (kept out of the const JSON so they translate). + ["quantityLabel"] = Resources.SetQty_Card_Quantity, + ["quantityError"] = Resources.SetQty_Card_QuantityError, + ["costBasisLabel"] = Resources.SetQty_Card_CostBasis, + ["costBasisHelp"] = Resources.SetQty_Card_Help, + ["saveLabel"] = Resources.SetQty_Card_Save, + }; + return data.ToJsonString(); + } + + public override ICommandResult SubmitForm(string inputs, string data) + { + if (!TryReadDecimal(inputs, "quantity", out var quantity)) + return Error(Resources.SetQty_Error_Number); + if (quantity <= 0m) + return Error(Resources.SetQty_Error_Positive); + + // Cost basis is optional: a missing/blank/≤0 value means "not recorded" → store null (clears any + // previous basis), which just hides total return. A positive value is the average price paid per + // unit, in the instrument's native trading currency. + decimal? costBasis = TryReadDecimal(inputs, "costBasis", out var basis) && basis > 0m ? basis : null; + + PortfolioStore.Instance.SetPosition(_instrument, quantity, costBasis); + var basisNote = costBasis is { } b + ? Strings.Format(Resources.SetQty_BasisNote, b.ToString("0.########", CultureInfo.InvariantCulture)) + : string.Empty; + return CommandResult.ShowToast(new ToastArgs + { + Message = Strings.Format(Resources.SetQty_Toast_Set, + _instrument.Symbol, quantity.ToString("0.########", CultureInfo.InvariantCulture), basisNote), + Result = CommandResult.GoBack(), // back to the detail page; its subscription refreshes the bar + }); + } + + private static CommandResult Error(string message) => + CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); + + // Pull a named numeric field out of the host's inputs JSON (e.g. {"quantity":"10.5"} — the value may + // arrive as a JSON string or number depending on the host). Parse invariant first, then the current + // culture as a fallback (a localized host may format with a comma decimal separator). Returns false + // when the field is absent/blank/unparseable, so an optional field (cost basis) can default to 0. + private static bool TryReadDecimal(string inputs, string name, out decimal value) + { + value = 0m; + if (string.IsNullOrWhiteSpace(inputs)) + return false; + + try + { + using var doc = JsonDocument.Parse(inputs); + if (doc.RootElement.ValueKind != JsonValueKind.Object || + !doc.RootElement.TryGetProperty(name, out var field)) + return false; + + var raw = field.ValueKind == JsonValueKind.String ? field.GetString() : field.GetRawText(); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + return decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out value) + || decimal.TryParse(raw, NumberStyles.Number, CultureInfo.CurrentCulture, out value); + } + catch (JsonException) + { + return false; + } + } + + // Static card structure; the symbol/name/prefilled quantity bind from DataJson. One Save action. + private const string Template = """ + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { "type": "TextBlock", "text": "${symbol}", "size": "ExtraLarge", "weight": "Bolder", "wrap": true }, + { "type": "TextBlock", "text": "${name}", "isSubtle": true, "spacing": "None", "wrap": true }, + { "type": "Input.Number", "id": "quantity", "label": "${quantityLabel}", "value": ${quantity}, "min": 0, "isRequired": true, "errorMessage": "${quantityError}" }, + { "type": "Input.Number", "id": "costBasis", "label": "${costBasisLabel}", "value": ${costBasis}, "min": 0 }, + { "type": "TextBlock", "text": "${costBasisHelp}", "isSubtle": true, "size": "Small", "spacing": "None", "wrap": true } + ], + "actions": [ + { "type": "Action.Submit", "title": "${saveLabel}", "data": { "action": "save" } } + ] + } + """; + } +} diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs new file mode 100644 index 0000000..781ab75 --- /dev/null +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using MarketExtension.Properties; + +namespace MarketExtension; + +// The shared per-symbol detail screen, reached by pressing Enter on any instrument row (Search, +// Watchlist, Favorites) and by clicking any dock button. The body is a live price chart with +// Robinhood-style range tabs (1D / 1W / 1M / 1Y / 5Y): an adaptive-card FormContent whose Action.Submit +// tab buttons call back into SymbolChartForm.SubmitForm to switch range, refetch candles, and repaint. +// +// List management lives on the page's command bar: the add/remove-watchlist action is the primary +// command (Enter) and add/remove-favorite the secondary (Ctrl+Enter), labelled for current state. +// +// ⚠️ KNOWN ISSUE (unsolved — see the "Symbol detail + live chart" section of CLAUDE.md): with a single +// FormContent the host marks it OnlyControlOnPage and auto-focuses the card's first Action.Submit (the +// 1D tab), so Enter activates that tab instead of the primary command — and with focus trapped in the +// card, Ctrl+Enter doesn't reach the secondary either. Both attempted fixes were rejected (a 2nd content +// item to drop OnlyControlOnPage did NOT keep focus in the search box in practice; leading the card with +// membership buttons was too hacky). Left as-is for now. +// +// Reactive like the rest of the app (see Helpers/StateFlow.cs + the "prefer observable data flow" +// convention): it subscribes to WatchlistStore's two membership flows while visible — the +// INotifyItemsChanged add/remove lifecycle, the same seam the list pages use — and rebuilds its Commands +// on every change. That same "page became visible" hook starts the chart's first load, so candles are +// fetched only when the page is actually opened (not when a list builds a row per item), and subscribes +// the chart to the shared PollTicker so the visible range live-refreshes on each tick while the page is up. +internal sealed partial class SymbolDetailPage : ContentPage, INotifyItemsChanged +{ + private readonly DomainInstrument _instrument; + private readonly SymbolChartForm _chartForm; + private readonly List _subscriptions = []; + + private event TypedEventHandler? _itemsChanged; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // Replay-on-subscribe paints the initial command bar; later pushes flip it in place. + _subscriptions.Add(WatchlistStore.Instance.Watchlist.Subscribe(_ => RefreshCommands())); + _subscriptions.Add(WatchlistStore.Instance.Favorites.Subscribe(_ => RefreshCommands())); + // Same for portfolio membership, so "Add to Portfolio" flips to "Edit holding"/"Remove" the + // instant a quantity is saved (or the holding removed) from anywhere. + _subscriptions.Add(PortfolioStore.Instance.Positions.Subscribe(_ => RefreshCommands())); + // This accessor fires only when the page is actually shown — the right moment to kick the + // first candle fetch (once). Building a row's SymbolDetailPage doesn't subscribe, so list + // rows never trigger a fetch. + _chartForm.Start(); + // Live refresh: re-fetch the visible range on each poll tick while the page is shown, sharing + // the one PollTicker (and its Publish().RefCount() lifecycle) with the priced list pages + dock. + // The ticker is a pure event stream (no replay), so opening the page doesn't double-fetch — + // Start() already did the first load. Disposed in `remove` with the rest, so a hidden page + // neither polls nor leaks. + _subscriptions.Add(PollTicker.Subscribe(_chartForm.PollRefresh)); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } + } + + private new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + public SymbolDetailPage(DomainInstrument instrument, MarketRepository repository) + { + _instrument = instrument; + _chartForm = new SymbolChartForm(instrument, repository); + // Stable, unique per symbol — also lets this page stand in as a Dock band item's command, + // where a non-empty Id is required. + Id = $"com.costafotiadis.market.detail.{instrument.Symbol}"; + Icon = AssetIconResolver.Resolve(instrument); + Title = $"{instrument.Symbol} · {instrument.Name}"; + Name = Resources.Action_ViewDetails; + Commands = BuildCommands(); // initial bar; refreshed again on subscribe and on every change + } + + public override IContent[] GetContent() => [_chartForm]; + + // Membership changed → re-read the command bar (the chart is independent of membership). Setting + // Commands raises the property change the host watches, so the add/remove buttons flip in place. + private void RefreshCommands() => Commands = BuildCommands(); + + // The list-management actions, labelled for the instrument's current state. Watchlist is the page's + // primary command (Enter), favorite the secondary (Ctrl+Enter); the portfolio actions follow in the + // overflow menu — "Add to Portfolio" / "Set quantity" both open SetQuantityPage (which auto-labels + // itself Add vs Edit), and "Remove from Portfolio" appears once a holding exists. + private IContextItem[] BuildCommands() + { + var inWatchlist = WatchlistStore.Instance.IsInWatchlist(_instrument.Symbol); + var isFavorite = WatchlistStore.Instance.IsFavorite(_instrument.Symbol); + var inPortfolio = PortfolioStore.Instance.Contains(_instrument.Symbol); + + IContextItem watchlist = inWatchlist + ? new CommandContextItem(new RemoveFromWatchlistCommand(_instrument)) + : new CommandContextItem(new AddToWatchlistCommand(_instrument)); + + IContextItem favorite = isFavorite + ? new CommandContextItem(new RemoveFromFavoritesCommand(_instrument)) + : new CommandContextItem(new AddToFavoritesCommand(_instrument)); + + // SetQuantityPage is a page (it needs a number input), so it navigates when chosen; it sets its own + // Name to "Edit holding" or "Add to Portfolio" based on whether the holding exists. + IContextItem setQuantity = new CommandContextItem(new SetQuantityPage(_instrument)); + + return inPortfolio + ? [watchlist, favorite, setQuantity, new CommandContextItem(new RemoveFromPortfolioCommand(_instrument))] + : [watchlist, favorite, setQuantity]; + } + + // The adaptive-card chart body. Holds the current range + a per-range cache, fetches candles via + // the repository, and repaints by updating DataJson (FormContent : BaseObservable, so the host + // re-renders in place — the same mechanism Perf Monitor's widget cards use). PollRefresh re-fetches + // the visible range on each PollTicker tick so an open chart stays live, not just the list pages. + private sealed partial class SymbolChartForm : FormContent + { + private readonly DomainInstrument _instrument; + private readonly MarketRepository _repository; + private readonly Dictionary _cache = []; + private readonly Lock _gate = new(); + private ChartRange _range = ChartRange.OneDay; + private int _generation; // bumped per fetch so a superseded tab tap's late result is dropped + private bool _started; + private DomainCandleSeries? _displaySeries; // what's currently painted (null = a "Loading…" card) + + public SymbolChartForm(DomainInstrument instrument, MarketRepository repository) + { + _instrument = instrument; + _repository = repository; + TemplateJson = Template; + DataJson = BuildData(series: null, _range); // a "Loading…" card until the first fetch lands + } + + // Called from the page's visible hook — fetch the default range exactly once. + public void Start() + { + lock (_gate) + { + if (_started) + return; + _started = true; + } + + Load(_range); + } + + // A range tab was tapped. The selected range rides in the Action.Submit `data` ({ "range": "1W" }); + // fall back to the form inputs just in case the host merges them. Membership lives on the page's + // command bar (Enter / Ctrl+Enter), not the card — so the card only switches range. + public override ICommandResult SubmitForm(string inputs, string data) + { + var range = ParseRange(data) ?? ParseRange(inputs) ?? _range; + Load(range); + return CommandResult.KeepOpen(); + } + + private void Load(ChartRange range) + { + _range = range; + + DomainCandleSeries? cached; + bool hasChart; + lock (_gate) + { + _cache.TryGetValue(range, out cached); + hasChart = _displaySeries?.HasData == true; + } + + if (cached is not null) + { + Render(cached); + return; + } + + int generation; + lock (_gate) + generation = ++_generation; + + // Only blank to a "Loading…" card when no chart is on screen yet (the very first load). On + // later range switches we leave the prior chart visible and swap it in place once the fetch + // lands — each DataJson write rebuilds the whole card, so a Loading→result pair flashes. + if (!hasChart) + DataJson = BuildData(series: null, range); + + Task.Run(async () => + { + var series = await _repository.GetCandlesAsync(_instrument, range); + lock (_gate) + { + if (generation != _generation) + return; // a newer tab tap superseded this one — drop the stale result + if (series.HasData) + _cache[range] = series; + } + + Render(series); + }); + } + + private void Render(DomainCandleSeries series) + { + // Remember what's on screen — drives the flicker guard: a real chart present means a later + // range switch keeps it up instead of flashing "Loading" (see Load). + lock (_gate) + _displaySeries = series; + + DataJson = BuildData(series, series.Range); + } + + // Live-poll repaint, driven by PollTicker while the page is visible. Re-fetches the CURRENTLY + // shown range — bypassing the per-range cache so the on-screen range actually refreshes — then + // swaps the result in place and refreshes that cache entry. Silent by design: a chart is already + // up, so it never writes the "Loading…" card (no flicker), unlike a tab tap's first paint. Reuses + // the generation guard so a concurrent tab tap still wins, and keeps the last good chart when a + // poll returns empty (a transient 403/429/no_data won't blank a chart that was fine) — the chart's + // analog of PricedListPage's keep-last-good guard. + internal void PollRefresh() + { + ChartRange range; + int generation; + bool hasChart; + lock (_gate) + { + if (!_started) + return; // first load hasn't run yet — nothing to refresh + range = _range; + hasChart = _displaySeries?.HasData == true; + generation = ++_generation; + } + + Task.Run(async () => + { + var series = await _repository.GetCandlesAsync(_instrument, range); + lock (_gate) + { + if (generation != _generation) + return; // a tab tap or newer poll superseded this fetch — drop it + if (!series.HasData && hasChart) + return; // keep-last-good: don't blank a good chart on a transient empty poll + if (series.HasData) + _cache[range] = series; + } + + Render(series); + }); + } + + private static ChartRange? ParseRange(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("range", out var range) && + range.ValueKind == JsonValueKind.String) + return ChartRangeExtensions.FromLabel(range.GetString()); + } + catch (JsonException) + { + // Not our payload — ignore and let the caller fall back. + } + + return null; + } + + // Build the card payload for the current display state. A null series paints the "Loading…" + // placeholder for `range`; otherwise the chart (or the premium-gated "no data" message). + private string BuildData(DomainCandleSeries? series, ChartRange range) + { + var data = new JsonObject + { + ["symbol"] = _instrument.Symbol, + ["name"] = _instrument.Name, + }; + + if (series is null) + { + data["price"] = "—"; + data["change"] = string.Empty; + data["changeColor"] = "Default"; + data["chartUrl"] = string.Empty; + data["hasChart"] = false; + data["showStatus"] = true; + data["statusText"] = Strings.Format(Resources.Chart_Loading, range.Label()); + data["attributionText"] = Resources.Attribution_Title; + return data.ToJsonString(); + } + + var ui = UiCandleSeries.From(series); + var hasChart = ui.HasData; + data["price"] = ui.FormatPrice(); + data["change"] = ui.FormatRangeChange(); + data["changeColor"] = !hasChart ? "Default" : ui.IsUp ? "Good" : "Attention"; + data["chartUrl"] = ui.ChartImageUrl(); + data["hasChart"] = hasChart; + data["showStatus"] = !hasChart; + data["statusText"] = hasChart + ? string.Empty + : Resources.Chart_NoData; + data["attributionText"] = Resources.Attribution_Title; + return data.ToJsonString(); + } + + // Static card structure; values bind from DataJson. The five Action.Submit buttons each carry + // their range label, which SubmitForm reads back. + private const string Template = """ + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { "type": "TextBlock", "text": "${symbol}", "size": "ExtraLarge", "weight": "Bolder", "wrap": true }, + { "type": "TextBlock", "text": "${name}", "isSubtle": true, "spacing": "None", "wrap": true }, + { "type": "TextBlock", "text": "${price}", "size": "Large", "weight": "Bolder", "spacing": "Medium" }, + { "type": "TextBlock", "text": "${change}", "color": "${changeColor}", "weight": "Bolder", "spacing": "None", "wrap": true }, + { "type": "Image", "url": "${chartUrl}", "size": "Stretch", "$when": "${hasChart}" }, + { "type": "TextBlock", "text": "${statusText}", "isSubtle": true, "wrap": true, "spacing": "Medium", "$when": "${showStatus}" }, + { + "type": "ActionSet", + "spacing": "Medium", + "actions": [ + { "type": "Action.Submit", "title": "1D", "data": { "range": "1D" } }, + { "type": "Action.Submit", "title": "1W", "data": { "range": "1W" } }, + { "type": "Action.Submit", "title": "1M", "data": { "range": "1M" } }, + { "type": "Action.Submit", "title": "1Y", "data": { "range": "1Y" } }, + { "type": "Action.Submit", "title": "5Y", "data": { "range": "5Y" } } + ] + }, + { "type": "TextBlock", "text": "${attributionText}", "isSubtle": true, "wrap": true, "spacing": "Medium" } + ] + } + """; + } +} diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs new file mode 100644 index 0000000..83d1a0f --- /dev/null +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Top-level screen: the instruments the user tracks, priced live and grouped by asset class. A ★ +// prefix marks rows that are also favorited. Enter opens the row's SymbolDetailPage, which is the +// single place to remove it or toggle its favorite mark. The row carries no context actions. +internal sealed partial class WatchlistPage : PricedListPage +{ + public WatchlistPage(MarketRepository repository) : base(repository, WatchlistStore.Instance.Watchlist) + { + Title = Resources.Page_Watchlist_Title; + Name = Resources.Action_Open; + PlaceholderText = Resources.Watchlist_Placeholder; + } + + // A favorite toggled here doesn't change the watchlist set, so observe the Favorites flow too and + // re-render when it changes — that keeps the ★ marks current after a Ctrl+Enter toggle. + protected override IEnumerable>> RelistTriggers + => [WatchlistStore.Instance.Favorites]; + + protected override IListItem BuildRow(UiQuote q) + { + var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); + var isFavorite = WatchlistStore.Instance.IsFavorite(q.Symbol); + + return new ListItem(new SymbolDetailPage(instrument, Repository)) + { + Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", + Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", + Section = SectionLabel(q.Category), + Icon = AssetIconResolver.Resolve(q), + }; + } + + protected override IListItem[] EmptyState() => + [ + new ListItem(new NoOpCommand()) + { + Title = Resources.Watchlist_Empty_Title, + Subtitle = Resources.Watchlist_Empty_Subtitle, + }, + ]; + + private static string SectionLabel(AssetCategory category) => category switch + { + AssetCategory.Stock => Resources.Section_Stocks, + AssetCategory.Crypto => Resources.Section_Crypto, + AssetCategory.Currency => Resources.Section_Currency, + _ => Resources.Section_Other, + }; +} diff --git a/MarketExtension/Program.cs b/MarketExtension/Program.cs index 48683e0..c687d23 100644 --- a/MarketExtension/Program.cs +++ b/MarketExtension/Program.cs @@ -1,5 +1,4 @@ using Microsoft.CommandPalette.Extensions; -using Sentry; using Shmuelie.WinRTServer; using Shmuelie.WinRTServer.CsWinRT; using System; @@ -16,45 +15,36 @@ public class Program [MTAThread] public static void Main(string[] args) { - // Optional crash/usage telemetry. Leave the DSN empty to disable Sentry, or set - // your own DSN (do NOT reuse another project's DSN). Remove this block entirely - // if you don't want Sentry. - using var _ = SentrySdk.Init(options => - { - options.Dsn = ""; // <-- your Sentry DSN here (empty = disabled) - options.AutoSessionTracking = true; - }); - - Log.Info("MarketExtension starting"); + Log.Info("Startup", "MarketExtension starting"); if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") { - Log.Info("RegisterProcessAsComServer mode detected."); + Log.Info("ComServer", "RegisterProcessAsComServer mode detected"); try { global::Shmuelie.WinRTServer.ComServer server = new(); ManualResetEvent extensionDisposedEvent = new(false); MarketExtension extensionInstance = new(extensionDisposedEvent); server.RegisterClass(() => extensionInstance); - Log.Info("COM server registered. Starting..."); + Log.Info("ComServer", "COM server registered, starting..."); server.Start(); - Log.Info("COM server started. Waiting for disposal signal."); + Log.Info("ComServer", "COM server started, waiting for disposal signal"); extensionDisposedEvent.WaitOne(); - Log.Info("Disposal signal received. Stopping server."); + Log.Info("ComServer", "Disposal signal received, stopping server"); server.Stop(); server.UnsafeDispose(); } catch (Exception ex) { - Log.Error("COM server failed", ex); + Log.Error("ComServer", "COM server failed", ex); } } else { - MessageBox( + _ = MessageBox( IntPtr.Zero, - "Market Extension is a background extension.\n\nTo use it, open PowerToys Command Palette and search for \"Market\".", - "Market Extension", + "Markets Extension for Command Palette is a background extension.\n\nTo use it, open PowerToys Command Palette and search for \"Markets\".", + "Markets Extension for Command Palette", 0x40 /* MB_ICONINFORMATION */); } } diff --git a/MarketExtension/Properties/Resources.Designer.cs b/MarketExtension/Properties/Resources.Designer.cs new file mode 100644 index 0000000..acf942d --- /dev/null +++ b/MarketExtension/Properties/Resources.Designer.cs @@ -0,0 +1,1206 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace MarketExtension.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MarketExtension.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Open. + /// + public static string Action_Open { + get { + return ResourceManager.GetString("Action_Open", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Refresh. + /// + public static string Action_Refresh { + get { + return ResourceManager.GetString("Action_Refresh", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search. + /// + public static string Action_Search { + get { + return ResourceManager.GetString("Action_Search", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View details. + /// + public static string Action_ViewDetails { + get { + return ResourceManager.GetString("Action_ViewDetails", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open Elbstream. + /// + public static string Attribution_OpenName { + get { + return ResourceManager.GetString("Attribution_OpenName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Logo images via elbstream.com. + /// + public static string Attribution_Subtitle { + get { + return ResourceManager.GetString("Attribution_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Logos provided by Elbstream. + /// + public static string Attribution_Title { + get { + return ResourceManager.GetString("Attribution_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Loading {0} chart…. + /// + public static string Chart_Loading { + get { + return ResourceManager.GetString("Chart_Loading", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No chart data for this range.. + /// + public static string Chart_NoData { + get { + return ResourceManager.GetString("Chart_NoData", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add to Favorites. + /// + public static string Cmd_AddToFavorites { + get { + return ResourceManager.GetString("Cmd_AddToFavorites", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add to Watchlist. + /// + public static string Cmd_AddToWatchlist { + get { + return ResourceManager.GetString("Cmd_AddToWatchlist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove from Favorites. + /// + public static string Cmd_RemoveFromFavorites { + get { + return ResourceManager.GetString("Cmd_RemoveFromFavorites", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove from Portfolio. + /// + public static string Cmd_RemoveFromPortfolio { + get { + return ResourceManager.GetString("Cmd_RemoveFromPortfolio", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove from Watchlist. + /// + public static string Cmd_RemoveFromWatchlist { + get { + return ResourceManager.GetString("Cmd_RemoveFromWatchlist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets. + /// + public static string Command_Markets { + get { + return ResourceManager.GetString("Command_Markets", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets Portfolio. + /// + public static string Command_MarketsPortfolio { + get { + return ResourceManager.GetString("Command_MarketsPortfolio", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Australian Dollar (AUD). + /// + public static string Currency_AUD { + get { + return ResourceManager.GetString("Currency_AUD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Brazilian Real (BRL). + /// + public static string Currency_BRL { + get { + return ResourceManager.GetString("Currency_BRL", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Canadian Dollar (CAD). + /// + public static string Currency_CAD { + get { + return ResourceManager.GetString("Currency_CAD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Swiss Franc (CHF). + /// + public static string Currency_CHF { + get { + return ResourceManager.GetString("Currency_CHF", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Chinese Yuan (CNY). + /// + public static string Currency_CNY { + get { + return ResourceManager.GetString("Currency_CNY", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Danish Krone (DKK). + /// + public static string Currency_DKK { + get { + return ResourceManager.GetString("Currency_DKK", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Euro (EUR). + /// + public static string Currency_EUR { + get { + return ResourceManager.GetString("Currency_EUR", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to British Pound (GBP). + /// + public static string Currency_GBP { + get { + return ResourceManager.GetString("Currency_GBP", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Hong Kong Dollar (HKD). + /// + public static string Currency_HKD { + get { + return ResourceManager.GetString("Currency_HKD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Indian Rupee (INR). + /// + public static string Currency_INR { + get { + return ResourceManager.GetString("Currency_INR", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Japanese Yen (JPY). + /// + public static string Currency_JPY { + get { + return ResourceManager.GetString("Currency_JPY", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to South Korean Won (KRW). + /// + public static string Currency_KRW { + get { + return ResourceManager.GetString("Currency_KRW", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Mexican Peso (MXN). + /// + public static string Currency_MXN { + get { + return ResourceManager.GetString("Currency_MXN", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Norwegian Krone (NOK). + /// + public static string Currency_NOK { + get { + return ResourceManager.GetString("Currency_NOK", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New Zealand Dollar (NZD). + /// + public static string Currency_NZD { + get { + return ResourceManager.GetString("Currency_NZD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Polish Złoty (PLN). + /// + public static string Currency_PLN { + get { + return ResourceManager.GetString("Currency_PLN", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Swedish Krona (SEK). + /// + public static string Currency_SEK { + get { + return ResourceManager.GetString("Currency_SEK", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Singapore Dollar (SGD). + /// + public static string Currency_SGD { + get { + return ResourceManager.GetString("Currency_SGD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to US Dollar (USD). + /// + public static string Currency_USD { + get { + return ResourceManager.GetString("Currency_USD", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to South African Rand (ZAR). + /// + public static string Currency_ZAR { + get { + return ResourceManager.GetString("Currency_ZAR", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets Extension for Command Palette. + /// + public static string Extension_DisplayName { + get { + return ResourceManager.GetString("Extension_DisplayName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open an instrument and star it from its detail page. + /// + public static string Favorites_Empty_Subtitle { + get { + return ResourceManager.GetString("Favorites_Empty_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No favorites yet. + /// + public static string Favorites_Empty_Title { + get { + return ResourceManager.GetString("Favorites_Empty_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter favorites.... + /// + public static string Favorites_Placeholder { + get { + return ResourceManager.GetString("Favorites_Placeholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search, Watchlist, Favorites.... + /// + public static string Hub_Placeholder { + get { + return ResourceManager.GetString("Hub_Placeholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ★ Favorite · Enter for details. + /// + public static string Membership_Favorite { + get { + return ResourceManager.GetString("Membership_Favorite", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter for details. + /// + public static string Membership_None { + get { + return ResourceManager.GetString("Membership_None", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to On watchlist · Enter for details. + /// + public static string Membership_Watchlist { + get { + return ResourceManager.GetString("Membership_Watchlist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to On watchlist · ★ Favorite · Enter for details. + /// + public static string Membership_WatchlistFavorite { + get { + return ResourceManager.GetString("Membership_WatchlistFavorite", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Data Sources. + /// + public static string Nav_DataSources_Title { + get { + return ResourceManager.GetString("Nav_DataSources_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Starred instruments, shown on the dock. + /// + public static string Nav_Favorites_Subtitle { + get { + return ResourceManager.GetString("Nav_Favorites_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Favorites. + /// + public static string Nav_Favorites_Title { + get { + return ResourceManager.GetString("Nav_Favorites_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Portfolio. + /// + public static string Nav_Portfolio_Title { + get { + return ResourceManager.GetString("Nav_Portfolio_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Look up a stock, crypto, or currency. + /// + public static string Nav_Search_Subtitle { + get { + return ResourceManager.GetString("Nav_Search_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search. + /// + public static string Nav_Search_Title { + get { + return ResourceManager.GetString("Nav_Search_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Settings. + /// + public static string Nav_Settings_Title { + get { + return ResourceManager.GetString("Nav_Settings_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Instruments you track. + /// + public static string Nav_Watchlist_Subtitle { + get { + return ResourceManager.GetString("Nav_Watchlist_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Watchlist. + /// + public static string Nav_Watchlist_Title { + get { + return ResourceManager.GetString("Nav_Watchlist_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Data Sources. + /// + public static string Page_DataSources_Title { + get { + return ResourceManager.GetString("Page_DataSources_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets Favorites. + /// + public static string Page_Favorites_Title { + get { + return ResourceManager.GetString("Page_Favorites_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets. + /// + public static string Page_Markets_Title { + get { + return ResourceManager.GetString("Page_Markets_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets Portfolio. + /// + public static string Page_Portfolio_Title { + get { + return ResourceManager.GetString("Page_Portfolio_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets Search. + /// + public static string Page_Search_Title { + get { + return ResourceManager.GetString("Page_Search_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Markets Watchlist. + /// + public static string Page_Watchlist_Title { + get { + return ResourceManager.GetString("Page_Watchlist_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add holdings from an instrument's detail page. + /// + public static string Portfolio_Empty_Subtitle { + get { + return ResourceManager.GetString("Portfolio_Empty_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No holdings yet. + /// + public static string Portfolio_Empty_Title { + get { + return ResourceManager.GetString("Portfolio_Empty_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter holdings.... + /// + public static string Portfolio_Placeholder { + get { + return ResourceManager.GetString("Portfolio_Placeholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Total {0}. + /// + public static string Portfolio_Row_TotalReturnPrefix { + get { + return ResourceManager.GetString("Portfolio_Row_TotalReturnPrefix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Portfolio {0}. + /// + public static string Portfolio_TotalsRow_Title { + get { + return ResourceManager.GetString("Portfolio_TotalsRow_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will refresh automatically once the limit clears.. + /// + public static string RateLimit_Subtitle { + get { + return ResourceManager.GetString("RateLimit_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rate-limited. + /// + public static string RateLimit_Tag { + get { + return ResourceManager.GetString("RateLimit_Tag", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rate-limited — showing last known prices. + /// + public static string RateLimit_Title { + get { + return ResourceManager.GetString("RateLimit_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requests are being throttled — prices may be stale. + /// + public static string RateLimit_Tooltip { + get { + return ResourceManager.GetString("RateLimit_Tooltip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter to search. + /// + public static string Search_Action_Subtitle { + get { + return ResourceManager.GetString("Search_Action_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search markets for "{0}". + /// + public static string Search_Action_Title { + get { + return ResourceManager.GetString("Search_Action_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No matches for "{0}". + /// + public static string Search_NoMatches { + get { + return ResourceManager.GetString("Search_NoMatches", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search by symbol or name, then Enter. + /// + public static string Search_Placeholder { + get { + return ResourceManager.GetString("Search_Placeholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search results. + /// + public static string Search_ResultsSection { + get { + return ResourceManager.GetString("Search_ResultsSection", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Crypto. + /// + public static string Section_Crypto { + get { + return ResourceManager.GetString("Section_Crypto", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Currency. + /// + public static string Section_Currency { + get { + return ResourceManager.GetString("Section_Currency", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Other. + /// + public static string Section_Other { + get { + return ResourceManager.GetString("Section_Other", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stocks. + /// + public static string Section_Stocks { + get { + return ResourceManager.GetString("Section_Stocks", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to at {0}/unit. + /// + public static string SetQty_BasisNote { + get { + return ResourceManager.GetString("SetQty_BasisNote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Average cost per unit (optional). + /// + public static string SetQty_Card_CostBasis { + get { + return ResourceManager.GetString("SetQty_Card_CostBasis", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Average price you paid, in the instrument's currency. Leave 0 if unknown.. + /// + public static string SetQty_Card_Help { + get { + return ResourceManager.GetString("SetQty_Card_Help", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Quantity held. + /// + public static string SetQty_Card_Quantity { + get { + return ResourceManager.GetString("SetQty_Card_Quantity", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter a quantity greater than 0. + /// + public static string SetQty_Card_QuantityError { + get { + return ResourceManager.GetString("SetQty_Card_QuantityError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save. + /// + public static string SetQty_Card_Save { + get { + return ResourceManager.GetString("SetQty_Card_Save", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter a number greater than 0.. + /// + public static string SetQty_Error_Number { + get { + return ResourceManager.GetString("SetQty_Error_Number", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Quantity must be greater than 0. To remove a holding, use Remove from Portfolio.. + /// + public static string SetQty_Error_Positive { + get { + return ResourceManager.GetString("SetQty_Error_Positive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add to Portfolio. + /// + public static string SetQty_Name_Add { + get { + return ResourceManager.GetString("SetQty_Name_Add", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit holding. + /// + public static string SetQty_Name_Edit { + get { + return ResourceManager.GetString("SetQty_Name_Edit", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add {0} to portfolio. + /// + public static string SetQty_Title_Add { + get { + return ResourceManager.GetString("SetQty_Title_Add", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit {0} holding. + /// + public static string SetQty_Title_Edit { + get { + return ResourceManager.GetString("SetQty_Title_Edit", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set {0} to {1}{2}. + /// + public static string SetQty_Toast_Set { + get { + return ResourceManager.GetString("SetQty_Toast_Set", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Paste your API key. + /// + public static string Settings_ApiKey_Placeholder { + get { + return ResourceManager.GetString("Settings_ApiKey_Placeholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show built-in sample data instead of live prices. Ideal for trying out the extension.. + /// + public static string Settings_Demo_Desc { + get { + return ResourceManager.GetString("Settings_Demo_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Demo mode. + /// + public static string Settings_Demo_Label { + get { + return ResourceManager.GetString("Settings_Demo_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Finnhub API key.. + /// + public static string Settings_Finnhub_Desc { + get { + return ResourceManager.GetString("Settings_Finnhub_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Finnhub API key. + /// + public static string Settings_Finnhub_Label { + get { + return ResourceManager.GetString("Settings_Finnhub_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Currency for portfolio totals. Other holdings are converted into it.. + /// + public static string Settings_PortfolioCurrency_Desc { + get { + return ResourceManager.GetString("Settings_PortfolioCurrency_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Portfolio currency. + /// + public static string Settings_PortfolioCurrency_Label { + get { + return ResourceManager.GetString("Settings_PortfolioCurrency_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show a banner when prices are stale from provider rate-limiting.. + /// + public static string Settings_RateLimit_Desc { + get { + return ResourceManager.GetString("Settings_RateLimit_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show rate-limit warnings. + /// + public static string Settings_RateLimit_Label { + get { + return ResourceManager.GetString("Settings_RateLimit_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Price refresh interval in minutes (0 = off).. + /// + public static string Settings_Refresh_Desc { + get { + return ResourceManager.GetString("Settings_Refresh_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Price refresh interval (minutes). + /// + public static string Settings_Refresh_Label { + get { + return ResourceManager.GetString("Settings_Refresh_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Twelve Data API key. Used first when set.. + /// + public static string Settings_TwelveData_Desc { + get { + return ResourceManager.GetString("Settings_TwelveData_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Twelve Data API key. + /// + public static string Settings_TwelveData_Label { + get { + return ResourceManager.GetString("Settings_TwelveData_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Simulated prices. Enter to turn off in Settings.. + /// + public static string Status_Demo_Subtitle { + get { + return ResourceManager.GetString("Status_Demo_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Demo mode. + /// + public static string Status_Demo_Tag { + get { + return ResourceManager.GetString("Status_Demo_Tag", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Demo mode — showing sample data. + /// + public static string Status_Demo_Title { + get { + return ResourceManager.GetString("Status_Demo_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Showing sample data, not live prices. + /// + public static string Status_Demo_Tooltip { + get { + return ResourceManager.GetString("Status_Demo_Tooltip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter to add one in Settings.. + /// + public static string Status_NoKey_Subtitle { + get { + return ResourceManager.GetString("Status_NoKey_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Action required. + /// + public static string Status_NoKey_Tag { + get { + return ResourceManager.GetString("Status_NoKey_Tag", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No API key set. + /// + public static string Status_NoKey_Title { + get { + return ResourceManager.GetString("Status_NoKey_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No API key set — functionality is limited. + /// + public static string Status_NoKey_Tooltip { + get { + return ResourceManager.GetString("Status_NoKey_Tooltip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Added {0} to favorites. + /// + public static string Toast_AddedToFavorites { + get { + return ResourceManager.GetString("Toast_AddedToFavorites", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Added {0} to watchlist. + /// + public static string Toast_AddedToWatchlist { + get { + return ResourceManager.GetString("Toast_AddedToWatchlist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't open {0}: {1}. + /// + public static string Toast_OpenUrlFailed { + get { + return ResourceManager.GetString("Toast_OpenUrlFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Removed {0} from favorites. + /// + public static string Toast_RemovedFromFavorites { + get { + return ResourceManager.GetString("Toast_RemovedFromFavorites", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Removed {0} from portfolio. + /// + public static string Toast_RemovedFromPortfolio { + get { + return ResourceManager.GetString("Toast_RemovedFromPortfolio", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Removed {0} from watchlist. + /// + public static string Toast_RemovedFromWatchlist { + get { + return ResourceManager.GetString("Toast_RemovedFromWatchlist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} ({1}%) today. + /// + public static string Ui_Portfolio_DailyChange { + get { + return ResourceManager.GetString("Ui_Portfolio_DailyChange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to · Total {0} ({1}%). + /// + public static string Ui_Portfolio_TotalReturnNote { + get { + return ResourceManager.GetString("Ui_Portfolio_TotalReturnNote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to · {0} holdings not converted. + /// + public static string Ui_Portfolio_Unconverted_Many { + get { + return ResourceManager.GetString("Ui_Portfolio_Unconverted_Many", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to · 1 holding not converted. + /// + public static string Ui_Portfolio_Unconverted_One { + get { + return ResourceManager.GetString("Ui_Portfolio_Unconverted_One", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to sh. + /// + public static string Ui_Position_Unit_Shares { + get { + return ResourceManager.GetString("Ui_Position_Unit_Shares", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to units. + /// + public static string Ui_Position_Unit_Units { + get { + return ResourceManager.GetString("Ui_Position_Unit_Units", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open an instrument and add it from its detail page. + /// + public static string Watchlist_Empty_Subtitle { + get { + return ResourceManager.GetString("Watchlist_Empty_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your watchlist is empty. + /// + public static string Watchlist_Empty_Title { + get { + return ResourceManager.GetString("Watchlist_Empty_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter watchlist.... + /// + public static string Watchlist_Placeholder { + get { + return ResourceManager.GetString("Watchlist_Placeholder", resourceCulture); + } + } + } +} diff --git a/MarketExtension/Properties/Resources.de.resx b/MarketExtension/Properties/Resources.de.resx new file mode 100644 index 0000000..10becf8 --- /dev/null +++ b/MarketExtension/Properties/Resources.de.resx @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Öffnen + + + Aktualisieren + + + Suchen + + + Details anzeigen + + + Markterweiterung für Command Palette + + + Märkte + + + Märkte-Portfolio + + + Märkte + + + Märkte-Suche + + + Märkte-Beobachtungsliste + + + Märkte-Favoriten + + + Märkte-Portfolio + + + Datenquellen + + + Suche, Beobachtungsliste, Favoriten… + + + Suchen + + + Aktie, Krypto oder Währung nachschlagen + + + Beobachtungsliste + + + Instrumente, die Sie verfolgen + + + Favoriten + + + Markierte Instrumente, im Dock angezeigt + + + Portfolio + + + Datenquellen + + + Einstellungen + + + Nach Symbol oder Name suchen, dann Enter + + + Beobachtungsliste filtern… + + + Favoriten filtern… + + + Positionen filtern… + + + Märkte nach „{0}“ durchsuchen + + + Zum Suchen Enter drücken + + + Keine Treffer für „{0}“ + + + Suchergebnisse + + + Auf Beobachtungsliste · ★ Favorit · Enter für Details + + + Auf Beobachtungsliste · Enter für Details + + + ★ Favorit · Enter für Details + + + Enter für Details + + + Aktien + + + Krypto + + + Devisen + + + Sonstige + + + Ihre Beobachtungsliste ist leer + + + Öffnen Sie ein Instrument und fügen Sie es auf seiner Detailseite hinzu + + + Noch keine Favoriten + + + Öffnen Sie ein Instrument und markieren Sie es auf seiner Detailseite + + + Noch keine Positionen + + + Fügen Sie Positionen über die Detailseite eines Instruments hinzu + + + Demomodus — Beispieldaten werden angezeigt + + + Simulierte Preise. Zum Deaktivieren Enter in den Einstellungen drücken. + + + Demomodus + + + Beispieldaten werden angezeigt, keine Live-Preise + + + Kein API-Schlüssel festgelegt + + + Enter drücken, um einen in den Einstellungen hinzuzufügen. + + + Aktion erforderlich + + + Kein API-Schlüssel festgelegt — Funktionalität eingeschränkt + + + Ratenbegrenzt — letzte bekannte Preise werden angezeigt + + + Wird automatisch aktualisiert, sobald die Begrenzung aufgehoben ist. + + + Ratenbegrenzt + + + Anfragen werden gedrosselt — Preise sind möglicherweise veraltet + + + Logos bereitgestellt von Elbstream + + + Logo-Bilder über elbstream.com + + + Elbstream öffnen + + + Zur Beobachtungsliste hinzufügen + + + Von Beobachtungsliste entfernen + + + Zu Favoriten hinzufügen + + + Aus Favoriten entfernen + + + Aus Portfolio entfernen + + + {0} zur Beobachtungsliste hinzugefügt + + + {0} von Beobachtungsliste entfernt + + + {0} zu Favoriten hinzugefügt + + + {0} aus Favoriten entfernt + + + {0} aus Portfolio entfernt + + + {0} konnte nicht geöffnet werden: {1} + + + {0}-Chart wird geladen… + + + Keine Chartdaten für diesen Zeitraum. + + + {0}-Position bearbeiten + + + {0} zum Portfolio hinzufügen + + + Position bearbeiten + + + Zum Portfolio hinzufügen + + + {0} auf {1}{2} festgelegt + + + zu {0}/Einheit + + + Geben Sie eine Zahl größer als 0 ein. + + + Die Menge muss größer als 0 sein. Um eine Position zu entfernen, verwenden Sie „Aus Portfolio entfernen“. + + + Gehaltene Menge + + + Geben Sie eine Menge größer als 0 ein + + + Durchschnittliche Kosten pro Einheit (optional) + + + Durchschnittspreis, den Sie gezahlt haben, in der Währung des Instruments. Bei Unbekanntheit 0 lassen. + + + Speichern + + + Portfolio {0} + + + Gesamt {0} + + + {0} ({1}%) heute + + + · Gesamt {0} ({1}%) + + + · 1 Position nicht umgerechnet + + + · {0} Positionen nicht umgerechnet + + + Stk. + + + Einh. + + + Twelve Data API-Schlüssel + + + Twelve Data API-Schlüssel. Wird zuerst verwendet, wenn festgelegt. + + + Finnhub API-Schlüssel + + + Finnhub API-Schlüssel. + + + Fügen Sie Ihren API-Schlüssel ein + + + Preisaktualisierungsintervall (Minuten) + + + Preisaktualisierungsintervall in Minuten (0 = aus). + + + Ratenbegrenzungswarnungen anzeigen + + + Zeigt ein Banner an, wenn Preise aufgrund der Ratenbegrenzung des Anbieters veraltet sind. + + + Demomodus + + + Integrierte Beispieldaten anstelle von Live-Preisen anzeigen. Ideal zum Ausprobieren der Erweiterung. + + + Portfolio-Währung + + + Währung für Portfolio-Summen. Andere Positionen werden in sie umgerechnet. + + + US-Dollar (USD) + + + Euro (EUR) + + + Britisches Pfund (GBP) + + + Japanischer Yen (JPY) + + + Schweizer Franken (CHF) + + + Australischer Dollar (AUD) + + + Kanadischer Dollar (CAD) + + + Neuseeland-Dollar (NZD) + + + Chinesischer Yuan (CNY) + + + Hongkong-Dollar (HKD) + + + Singapur-Dollar (SGD) + + + Schwedische Krone (SEK) + + + Norwegische Krone (NOK) + + + Dänische Krone (DKK) + + + Polnischer Złoty (PLN) + + + Südafrikanischer Rand (ZAR) + + + Mexikanischer Peso (MXN) + + + Indische Rupie (INR) + + + Brasilianischer Real (BRL) + + + Südkoreanischer Won (KRW) + + diff --git a/MarketExtension/Properties/Resources.es.resx b/MarketExtension/Properties/Resources.es.resx new file mode 100644 index 0000000..c2e2f4d --- /dev/null +++ b/MarketExtension/Properties/Resources.es.resx @@ -0,0 +1,501 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Abrir + + + Actualizar + + + Buscar + + + Ver detalles + + + Extensión de mercados para Command Palette + + + Mercados + + + Cartera de mercados + + + Mercados + + + Búsqueda de mercados + + + Lista de seguimiento de mercados + + + Favoritos de mercados + + + Cartera de mercados + + + Fuentes de datos + + + Buscar, seguimiento, favoritos… + + + Buscar + + + Busca una acción, cripto o divisa + + + Lista de seguimiento + + + Instrumentos que sigues + + + Favoritos + + + Instrumentos destacados, mostrados en el dock + + + Cartera + + + Fuentes de datos + + + Configuración + + + Busca por símbolo o nombre y pulsa Enter + + + Filtrar lista de seguimiento… + + + Filtrar favoritos… + + + Filtrar posiciones… + + + Buscar «{0}» en los mercados + + + Pulsa Enter para buscar + + + Sin coincidencias para «{0}» + + + Resultados de la búsqueda + + + En seguimiento · ★ Favorito · Enter para detalles + + + En seguimiento · Enter para detalles + + + ★ Favorito · Enter para detalles + + + Enter para detalles + + + Acciones + + + Cripto + + + Divisas + + + Otros + + + Tu lista de seguimiento está vacía + + + Abre un instrumento y agrégalo desde su página de detalles + + + Aún no hay favoritos + + + Abre un instrumento y márcalo como favorito desde su página de detalles + + + Aún no hay posiciones + + + Agrega posiciones desde la página de detalles de un instrumento + + + Modo demo — mostrando datos de ejemplo + + + Precios simulados. Pulsa Enter para desactivarlo en Configuración. + + + Modo demo + + + Mostrando datos de ejemplo, no precios en vivo + + + No se configuró ninguna clave de API + + + Pulsa Enter para agregar una en Configuración. + + + Acción requerida + + + No se configuró ninguna clave de API — la funcionalidad es limitada + + + Límite de solicitudes alcanzado — mostrando los últimos precios conocidos + + + Se actualizará automáticamente cuando se restablezca el límite. + + + Límite alcanzado + + + Las solicitudes están siendo limitadas — los precios pueden estar desactualizados + + + Logos proporcionados por Elbstream + + + Imágenes de logos vía elbstream.com + + + Abrir Elbstream + + + Agregar a la lista de seguimiento + + + Quitar de la lista de seguimiento + + + Agregar a favoritos + + + Quitar de favoritos + + + Quitar de la cartera + + + Se agregó {0} a la lista de seguimiento + + + Se quitó {0} de la lista de seguimiento + + + Se agregó {0} a favoritos + + + Se quitó {0} de favoritos + + + Se quitó {0} de la cartera + + + No se pudo abrir {0}: {1} + + + Cargando gráfico de {0}… + + + No hay datos de gráfico para este rango. + + + Editar posición de {0} + + + Agregar {0} a la cartera + + + Editar posición + + + Agregar a la cartera + + + Se estableció {0} en {1}{2} + + + a {0}/unidad + + + Ingresa un número mayor que 0. + + + La cantidad debe ser mayor que 0. Para eliminar una posición, usa Quitar de la cartera. + + + Cantidad en posesión + + + Ingresa una cantidad mayor que 0 + + + Costo promedio por unidad (opcional) + + + Precio promedio que pagaste, en la divisa del instrumento. Deja 0 si lo desconoces. + + + Guardar + + + Cartera {0} + + + Total {0} + + + {0} ({1}%) hoy + + + · Total {0} ({1}%) + + + · 1 posición sin convertir + + + · {0} posiciones sin convertir + + + acc. + + + uds. + + + Clave de API de Twelve Data + + + Clave de API de Twelve Data. Se usa primero cuando está configurada. + + + Clave de API de Finnhub + + + Clave de API de Finnhub. + + + Pega tu clave de API + + + Intervalo de actualización de precios (minutos) + + + Intervalo de actualización de precios en minutos (0 = desactivado). + + + Mostrar avisos de límite de solicitudes + + + Muestra un aviso cuando los precios están desactualizados por el límite de solicitudes del proveedor. + + + Modo demo + + + Muestra datos de ejemplo integrados en lugar de precios en vivo. Ideal para probar la extensión. + + + Divisa de la cartera + + + Divisa para los totales de la cartera. Las demás posiciones se convierten a ella. + + + Dólar estadounidense (USD) + + + Euro (EUR) + + + Libra esterlina (GBP) + + + Yen japonés (JPY) + + + Franco suizo (CHF) + + + Dólar australiano (AUD) + + + Dólar canadiense (CAD) + + + Dólar neozelandés (NZD) + + + Yuan chino (CNY) + + + Dólar de Hong Kong (HKD) + + + Dólar de Singapur (SGD) + + + Corona sueca (SEK) + + + Corona noruega (NOK) + + + Corona danesa (DKK) + + + Złoty polaco (PLN) + + + Rand sudafricano (ZAR) + + + Peso mexicano (MXN) + + + Rupia india (INR) + + + Real brasileño (BRL) + + + Won surcoreano (KRW) + + \ No newline at end of file diff --git a/MarketExtension/Properties/Resources.hi.resx b/MarketExtension/Properties/Resources.hi.resx new file mode 100644 index 0000000..b984628 --- /dev/null +++ b/MarketExtension/Properties/Resources.hi.resx @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + खोलें + + + रिफ़्रेश करें + + + खोजें + + + विवरण देखें + + + Command Palette के लिए मार्केट एक्सटेंशन + + + मार्केट + + + मार्केट पोर्टफ़ोलियो + + + मार्केट + + + मार्केट खोज + + + मार्केट वॉचलिस्ट + + + मार्केट पसंदीदा + + + मार्केट पोर्टफ़ोलियो + + + डेटा स्रोत + + + खोज, वॉचलिस्ट, पसंदीदा… + + + खोजें + + + कोई स्टॉक, क्रिप्टो या मुद्रा खोजें + + + वॉचलिस्ट + + + आपके द्वारा ट्रैक किए जाने वाले इंस्ट्रूमेंट + + + पसंदीदा + + + तारांकित इंस्ट्रूमेंट, डॉक पर दिखाए जाते हैं + + + पोर्टफ़ोलियो + + + डेटा स्रोत + + + सेटिंग्स + + + सिंबल या नाम से खोजें, फिर Enter दबाएँ + + + वॉचलिस्ट फ़िल्टर करें… + + + पसंदीदा फ़िल्टर करें… + + + होल्डिंग्स फ़िल्टर करें… + + + मार्केट में “{0}” खोजें + + + खोजने के लिए Enter दबाएँ + + + “{0}” के लिए कोई मिलान नहीं + + + खोज परिणाम + + + वॉचलिस्ट में · ★ पसंदीदा · विवरण के लिए Enter + + + वॉचलिस्ट में · विवरण के लिए Enter + + + ★ पसंदीदा · विवरण के लिए Enter + + + विवरण के लिए Enter + + + स्टॉक + + + क्रिप्टो + + + मुद्रा + + + अन्य + + + आपकी वॉचलिस्ट खाली है + + + कोई इंस्ट्रूमेंट खोलें और उसके विवरण पेज से जोड़ें + + + अभी तक कोई पसंदीदा नहीं + + + कोई इंस्ट्रूमेंट खोलें और उसके विवरण पेज से तारांकित करें + + + अभी तक कोई होल्डिंग नहीं + + + किसी इंस्ट्रूमेंट के विवरण पेज से होल्डिंग्स जोड़ें + + + डेमो मोड — नमूना डेटा दिखाया जा रहा है + + + सिम्युलेटेड कीमतें। सेटिंग्स में बंद करने के लिए Enter दबाएँ। + + + डेमो मोड + + + नमूना डेटा दिखाया जा रहा है, लाइव कीमतें नहीं + + + कोई API कुंजी सेट नहीं है + + + सेटिंग्स में एक जोड़ने के लिए Enter दबाएँ। + + + कार्रवाई आवश्यक + + + कोई API कुंजी सेट नहीं है — कार्यक्षमता सीमित है + + + दर-सीमित — अंतिम ज्ञात कीमतें दिखाई जा रही हैं + + + सीमा हटते ही स्वचालित रूप से रिफ़्रेश हो जाएगा। + + + दर-सीमित + + + अनुरोध सीमित किए जा रहे हैं — कीमतें पुरानी हो सकती हैं + + + लोगो Elbstream द्वारा प्रदान किए गए + + + लोगो छवियाँ elbstream.com के माध्यम से + + + Elbstream खोलें + + + वॉचलिस्ट में जोड़ें + + + वॉचलिस्ट से हटाएँ + + + पसंदीदा में जोड़ें + + + पसंदीदा से हटाएँ + + + पोर्टफ़ोलियो से हटाएँ + + + {0} को वॉचलिस्ट में जोड़ा गया + + + {0} को वॉचलिस्ट से हटाया गया + + + {0} को पसंदीदा में जोड़ा गया + + + {0} को पसंदीदा से हटाया गया + + + {0} को पोर्टफ़ोलियो से हटाया गया + + + {0} को खोला नहीं जा सका: {1} + + + {0} चार्ट लोड हो रहा है… + + + इस अवधि के लिए कोई चार्ट डेटा नहीं है। + + + {0} होल्डिंग संपादित करें + + + {0} को पोर्टफ़ोलियो में जोड़ें + + + होल्डिंग संपादित करें + + + पोर्टफ़ोलियो में जोड़ें + + + {0} को {1}{2} पर सेट किया गया + + + ({0}/यूनिट) + + + 0 से बड़ी संख्या दर्ज करें। + + + मात्रा 0 से बड़ी होनी चाहिए। होल्डिंग हटाने के लिए, 'पोर्टफ़ोलियो से हटाएँ' का उपयोग करें। + + + रखी गई मात्रा + + + 0 से बड़ी मात्रा दर्ज करें + + + प्रति यूनिट औसत लागत (वैकल्पिक) + + + आपके द्वारा चुकाई गई औसत कीमत, इंस्ट्रूमेंट की मुद्रा में। अज्ञात होने पर 0 छोड़ दें। + + + सहेजें + + + पोर्टफ़ोलियो {0} + + + कुल {0} + + + आज {0} ({1}%) + + + · कुल {0} ({1}%) + + + · 1 होल्डिंग परिवर्तित नहीं + + + · {0} होल्डिंग्स परिवर्तित नहीं + + + शेयर + + + यूनिट + + + Twelve Data API कुंजी + + + Twelve Data API कुंजी। सेट होने पर पहले उपयोग की जाती है। + + + Finnhub API कुंजी + + + Finnhub API कुंजी। + + + अपनी API कुंजी पेस्ट करें + + + कीमत रिफ़्रेश अंतराल (मिनट) + + + कीमत रिफ़्रेश अंतराल मिनटों में (0 = बंद)। + + + दर-सीमा चेतावनियाँ दिखाएँ + + + जब प्रदाता की दर-सीमा के कारण कीमतें पुरानी हों, तो एक बैनर दिखाएँ। + + + डेमो मोड + + + लाइव कीमतों के बजाय अंतर्निहित नमूना डेटा दिखाएँ। एक्सटेंशन आज़माने के लिए आदर्श। + + + पोर्टफ़ोलियो मुद्रा + + + पोर्टफ़ोलियो योग के लिए मुद्रा। अन्य होल्डिंग्स इसमें परिवर्तित की जाती हैं। + + + अमेरिकी डॉलर (USD) + + + यूरो (EUR) + + + ब्रिटिश पाउंड (GBP) + + + जापानी येन (JPY) + + + स्विस फ़्रैंक (CHF) + + + ऑस्ट्रेलियाई डॉलर (AUD) + + + कैनेडियन डॉलर (CAD) + + + न्यूज़ीलैंड डॉलर (NZD) + + + चीनी युआन (CNY) + + + हांगकांग डॉलर (HKD) + + + सिंगापुर डॉलर (SGD) + + + स्वीडिश क्रोना (SEK) + + + नॉर्वेजियन क्रोन (NOK) + + + डेनिश क्रोन (DKK) + + + पोलिश ज़्लॉटी (PLN) + + + दक्षिण अफ़्रीकी रैंड (ZAR) + + + मैक्सिकन पेसो (MXN) + + + भारतीय रुपया (INR) + + + ब्राज़ीलियन रियल (BRL) + + + दक्षिण कोरियाई वॉन (KRW) + + diff --git a/MarketExtension/Properties/Resources.ja.resx b/MarketExtension/Properties/Resources.ja.resx new file mode 100644 index 0000000..e0f843a --- /dev/null +++ b/MarketExtension/Properties/Resources.ja.resx @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 開く + + + 更新 + + + 検索 + + + 詳細を表示 + + + Command Palette 用マーケット拡張機能 + + + マーケット + + + マーケット ポートフォリオ + + + マーケット + + + マーケット検索 + + + マーケット ウォッチリスト + + + マーケット お気に入り + + + マーケット ポートフォリオ + + + データソース + + + 検索、ウォッチリスト、お気に入り… + + + 検索 + + + 株式、暗号資産、通貨を検索 + + + ウォッチリスト + + + 追跡している銘柄 + + + お気に入り + + + スター付きの銘柄。ドックに表示されます + + + ポートフォリオ + + + データソース + + + 設定 + + + シンボルまたは名前で検索して Enter キーを押す + + + ウォッチリストを絞り込む… + + + お気に入りを絞り込む… + + + 保有銘柄を絞り込む… + + + マーケットで「{0}」を検索 + + + Enter キーで検索 + + + 「{0}」に一致する結果はありません + + + 検索結果 + + + ウォッチリスト登録済み · ★ お気に入り · Enter で詳細 + + + ウォッチリスト登録済み · Enter で詳細 + + + ★ お気に入り · Enter で詳細 + + + Enter で詳細 + + + 株式 + + + 暗号資産 + + + 為替 + + + その他 + + + ウォッチリストは空です + + + 銘柄を開いて、詳細ページから追加してください + + + お気に入りはまだありません + + + 銘柄を開いて、詳細ページからスターを付けてください + + + 保有銘柄はまだありません + + + 銘柄の詳細ページから保有銘柄を追加してください + + + デモモード — サンプルデータを表示中 + + + シミュレートされた価格です。Enter キーで設定からオフにできます。 + + + デモモード + + + ライブ価格ではなくサンプルデータを表示しています + + + API キーが設定されていません + + + Enter キーで設定から追加できます。 + + + 操作が必要です + + + API キーが未設定です — 機能が制限されます + + + レート制限中 — 最後に取得した価格を表示しています + + + 制限が解除されると自動的に更新されます。 + + + レート制限中 + + + リクエストが制限されています — 価格が古い可能性があります + + + ロゴは Elbstream から提供されています + + + ロゴ画像は elbstream.com 提供 + + + Elbstream を開く + + + ウォッチリストに追加 + + + ウォッチリストから削除 + + + お気に入りに追加 + + + お気に入りから削除 + + + ポートフォリオから削除 + + + {0} をウォッチリストに追加しました + + + {0} をウォッチリストから削除しました + + + {0} をお気に入りに追加しました + + + {0} をお気に入りから削除しました + + + {0} をポートフォリオから削除しました + + + {0} を開けませんでした: {1} + + + {0} のチャートを読み込み中… + + + この期間のチャートデータはありません。 + + + {0} の保有を編集 + + + {0} をポートフォリオに追加 + + + 保有を編集 + + + ポートフォリオに追加 + + + {0} を {1}{2} に設定しました + + + (単価 {0}) + + + 0 より大きい数値を入力してください。 + + + 数量は 0 より大きくする必要があります。保有を削除するには「ポートフォリオから削除」を使用してください。 + + + 保有数量 + + + 0 より大きい数量を入力してください + + + 1 単位あたりの平均取得単価(任意) + + + 支払った平均価格(銘柄の通貨建て)。不明な場合は 0 のままにします。 + + + 保存 + + + ポートフォリオ {0} + + + 累計 {0} + + + 本日 {0}({1}%) + + + · 累計 {0}({1}%) + + + · 1 銘柄が未換算 + + + · {0} 銘柄が未換算 + + + 株 + + + 単位 + + + Twelve Data API キー + + + Twelve Data の API キー。設定されている場合に優先して使用されます。 + + + Finnhub API キー + + + Finnhub の API キー。 + + + API キーを貼り付け + + + 価格の更新間隔(分) + + + 価格の更新間隔(分単位、0 = オフ)。 + + + レート制限の警告を表示 + + + プロバイダーのレート制限により価格が古くなった場合にバナーを表示します。 + + + デモモード + + + ライブ価格の代わりに組み込みのサンプルデータを表示します。拡張機能を試すのに最適です。 + + + ポートフォリオの通貨 + + + ポートフォリオ合計の通貨。他の保有銘柄はこの通貨に換算されます。 + + + 米ドル (USD) + + + ユーロ (EUR) + + + 英ポンド (GBP) + + + 日本円 (JPY) + + + スイスフラン (CHF) + + + 豪ドル (AUD) + + + カナダドル (CAD) + + + ニュージーランドドル (NZD) + + + 中国人民元 (CNY) + + + 香港ドル (HKD) + + + シンガポールドル (SGD) + + + スウェーデンクローナ (SEK) + + + ノルウェークローネ (NOK) + + + デンマーククローネ (DKK) + + + ポーランドズロチ (PLN) + + + 南アフリカランド (ZAR) + + + メキシコペソ (MXN) + + + インドルピー (INR) + + + ブラジルレアル (BRL) + + + 韓国ウォン (KRW) + + diff --git a/MarketExtension/Properties/Resources.ko.resx b/MarketExtension/Properties/Resources.ko.resx new file mode 100644 index 0000000..52e2b43 --- /dev/null +++ b/MarketExtension/Properties/Resources.ko.resx @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 열기 + + + 새로 고침 + + + 검색 + + + 세부 정보 보기 + + + Command Palette용 마켓 확장 + + + 마켓 + + + 마켓 포트폴리오 + + + 마켓 + + + 마켓 검색 + + + 마켓 관심목록 + + + 마켓 즐겨찾기 + + + 마켓 포트폴리오 + + + 데이터 소스 + + + 검색, 관심목록, 즐겨찾기… + + + 검색 + + + 주식, 암호화폐 또는 통화 검색 + + + 관심목록 + + + 추적 중인 종목 + + + 즐겨찾기 + + + 별표 표시된 종목, 독에 표시됨 + + + 포트폴리오 + + + 데이터 소스 + + + 설정 + + + 심볼 또는 이름으로 검색한 후 Enter + + + 관심목록 필터링… + + + 즐겨찾기 필터링… + + + 보유 종목 필터링… + + + 마켓에서 '{0}' 검색 + + + 검색하려면 Enter + + + '{0}'에 대한 일치 항목 없음 + + + 검색 결과 + + + 관심목록에 있음 · ★ 즐겨찾기 · Enter로 세부 정보 + + + 관심목록에 있음 · Enter로 세부 정보 + + + ★ 즐겨찾기 · Enter로 세부 정보 + + + Enter로 세부 정보 + + + 주식 + + + 암호화폐 + + + 외환 + + + 기타 + + + 관심목록이 비어 있습니다 + + + 종목을 열고 세부 정보 페이지에서 추가하세요 + + + 아직 즐겨찾기가 없습니다 + + + 종목을 열고 세부 정보 페이지에서 별표를 지정하세요 + + + 아직 보유 종목이 없습니다 + + + 종목의 세부 정보 페이지에서 보유 종목을 추가하세요 + + + 데모 모드 — 샘플 데이터 표시 중 + + + 시뮬레이션된 가격입니다. 설정에서 끄려면 Enter를 누르세요. + + + 데모 모드 + + + 실시간 가격이 아닌 샘플 데이터를 표시하고 있습니다 + + + API 키가 설정되지 않았습니다 + + + 설정에서 추가하려면 Enter를 누르세요. + + + 조치 필요 + + + API 키가 설정되지 않음 — 기능이 제한됩니다 + + + 요청 제한됨 — 마지막으로 확인된 가격 표시 중 + + + 제한이 해제되면 자동으로 새로 고쳐집니다. + + + 요청 제한됨 + + + 요청이 제한되고 있습니다 — 가격이 오래되었을 수 있습니다 + + + 로고 제공: Elbstream + + + 로고 이미지 제공: elbstream.com + + + Elbstream 열기 + + + 관심목록에 추가 + + + 관심목록에서 제거 + + + 즐겨찾기에 추가 + + + 즐겨찾기에서 제거 + + + 포트폴리오에서 제거 + + + {0}을(를) 관심목록에 추가했습니다 + + + {0}을(를) 관심목록에서 제거했습니다 + + + {0}을(를) 즐겨찾기에 추가했습니다 + + + {0}을(를) 즐겨찾기에서 제거했습니다 + + + {0}을(를) 포트폴리오에서 제거했습니다 + + + {0}을(를) 열 수 없습니다: {1} + + + {0} 차트 로드 중… + + + 이 기간에 대한 차트 데이터가 없습니다. + + + {0} 보유 종목 편집 + + + {0}을(를) 포트폴리오에 추가 + + + 보유 종목 편집 + + + 포트폴리오에 추가 + + + {0}을(를) {1}{2}(으)로 설정했습니다 + + + (단가 {0}) + + + 0보다 큰 숫자를 입력하세요. + + + 수량은 0보다 커야 합니다. 보유 종목을 제거하려면 '포트폴리오에서 제거'를 사용하세요. + + + 보유 수량 + + + 0보다 큰 수량을 입력하세요 + + + 단위당 평균 매입가 (선택 사항) + + + 지불한 평균 가격이며, 종목의 통화로 표시됩니다. 모를 경우 0으로 두세요. + + + 저장 + + + 포트폴리오 {0} + + + 총 {0} + + + 오늘 {0} ({1}%) + + + · 총 {0} ({1}%) + + + · 1개 종목 미환산 + + + · {0}개 종목 미환산 + + + 주 + + + 단위 + + + Twelve Data API 키 + + + Twelve Data API 키. 설정된 경우 먼저 사용됩니다. + + + Finnhub API 키 + + + Finnhub API 키. + + + API 키 붙여넣기 + + + 가격 새로 고침 간격 (분) + + + 가격 새로 고침 간격(분 단위, 0 = 끔). + + + 요청 제한 경고 표시 + + + 공급자의 요청 제한으로 가격이 오래되었을 때 배너를 표시합니다. + + + 데모 모드 + + + 실시간 가격 대신 내장된 샘플 데이터를 표시합니다. 확장을 사용해 보기에 적합합니다. + + + 포트폴리오 통화 + + + 포트폴리오 합계의 통화. 다른 보유 종목은 이 통화로 환산됩니다. + + + 미국 달러 (USD) + + + 유로 (EUR) + + + 영국 파운드 (GBP) + + + 일본 엔 (JPY) + + + 스위스 프랑 (CHF) + + + 호주 달러 (AUD) + + + 캐나다 달러 (CAD) + + + 뉴질랜드 달러 (NZD) + + + 중국 위안 (CNY) + + + 홍콩 달러 (HKD) + + + 싱가포르 달러 (SGD) + + + 스웨덴 크로나 (SEK) + + + 노르웨이 크로네 (NOK) + + + 덴마크 크로네 (DKK) + + + 폴란드 즈워티 (PLN) + + + 남아프리카 랜드 (ZAR) + + + 멕시코 페소 (MXN) + + + 인도 루피 (INR) + + + 브라질 헤알 (BRL) + + + 대한민국 원 (KRW) + + diff --git a/MarketExtension/Properties/Resources.pt-BR.resx b/MarketExtension/Properties/Resources.pt-BR.resx new file mode 100644 index 0000000..5425aec --- /dev/null +++ b/MarketExtension/Properties/Resources.pt-BR.resx @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Abrir + + + Atualizar + + + Pesquisar + + + Ver detalhes + + + Extensão de mercados para Command Palette + + + Mercados + + + Carteira de mercados + + + Mercados + + + Pesquisa de mercados + + + Lista de acompanhamento de mercados + + + Favoritos de mercados + + + Carteira de mercados + + + Fontes de dados + + + Pesquisar, acompanhamento, favoritos… + + + Pesquisar + + + Pesquise uma ação, cripto ou moeda + + + Lista de acompanhamento + + + Instrumentos que você acompanha + + + Favoritos + + + Instrumentos marcados, exibidos no dock + + + Carteira + + + Fontes de dados + + + Configurações + + + Pesquise por símbolo ou nome e pressione Enter + + + Filtrar lista de acompanhamento… + + + Filtrar favoritos… + + + Filtrar posições… + + + Pesquisar “{0}” nos mercados + + + Pressione Enter para pesquisar + + + Nenhum resultado para “{0}” + + + Resultados da pesquisa + + + Na lista de acompanhamento · ★ Favorito · Enter para detalhes + + + Na lista de acompanhamento · Enter para detalhes + + + ★ Favorito · Enter para detalhes + + + Enter para detalhes + + + Ações + + + Cripto + + + Câmbio + + + Outros + + + Sua lista de acompanhamento está vazia + + + Abra um instrumento e adicione-o na página de detalhes + + + Ainda não há favoritos + + + Abra um instrumento e marque-o como favorito na página de detalhes + + + Ainda não há posições + + + Adicione posições na página de detalhes de um instrumento + + + Modo demo — exibindo dados de exemplo + + + Preços simulados. Pressione Enter para desativar nas Configurações. + + + Modo demo + + + Exibindo dados de exemplo, não preços em tempo real + + + Nenhuma chave de API configurada + + + Pressione Enter para adicionar uma nas Configurações. + + + Ação necessária + + + Nenhuma chave de API configurada — funcionalidade limitada + + + Limite de requisições atingido — exibindo os últimos preços conhecidos + + + Será atualizado automaticamente quando o limite for liberado. + + + Limite atingido + + + As requisições estão sendo limitadas — os preços podem estar desatualizados + + + Logos fornecidos pela Elbstream + + + Imagens de logos via elbstream.com + + + Abrir a Elbstream + + + Adicionar à lista de acompanhamento + + + Remover da lista de acompanhamento + + + Adicionar aos favoritos + + + Remover dos favoritos + + + Remover da carteira + + + {0} adicionado à lista de acompanhamento + + + {0} removido da lista de acompanhamento + + + {0} adicionado aos favoritos + + + {0} removido dos favoritos + + + {0} removido da carteira + + + Não foi possível abrir {0}: {1} + + + Carregando o gráfico de {0}… + + + Sem dados de gráfico para este período. + + + Editar posição de {0} + + + Adicionar {0} à carteira + + + Editar posição + + + Adicionar à carteira + + + {0} definido como {1}{2} + + + a {0}/unidade + + + Insira um número maior que 0. + + + A quantidade deve ser maior que 0. Para remover uma posição, use Remover da carteira. + + + Quantidade mantida + + + Insira uma quantidade maior que 0 + + + Custo médio por unidade (opcional) + + + Preço médio que você pagou, na moeda do instrumento. Deixe 0 se não souber. + + + Salvar + + + Carteira {0} + + + Total {0} + + + {0} ({1}%) hoje + + + · Total {0} ({1}%) + + + · 1 posição não convertida + + + · {0} posições não convertidas + + + ações + + + unid. + + + Chave de API da Twelve Data + + + Chave de API da Twelve Data. Usada primeiro quando configurada. + + + Chave de API da Finnhub + + + Chave de API da Finnhub. + + + Cole sua chave de API + + + Intervalo de atualização de preços (minutos) + + + Intervalo de atualização de preços em minutos (0 = desativado). + + + Mostrar avisos de limite de requisições + + + Mostra um aviso quando os preços estão desatualizados devido ao limite de requisições do provedor. + + + Modo demo + + + Mostra dados de exemplo integrados em vez de preços em tempo real. Ideal para experimentar a extensão. + + + Moeda da carteira + + + Moeda para os totais da carteira. As demais posições são convertidas para ela. + + + Dólar americano (USD) + + + Euro (EUR) + + + Libra esterlina (GBP) + + + Iene japonês (JPY) + + + Franco suíço (CHF) + + + Dólar australiano (AUD) + + + Dólar canadense (CAD) + + + Dólar neozelandês (NZD) + + + Yuan chinês (CNY) + + + Dólar de Hong Kong (HKD) + + + Dólar de Singapura (SGD) + + + Coroa sueca (SEK) + + + Coroa norueguesa (NOK) + + + Coroa dinamarquesa (DKK) + + + Złoty polonês (PLN) + + + Rand sul-africano (ZAR) + + + Peso mexicano (MXN) + + + Rupia indiana (INR) + + + Real brasileiro (BRL) + + + Won sul-coreano (KRW) + + diff --git a/MarketExtension/Properties/Resources.resx b/MarketExtension/Properties/Resources.resx new file mode 100644 index 0000000..6db82ab --- /dev/null +++ b/MarketExtension/Properties/Resources.resx @@ -0,0 +1,501 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Open + + + Refresh + + + Search + + + View details + + + Markets Extension for Command Palette + + + Markets + + + Markets Portfolio + + + Markets + + + Markets Search + + + Markets Watchlist + + + Markets Favorites + + + Markets Portfolio + + + Data Sources + + + Search, Watchlist, Favorites... + + + Search + + + Look up a stock, crypto, or currency + + + Watchlist + + + Instruments you track + + + Favorites + + + Starred instruments, shown on the dock + + + Portfolio + + + Data Sources + + + Settings + + + Search by symbol or name, then Enter + + + Filter watchlist... + + + Filter favorites... + + + Filter holdings... + + + Search markets for "{0}" + + + Enter to search + + + No matches for "{0}" + + + Search results + + + On watchlist · ★ Favorite · Enter for details + + + On watchlist · Enter for details + + + ★ Favorite · Enter for details + + + Enter for details + + + Stocks + + + Crypto + + + Currency + + + Other + + + Your watchlist is empty + + + Open an instrument and add it from its detail page + + + No favorites yet + + + Open an instrument and star it from its detail page + + + No holdings yet + + + Add holdings from an instrument's detail page + + + Demo mode — showing sample data + + + Simulated prices. Enter to turn off in Settings. + + + Demo mode + + + Showing sample data, not live prices + + + No API key set + + + Enter to add one in Settings. + + + Action required + + + No API key set — functionality is limited + + + Rate-limited — showing last known prices + + + Will refresh automatically once the limit clears. + + + Rate-limited + + + Requests are being throttled — prices may be stale + + + Logos provided by Elbstream + + + Logo images via elbstream.com + + + Open Elbstream + + + Add to Watchlist + + + Remove from Watchlist + + + Add to Favorites + + + Remove from Favorites + + + Remove from Portfolio + + + Added {0} to watchlist + + + Removed {0} from watchlist + + + Added {0} to favorites + + + Removed {0} from favorites + + + Removed {0} from portfolio + + + Couldn't open {0}: {1} + + + Loading {0} chart… + + + No chart data for this range. + + + Edit {0} holding + + + Add {0} to portfolio + + + Edit holding + + + Add to Portfolio + + + Set {0} to {1}{2} + + + at {0}/unit + + + Enter a number greater than 0. + + + Quantity must be greater than 0. To remove a holding, use Remove from Portfolio. + + + Quantity held + + + Enter a quantity greater than 0 + + + Average cost per unit (optional) + + + Average price you paid, in the instrument's currency. Leave 0 if unknown. + + + Save + + + Portfolio {0} + + + Total {0} + + + {0} ({1}%) today + + + · Total {0} ({1}%) + + + · 1 holding not converted + + + · {0} holdings not converted + + + sh + + + units + + + Twelve Data API key + + + Twelve Data API key. Used first when set. + + + Finnhub API key + + + Finnhub API key. + + + Paste your API key + + + Price refresh interval (minutes) + + + Price refresh interval in minutes (0 = off). + + + Show rate-limit warnings + + + Show a banner when prices are stale from provider rate-limiting. + + + Demo mode + + + Show built-in sample data instead of live prices. Ideal for trying out the extension. + + + Portfolio currency + + + Currency for portfolio totals. Other holdings are converted into it. + + + US Dollar (USD) + + + Euro (EUR) + + + British Pound (GBP) + + + Japanese Yen (JPY) + + + Swiss Franc (CHF) + + + Australian Dollar (AUD) + + + Canadian Dollar (CAD) + + + New Zealand Dollar (NZD) + + + Chinese Yuan (CNY) + + + Hong Kong Dollar (HKD) + + + Singapore Dollar (SGD) + + + Swedish Krona (SEK) + + + Norwegian Krone (NOK) + + + Danish Krone (DKK) + + + Polish Złoty (PLN) + + + South African Rand (ZAR) + + + Mexican Peso (MXN) + + + Indian Rupee (INR) + + + Brazilian Real (BRL) + + + South Korean Won (KRW) + + \ No newline at end of file diff --git a/MarketExtension/Properties/Resources.zh-Hans.resx b/MarketExtension/Properties/Resources.zh-Hans.resx new file mode 100644 index 0000000..81af3a1 --- /dev/null +++ b/MarketExtension/Properties/Resources.zh-Hans.resx @@ -0,0 +1,501 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 打开 + + + 刷新 + + + 搜索 + + + 查看详情 + + + Command Palette 行情扩展 + + + 市场 + + + 市场投资组合 + + + 市场 + + + 市场搜索 + + + 市场自选 + + + 市场收藏 + + + 市场投资组合 + + + 数据源 + + + 搜索、自选、收藏… + + + 搜索 + + + 查找股票、加密货币或外汇 + + + 自选 + + + 您关注的标的 + + + 收藏 + + + 已加星标的标的,显示在停靠栏中 + + + 投资组合 + + + 数据源 + + + 设置 + + + 按代码或名称搜索,然后按 Enter + + + 筛选自选… + + + 筛选收藏… + + + 筛选持仓… + + + 在市场中搜索“{0}” + + + 按 Enter 搜索 + + + 没有与“{0}”匹配的结果 + + + 搜索结果 + + + 已在自选 · ★ 收藏 · 按 Enter 查看详情 + + + 已在自选 · 按 Enter 查看详情 + + + ★ 收藏 · 按 Enter 查看详情 + + + 按 Enter 查看详情 + + + 股票 + + + 加密货币 + + + 外汇 + + + 其他 + + + 您的自选列表为空 + + + 打开某个标的,并在其详情页中添加 + + + 暂无收藏 + + + 打开某个标的,并在其详情页中加星标 + + + 暂无持仓 + + + 在标的的详情页中添加持仓 + + + 演示模式 — 显示示例数据 + + + 模拟价格。按 Enter 在设置中关闭。 + + + 演示模式 + + + 显示示例数据,而非实时价格 + + + 未设置 API 密钥 + + + 按 Enter 在设置中添加。 + + + 需要操作 + + + 未设置 API 密钥 — 功能受限 + + + 已触发速率限制 — 显示最近已知价格 + + + 限制解除后将自动刷新。 + + + 速率受限 + + + 请求正在被限流 — 价格可能不是最新 + + + 徽标由 Elbstream 提供 + + + 徽标图像来自 elbstream.com + + + 打开 Elbstream + + + 添加到自选 + + + 从自选中移除 + + + 添加到收藏 + + + 从收藏中移除 + + + 从投资组合中移除 + + + 已将 {0} 添加到自选 + + + 已将 {0} 从自选中移除 + + + 已将 {0} 添加到收藏 + + + 已将 {0} 从收藏中移除 + + + 已将 {0} 从投资组合中移除 + + + 无法打开 {0}:{1} + + + 正在加载 {0} 图表… + + + 此时间范围没有图表数据。 + + + 编辑 {0} 持仓 + + + 将 {0} 添加到投资组合 + + + 编辑持仓 + + + 添加到投资组合 + + + 已将 {0} 设置为 {1}{2} + + + ,单价 {0} + + + 请输入大于 0 的数字。 + + + 数量必须大于 0。如需删除持仓,请使用“从投资组合中移除”。 + + + 持有数量 + + + 请输入大于 0 的数量 + + + 每单位平均成本(可选) + + + 您支付的平均价格,以该标的的货币计价。未知则留 0。 + + + 保存 + + + 投资组合 {0} + + + 总回报 {0} + + + 今日 {0}({1}%) + + + · 总回报 {0}({1}%) + + + · 1 项持仓未换算 + + + · {0} 项持仓未换算 + + + 股 + + + 单位 + + + Twelve Data API 密钥 + + + Twelve Data API 密钥。设置后将优先使用。 + + + Finnhub API 密钥 + + + Finnhub API 密钥。 + + + 粘贴您的 API 密钥 + + + 价格刷新间隔(分钟) + + + 价格刷新间隔(分钟,0 = 关闭)。 + + + 显示速率限制警告 + + + 当因数据源速率限制导致价格过时时显示横幅提示。 + + + 演示模式 + + + 显示内置示例数据而非实时价格。适合体验此扩展。 + + + 投资组合货币 + + + 用于投资组合合计的货币。其他持仓将换算为该货币。 + + + 美元 (USD) + + + 欧元 (EUR) + + + 英镑 (GBP) + + + 日元 (JPY) + + + 瑞士法郎 (CHF) + + + 澳大利亚元 (AUD) + + + 加拿大元 (CAD) + + + 新西兰元 (NZD) + + + 人民币 (CNY) + + + 港元 (HKD) + + + 新加坡元 (SGD) + + + 瑞典克朗 (SEK) + + + 挪威克朗 (NOK) + + + 丹麦克朗 (DKK) + + + 波兰兹罗提 (PLN) + + + 南非兰特 (ZAR) + + + 墨西哥比索 (MXN) + + + 印度卢比 (INR) + + + 巴西雷亚尔 (BRL) + + + 韩元 (KRW) + + \ No newline at end of file diff --git a/MarketExtension/Settings/FavoritesStore.cs b/MarketExtension/Settings/FavoritesStore.cs deleted file mode 100644 index 635e165..0000000 --- a/MarketExtension/Settings/FavoritesStore.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Threading; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace MarketExtension; - -// JSON-backed set of favorited instrument symbols, persisted under the CmdPal settings folder -// so favorites survive reloads. Adapted from reference/settings/FavoritesStore.cs. -internal sealed class FavoritesStore -{ - public static readonly FavoritesStore Instance = new(); - - private readonly Lock _lock = new(); - private HashSet _favorites = []; - - private static string JsonPath - { - get - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - return Path.Combine(directory, "market_favorites.json"); - } - } - - private FavoritesStore() => Load(); - - public bool IsFavorite(string symbol) - { - lock (_lock) return _favorites.Contains(Normalize(symbol)); - } - - public void Toggle(string symbol) - { - var key = Normalize(symbol); - lock (_lock) - { - if (!_favorites.Remove(key)) - _favorites.Add(key); - } - - Save(); - } - - private static string Normalize(string symbol) => symbol.Trim().ToUpperInvariant(); - - private void Load() - { - try - { - if (!File.Exists(JsonPath)) - return; - - var json = File.ReadAllText(JsonPath); - var ids = JsonSerializer.Deserialize>(json); - if (ids != null) - _favorites = [.. ids]; - } - catch - { - _favorites = []; - } - } - - private void Save() - { - try - { - List snapshot; - lock (_lock) snapshot = [.. _favorites]; - File.WriteAllText(JsonPath, JsonSerializer.Serialize(snapshot)); - } - catch - { - // best-effort - } - } -} diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs new file mode 100644 index 0000000..4c1fd50 --- /dev/null +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Microsoft.CommandPalette.Extensions.Toolkit; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Extension settings, surfaced in Command Palette's Settings UI and persisted to +// market.settings.json under the CmdPal settings folder. Singleton, modeled on +// reference/settings/AdbSettingsManager.cs. Wired into the host via +// MarketExtensionCommandsProvider (Settings = MarketSettingsManager.Instance.Settings). +// +// Settings: one API key per data provider, plus the price refresh interval (minutes; 0 = off). Keys +// are user-supplied at runtime — there is no built-in/baked key, and an empty key just means that +// provider is skipped. Keys are named per-provider because each provider has its own. +// +// Note: keys are NOT masked — the toolkit's TextSetting has no password mode; settings live in a +// local plaintext JSON file anyway. +// +// ⚠️ Toolkit quirk: TextSetting renders **Description** as the field's on-screen label (it maps Label → +// the Adaptive Card `title`, which Input.Text ignores). So the user-visible text must go in Description; +// Label is kept only as the semantic name. +internal sealed class MarketSettingsManager : JsonSettingsManager +{ + public static readonly MarketSettingsManager Instance = new(); + + // Default auto-refresh cadence when the field is blank/unparseable. + private const int DefaultRefreshMinutes = 10; + + private readonly TextSetting _twelveDataApiKey = new("twelveDataApiKey", string.Empty) + { + Label = Resources.Settings_TwelveData_Label, + Description = Resources.Settings_TwelveData_Desc, + Placeholder = Resources.Settings_ApiKey_Placeholder, + }; + + private readonly TextSetting _finnhubApiKey = new("finnhubApiKey", string.Empty) + { + Label = Resources.Settings_Finnhub_Label, + Description = Resources.Settings_Finnhub_Desc, + Placeholder = Resources.Settings_ApiKey_Placeholder, + }; + + private readonly TextSetting _refreshMinutes = new( + "refreshMinutes", DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture)) + { + Label = Resources.Settings_Refresh_Label, + Description = Resources.Settings_Refresh_Desc, + Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), + }; + + private readonly ToggleSetting _showRateLimitErrors = new("showRateLimitErrors", true) + { + Label = Resources.Settings_RateLimit_Label, + Description = Resources.Settings_RateLimit_Desc, + }; + + private readonly ToggleSetting _demoMode = new("demoMode", false) + { + Label = Resources.Settings_Demo_Label, + Description = Resources.Settings_Demo_Desc, + }; + + // The reporting currency for the (upcoming) Portfolio screen: the single currency its totals are shown + // in, so holdings priced in other currencies can be converted into it (conversion itself is future + // work — for now this just records the preference). The choice list mirrors the currencies the FX + // provider (Frankfurter/ECB) can convert between, which is also the set AssetIconResolver maps to + // flags. The first entry (USD) is the default — matching the app's current single-quote-currency + // assumption. ChoiceSetSetting renders a dropdown; its stored Value is the selected code (e.g. "USD"). + private readonly ChoiceSetSetting _portfolioCurrency = new("portfolioCurrency", BuildCurrencyChoices()) + { + Label = Resources.Settings_PortfolioCurrency_Label, + Description = Resources.Settings_PortfolioCurrency_Desc, + }; + + // The supported reporting currencies (ISO-4217), in display order — the set the FX provider (ECB) can + // convert between. The display name for each comes from the resx ("Currency_"), so the dropdown + // localizes with everything else; the stored value is always the bare code. The codes are a method-local + // (NOT a static field) on purpose: this runs from the _portfolioCurrency field initializer during + // Instance's own static construction, so a static field declared after Instance would still be null here. + private static List BuildCurrencyChoices() + { + string[] codes = + [ + "USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CAD", "NZD", "CNY", "HKD", + "SGD", "SEK", "NOK", "DKK", "PLN", "ZAR", "MXN", "INR", "BRL", "KRW", + ]; + var choices = new List(codes.Length); + foreach (var code in codes) + choices.Add(new ChoiceSetSetting.Choice(Strings.Get($"Currency_{code}"), code)); + return choices; + } + + // Observable "is any pricing key configured" flag, driven by SettingsChanged (below). UI surfaces + // (the missing-key hint) subscribe and re-render the instant the user adds/removes a key, instead of + // re-querying on each GetItems. Distinct-until-changed means it emits only when the bool actually + // flips. The providers do NOT use this — they read the key values pull-style on each request. + private readonly MutableStateFlow _hasAnyApiKey = new(false); + + // Observable form of the Demo-mode toggle (below), driven by SettingsChanged. Unlike the other settings + // — read pull-style on the next refresh — flipping demo mode swaps the entire data SOURCE, so every + // cached price and FX rate the app is holding was produced by the OTHER source and is now wrong. Surfaces + // subscribe and reset the instant it flips: the priced pages drop their price cache (re-fetching if + // visible, else on next open), the dock bands re-price, and CurrencyConverter clears its rate cache. + // Distinct-until-changed → emits only when the toggle actually flips. + private readonly MutableStateFlow _demoModeFlow = new(false); + + // The key the Twelve Data provider should use — the user's setting, or empty when unset. Read as a + // property (not cached) so a key change applies on the next request without a reload. + public string TwelveDataApiKey => _twelveDataApiKey.Value?.Trim() ?? string.Empty; + + // Whether a Twelve Data key has been configured. The provider's Supports() returns false when this + // is false, so the repository's first-match routing falls through to Finnhub/Frankfurter. + public bool HasTwelveDataApiKey => TwelveDataApiKey.Length > 0; + + // The key the Finnhub provider should use — the user's setting, or empty when unset (there is no + // built-in fallback). Read as a property (not cached) so a key change in settings applies on the + // next request without a reload. + public string FinnhubApiKey => _finnhubApiKey.Value?.Trim() ?? string.Empty; + + // Whether a Finnhub key has been configured. The provider short-circuits to "no data" when false + // rather than firing keyless requests that would just 401. + public bool HasFinnhubApiKey => FinnhubApiKey.Length > 0; + + // Observable form of "is at least one pricing-provider key set" (Twelve Data OR Finnhub). Frankfurter + // FX is keyless, but stocks/crypto need a key — so this drives the missing-key hint. Read .Value for a + // snapshot, or Subscribe to re-render when it flips. Updated by the SettingsChanged handler in the ctor. + public StateFlow HasAnyApiKey => _hasAnyApiKey; + + // Auto-refresh cadence in minutes; 0 means off. Bad/negative input falls back to the default. + public int RefreshMinutes => + int.TryParse(_refreshMinutes.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) && v >= 0 + ? v + : DefaultRefreshMinutes; + + // Whether the poll loop should run at all (false when the user picked 0 = off). + public bool AutoRefreshEnabled => RefreshMinutes > 0; + + // The cadence as a TimeSpan for the (upcoming) PeriodicTimer-driven poll loop. + public TimeSpan RefreshInterval => TimeSpan.FromMinutes(RefreshMinutes); + + // Whether the rate-limited banner (RateLimitHint) shows while a provider is throttling requests. + // Default on; the user can hide it in Settings if they'd rather not see it. Read pull-style each render, + // so a toggle applies the next time a priced page re-renders (e.g. on navigating back to it). + public bool ShowRateLimitErrors => _showRateLimitErrors.Value; + + // When on, the app serves built-in sample data (MockMarketDataProvider + a static FX table in + // CurrencyConverter) instead of calling any live market-data or FX API — no key or connectivity needed. + // The VALUE is read pull-style per request (MockMarketDataProvider.Supports/SearchAsync and + // CurrencyConverter check it each call), but flipping the toggle applies IMMEDIATELY: the SettingsChanged + // handler publishes DemoModeChanged (below), and every surface subscribes to drop its cached prices/rates + // and re-fetch through the now-current routing — no reload, no waiting for the next refresh. Default off + // → ships live. + public bool DemoMode => _demoMode.Value; + + // Observable form of DemoMode: subscribe to reset cached data the instant the toggle flips (see the + // _demoModeFlow field note for what each surface does). Read .Value for a snapshot. Updated by the + // SettingsChanged handler in the ctor. + public StateFlow DemoModeChanged => _demoModeFlow; + + // The reporting currency the Portfolio screen rolls up into (ISO 4217 code, e.g. "USD"). Read + // pull-style so a change applies the next time the portfolio re-prices, no reload needed. Falls back + // to USD if somehow unset. Conversion of foreign-currency holdings into this is future work. + public string PortfolioCurrency => + string.IsNullOrWhiteSpace(_portfolioCurrency.Value) ? "USD" : _portfolioCurrency.Value; + + private MarketSettingsManager() + { + FilePath = Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), "market.settings.json"); + Settings.Add(_twelveDataApiKey); + Settings.Add(_finnhubApiKey); + Settings.Add(_refreshMinutes); + Settings.Add(_showRateLimitErrors); + Settings.Add(_demoMode); + Settings.Add(_portfolioCurrency); + LoadSettings(); + _hasAnyApiKey.Update(HasTwelveDataApiKey || HasFinnhubApiKey); // seed from persisted keys (no subscribers yet) + _demoModeFlow.Update(DemoMode); // seed from the persisted toggle (no subscribers yet) + Settings.SettingsChanged += (_, _) => + { + SaveSettings(); + // Publish the key-presence flag so the missing-key hint reacts immediately. Distinct-until- + // changed swallows this unless a key was just added or cleared. + _hasAnyApiKey.Update(HasTwelveDataApiKey || HasFinnhubApiKey); + // Publish the demo-mode flag so every surface resets the instant it flips (the cached prices + + // FX rates came from the other data source). Distinct-until-changed → only on a real flip. + _demoModeFlow.Update(DemoMode); + }; + } +} diff --git a/MarketExtension/Settings/PortfolioStore.cs b/MarketExtension/Settings/PortfolioStore.cs new file mode 100644 index 0000000..41e4cfb --- /dev/null +++ b/MarketExtension/Settings/PortfolioStore.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// JSON-backed set of the user's portfolio HOLDINGS (a quantity per instrument), persisted under the +// CmdPal settings folder so it survives reloads. Modeled on WatchlistStore, but it tracks a quantity +// (and an optional, currently-unused cost basis) rather than membership flags, and it does NOT seed any +// defaults — a fresh install starts with an empty portfolio. +// +// Two observable StateFlows drive the UI: +// * Positions — the holdings (instrument + quantity), for rendering rows and the totals summary, and +// for the detail page's command bar (Add vs Edit/Remove). +// * Instruments — just the symbols to price, which the Portfolio screen (a PricedListPage) consumes. +// +// Both use DEFAULT (reference) equality — NOT InstrumentListComparer — so EVERY mutation re-emits, +// including a quantity-only edit that leaves the symbol set unchanged. That's what makes an edited +// quantity repaint: the PricedListPage's reconcile finds nothing missing (no fetch) and just re-renders, +// re-reading the new quantity. (Watchlist/Favorites dedup by symbol because only membership matters +// there; here the quantity matters too, so we want every change through.) +internal sealed class PortfolioStore +{ + public static readonly PortfolioStore Instance = new(); + + private readonly Lock _lock = new(); + // Keyed by normalized symbol (reusing WatchlistStore.Normalize so a holding and its watchlist/quote + // cache entries line up); value is the full holding. + private Dictionary _positions = []; + + private readonly MutableStateFlow> _positionsFlow = new([]); + private readonly MutableStateFlow> _instrumentsFlow = new([]); + + // The holdings, in insertion order. Subscribe for live updates; read .Value for a snapshot. + public StateFlow> Positions => _positionsFlow; + + // The instruments to price (holding identities only), for the Portfolio PricedListPage. + public StateFlow> Instruments => _instrumentsFlow; + + private static string JsonPath => Path.Combine(SettingsDirectory, "market_portfolio.json"); + + private static string SettingsDirectory + { + get + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return directory; + } + } + + private PortfolioStore() + { + Load(); + PublishState(); // seed the flows with the loaded holdings, before any subscriber exists + } + + public bool Contains(string symbol) + { + lock (_lock) return _positions.ContainsKey(WatchlistStore.Normalize(symbol)); + } + + public bool TryGetQuantity(string symbol, out decimal quantity) + { + lock (_lock) + { + if (_positions.TryGetValue(WatchlistStore.Normalize(symbol), out var p)) + { + quantity = p.Quantity; + return true; + } + } + + quantity = 0m; + return false; + } + + // The full holding for a symbol (quantity + cost basis), or null if not held. Used by the Portfolio + // screen to render total return alongside daily P&L — the quantity-only TryGetQuantity isn't enough. + public DomainPosition? GetPosition(string symbol) + { + lock (_lock) + return _positions.TryGetValue(WatchlistStore.Normalize(symbol), out var p) ? p : null; + } + + // Add or update a holding (a SetPosition with the same symbol overwrites it). The cost basis is applied + // VERBATIM: a value sets it, null clears it — there is no preserve-on-null rule. The editor + // (SetQuantityPage) is the only caller and is authoritative: it prefills the field with the current + // basis, so a quantity-only edit round-trips (and thus preserves) the existing basis on its own. + public void SetPosition(DomainInstrument instrument, decimal quantity, decimal? costBasis = null) + { + var key = WatchlistStore.Normalize(instrument.Symbol); + lock (_lock) + _positions[key] = new DomainPosition(instrument, quantity, costBasis); + + Save(); + PublishState(); + } + + public void Remove(string symbol) + { + lock (_lock) + _positions.Remove(WatchlistStore.Normalize(symbol)); + + Save(); + PublishState(); + } + + // Recompute both flows and push them. Each call produces fresh list instances, so with default + // equality every mutation emits (see the class comment) — quantity edits included. + private void PublishState() + { + IReadOnlyList positions; + IReadOnlyList instruments; + lock (_lock) + { + positions = [.. _positions.Values]; + instruments = [.. _positions.Values.Select(p => p.Instrument)]; + } + + _positionsFlow.Update(positions); + _instrumentsFlow.Update(instruments); + } + + private void Load() + { + try + { + if (File.Exists(JsonPath)) + { + var items = JsonSerializer.Deserialize(File.ReadAllText(JsonPath), PortfolioJsonContext.Default.ListPortfolioItem); + if (items is not null) + { + _positions = items.ToDictionary( + i => WatchlistStore.Normalize(i.Symbol), + i => new DomainPosition(new DomainInstrument(i.Symbol, i.Name, i.Category), i.Quantity, i.CostBasis)); + return; + } + } + + // No file yet → empty portfolio. Unlike the watchlist, there's nothing to seed. + _positions = []; + } + catch + { + _positions = []; + } + } + + private void Save() + { + try + { + List snapshot; + lock (_lock) + snapshot = [.. _positions.Values.Select(p => + new PortfolioItem(p.Instrument.Symbol, p.Instrument.Name, p.Instrument.Category, p.Quantity, p.CostBasis))]; + File.WriteAllText(JsonPath, JsonSerializer.Serialize(snapshot, PortfolioJsonContext.Default.ListPortfolioItem)); + } + catch + { + // best-effort + } + } +} + +// Persistence DTO for one holding. Source-gen (de)serialized, matching the WatchlistStore convention. +internal sealed record PortfolioItem(string Symbol, string Name, AssetCategory Category, decimal Quantity, decimal? CostBasis); + +// ⚠️ Keep ALL [JsonSerializable] on this single declaration — splitting them across partials silently +// breaks the JSON source generator (see CLAUDE.md AOT/trim gotcha). +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] // category as readable, reorder-safe text +[JsonSerializable(typeof(List))] +internal sealed partial class PortfolioJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Settings/WatchlistStore.cs b/MarketExtension/Settings/WatchlistStore.cs new file mode 100644 index 0000000..d1e0739 --- /dev/null +++ b/MarketExtension/Settings/WatchlistStore.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// JSON-backed set of the instruments the user tracks, persisted under the CmdPal settings folder so +// it survives reloads. Each entry carries two INDEPENDENT membership flags: +// InWatchlist — shows on the Watchlist screen. +// IsFavorite — shows on the Favorites screen AND the dock band. +// An instrument can be on either list, both, or (transiently) neither — an entry whose flags both +// fall to false is dropped. Stores full identity (symbol + name + category), not just the symbol, +// so an instrument added via search — which isn't in InstrumentCatalog — can still be re-priced on +// later loads. Replaces the old FavoritesStore, which conflated "tracked" with "favorite". +internal sealed class WatchlistStore +{ + public static readonly WatchlistStore Instance = new(); + + private readonly Lock _lock = new(); + // Keyed by normalized symbol; value carries the identity plus the two membership flags. + private Dictionary _entries = []; + + // The two membership subsets as observable StateFlows. Every mutation re-publishes both via + // PublishState(); consumers (the priced pages and the dock band) Subscribe and react instead of + // being poked manually. The InstrumentListComparer dedup means a watchlist-only change won't wake + // favorites subscribers (and vice-versa). Exposed read-only — only this store writes them. + private readonly MutableStateFlow> _watchlist = new([], InstrumentListComparer.Instance); + private readonly MutableStateFlow> _favorites = new([], InstrumentListComparer.Instance); + + // The watchlisted instruments, in insertion order. Subscribe for live membership; read .Value for a snapshot. + public StateFlow> Watchlist => _watchlist; + + // The favorited instruments (the dock subset), in insertion order. + public StateFlow> Favorites => _favorites; + + private sealed class Entry(DomainInstrument instrument) + { + public DomainInstrument Instrument { get; } = instrument; + public bool InWatchlist { get; set; } + public bool IsFavorite { get; set; } + } + + private static string JsonPath => Path.Combine(SettingsDirectory, "market_watchlist.json"); + private static string LegacyFavoritesPath => Path.Combine(SettingsDirectory, "market_favorites.json"); + + private static string SettingsDirectory + { + get + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return directory; + } + } + + private WatchlistStore() + { + Load(); + PublishState(); // seed the flows with the loaded subsets, before any subscriber exists + } + + public bool IsInWatchlist(string symbol) + { + lock (_lock) return _entries.TryGetValue(Normalize(symbol), out var e) && e.InWatchlist; + } + + public bool IsFavorite(string symbol) + { + lock (_lock) return _entries.TryGetValue(Normalize(symbol), out var e) && e.IsFavorite; + } + + public void AddToWatchlist(DomainInstrument instrument) => SetFlag(instrument, watchlist: true); + public void RemoveFromWatchlist(string symbol) => SetFlag(symbol, watchlist: false); + public void AddToFavorites(DomainInstrument instrument) => SetFlag(instrument, favorite: true); + public void RemoveFromFavorites(string symbol) => SetFlag(symbol, favorite: false); + + private void SetFlag(string symbol, bool? watchlist = null, bool? favorite = null) + { + lock (_lock) + { + if (_entries.TryGetValue(Normalize(symbol), out var entry)) + Apply(entry, watchlist, favorite); + } + + Save(); + PublishState(); + } + + private void SetFlag(DomainInstrument instrument, bool? watchlist = null, bool? favorite = null) + { + var key = Normalize(instrument.Symbol); + lock (_lock) + { + if (!_entries.TryGetValue(key, out var entry)) + entry = _entries[key] = new Entry(instrument); + Apply(entry, watchlist, favorite); + } + + Save(); + PublishState(); + } + + // Recompute both membership subsets and push them onto the flows. Called after every mutation (and + // once at construction). The flows' InstrumentListComparer dedups, so subscribers of an unchanged + // subset aren't woken. Snapshots under the lock, then lets the flow fire its handlers outside it. + private void PublishState() + { + IReadOnlyList watchlist, favorites; + lock (_lock) + { + watchlist = [.. _entries.Values.Where(e => e.InWatchlist).Select(e => e.Instrument)]; + favorites = [.. _entries.Values.Where(e => e.IsFavorite).Select(e => e.Instrument)]; + } + + _watchlist.Update(watchlist); + _favorites.Update(favorites); + } + + // Apply a flag change, then drop the entry entirely once it's on neither list. + private void Apply(Entry entry, bool? watchlist, bool? favorite) + { + if (watchlist is { } w) entry.InWatchlist = w; + if (favorite is { } f) entry.IsFavorite = f; + if (!entry.InWatchlist && !entry.IsFavorite) + _entries.Remove(Normalize(entry.Instrument.Symbol)); + } + + // The canonical key form for a symbol. Internal so priced pages can key their local quote cache the + // same way the store keys its entries. + internal static string Normalize(string symbol) => symbol.Trim().ToUpperInvariant(); + + private void Load() + { + try + { + if (File.Exists(JsonPath)) + { + var items = JsonSerializer.Deserialize(File.ReadAllText(JsonPath), WatchlistJsonContext.Default.ListWatchlistItem); + if (items is not null) + { + _entries = items.ToDictionary( + i => Normalize(i.Symbol), + i => new Entry(new DomainInstrument(i.Symbol, i.Name, i.Category)) + { + InWatchlist = i.InWatchlist, + IsFavorite = i.IsFavorite, + }); + return; + } + } + + // First run: migrate the old FavoritesStore file if present (its pinned items were both + // tracked and dock-shown, so they become watchlisted AND favorited), otherwise seed the + // built-in catalog onto the watchlist. Either way we persist so this is one-time. The + // constructor's PublishState() seeds the flows afterward, before any subscriber exists. + _entries = SeedEntries(); + Save(); + } + catch + { + _entries = []; + } + } + + private static Dictionary SeedEntries() + { + if (File.Exists(LegacyFavoritesPath)) + { + var legacy = JsonSerializer.Deserialize(File.ReadAllText(LegacyFavoritesPath), WatchlistJsonContext.Default.ListPinnedItem); + if (legacy is { Count: > 0 }) + return legacy.ToDictionary( + i => Normalize(i.Symbol), + i => new Entry(new DomainInstrument(i.Symbol, i.Name, i.Category)) { InWatchlist = true, IsFavorite = true }); + } + + return InstrumentCatalog.All.ToDictionary( + i => Normalize(i.Symbol), + i => new Entry(i) { InWatchlist = true, IsFavorite = false }); + } + + private void Save() + { + try + { + List snapshot; + lock (_lock) + snapshot = [.. _entries.Values.Select(e => + new WatchlistItem(e.Instrument.Symbol, e.Instrument.Name, e.Instrument.Category, e.InWatchlist, e.IsFavorite))]; + File.WriteAllText(JsonPath, JsonSerializer.Serialize(snapshot, WatchlistJsonContext.Default.ListWatchlistItem)); + } + catch + { + // best-effort + } + } +} + +// Persistence DTO for a tracked instrument and its two membership flags. Source-gen (de)serialized so +// the trimmed Release build stays clean (reflection JSON is the IL2026/IL3050 debt flagged in CLAUDE.md). +internal sealed record WatchlistItem(string Symbol, string Name, AssetCategory Category, bool InWatchlist, bool IsFavorite); + +// Legacy persistence DTO from the old FavoritesStore — read once for first-run migration only. +internal sealed record PinnedItem(string Symbol, string Name, AssetCategory Category); + +// ⚠️ Keep ALL [JsonSerializable] on this single declaration — splitting them across partials +// silently breaks the JSON source generator (see CLAUDE.md AOT/trim gotcha). +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] // category as readable, reorder-safe text +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] // legacy market_favorites.json migration +internal sealed partial class WatchlistJsonContext : JsonSerializerContext; diff --git a/MarketExtension/app.manifest b/MarketExtension/app.manifest index db22c48..867ea1a 100644 --- a/MarketExtension/app.manifest +++ b/MarketExtension/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/MarketExtension/build-exe.ps1 b/MarketExtension/build-exe.ps1 index d78f354..919a73c 100644 --- a/MarketExtension/build-exe.ps1 +++ b/MarketExtension/build-exe.ps1 @@ -1,7 +1,7 @@ param( [string]$ExtensionName = "MarketExtension", [string]$Configuration = "Release", - [string]$Version = "0.0.1.0", + [string]$Version = "1.0.0.0", [string[]]$Platforms = @("x64", "arm64") ) diff --git a/MarketExtension/create-signing-cert.ps1 b/MarketExtension/create-signing-cert.ps1 index 7de25cb..d3e9c9e 100644 --- a/MarketExtension/create-signing-cert.ps1 +++ b/MarketExtension/create-signing-cert.ps1 @@ -33,7 +33,7 @@ $cert = New-SelfSignedCertificate ` -Type Custom ` -Subject $publisher ` -KeyUsage DigitalSignature ` - -FriendlyName "Market Extension Signing" ` + -FriendlyName "Markets Extension Signing" ` -CertStoreLocation "Cert:\CurrentUser\My" ` -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") diff --git a/MarketExtension/setup-template.iss b/MarketExtension/setup-template.iss index b8805f8..d538053 100644 --- a/MarketExtension/setup-template.iss +++ b/MarketExtension/setup-template.iss @@ -1,10 +1,10 @@ -; Inno Setup Script for Market Extension +; Inno Setup Script for Markets Extension for Command Palette -#define AppVersion "0.0.1.0" +#define AppVersion "1.0.0.0" [Setup] AppId={{6b38c9aa-bbee-45e9-81e9-cf25707910e7}} -AppName=Market Extension +AppName=Markets Extension for Command Palette AppVersion={#AppVersion} AppPublisher=Costa Fotiadis DefaultDirName={localappdata}\MarketExtension @@ -22,7 +22,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" Source: "bin\Release\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs [Icons] -Name: "{group}\Market Extension"; Filename: "{app}\MarketExtension.exe" +Name: "{group}\Markets Extension for Command Palette"; Filename: "{app}\MarketExtension.exe" [Registry] Root: HKCU; Subkey: "SOFTWARE\Classes\CLSID\{{6b38c9aa-bbee-45e9-81e9-cf25707910e7}}"; ValueType: string; ValueName: ""; ValueData: "MarketExtension" diff --git a/reference/finnhub-spec.md b/reference/finnhub-spec.md new file mode 100644 index 0000000..987cb5d --- /dev/null +++ b/reference/finnhub-spec.md @@ -0,0 +1,224 @@ +# Market Go Up — Command Palette Extension Spec + +A PowerToys Command Palette extension that shows live-ish market quotes (stocks, crypto, FX) in the **Command Palette dock** as a pinnable band, plus a searchable add/remove flow inside the palette itself. Single data provider: **Finnhub** (free tier, user supplies their own API key). + +> Subtitle / store description: *stocks · crypto · FX in your Command Palette* +> Command alias: `quotes` (or `mgu`) + +--- + +## 0. Context for the implementer + +- Target: **PowerToys Command Palette**, v0.100.0+ (the Dock + `GetDockBands` API shipped in 0.98/CmdPal 0.9, multi-monitor dock in 0.100.0). +- Language/stack: **C# / WinUI 3**, the standard CmdPal extension stack. Start from the official CmdPal extension project template (`dotnet new` template shipped in the PowerToys repo, or scaffold via Visual Studio). +- The dock band API is `ICommandProvider3::GetDockBands()`. **Before writing band code, read the actual API spec** in the PowerToys repo at `src/modules/cmdpal/doc/` and the CmdPal sample extensions under `src/modules/cmdpal/`. The band rendering capabilities (static button vs. dynamic text label) are the single load-bearing unknown — confirm from the sample code what a band can actually render and how its refresh/invoke works before committing to the inline-price UI. +- The dock is **always visible** (no auto-hide), so band content must be compact. Three pin regions exist: start / center / end. + +### Build order (each step independently shippable) + +1. Vanilla command provider loads in CmdPal (from template). +2. Implement `ICommandProvider3`, return ONE hardcoded band (`"BTC 64,000"`). Verify it renders + manual refresh works. +3. Wire Finnhub `/quote` behind that one band (hardcoded symbol). +4. Add/remove flow: search via `/search`, pin/unpin, persist. +5. Persistence (pinned items + API key in local app data). +6. Polish: detail page, color coding, error states. + +--- + +## 1. Data provider — Finnhub + +Base URL: `https://finnhub.io/api/v1` +Auth: append `&token={API_KEY}` to every request (or `X-Finnhub-Token` header). +Key: user-supplied, free tier. No credit card. Register at finnhub.io. + +### Free-tier limits (design around these) +- **60 calls/minute**, plus a soft **~300 calls/day** ceiling. +- Manual refresh across a handful of pinned symbols stays far under both. Do NOT auto-poll aggressively. +- Real-time quotes solid for **US stocks**; international thinner. +- **Historical candles (`/stock/candle`) are PREMIUM** — return 403 on free keys. No sparklines/charts from Finnhub free. Band needs only `/quote`, so this only affects an optional detail view. Do not architect a chart dependency. + +### Endpoint 1 — Quote (the workhorse; powers the band) + +``` +GET /api/v1/quote?symbol={SYMBOL}&token={KEY} +``` + +Response: +```json +{ + "c": 261.74, // current price + "d": 1.23, // change (absolute) + "dp": 0.47, // percent change <- drives the green/red arrow + "h": 263.31, // high of day + "l": 260.68, // low of day + "o": 261.07, // open + "pc": 260.51, // previous close + "t": 1739808000 // unix timestamp +} +``` + +Notes: +- A bad/unknown symbol returns all-zero fields (`c: 0`, etc.), NOT an error. Treat all-zero as "no data / invalid symbol." +- Works for stocks, crypto, and FX — same response shape — as long as the symbol string is formatted per asset class (see §2). + +### Endpoint 2 — Symbol search (powers the add flow) + +``` +GET /api/v1/search?q={QUERY}&token={KEY} +``` + +Response: +```json +{ + "count": 4, + "result": [ + { + "description": "APPLE INC", + "displaySymbol": "AAPL", + "symbol": "AAPL", + "type": "Common Stock" + } + ] +} +``` +Use `description` for the human label, `symbol` for the value to store + query. + +### Endpoint 3 (optional) — Company profile (for detail view) + +``` +GET /api/v1/stock/profile2?symbol={SYMBOL}&token={KEY} +``` +Returns `name`, `logo`, `marketCapitalization`, `exchange`, `finnhubIndustry`, `weburl`, `ticker`. Free. Nice-to-have for a click-through detail page. Stocks only. + +### Symbol enumeration helpers (optional, for nicer add UX) +- Crypto symbols on an exchange: `GET /crypto/symbol?exchange=binance&token={KEY}` +- Forex pairs: `GET /forex/symbol?token={KEY}` + +--- + +## 2. Asset classes & symbol formatting + +The provider abstraction's only real job is formatting the symbol string per asset class, then calling `/quote`. Store the asset class alongside each pinned symbol. + +| Asset class | `/quote` symbol format | Example | Notes | +|---|---|---|---| +| `Stock` | bare ticker | `AAPL` | US tickers best supported | +| `Crypto` | `EXCHANGE:PAIR` | `BINANCE:BTCUSDT` | Binance is the safe default exchange | +| `Forex` | `BROKER:PAIR` | `OANDA:EUR_USD` | OANDA is the safe default broker | + +For search/add: Finnhub `/search` primarily returns equities. For crypto/FX, either (a) let the user pick asset class first then type the pair, or (b) preload pair lists from the enumeration helpers above and fuzzy-match locally. Simplest v1: an asset-class selector (Stock / Crypto / FX) in the add flow that determines the symbol prefix. + +--- + +## 3. Provider interface + +```csharp +public enum AssetClass { Stock, Crypto, Forex } + +public record Quote( + string Symbol, // the stored symbol, e.g. "BINANCE:BTCUSDT" + string DisplayLabel, // what shows on the band, e.g. "BTC" + decimal Price, // Finnhub "c" + decimal ChangePct, // Finnhub "dp" + bool IsValid, // false when Finnhub returned all-zeros + DateTimeOffset FetchedAt +); + +public record SymbolMatch( + string Symbol, + string Description, + AssetClass AssetClass +); + +public interface IQuoteProvider +{ + Task GetQuoteAsync(PinnedItem item, CancellationToken ct); + Task> SearchAsync(string query, AssetClass assetClass, CancellationToken ct); + bool RequiresApiKey { get; } // true for Finnhub + string ProviderName { get; } // "Finnhub" +} +``` + +`FinnhubQuoteProvider` is the only concrete impl for v1. Keep the interface so a no-key fallback provider (CoinGecko/Stooq/Frankfurter) can be added later without touching the band layer. + +### Symbol formatting helper +```csharp +static string ToFinnhubSymbol(string raw, AssetClass cls) => cls switch +{ + AssetClass.Stock => raw.ToUpperInvariant(), + AssetClass.Crypto => raw.Contains(':') ? raw : $"BINANCE:{raw.ToUpperInvariant()}", + AssetClass.Forex => raw.Contains(':') ? raw : $"OANDA:{raw.ToUpperInvariant()}", + _ => raw +}; +``` + +--- + +## 4. Persistence + +Store in the extension's local app data (e.g. `%LOCALAPPDATA%\MarketGoUp\settings.json`). Single JSON file, last-write-wins. + +```json +{ + "apiKey": "USER_FINNHUB_KEY", + "refreshOnLoad": true, + "pinned": [ + { "symbol": "AAPL", "displayLabel": "AAPL", "assetClass": "Stock" }, + { "symbol": "BINANCE:BTCUSDT", "displayLabel": "BTC", "assetClass": "Crypto" }, + { "symbol": "OANDA:EUR_USD", "displayLabel": "EUR/GBP", "assetClass": "Forex" } + ] +} +``` + +```csharp +public record PinnedItem(string Symbol, string DisplayLabel, AssetClass AssetClass); +``` + +- API key in plaintext local config is the normal bar for this class of tool. Don't log it; don't put it in URLs that get logged. +- The Command Palette settings surface (`ISettings` / settings page in the CmdPal SDK) is the right place for the API-key input — use it rather than rolling your own. + +--- + +## 5. UI surfaces + +### A. Dock band (the headline feature) +- One band per pinned item, OR one band rendering all pinned items inline — **depends on what the band API allows** (resolve in build step 2). +- Content per item: `{DisplayLabel} {Price} {▲|▼}{ChangePct}%`, e.g. `AAPL 261.74 ▲0.47%`. +- Color: green when `ChangePct >= 0`, red when `< 0`. Use a neutral/grey state for `IsValid == false` (show `—` instead of a price). +- Click a band item → open the detail page (or the symbol on finnhub.io / a provider URL). +- Refresh: a manual "refresh" command/action. Optionally refresh-on-palette-open. No tight polling loop. + +### B. Command Palette pages (the extension surface) +- **Top-level command** (`quotes`): opens a list page of currently pinned items with live values; each has context actions Refresh / Unpin / Move to dock region. +- **Add command**: asset-class selector → search box → results from `SearchAsync` with asset-class badges → selecting one appends to `pinned`, persists, refreshes bands. +- **Settings**: Finnhub API key field, refresh-on-load toggle. + +### Display style decision (deferred) +Static-inline (all pinned shown at once) vs. rotating (cycle one at a time) was left open pending seeing the band render. Build static-inline first; it's simpler and matches the always-visible dock. Revisit rotation only if horizontal space is tight. + +--- + +## 6. Error handling (Finnhub specifics) + +- Always check HTTP status first. On non-2xx, attempt JSON parse but **fall back to raw text** — Finnhub error bodies are not always clean JSON. +- `429 Too Many Requests` → exponential backoff; surface a quiet "rate limited, retry shortly" state, don't crash the band. +- `403` on any endpoint → premium-gated (e.g. candles). Surface clearly; don't retry. +- `401` → invalid/missing API key → prompt user to set it in settings. +- All-zero `/quote` response → treat as invalid symbol, show `—`, optionally flag in the pinned list. +- Crypto/FX candle calls (if ever used) return `{"s":"no_data"}` for empty ranges — handle as empty, not error. + +--- + +## 7. Out of scope for v1 (note, don't build) +- Sparklines / charts (Finnhub historical candles are premium). +- WebSocket streaming (free tier allows 50 symbols, but manual-refresh REST is simpler and sufficient; revisit later). +- No-key fallback providers (CoinGecko / Stooq / Frankfurter) — interface leaves room, implement later. +- Portfolio quantities / cost basis / P&L. + +--- + +## 8. Naming / packaging +- Extension display name: **Market Go Up** +- Subtitle: *stocks · crypto · FX in your Command Palette* +- Store/WinGet tags (these do the discovery work): `market`, `stocks`, `crypto`, `forex`, `ticker`, `quotes`, `watchlist`, `finance`, `dock` +- Command alias: `quotes` diff --git a/tools/gen-resources.ps1 b/tools/gen-resources.ps1 new file mode 100644 index 0000000..0770103 --- /dev/null +++ b/tools/gen-resources.ps1 @@ -0,0 +1,135 @@ +<# +.SYNOPSIS + Regenerates MarketExtension/Properties/Resources.Designer.cs from Resources.resx. + +.DESCRIPTION + A CLI stand-in for Visual Studio's PublicResXFileCodeGenerator custom tool. VS regenerates the + Designer automatically when you save the .resx, so you normally never need this. Use it only when + you edit Resources.resx WITHOUT Visual Studio (a text editor, a script, etc.) — `dotnet build` does + NOT run the VS generator, so the Designer would otherwise go stale. + + It mirrors System.Resources.Tools.StronglyTypedResourceBuilder: one `public static string ` + property per entry, keys sorted ordinally, XML-escaped doc comments — i.e. the same output + VS produces, so a later VS save yields a near-zero diff. + + Always commit the regenerated Resources.Designer.cs alongside the .resx. + +.EXAMPLE + pwsh tools/gen-resources.ps1 +#> +[CmdletBinding()] +param( + [string]$Resx, + [string]$Out +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path $PSScriptRoot -Parent +if (-not $Resx) { $Resx = Join-Path $repoRoot 'MarketExtension\Properties\Resources.resx' } +if (-not $Out) { $Out = Join-Path $repoRoot 'MarketExtension\Properties\Resources.Designer.cs' } + +if (-not (Test-Path $Resx)) { throw "resx not found: $Resx" } + +[xml]$doc = Get-Content -Raw -Path $Resx + +# string resources only (skip any entry that declares a 'type' or 'mimetype' — those are non-strings) +$data = $doc.root.data | Where-Object { -not $_.type -and -not $_.mimetype } + +# StronglyTypedResourceBuilder emits properties sorted ordinal-ascending by name +$map = [ordered]@{} +foreach ($n in $data) { $map[$n.name] = $n } +$names = [string[]]@($map.Keys) +[Array]::Sort($names, [System.StringComparer]::Ordinal) + +$nl = "`r`n" +$sb = [System.Text.StringBuilder]::new() + +$header = @' +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace MarketExtension.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MarketExtension.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } +'@ +[void]$sb.Append($header).Append($nl) + +foreach ($key in $names) { + $val = [string]$map[$key].value + # collapse newlines for the doc comment, then XML-escape (< > & ' ") + $val = $val -replace "`r`n", ' ' -replace "`n", ' ' -replace "`r", ' ' + $esc = [System.Security.SecurityElement]::Escape($val) + [void]$sb.Append(' ').Append($nl) + [void]$sb.Append(' /// ').Append($nl) + [void]$sb.Append(' /// Looks up a localized string similar to ').Append($esc).Append('.').Append($nl) + [void]$sb.Append(' /// ').Append($nl) + [void]$sb.Append(' public static string ').Append($key).Append(' {').Append($nl) + [void]$sb.Append(' get {').Append($nl) + [void]$sb.Append(' return ResourceManager.GetString("').Append($key).Append('", resourceCulture);').Append($nl) + [void]$sb.Append(' }').Append($nl) + [void]$sb.Append(' }').Append($nl) +} + +[void]$sb.Append(' }').Append($nl) +[void]$sb.Append('}').Append($nl) + +# UTF-8 WITH BOM — matches what Visual Studio writes for generated Designer files +$enc = [System.Text.UTF8Encoding]::new($true) +[System.IO.File]::WriteAllText($Out, $sb.ToString(), $enc) +Write-Host "Wrote $($names.Count) properties to $Out"